diff --git a/RELEASING.md b/RELEASING.md index a4cf372..ad509d8 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -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. diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..123c9cf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +release/ +*.log +.vite/ diff --git a/app/DISTRIBUTION.md b/app/DISTRIBUTION.md new file mode 100644 index 0000000..88b59f9 --- /dev/null +++ b/app/DISTRIBUTION.md @@ -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--arm64.dmg`, `CodeBurn-.dmg` — installer images +- `CodeBurn--arm64-mac.zip`, `CodeBurn--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: ()"` + 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. diff --git a/app/README.md b/app/README.md new file mode 100644 index 0000000..05d473b --- /dev/null +++ b/app/README.md @@ -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 [--provider ]` +- Plans: `codeburn status --format json --period ` +- Models: `codeburn models --format json --period [--provider ] [--by-task]` +- Optimize: `codeburn yield --format json --period ` +- Spend flow: `codeburn spend --format flow-json --period [--provider ]` +- Devices: `codeburn devices --format json --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. diff --git a/app/build/icon.png b/app/build/icon.png new file mode 100644 index 0000000..f758830 Binary files /dev/null and b/app/build/icon.png differ diff --git a/app/electron/cli.test.ts b/app/electron/cli.test.ts new file mode 100644 index 0000000..d0ac8a9 --- /dev/null +++ b/app/electron/cli.test.ts @@ -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) + }) + + 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) + }) +}) + +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 }) + } + }) +}) diff --git a/app/electron/cli.ts b/app/electron/cli.ts new file mode 100644 index 0000000..d344277 --- /dev/null +++ b/app/electron/cli.ts @@ -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() +const readInflight = new Map>() +const readCache = new Map() + +/** 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 { + return new Promise((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 ` 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 { + 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 { + const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS + return new Promise(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 })) + }) +} diff --git a/app/electron/main.test.ts b/app/electron/main.test.ts new file mode 100644 index 0000000..77ae693 --- /dev/null +++ b/app/electron/main.test.ts @@ -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 = (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 = (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') + }) +}) diff --git a/app/electron/main.ts b/app/electron/main.ts new file mode 100644 index 0000000..8572e9f --- /dev/null +++ b/app/electron/main.ts @@ -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 = { 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 `:` (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 + spawnCliAction: (args: string[], opts?: { timeoutMs?: number }) => Promise + resolveCodeburnPath: () => string | null + getQuota: typeof getQuota +} + +type Handler = (...args: any[]) => Promise + +/** + * 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 { + 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() diff --git a/app/electron/preload.ts b/app/electron/preload.ts new file mode 100644 index 0000000..8f651df --- /dev/null +++ b/app/electron/preload.ts @@ -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(channel: string, ...args: unknown[]): Promise { + const res = (await ipcRenderer.invoke(channel, ...args)) as Envelope + 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) diff --git a/app/electron/quota/claude.test.ts b/app/electron/quota/claude.test.ts new file mode 100644 index 0000000..c4784d0 --- /dev/null +++ b/app/electron/quota/claude.test.ts @@ -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() + }) +}) diff --git a/app/electron/quota/claude.ts b/app/electron/quota/claude.ts new file mode 100644 index 0000000..cc24de2 --- /dev/null +++ b/app/electron/quota/claude.ts @@ -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 +} + +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 }).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 { + const raw = await deps.readFile(deps.credentialPath, 64 * 1024) + return raw ? parseCredential(raw) : null +} + +export async function readClaudeKeychain(): Promise { + // 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 + 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 : {} + 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 + 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 { + 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 & { signal?: AbortSignal; allowKeychain?: boolean } = {}): Promise { + 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).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') } + } +} diff --git a/app/electron/quota/codex.test.ts b/app/electron/quota/codex.test.ts new file mode 100644 index 0000000..b8e2de4 --- /dev/null +++ b/app/electron/quota/codex.test.ts @@ -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).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' }) + }) +}) diff --git a/app/electron/quota/codex.ts b/app/electron/quota/codex.ts new file mode 100644 index 0000000..b339d50 --- /dev/null +++ b/app/electron/quota/codex.ts @@ -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 & { + 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 + 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 +} + +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 { + 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 + try { record = JSON.parse(raw) as Record } 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 { + 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 + 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 = { + 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 : {} + 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 { + 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 + 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 { + const token = auth.tokens?.access_token + if (!token) return null + const headers: Record = { 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 & { signal?: AbortSignal; allowKeychain?: boolean } = {}): Promise { + 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') } + } +} diff --git a/app/electron/quota/index.test.ts b/app/electron/quota/index.test.ts new file mode 100644 index 0000000..8250c45 --- /dev/null +++ b/app/electron/quota/index.test.ts @@ -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(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) + }) +}) + diff --git a/app/electron/quota/index.ts b/app/electron/quota/index.ts new file mode 100644 index 0000000..5043623 --- /dev/null +++ b/app/electron/quota/index.ts @@ -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> +type FetchResult = { quota: QuotaProvider; retryAfterSeconds?: number } +type QuotaDeps = { + claude: (options: { signal: AbortSignal; allowKeychain: boolean }) => Promise + codex: (options: { signal: AbortSignal; allowKeychain: boolean }) => Promise + 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 | null = null + private generations: Record = { claude: 0, codex: 0 } + private controllers: Partial> = {} + + constructor(deps: Partial = {}) { 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 { + 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 { + 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 { + 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 { + const startingGenerations = { ...this.generations } + const prior = this.cache?.value ?? [] + const blocked = await this.readBlocked() + const run = async (provider: ProviderName): Promise => { + 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 => + quotaService.getQuota({ force: options.force, allowKeychain: Boolean(options.force) }) diff --git a/app/electron/quota/security.test.ts b/app/electron/quota/security.test.ts new file mode 100644 index 0000000..6df3f0f --- /dev/null +++ b/app/electron/quota/security.test.ts @@ -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' }) + }) +}) diff --git a/app/electron/quota/security.ts b/app/electron/quota/security.ts new file mode 100644 index 0000000..3637f06 --- /dev/null +++ b/app/electron/quota/security.ts @@ -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 { + 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 { + 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 { + 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)) +} diff --git a/app/electron/quota/types.ts b/app/electron/quota/types.ts new file mode 100644 index 0000000..143a7d8 --- /dev/null +++ b/app/electron/quota/types.ts @@ -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'] + diff --git a/app/package-lock.json b/app/package-lock.json new file mode 100644 index 0000000..a20bcde --- /dev/null +++ b/app/package-lock.json @@ -0,0 +1,7664 @@ +{ + "name": "codeburn-desktop", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "codeburn-desktop", + "version": "0.1.0", + "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" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.4.tgz", + "integrity": "sha512-Zr1Vs7E9tpCNhZHDAbFVXc2gEVCG9RqPDjrno5+bdgB6LRAuvgyMHJut4NCVyYwtAieapMzc3fiQ3CSTi75ARg==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/fuses": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", + "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.1", + "fs-extra": "^9.0.1", + "minimist": "^1.2.5" + }, + "bin": { + "electron-fuses": "dist/bin.js" + } + }, + "node_modules/@electron/fuses/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@electron/fuses/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@electron/fuses/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/fuses/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@electron/get": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.0.0.tgz", + "integrity": "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", + "progress": "^2.0.3", + "semver": "^7.6.3", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=22.12.0" + }, + "optionalDependencies": { + "undici": "^7.24.4" + } + }, + "node_modules/@electron/get/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", + "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/rebuild": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.2.0.tgz", + "integrity": "sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.1.1", + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^12.2.0", + "read-binary-file-arch": "^1.0.6" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/universal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", + "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.3.1", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/universal/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.6.tgz", + "integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@electron/windows-sign": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", + "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/fs-extra": { + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.6.tgz", + "integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@gsap/react": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@gsap/react/-/react-2.1.2.tgz", + "integrity": "sha512-JqliybO1837UcgH2hVOM4VO+38APk3ECNrsuSM4MuXp+rbf+/2IG2K1YJiqfTcXQHH7XlA0m3ykniFYstfq0Iw==", + "license": "SEE LICENSE AT https://gsap.com/standard-license", + "peerDependencies": { + "gsap": "^3.12.5", + "react": ">=17" + } + }, + "node_modules/@hapi/address": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", + "integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/formula": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz", + "integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/pinpoint": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz", + "integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/tlds": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.7.tgz", + "integrity": "sha512-MgNjRwy9Ti92yVAixLmDc8dd1bJIKwO9qlWCfFQRwRmUEDPQHYn4G6hwPFvFGUTzAa0FsS+inMjLin7GnyBRhA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", + "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz", + "integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "tslib": "^2.8.1", + "webcrypto-core": "^1.9.2" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-3.2.7.tgz", + "integrity": "sha512-eVtcpJXGhS0GjMuHROfbXLhlxooyUcuip8GNzzjDD5jzafZqzanJH4W3VGmUxHNx4fv6qQGUGJHRpGUdj+9D6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "fflate": "^0.8.2", + "flatted": "^3.3.3", + "pathe": "^2.0.3", + "sirv": "^3.0.1", + "tinyglobby": "^0.2.14", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "3.2.7" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/app-builder-lib": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.15.3.tgz", + "integrity": "sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "3.4.1", + "@electron/fuses": "^1.8.0", + "@electron/get": "^3.0.0", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.3", + "@electron/rebuild": "^4.0.4", + "@electron/universal": "2.0.3", + "@malept/flatpak-bundler": "^0.4.0", + "@noble/hashes": "^2.2.0", + "@peculiar/webcrypto": "^1.7.1", + "@types/fs-extra": "9.0.13", + "ajv": "^8.18.0", + "asn1js": "^3.0.10", + "async-exit-hook": "^2.0.1", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chromium-pickle-js": "^0.2.0", + "ci-info": "4.3.1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "26.15.3", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "isbinaryfile": "^5.0.0", + "jiti": "^2.4.2", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.2.5", + "pkijs": "^3.4.0", + "plist": "3.1.0", + "proper-lockfile": "^4.1.2", + "resedit": "^1.7.0", + "semver": "~7.7.3", + "tar": "^7.5.7", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0", + "unzipper": "^0.12.3", + "which": "^5.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "26.15.3", + "electron-builder-squirrel-windows": "26.15.3" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", + "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/app-builder-lib/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/app-builder-lib/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/app-builder-lib/node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/app-builder-lib/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/app-builder-lib/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.42", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", + "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", + "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001800", + "electron-to-chromium": "^1.5.387", + "node-releases": "^2.0.50", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.15.3.tgz", + "integrity": "sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.6", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "js-yaml": "^4.1.0", + "sanitize-filename": "^1.6.3", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", + "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/builder-util/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/builder-util/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/builder-util/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/builder-util/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/builder-util/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bytestreamjs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001803", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz", + "integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concurrently": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz", + "integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "5.6.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.4", + "supports-color": "10.2.2", + "tree-kill": "1.2.2", + "yargs": "18.0.0" + }, + "bin": { + "conc": "dist/bin/index.js", + "concurrently": "dist/bin/index.js" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-dirname": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", + "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/dir-compare": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", + "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " + } + }, + "node_modules/dir-compare/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.15.3.tgz", + "integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "43.1.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-43.1.0.tgz", + "integrity": "sha512-DPfxpQLd4NL3BJ8DBxYAfmLUKKesF5Rx9dQx5FyczAP8bhOPScjHE48GArVeXu68LlAainuwkmQTQvdZwpIIAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" + }, + "bin": { + "electron": "cli.js", + "install-electron": "install.js" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "node_modules/electron-builder": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.15.3.tgz", + "integrity": "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "dmg-builder": "26.15.3", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.3.tgz", + "integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "electron-winstaller": "5.4.0" + } + }, + "node_modules/electron-builder/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/electron-builder/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/electron-builder/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-builder/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-builder/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-builder/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-builder/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/electron-builder/node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-publish": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz", + "integrity": "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "aws4": "^1.13.2", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "form-data": "^4.0.5", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-publish/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/electron-publish/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/electron-publish/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.389", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", + "integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==", + "dev": true, + "license": "ISC" + }, + "node_modules/electron-winstaller": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", + "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@electron/asar": "^3.2.1", + "debug": "^4.1.1", + "fs-extra": "^7.0.1", + "lodash": "^4.17.21", + "temp": "^0.9.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "@electron/windows-sign": "^1.1.2" + } + }, + "node_modules/electron-winstaller/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/electron-winstaller/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "peer": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-winstaller/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/electron/node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/electron/node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/gsap": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz", + "integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==", + "license": "Standard 'no charge' license: https://gsap.com/standard-license." + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/joi": { + "version": "18.2.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.3.tgz", + "integrity": "sha512-N5A3KTWQpPWT4ExxxPlUx7WmykGXRzhNidWhV41d6Abu9YfI2NyWCJuxdPnslJCPWtbRpSVOWSnSS6GakLM/Rg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/address": "^5.1.1", + "@hapi/formula": "^3.0.2", + "@hapi/hoek": "^11.0.7", + "@hapi/pinpoint": "^2.0.1", + "@hapi/tlds": "^1.1.1", + "@hapi/topo": "^6.0.2", + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-abi": { + "version": "4.33.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.33.0.tgz", + "integrity": "sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.6.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-api-version": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", + "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + } + }, + "node_modules/node-api-version/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-gyp": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-gyp/node_modules/undici": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/pe-library": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", + "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkijs": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", + "integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@noble/hashes": "1.4.0", + "asn1js": "^3.0.6", + "bytestreamjs": "^2.0.1", + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/pkijs/node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", + "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resedit": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", + "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^0.4.1" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", + "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tar": { + "version": "7.5.20", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz", + "integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/temp": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", + "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/tiny-async-pool": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", + "integrity": "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.5.0" + } + }, + "node_modules/tiny-async-pool/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.8", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.8.tgz", + "integrity": "sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.8" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.8", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.8.tgz", + "integrity": "sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unzipper": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.5.tgz", + "integrity": "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "~3.7.2", + "duplexer2": "~0.1.4", + "fs-extra": "11.3.1", + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" + } + }, + "node_modules/unzipper/node_modules/fs-extra": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz", + "integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/wait-on": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.10.tgz", + "integrity": "sha512-rCoJEhvMr0X6alHmwc9abbrA5ZrLZFKpFQVKPNFwl2h7DapXOGdmimIHDtLOWhT4PjhZhxFEtZoQgEXbkDWdZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "axios": "^1.16.0", + "joi": "^18.2.1", + "lodash": "^4.18.1", + "minimist": "^1.2.8", + "rxjs": "^7.8.2" + }, + "bin": { + "wait-on": "bin/wait-on" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/webcrypto-core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", + "integrity": "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/app/package.json b/app/package.json new file mode 100644 index 0000000..d545881 --- /dev/null +++ b/app/package.json @@ -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": {} + } +} diff --git a/app/renderer/App.test.tsx b/app/renderer/App.test.tsx new file mode 100644 index 0000000..c98b5d5 --- /dev/null +++ b/app/renderer/App.test.tsx @@ -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() +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>(), + getSpendFlow: vi.fn<(period: string, provider: string, range?: DateRange) => Promise>(), + getOptimizeReport: vi.fn<(period: string, provider: string, range?: DateRange) => Promise>(), + 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() + 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() + 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() + await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('today', 'all')) + }) + + it('switches sections with command-number shortcuts', async () => { + render() + + 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() + + 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() + + 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() + 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() + 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() + + 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() + 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() + 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() + + 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() + + 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() + 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() + 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() + // 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() + 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() + 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()) + }) +}) diff --git a/app/renderer/App.tsx b/app/renderer/App.tsx new file mode 100644 index 0000000..aa9cbd1 --- /dev/null +++ b/app/renderer/App.tsx @@ -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 = { + overview: 'Overview', + sessions: 'Sessions', + spend: 'Spend', + optimize: 'Optimize', + models: 'Models', + compare: 'Compare', + plans: 'Plans', + settings: 'Settings', +} + +const PERIOD_LABELS: Record = { + 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
('overview') + const [settingsPane, setSettingsPane] = useState('general') + const [period, setPeriod] = useState(initialPeriod) + const [provider, setProvider] = useState('all') + const [detectedProviders, setDetectedProviders] = useState>([]) + const [customRange, setCustomRange] = useState(null) + const [claudeConfigSource, setClaudeConfigSource] = useState(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( + () => 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 ( + + } /> + + +
+ + + {section === 'plans' ? ( + + ) : section === 'settings' ? ( + + ) : ( + <> + +
+ {section === 'overview' ? ( + + ) : section === 'sessions' ? ( + + ) : section === 'spend' ? ( + + ) : section === 'optimize' ? ( + + ) : section === 'models' ? ( + + ) : section === 'compare' ? ( + + ) : ( + + )} +
+ + )} +
+ {section !== 'settings' && ( + + )} +
+
+ ) +} + +function StatusLine({ polled }: { polled: ReturnType> }) { + if (polled.data) { + return ( + <> + {polled.data.current.label} {formatUsd(polled.data.current.cost)} + + ) + } + if (polled.error?.kind === 'not-found') return <>CLI not found + if (polled.loading) return <>scanning… + return <>— +} + +function SectionPlaceholder({ title }: { title: string }) { + return ( + + {title} lands in a later task. The shell, data bridge, and design system are in place. + + ) +} + +/** 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 ( +
+ {text} + +
+ ) +} diff --git a/app/renderer/assets/flame.png b/app/renderer/assets/flame.png new file mode 100644 index 0000000..affcada Binary files /dev/null and b/app/renderer/assets/flame.png differ diff --git a/app/renderer/assets/providers/claude.svg b/app/renderer/assets/providers/claude.svg new file mode 100644 index 0000000..d300701 --- /dev/null +++ b/app/renderer/assets/providers/claude.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/renderer/assets/providers/copilot-dark.svg b/app/renderer/assets/providers/copilot-dark.svg new file mode 100644 index 0000000..7e49f6d --- /dev/null +++ b/app/renderer/assets/providers/copilot-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/renderer/assets/providers/copilot-light.svg b/app/renderer/assets/providers/copilot-light.svg new file mode 100644 index 0000000..466d4a8 --- /dev/null +++ b/app/renderer/assets/providers/copilot-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/renderer/assets/providers/cursor-agent.jpg b/app/renderer/assets/providers/cursor-agent.jpg new file mode 100644 index 0000000..447a9ed Binary files /dev/null and b/app/renderer/assets/providers/cursor-agent.jpg differ diff --git a/app/renderer/assets/providers/cursor-dark.svg b/app/renderer/assets/providers/cursor-dark.svg new file mode 100644 index 0000000..10d50ca --- /dev/null +++ b/app/renderer/assets/providers/cursor-dark.svg @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/app/renderer/assets/providers/cursor-light.svg b/app/renderer/assets/providers/cursor-light.svg new file mode 100644 index 0000000..635d3cc --- /dev/null +++ b/app/renderer/assets/providers/cursor-light.svg @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/app/renderer/assets/providers/gemini.svg b/app/renderer/assets/providers/gemini.svg new file mode 100644 index 0000000..f8d7189 --- /dev/null +++ b/app/renderer/assets/providers/gemini.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/renderer/assets/providers/goose.png b/app/renderer/assets/providers/goose.png new file mode 100644 index 0000000..757649e Binary files /dev/null and b/app/renderer/assets/providers/goose.png differ diff --git a/app/renderer/assets/providers/grok-dark.svg b/app/renderer/assets/providers/grok-dark.svg new file mode 100644 index 0000000..7057a3b --- /dev/null +++ b/app/renderer/assets/providers/grok-dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/renderer/assets/providers/grok-light.svg b/app/renderer/assets/providers/grok-light.svg new file mode 100644 index 0000000..642d7b6 --- /dev/null +++ b/app/renderer/assets/providers/grok-light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/renderer/assets/providers/openai-dark.svg b/app/renderer/assets/providers/openai-dark.svg new file mode 100644 index 0000000..b6d542d --- /dev/null +++ b/app/renderer/assets/providers/openai-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/renderer/assets/providers/openai-light.svg b/app/renderer/assets/providers/openai-light.svg new file mode 100644 index 0000000..2ebab67 --- /dev/null +++ b/app/renderer/assets/providers/openai-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/renderer/assets/providers/opencode-dark.svg b/app/renderer/assets/providers/opencode-dark.svg new file mode 100644 index 0000000..157edc4 --- /dev/null +++ b/app/renderer/assets/providers/opencode-dark.svg @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/app/renderer/assets/providers/opencode-light.svg b/app/renderer/assets/providers/opencode-light.svg new file mode 100644 index 0000000..542a918 --- /dev/null +++ b/app/renderer/assets/providers/opencode-light.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/renderer/assets/providers/pi.png b/app/renderer/assets/providers/pi.png new file mode 100644 index 0000000..17bc550 Binary files /dev/null and b/app/renderer/assets/providers/pi.png differ diff --git a/app/renderer/assets/providers/qwen-dark.svg b/app/renderer/assets/providers/qwen-dark.svg new file mode 100644 index 0000000..3a2f756 --- /dev/null +++ b/app/renderer/assets/providers/qwen-dark.svg @@ -0,0 +1 @@ +Qwen \ No newline at end of file diff --git a/app/renderer/assets/providers/qwen-light.svg b/app/renderer/assets/providers/qwen-light.svg new file mode 100644 index 0000000..a4bb382 --- /dev/null +++ b/app/renderer/assets/providers/qwen-light.svg @@ -0,0 +1 @@ +Qwen \ No newline at end of file diff --git a/app/renderer/assets/providers/warp.jpg b/app/renderer/assets/providers/warp.jpg new file mode 100644 index 0000000..f8575e7 Binary files /dev/null and b/app/renderer/assets/providers/warp.jpg differ diff --git a/app/renderer/assets/splash-loader.webm b/app/renderer/assets/splash-loader.webm new file mode 100644 index 0000000..b312910 Binary files /dev/null and b/app/renderer/assets/splash-loader.webm differ diff --git a/app/renderer/components/AboutModal.tsx b/app/renderer/components/AboutModal.tsx new file mode 100644 index 0000000..5e374e9 --- /dev/null +++ b/app/renderer/components/AboutModal.tsx @@ -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, 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 ( +
+
event.stopPropagation()} + > + +
+
+ +
CodeBurn
+
v{version}
+
Know where every token goes, across every AI coding tool.
+
+
+ +
+
Updates
+ +
+
+
+
Developed by Resham Joshi · github.com/iamtoruk
+
+
+ ) +} diff --git a/app/renderer/components/ActivityHeatmap.tsx b/app/renderer/components/ActivityHeatmap.tsx new file mode 100644 index 0000000..e4b2b5f --- /dev/null +++ b/app/renderer/components/ActivityHeatmap.tsx @@ -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(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 = ( +
+ {bare ? Daily activity :

Daily activity

} + {activeDays} active days +
+ ) + const grid = ( +
+
+ +
+ {days.map(day => ( +
+
+
+ ) + const tooltip = tip + ? createPortal( +
+
{formatDate(tip.day.date)}
+
{tip.day.isFuture ? 'Future day' : formatUsd(tip.day.cost)}
+
{tip.day.isFuture ? 'No activity yet' : `${tip.day.calls} calls`}
+
, + document.body, + ) + : null + + if (bare) { + return
{head}{grid}{tooltip}
+ } + return ( +
+ {head} +
{grid}
+ {tooltip} +
+ ) +} diff --git a/app/renderer/components/CliErrorPanel.tsx b/app/renderer/components/CliErrorPanel.tsx new file mode 100644 index 0000000..5260a31 --- /dev/null +++ b/app/renderer/components/CliErrorPanel.tsx @@ -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

{display.message}

+} + +export function CliErrorPanel({ error, subject = 'usage' }: { error: CliError; subject?: string }) { + const display = cliErrorDisplay(error) + if (error.kind === 'not-found') { + return ( + +

+ CodeBurn Desktop reads {subject} by running the{' '} + codeburn command, but it isn't + on your PATH yet. +

+

+ Install it with npm i -g codeburn, + then reopen this window. +

+
+ ) + } + return ( + + + + ) +} diff --git a/app/renderer/components/ConnectAffordance.tsx b/app/renderer/components/ConnectAffordance.tsx new file mode 100644 index 0000000..c58bb71 --- /dev/null +++ b/app/renderer/components/ConnectAffordance.tsx @@ -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 = { + 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 ( +
+ {message} + + {open && ( +
+

Sign in from a terminal, then Refresh:

+

{login.command}{login.hint ? {login.hint} : null}

+ {connection === 'accessDenied' &&

Already logged in? Click Allow when macOS asks for keychain access.

} + +
+ )} +
+ ) +} diff --git a/app/renderer/components/Dropdown.tsx b/app/renderer/components/Dropdown.tsx new file mode 100644 index 0000000..8712a10 --- /dev/null +++ b/app/renderer/components/Dropdown.tsx @@ -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(null) + const triggerRef = useRef(null) + const optionRefs = useRef>([]) + 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 ( +
+ + {open && ( +
+ {options.map((option, index) => ( + + ))} + {footer &&
{footer}
} +
+ )} +
+ ) +} diff --git a/app/renderer/components/EmptyState.tsx b/app/renderer/components/EmptyState.tsx new file mode 100644 index 0000000..3e3e5ea --- /dev/null +++ b/app/renderer/components/EmptyState.tsx @@ -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

{children}

+} diff --git a/app/renderer/components/ErrorBoundary.test.tsx b/app/renderer/components/ErrorBoundary.test.tsx new file mode 100644 index 0000000..a2fbb13 --- /dev/null +++ b/app/renderer/components/ErrorBoundary.test.tsx @@ -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(

all good

) + expect(screen.getByText('all good')).toBeTruthy() + }) + + it('shows the error message instead of white-screening when a child throws', () => { + render() + expect(screen.getByText('This screen hit an error')).toBeTruthy() + expect(screen.getByText('kaboom detail')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Reload' })).toBeTruthy() + }) +}) diff --git a/app/renderer/components/ErrorBoundary.tsx b/app/renderer/components/ErrorBoundary.tsx new file mode 100644 index 0000000..7531b05 --- /dev/null +++ b/app/renderer/components/ErrorBoundary.tsx @@ -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 { + state: State = { error: null, stack: null } + + static getDerivedStateFromError(error: Error): Partial { + 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 ( +
+
+

This screen hit an error

+

{error.message || String(error)}

+ {stack &&
{stack.trim()}
} + +
+
+ ) + } +} diff --git a/app/renderer/components/FlameMark.tsx b/app/renderer/components/FlameMark.tsx new file mode 100644 index 0000000..82f7a7b --- /dev/null +++ b/app/renderer/components/FlameMark.tsx @@ -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 ( + + ) +} diff --git a/app/renderer/components/Hint.tsx b/app/renderer/components/Hint.tsx new file mode 100644 index 0000000..28bc049 --- /dev/null +++ b/app/renderer/components/Hint.tsx @@ -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 ( +
+ {items.map((item, i) => ( + + {item.k && {item.k}} + {item.label} + + ))} + {right !== undefined && {right}} +
+ ) +} diff --git a/app/renderer/components/ListRow.tsx b/app/renderer/components/ListRow.tsx new file mode 100644 index 0000000..43fed62 --- /dev/null +++ b/app/renderer/components/ListRow.tsx @@ -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) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onClick() + } + } + : undefined + return ( +
+ {no !== undefined && {no}} + {dotColor !== undefined && } +
+ {title} + {sub !== undefined && {sub}} +
+ {value !== undefined && {value}} + {interactive && } +
+ ) +} diff --git a/app/renderer/components/Panel.tsx b/app/renderer/components/Panel.tsx new file mode 100644 index 0000000..90cb1ab --- /dev/null +++ b/app/renderer/components/Panel.tsx @@ -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 ( +
+ {title !== undefined && ( +
+ {title} + {right !== undefined && {right}} +
+ )} +
+ {children} +
+
+ ) +} diff --git a/app/renderer/components/ProviderLogo.tsx b/app/renderer/components/ProviderLogo.tsx new file mode 100644 index 0000000..6c8cb0e --- /dev/null +++ b/app/renderer/components/ProviderLogo.tsx @@ -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 = { + claude, + 'cursor-agent': cursorAgent, + gemini, + goose, + pi, + warp, +} + +const THEMED_LOGOS: Record = { + 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 + } + + const logos = THEMED_LOGOS[provider] + if (!logos) return null + + return <> + + + +} diff --git a/app/renderer/components/ProviderPop.tsx b/app/renderer/components/ProviderPop.tsx new file mode 100644 index 0000000..9123296 --- /dev/null +++ b/app/renderer/components/ProviderPop.tsx @@ -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 ( + } + /> + ) +} diff --git a/app/renderer/components/RangeCalendar.tsx b/app/renderer/components/RangeCalendar.tsx new file mode 100644 index 0000000..2fa21f8 --- /dev/null +++ b/app/renderer/components/RangeCalendar.tsx @@ -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(value) + const dragAnchor = useRef(null) + const clickAnchor = useRef(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 ( +
+
+ + {month.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })} + +
+
+ {WEEKDAYS.map(day => {day})} + {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 ( + + ) + })} +
+
+ ) +} diff --git a/app/renderer/components/Sankey.tsx b/app/renderer/components/Sankey.tsx new file mode 100644 index 0000000..1dd7b4d --- /dev/null +++ b/app/renderer/components/Sankey.tsx @@ -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() + const targetOffset = new Map() + + 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 [ + , + ] + }) + + return ( + + + {models.map(model => ( + + + + + ))} + + + {ribbons} + + {models.map(node => ( + + ))} + {projects.map(node => ( + + ))} + + {models.map(node => ( + + {node.displayLabel} · {formatUsd(node.cost)} + + ))} + {projects.map(node => ( + + {node.displayLabel} · {formatUsd(node.cost)} + + ))} + + ) +} + +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 +} diff --git a/app/renderer/components/SegTabs.tsx b/app/renderer/components/SegTabs.tsx new file mode 100644 index 0000000..ed0c1ce --- /dev/null +++ b/app/renderer/components/SegTabs.tsx @@ -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 ( +
+ {options.map(opt => ( + onChange(opt.value)} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onChange(opt.value) + } + }} + > + {opt.label} + + ))} +
+ ) +} diff --git a/app/renderer/components/Sidebar.test.tsx b/app/renderer/components/Sidebar.test.tsx new file mode 100644 index 0000000..853c239 --- /dev/null +++ b/app/renderer/components/Sidebar.test.tsx @@ -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( {}} />) + 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() + fireEvent.click(screen.getByRole('button', { name: /Spend/ })) + expect(onNavigate).toHaveBeenCalledWith('spend') + }) + + it('marks the active item with the "on" class', () => { + render( {}} />) + 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( {}} />) + 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() + }) +}) diff --git a/app/renderer/components/Sidebar.tsx b/app/renderer/components/Sidebar.tsx new file mode 100644 index 0000000..8c493a5 --- /dev/null +++ b/app/renderer/components/Sidebar.tsx @@ -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: ( + + ) }, + { id: 'sessions', label: 'Sessions', key: '⌘2', icon: ( + + ) }, + { id: 'spend', label: 'Spend', key: '⌘3', icon: ( + + ) }, + { id: 'optimize', label: 'Optimize', key: '⌘4', icon: ( + + ) }, + { id: 'models', label: 'Models', key: '⌘5', icon: ( + + ) }, + { id: 'compare', label: 'Compare', key: '⌘6', icon: ( + + ) }, + { id: 'plans', label: 'Plans', key: '⌘7', icon: ( + + ) }, + { id: 'settings', label: 'Settings', key: '⌘,', icon: ( + + ) }, +] + +const SOCIALS: SocialLink[] = [ + { label: 'GitHub', url: 'https://github.com/getagentseal/codeburn', icon: }, + { label: 'Discord', url: 'https://discord.com/invite/w2sw8mCqep', icon: }, + { label: 'X', url: 'https://x.com/_codeburn', icon: }, + { label: 'YouTube', url: 'https://www.youtube.com/@codeburnn', icon: }, + { label: 'LinkedIn', url: 'https://www.linkedin.com/showcase/codeburnn/', icon: }, +] + +export function Sidebar({ + active, + onNavigate, +}: { + active: Section + onNavigate: (section: Section) => void + status?: ReactNode +}) { + const [aboutOpen, setAboutOpen] = useState(false) + + return ( + <> + + {aboutOpen ? setAboutOpen(false)} /> : null} + + ) +} diff --git a/app/renderer/components/Skeleton.test.tsx b/app/renderer/components/Skeleton.test.tsx new file mode 100644 index 0000000..5be0532 --- /dev/null +++ b/app/renderer/components/Skeleton.test.tsx @@ -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() + + 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') + }) +}) diff --git a/app/renderer/components/Skeleton.tsx b/app/renderer/components/Skeleton.tsx new file mode 100644 index 0000000..7adcb4a --- /dev/null +++ b/app/renderer/components/Skeleton.tsx @@ -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 ( +
+ {label} + + +
+ ) +} diff --git a/app/renderer/components/Splash.test.tsx b/app/renderer/components/Splash.test.tsx new file mode 100644 index 0000000..95207d0 --- /dev/null +++ b/app/renderer/components/Splash.test.tsx @@ -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() + 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() + expect(splashEl()).toBeInTheDocument() + + // First data lands immediately (warm cache): the floor must keep it up. + rerender() + 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() + expect(splashEl()).toBeInTheDocument() + + rerender() + expect(splashEl()).not.toBeInTheDocument() + }) + + it('never reappears on a later loading state after it has dismissed', () => { + vi.useFakeTimers() + const { rerender } = render() + rerender() + 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() + expect(splashEl()).not.toBeInTheDocument() + rerender() + expect(splashEl()).not.toBeInTheDocument() + }) + + it('swaps instantly under reduced motion (no fade, no min-time)', () => { + mockReducedMotion(true) + const { rerender } = render() + 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() + expect(splashEl()).not.toBeInTheDocument() + }) +}) diff --git a/app/renderer/components/Splash.tsx b/app/renderer/components/Splash.tsx new file mode 100644 index 0000000..fb82333 --- /dev/null +++ b/app/renderer/components/Splash.tsx @@ -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('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( + , + document.body, + ) +} diff --git a/app/renderer/components/StackedBars.test.tsx b/app/renderer/components/StackedBars.test.tsx new file mode 100644 index 0000000..c0c2ce3 --- /dev/null +++ b/app/renderer/components/StackedBars.test.tsx @@ -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() + + 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() + + 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') + }) +}) diff --git a/app/renderer/components/StackedBars.tsx b/app/renderer/components/StackedBars.tsx new file mode 100644 index 0000000..f484c43 --- /dev/null +++ b/app/renderer/components/StackedBars.tsx @@ -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(null) + useBarGrowIn(barsRef, '.c', [animateKey]) + const presentSeries = new Set() + 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 ( +
+
+ {daily.map(day => ( +
+ {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 ( + + ) + }) + ) : day.cost > 0 ? ( + + ) : null} +
+ ))} +
+
+ {ticks.map(day => { + const index = daily.indexOf(day) + return ( + 1 ? index / (daily.length - 1) * 100 : 0}%` }}> + {formatChartDate(day.date)} + + ) + })} +
+
+ {legendSeries.map(series => ( + + + {SERIES_LABELS[series]} + + ))} + {usesFallback && !presentSeries.has('other') && ( + + + {fallbackLabel} + + )} +
+
+ ) +} diff --git a/app/renderer/components/StaleBanner.test.tsx b/app/renderer/components/StaleBanner.test.tsx new file mode 100644 index 0000000..098f4f1 --- /dev/null +++ b/app/renderer/components/StaleBanner.test.tsx @@ -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() + + const banner = screen.getByRole('status') + expect(banner).toHaveTextContent('Refresh failed, showing last good data · codeburn exited 1') + }) +}) diff --git a/app/renderer/components/StaleBanner.tsx b/app/renderer/components/StaleBanner.tsx new file mode 100644 index 0000000..65ad390 --- /dev/null +++ b/app/renderer/components/StaleBanner.tsx @@ -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 ( +
+ Refresh failed, showing last good data · {error.message} +
+ ) +} diff --git a/app/renderer/components/Stat.tsx b/app/renderer/components/Stat.tsx new file mode 100644 index 0000000..f3677a2 --- /dev/null +++ b/app/renderer/components/Stat.tsx @@ -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 ( +
+
{label}
+
+
{value}
+ {delta !== undefined &&
{delta}
} +
+
+ ) +} diff --git a/app/renderer/components/ToastHost.test.tsx b/app/renderer/components/ToastHost.test.tsx new file mode 100644 index 0000000..c81de91 --- /dev/null +++ b/app/renderer/components/ToastHost.test.tsx @@ -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() + + 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() + + 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') + }) +}) diff --git a/app/renderer/components/ToastHost.tsx b/app/renderer/components/ToastHost.tsx new file mode 100644 index 0000000..7948f3d --- /dev/null +++ b/app/renderer/components/ToastHost.tsx @@ -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( +
+
+ {toast.text} +
+
, + document.body, + ) +} diff --git a/app/renderer/components/TopBar.tsx b/app/renderer/components/TopBar.tsx new file mode 100644 index 0000000..4c789b2 --- /dev/null +++ b/app/renderer/components/TopBar.tsx @@ -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 ( +
+
{title}
+ {scope !== undefined && {scope}} +
+ + + + {claudeConfigs && } +
+ ) +} + +/** 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 ( + + ) +} + +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(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 ( +
+ + {open && ( +
+ { + onSelect(range) + setOpen(false) + }} + /> +
+ )} +
+ ) +} diff --git a/app/renderer/components/Window.tsx b/app/renderer/components/Window.tsx new file mode 100644 index 0000000..65373c3 --- /dev/null +++ b/app/renderer/components/Window.tsx @@ -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
{children}
+} diff --git a/app/renderer/hooks/usePolled.test.ts b/app/renderer/hooks/usePolled.test.ts new file mode 100644 index 0000000..3e89de5 --- /dev/null +++ b/app/renderer/hooks/usePolled.test.ts @@ -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(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((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' }) + }) +}) diff --git a/app/renderer/hooks/usePolled.ts b/app/renderer/hooks/usePolled.ts new file mode 100644 index 0000000..a29a85c --- /dev/null +++ b/app/renderer/hooks/usePolled.ts @@ -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 = { + 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(fetcher: () => Promise, deps: unknown[], intervalMs = 30_000): Polled { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(true) + const [lastSuccessAt, setLastSuccessAt] = useState(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 } +} diff --git a/app/renderer/index.html b/app/renderer/index.html new file mode 100644 index 0000000..3ada1c4 --- /dev/null +++ b/app/renderer/index.html @@ -0,0 +1,34 @@ + + + + + + + CodeBurn + + + +
+ + + diff --git a/app/renderer/lib/budget.ts b/app/renderer/lib/budget.ts new file mode 100644 index 0000000..1719249 --- /dev/null +++ b/app/renderer/lib/budget.ts @@ -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 + 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 +} diff --git a/app/renderer/lib/format.test.ts b/app/renderer/lib/format.test.ts new file mode 100644 index 0000000..f2fff1f --- /dev/null +++ b/app/renderer/lib/format.test.ts @@ -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('—') + }) +}) diff --git a/app/renderer/lib/format.ts b/app/renderer/lib/format.ts new file mode 100644 index 0000000..1fa78a6 --- /dev/null +++ b/app/renderer/lib/format.ts @@ -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` +} diff --git a/app/renderer/lib/ipc.ts b/app/renderer/lib/ipc.ts new file mode 100644 index 0000000..88dd35d --- /dev/null +++ b/app/renderer/lib/ipc.ts @@ -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 } +} diff --git a/app/renderer/lib/modelSeries.ts b/app/renderer/lib/modelSeries.ts new file mode 100644 index 0000000..2d00755 --- /dev/null +++ b/app/renderer/lib/modelSeries.ts @@ -0,0 +1,55 @@ +export type SeriesKey = 'opus' | 'fable' | 'sonnet' | 'haiku' | 'gpt' | 'other' + +export const SERIES_LABELS: Record = { + opus: 'Opus', + fable: 'Fable', + sonnet: 'Sonnet', + haiku: 'Haiku', + gpt: 'GPT / Codex', + other: 'Other', +} + +const SERIES_CSS_VAR: Record = { + 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 = { + 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' +} diff --git a/app/renderer/lib/motion.test.tsx b/app/renderer/lib/motion.test.tsx new file mode 100644 index 0000000..66b4936 --- /dev/null +++ b/app/renderer/lib/motion.test.tsx @@ -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() + expect(spy).not.toHaveBeenCalled() + // The bars still render at their natural, un-transformed size. + expect(container.querySelectorAll('.sbars .c')).toHaveLength(2) + }) +}) diff --git a/app/renderer/lib/motion.ts b/app/renderer/lib/motion.ts new file mode 100644 index 0000000..83cbe00 --- /dev/null +++ b/app/renderer/lib/motion.ts @@ -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, selector: string, deps: unknown[]): void { + useGSAP(() => { + if (!motionEnabled()) return + const bars = gsap.utils.toArray(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 }) +} diff --git a/app/renderer/lib/period.test.ts b/app/renderer/lib/period.test.ts new file mode 100644 index 0000000..6a32ff2 --- /dev/null +++ b/app/renderer/lib/period.test.ts @@ -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') + }) +}) diff --git a/app/renderer/lib/period.ts b/app/renderer/lib/period.ts new file mode 100644 index 0000000..c024f25 --- /dev/null +++ b/app/renderer/lib/period.ts @@ -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' }) +} diff --git a/app/renderer/lib/testMatchMedia.ts b/app/renderer/lib/testMatchMedia.ts new file mode 100644 index 0000000..f51fab3 --- /dev/null +++ b/app/renderer/lib/testMatchMedia.ts @@ -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 +} diff --git a/app/renderer/lib/toast.ts b/app/renderer/lib/toast.ts new file mode 100644 index 0000000..00998a4 --- /dev/null +++ b/app/renderer/lib/toast.ts @@ -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 | 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 +} diff --git a/app/renderer/lib/types.ts b/app/renderer/lib/types.ts new file mode 100644 index 0000000..03508bf --- /dev/null +++ b/app/renderer/lib/types.ts @@ -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 + // 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> +} + +// ————— 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 + getOverview(period: Period, provider: string, range?: DateRange, configSource?: string | null): Promise + getPlans(period: Period): Promise + getActReport(): Promise + readonly platform: string + getModels(period: Period, provider: string, byTask: boolean, range?: DateRange): Promise + getSessions(period: Period, provider: string, range?: DateRange): Promise + getCompareModels(period: Period, provider: string): Promise + getCompare(period: Period, provider: string, modelA: string, modelB: string): Promise + getYield(period: Period, provider: string, range?: DateRange): Promise + getSpendFlow(period: Period, provider: string, range?: DateRange): Promise + getOptimizeReport(period: Period, provider: string, range?: DateRange): Promise + getDevices(period: Period): Promise + getDevicesScan(): Promise + getShareStatus(): Promise + getIdentity(): Promise + getAliases(): Promise + getProxyPaths(): Promise + getAudit(period: Period, provider: string, range?: DateRange): Promise + getPriceOverrides(): Promise + setPriceOverride(model: string, rates: PriceRates): Promise + removePriceOverride(model: string): Promise + setCurrency(code: string): Promise + resetCurrency(): Promise + addAlias(from: string, to: string): Promise + removeAlias(from: string): Promise + removeDevice(name: string): Promise + setPlan(id: string, provider: string): Promise + resetPlan(provider: string): Promise + exportData(format: string, provider: string, outPath: string): Promise + chooseDirectory(): Promise + cliStatus(): Promise<{ found: boolean; path: string | null; error?: string }> + openExternal(url: string): Promise +} diff --git a/app/renderer/main.tsx b/app/renderer/main.tsx new file mode 100644 index 0000000..9bff477 --- /dev/null +++ b/app/renderer/main.tsx @@ -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( + + + , +) diff --git a/app/renderer/sections/Compare.test.tsx b/app/renderer/sections/Compare.test.tsx new file mode 100644 index 0000000..c68c84a --- /dev/null +++ b/app/renderer/sections/Compare.test.tsx @@ -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>(), + getCompare: vi.fn<(period: string, provider: string, modelA: string, modelB: string) => Promise>(), +})) +vi.mock('../lib/ipc', async orig => { + const actual = await orig() + 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() + + 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('.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() + + const context = (await screen.findByText('Context')).closest('.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() + + 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() + + expect(await screen.findByText('Need at least two models with usage in this range to compare.')).toBeInTheDocument() + expect(mocks.getCompare).not.toHaveBeenCalled() + }) +}) diff --git a/app/renderer/sections/Compare.tsx b/app/renderer/sections/Compare.tsx new file mode 100644 index 0000000..fa0b5b0 --- /dev/null +++ b/app/renderer/sections/Compare.tsx @@ -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 ( +

+ Compare uses the selected period, custom dates are not supported yet. +

+ ) +} + +export function Compare({ + period, + provider, + range = null, + refreshToken = 0, +}: { + period: Period + provider: string + range?: DateRange | null + refreshToken?: number +}) { + const models = usePolled( + () => codeburn.getCompareModels(period, provider), + [period, provider, refreshToken], + ) + const [modelA, setModelA] = useState(null) + const [modelB, setModelB] = useState(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 + return + } + + if (models.data.length < 2) { + return ( + + Need at least two models with usage in this range to compare. + + ) + } + + const modelRows = models.data + const nudgeDistinct = (chosen: string) => modelRows.find(model => model.model !== chosen)?.model ?? null + + return ( + <> + {range && } +
+ ({ value: model.model, label: `${model.model} · ${model.calls.toLocaleString()} calls` }))} + onChange={next => { + setModelA(next) + if (next === modelB) setModelB(nudgeDistinct(next)) + }} + /> + vs + ({ value: model.model, label: `${model.model} · ${model.calls.toLocaleString()} calls` }))} + onChange={next => { + setModelB(next) + if (next === modelA) setModelA(nudgeDistinct(next)) + }} + /> +
+ {modelA && modelB && modelA !== modelB && ( + + )} + + ) +} + +function CompareReport({ + period, + provider, + modelA, + modelB, + refreshToken, + onError, +}: { + period: Period + provider: string + modelA: string + modelB: string + refreshToken: number + onError: () => void +}) { + const report = usePolled( + () => 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 + return + } + + const performance = report.data.metrics.filter(metric => metric.section === 'Performance') + const efficiency = report.data.metrics.filter(metric => metric.section === 'Efficiency') + + return ( +
+
+ + +
+ +
+ + +
+
+ ) +} + +function MetricCard({ + title, + rows, + modelA, + modelB, + showWinners = false, +}: { + title: string + rows: Array + modelA: string + modelB: string + showWinners?: boolean +}) { + return ( +
+

{title}

+
+ + {rows.map(row => { + const winner = 'winner' in row ? row.winner : 'none' + return ( +
+ {row.label} + {fmtMetric(row.valueA, row.formatFn)} + {fmtMetric(row.valueB, row.formatFn)} +
+ ) + })} +
+ {showWinners &&
Green = better on that metric.
} +
+ ) +} + +function MetricHeader({ modelA, modelB }: { modelA: string; modelB: string }) { + return
Metric{modelA}{modelB}
+} + +function CategoryCard({ report }: { report: CompareJsonReport }) { + return ( +
+

Category head-to-head

One-shot rate · edit turns
+
+
+ {report.modelA.model} + {report.modelB.model} +
+
+ {report.categories.map(category => ( +
+ {category.category} +
+
+ + {fmtMetric(category.oneShotRateA, 'percent')} ({category.editTurnsA}) +
+
+ + {fmtMetric(category.oneShotRateB, 'percent')} ({category.editTurnsB}) +
+
+
+ ))} +
+
+
+ ) +} + +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 ( +
+

Context

+
+ + {rows.map(([label, valueA, valueB]) => ( +
+ {label}{valueA}{valueB} +
+ ))} +
+
+ ) +} diff --git a/app/renderer/sections/Models.test.tsx b/app/renderer/sections/Models.test.tsx new file mode 100644 index 0000000..799807f --- /dev/null +++ b/app/renderer/sections/Models.test.tsx @@ -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>(), + getAudit: vi.fn<(period: string, provider: string) => Promise>(), +})) +vi.mock('../lib/ipc', async orig => { + const actual = await orig() + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + }) +}) diff --git a/app/renderer/sections/Models.tsx b/app/renderer/sections/Models.tsx new file mode 100644 index 0000000..f2483a7 --- /dev/null +++ b/app/renderer/sections/Models.tsx @@ -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('model') + const onAddAlias = () => onNavigate?.('settings', 'aliases') + + return ( + <> +
+ setLens(value as ModelsLens)} /> + {lens !== 'audit' && ( + + )} +
+ {lens === 'audit' ? ( + + ) : ( + + )} + + ) +} + +function ModelsUsage({ + period, + provider, + range, + byTask, + refreshToken, + onAddAlias, +}: { + period: Period + provider: string + range: DateRange | null + byTask: boolean + refreshToken: number + onAddAlias: () => void +}) { + const report = usePolled( + () => 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 + return + } + + return ( + <> + {report.error && } + + {report.data.length ? ( + + ) : ( + No model usage in this range yet. + )} + + + ) +} + +// 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( + () => range ? codeburn.getAudit(period, provider, range) : codeburn.getAudit(period, provider), + [period, provider, range?.from, range?.to, refreshToken], + ) + + if (!report.data) { + if (report.error) return + return + } + + return ( + <> + {report.error && } + + {report.data.length ? ( + + ) : ( + No model usage to audit in this range yet. + )} + + + ) +} + +function AuditTable({ rows }: { rows: AuditRow[] }) { + return ( + + + + + + + + + + + + + + + + {rows.map((row, i) => ( + + ))} + +
ModelCallsInputOutputReasoningNorm outCache wrCache rdCost
+ ) +} + +function AuditTableRow({ row }: { row: AuditRow }) { + const estimated = auditEstimated(row) + return ( + + + + {row.modelDisplayName} + + {fmtInt(row.calls)} + {formatCompact(row.raw.inputTokens)} + {formatCompact(row.raw.outputTokens)} + {formatCompact(row.raw.reasoningTokens)} + {formatCompact(row.displayed.outputTokens)} + {formatCompact(row.displayed.cacheWriteTokens)} + {formatCompact(row.displayed.cacheReadTokens)} + + {formatUsd(row.attributedCostUSD)} + {estimated ? est : null} + + + ) +} + +function ModelsTable({ rows, byTask, onAddAlias }: { rows: ModelReportRow[]; byTask: boolean; onAddAlias: () => void }) { + if (byTask) return + + return ( + + + + + + + + + + + + + + {rows.map((row, i) => ( + + ))} + +
ModelCallsInputOutputCache readCostSaved
+ ) +} + +function ModelsByTaskTable({ rows, onAddAlias }: { rows: ModelReportRow[]; onAddAlias: () => void }) { + const groups = groupTaskRows(rows) + + return ( + + + + + + + + + + + + + {groups.map(group => ( + + + {group.rows.map((row, i) => ( + + ))} + + ))} +
TaskCallsInputOutputCache readCostSaved
+ ) +} + +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 ( + + + + {row.modelDisplayName} + {unpriced ? ( + <> + {' '} + + + ) : null} + + {fmtInt(row.calls)} + {tokenValue(row.inputTokens)} + {tokenValue(row.outputTokens)} + {tokenValue(row.cacheReadTokens)} + {unpriced ? '—' : formatUsd(row.costUSD)} + 0 ? 'pos' : undefined}>{unpriced ? '—' : formatUsd(row.savingsUSD)} + + ) +} + +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 ( + + + + + {model.modelDisplayName} + {unpriced ? : null} + + + {fmtInt(calls)} + + + + {unpriced ? '—' : formatUsd(costUSD)} + 0 ? 'pos' : undefined}>{unpriced ? '—' : formatUsd(savingsUSD)} + + ) +} + +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 ( + + {row.category ?? 'general'} + {fmtInt(row.calls)} + {tokenValue(row.inputTokens)} + {tokenValue(row.outputTokens)} + {tokenValue(row.cacheReadTokens)} + {unpriced ? '—' : formatUsd(row.costUSD)} + 0 ? 'pos' : undefined}>{unpriced ? '—' : formatUsd(row.savingsUSD)} + + ) +} + +function groupTaskRows(rows: ModelReportRow[]) { + const groups = new Map() + 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()] +} diff --git a/app/renderer/sections/Optimize.test.tsx b/app/renderer/sections/Optimize.test.tsx new file mode 100644 index 0000000..494e858 --- /dev/null +++ b/app/renderer/sections/Optimize.test.tsx @@ -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() + 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>() + + 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() + + 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() + 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() + 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() + + 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() + 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() + + 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() + + 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() + 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(() => {})) + const overview = { data: makePayload(), error: null, loading: false, lastSuccessAt: Date.now(), refresh: vi.fn() } + const { rerender } = render() + + 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() + await waitFor(() => expect(getYield).toHaveBeenCalledTimes(2)) + expect(screen.getByRole('tab', { name: 'Reverts $107.00' })).toBeInTheDocument() + expect(screen.getByText('codeburn')).toBeInTheDocument() + }) +}) diff --git a/app/renderer/sections/Optimize.tsx b/app/renderer/sections/Optimize.tsx new file mode 100644 index 0000000..466b892 --- /dev/null +++ b/app/renderer/sections/Optimize.tsx @@ -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( + () => range ? codeburn.getOverview(period, provider, range) : codeburn.getOverview(period, provider), + [period, provider, range?.from, range?.to], + ) + return +} + +export function OptimizeContent({ + period, + provider = 'all', + range = null, + overview, + refreshToken = 0, +}: { + period: Period + provider?: string + range?: DateRange | null + overview: Polled + refreshToken?: number +}) { + const optimizeReport = usePolled( + () => range ? codeburn.getOptimizeReport(period, provider, range) : codeburn.getOptimizeReport(period, provider), + [period, provider, range?.from, range?.to, refreshToken], + ) + const yieldReport = usePolled( + () => range ? codeburn.getYield(period, provider, range) : codeburn.getYield(period, provider), + [period, provider, range?.from, range?.to, refreshToken], + ) + const [tab, setTab] = useState('waste') + + if (!overview.data) { + if (overview.error) return + return + } + + 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 && } + setTab(value as OptimizeTab)} + style={{ alignSelf: 'flex-start' }} + /> + + {tab === 'waste' ? ( + + ) : tab === 'reverts' ? ( + + ) : tab === 'abandoned' ? ( + + ) : ( + + )} + + + ) +} + +function WasteRows({ report }: { report: Polled }) { + if (!report.data) { + if (report.error) return + return Scanning optimize findings… + } + + return ( +
+
+ {report.data.summary.findingCount.toLocaleString('en-US')} findings · {formatUsd(report.data.summary.potentialSavingsCostUSD)} potential · health {report.data.summary.healthScore}/100 +
+ +
+ ) +} + +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(null) + const [copiedId, setCopiedId] = useState(null) + + if (!findings.length) return No waste findings in this range yet. + + 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 ( +
+ {findings.map(finding => { + const expanded = expandedId === finding.id + return ( + + + {expanded && ( +
+

{finding.explanation}

+
+
+
+ {finding.fix.label} + {finding.fix.type === 'file-content' && {finding.fix.path}} +
+ +
+
{actionText(finding.fix)}
+
+
+ )} +
+ ) + })} +
+ ) +} + +type Finding = MenubarPayload['optimize']['topFindings'][number] + +function FindingRows({ findings, empty }: { findings: Finding[]; empty: string }) { + if (!findings.length) return {empty} + + return ( +
+ {findings.map((finding, i) => ( +
+ {String(i + 1).padStart(2, '0')} + {finding.title} + + + {finding.impact.charAt(0).toUpperCase() + finding.impact.slice(1)} + + {formatUsd(finding.savingsUSD)} +
+ ))} +
+ ) +} + +function YieldRows({ + report, + category, + empty, +}: { + report: Polled + category: SessionYieldJson['category'] + empty: string +}) { + if (report.error || !report.data) return Yield data is unavailable right now. + + const rows = report.data.details.filter(row => row.category === category) + if (!rows.length) return {empty} + + return ( + <> + {rows.map((row, i) => ( +
+ {String(i + 1).padStart(2, '0')} +
+ {row.project} + + {row.commitCount.toLocaleString('en-US')} {row.commitCount === 1 ? 'commit' : 'commits'} · {row.sessionId} + +
+ {formatUsd(row.costUSD)} +
+ ))} + + ) +} + +function FixesRows({ data }: { data: MenubarPayload }) { + return +} diff --git a/app/renderer/sections/Overview.test.tsx b/app/renderer/sections/Overview.test.tsx new file mode 100644 index 0000000..3f4b222 --- /dev/null +++ b/app/renderer/sections/Overview.test.tsx @@ -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 { + 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>(), + getActReport: vi.fn<() => Promise>(), + getYield: vi.fn<(period: string, provider: string) => Promise>(), +})) +vi.mock('../lib/ipc', async orig => { + const actual = await orig() + 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 + 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() + + // 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() + + 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() + + 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() + + 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() + + // 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() + + // 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() + + 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() + + 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() + + 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() + // 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() + + 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() + + 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 = { + data: makePayload(now), + error: { kind: 'nonzero', message: 'codeburn exited 1' }, + loading: false, + lastSuccessAt: Date.now(), + refresh: vi.fn(), + } + + render() + + 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() + + 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() + + 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() + + // 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() + }) +}) diff --git a/app/renderer/sections/Overview.tsx b/app/renderer/sections/Overview.tsx new file mode 100644 index 0000000..a3aac58 --- /dev/null +++ b/app/renderer/sections/Overview.tsx @@ -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 ( +
+
+
Efficiency
{Math.round(score)} / 100
+
{grade}
+
+
+
+
One-shot{formatRate(current.oneShotRate)}
+
+
+
+
Cache hit{Math.round(current.cacheHitPercent)}%
+
+
+
+
Retry tax{formatUsd(current.retryTax.totalUSD)} · {(retrySpendFraction * 100).toFixed(1)}% of spend
+
+
+
+

Composite of one-shot, cache hit, and retry tax.{current.oneShotRate === null ? ' Partial grade: one-shot is unavailable.' : ''}

+
+ ) +} + +function CostPerOutcome({ outcome }: { outcome: Polled }) { + const report = outcome.data + let body: React.ReactNode + + if (!report) { + body = {outcome.error ? 'Yield data is unavailable for this period.' : 'Correlating sessions with git…'} + } else if (report.summary.total.sessions === 0 && report.details.length === 0) { + body = No git-correlated outcomes in this period. + } 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 = ( + <> +
+
$ / commit{costPerCommit === null ? '—' : formatUsd(costPerCommit)}
+
$ / productive session{costPerProductiveSession === null ? '—' : formatUsd(costPerProductiveSession)}
+
+
+ productive {Math.round(productive.costPercent)}% · reverted {Math.round(report.summary.reverted.costPercent)}% · abandoned {Math.round(report.summary.abandoned.costPercent)}% +
+ + ) + } + + return ( +
+

Cost per outcome

Yield
+
+ {body} +

Git-correlated. Reverted/abandoned = spend that didn't ship.

+
+
+ ) +} + +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: <>, + }, + { + key: 'improvements' as const, + label: 'Improvements', + icon: <>, + }, + { + key: 'risks' as const, + label: 'Risks', + icon: <>, + }, +] + +function SignalsCard({ signals }: { signals: SignalGroups }) { + const groups = SIGNAL_GROUPS.filter(group => signals[group.key].length > 0) + if (!groups.length) return null + return ( +
+ {groups.map(group => ( +
+
+ + {group.label} +
+
    + {signals[group.key].map((signal, index) => ( +
  • + {signal.text} + {signal.trailing && {signal.trailing}} +
  • + ))} +
+
+ ))} +
+ ) +} + +function RoutingWhatIf({ routing, onNavigate }: { + routing: MenubarPayload['current']['routingWaste'] + onNavigate?: (section: 'optimize') => void +}) { + if (routing.totalSavingsUSD <= 0 || !routing.baselineModel) return null + return ( +
+
Routing what-if

Routing to {routing.baselineModel} could save ~{formatUsd(routing.totalSavingsUSD)} this period.

+ +
+ ) +} + +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 { + const index = new Map() + 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(null) + const keyRef = useRef(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
{formatUsd(value)}
+} + +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() + 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 No model usage in this range yet. + + return ( +
+ + + + + + + + + + + + {models.map(model => ( + + + + + + + + ))} + +
ModelInput tokOutput tokCostCalls
{model.name}{model.inputTokens === undefined ? '—' : formatCompact(model.inputTokens)}{model.outputTokens === undefined ? '—' : formatCompact(model.outputTokens)}{formatUsd(model.cost)}{model.calls.toLocaleString('en-US')}
+
+ ) +} + +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(null) + const chartRef = useRef(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 ( + <> +
+ {daily.map((day, index) => ( +
+
+ {ticks.map(day => { + const index = daily.indexOf(day) + return 1 ? index / (daily.length - 1) * 100 : 0}%` }}>{formatChartDate(day.date)} + })} +
+
+
Avg/day{formatUsd(average)}
+
Peak{peak ? `${formatUsd(peak.cost)} · ${formatShortDay(peak.date)}` : '$0.00'}
+
Yesterday{formatUsd(yesterday?.cost ?? 0)}
+
+ {tip && createPortal( +
+
{formatChartDate(tip.day.date)}
+
{formatUsd(tip.day.cost)}
+
{tip.day.calls} calls · {tip.day.topModels[0]?.name ?? 'No model'} led
+
, + 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 No activity in this range yet. + const maxCost = rows[0].cost + + return ( +
+ {rows.map(activity => ( +
+ +
+ {activity.name} + {formatUsd(activity.cost)} +
+
+ {activity.turns.toLocaleString('en-US')} turns + {formatRate(activity.oneShotRate)} one-shot +
+
+ ))} +
+ ) +} + +export function Overview({ period, provider }: { period: Period; provider: string }) { + const overview = usePolled(() => codeburn.getOverview(period, provider), [period, provider]) + return +} + +export function OverviewContent({ + period, + provider = 'all', + range = null, + overview, + onNavigate, +}: { + period: Period + provider?: string + range?: DateRange | null + overview: Polled + onNavigate?: (section: 'optimize' | 'sessions') => void +}) { + const actReport = usePolled(() => codeburn.getActReport(), []) + const yieldReport = usePolled(() => codeburn.getYield(period, provider), [period, provider]) + const { data, error } = overview + const modelIndex = useMemo(() => data ? buildModelIndex(data) : new Map(), [data]) + + if (!data) { + if (error) return + return + } + + 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 ( +
+ {error && } +
+
+
{data.current.label}{streakDays(data.history.daily, now)}-day streak
+ +
{data.current.calls.toLocaleString('en-US')} calls · {data.current.sessions.toLocaleString('en-US')} sessions
+
Saved by applied fixes{formatUsd(saved)}across {applied} {applied === 1 ? 'fix' : 'fixes'}
+ {localSaved > 0 && ( +
Saved via local models{formatUsd(localSaved)}local-model routing
+ )} +
+ + +
+ + {!rangeActive && ( +
+
Month to date
{formatUsd(stats.mtd)}
{stats.pacePct === null ? `No ${stats.prevMonthName} pace yet` : `${stats.pacePct >= 0 ? '+' : ''}${Math.round(stats.pacePct)}% vs ${stats.prevMonthName} pace`}
+
Projected month
{formatUsd(stats.projected)} est
{formatUsd(Math.max(0, stats.projected - stats.mtd))} to go
+
+ )} + +
+

Daily spend

{topModel ? `Biggest driver: ${topModel.name}` : 'No model driver yet'}
+
{data.history.daily.length ? : No spend yet.}
+
+ +
+
+ +
+ {rangeActive + ? <>{topModel ? <>{topModel.name} is the biggest driver in this range : 'No single model dominates this range'}. {formatUsd(data.optimize.savingsUSD)} is recoverable. + : <>{weeklyPct === null ? <>No prior-week pacing baseline yet : <>You're pacing {weeklyPct}% {weeklyDirection} than last week}{topModel ? <>; {topModel.name} is the biggest driver : ''}. {formatUsd(data.optimize.savingsUSD)} is recoverable.} +
+ +
+
+ + + +
+ + +
+ +
+
+
+

Models this period

Sorted by cost
+
+
+ +
+

Most expensive sessions

+
+ {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 onNavigate?.('sessions')} /> + }) : No sessions in this range.} +
+
+
+ +
+
+

Top activities

Sorted by cost
+
+
+
+
+
+ ) +} diff --git a/app/renderer/sections/Plans.test.tsx b/app/renderer/sections/Plans.test.tsx new file mode 100644 index 0000000..a5bb9d3 --- /dev/null +++ b/app/renderer/sections/Plans.test.tsx @@ -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>(), + getQuota: vi.fn<(force?: boolean) => Promise>(), +})) +vi.mock('../lib/ipc', async orig => { + const actual = await orig() + 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 + +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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + await screen.findByText('Max 20x') + expect(getQuota).toHaveBeenCalledWith(false) // mount is a steady poll + getQuota.mockClear() + + rerender() // manual refresh bumps the token + await waitFor(() => expect(getQuota).toHaveBeenCalledWith(true)) + + getQuota.mockClear() + rerender() // 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() + + 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() + + 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() + + expect(await screen.findByText('Keychain access needed: click Allow when macOS asks, then Refresh.')).toBeInTheDocument() + expect(screen.getByText('locked')).toBeInTheDocument() + }) +}) diff --git a/app/renderer/sections/Plans.tsx b/app/renderer/sections/Plans.tsx new file mode 100644 index 0000000..b6a2c5f --- /dev/null +++ b/app/renderer/sections/Plans.tsx @@ -0,0 +1,261 @@ +import { useRef, useState } from 'react' + +import { CliErrorPanel } from '../components/CliErrorPanel' +import { ConnectAffordance } from '../components/ConnectAffordance' +import { Panel } from '../components/Panel' +import { SectionSkeleton } from '../components/Skeleton' +import type { Section } from '../components/Sidebar' +import { StaleBanner } from '../components/StaleBanner' +import { usePolled } from '../hooks/usePolled' +import { formatConverted } from '../lib/format' +import { codeburn } from '../lib/ipc' +import { motionClass } from '../lib/motion' +import type { JsonPlanSummary, Period, PlanId, PlanProvider, QuotaProvider, QuotaWindow, StatusJson } from '../lib/types' +import type { SettingsPane } from './Settings' + +const PROVIDER_ORDER: PlanProvider[] = ['all', 'claude', 'codex', 'cursor', 'grok'] + +const PLAN_NAMES: Record = { + 'claude-pro': 'Claude Pro', + 'claude-max': 'Claude Max', + 'claude-max-5x': 'Claude Max 5x', + 'cursor-pro': 'Cursor Pro', + supergrok: 'SuperGrok', + 'supergrok-heavy': 'SuperGrok Heavy', + custom: 'Custom plan', + none: 'API usage', +} + +function fmtPct(n: number): string { + return Number.isInteger(n) ? `${n}%` : `${n.toFixed(1)}%` +} + +function cycleEndDate(plan: JsonPlanSummary): Date | null { + const date = new Date(plan.periodEnd) + if (Number.isNaN(date.getTime())) return null + date.setDate(date.getDate() - 1) + return date +} + +function formatShortDate(value: string | Date): string { + const date = value instanceof Date ? value : new Date(value) + if (Number.isNaN(date.getTime())) return 'unknown' + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + }).format(date) +} + +function planSummaries(status: StatusJson): JsonPlanSummary[] { + const plans = status.plans + if (plans) { + const ordered = PROVIDER_ORDER.flatMap(provider => { + const plan = plans[provider] + return plan ? [plan] : [] + }) + if (ordered.length > 0) return ordered + } + return status.plan ? [status.plan] : [] +} + +function manualPlanSummaries(status: StatusJson): JsonPlanSummary[] { + return planSummaries(status).filter(plan => plan.provider !== 'claude' && plan.provider !== 'codex') +} + +export function Plans({ period, refreshToken = 0, onNavigate }: { period: Period; refreshToken?: number; onNavigate?: (section: Section, pane?: SettingsPane) => void }) { + // Force a fresh fetch (bypassing QuotaService's 2-min cache, and its keychain + // guard) when the user hits ⌘R or clicks Refresh in the Connect affordance; + // the steady 30s poll keeps serving cached quota. + const [reconnectNonce, setReconnectNonce] = useState(0) + const lastForced = useRef(`${refreshToken}:${reconnectNonce}`) + const quota = usePolled(() => { + const key = `${refreshToken}:${reconnectNonce}` + const force = key !== lastForced.current + lastForced.current = key + return codeburn.getQuota(force) + }, [refreshToken, reconnectNonce]) + const reconnect = () => setReconnectNonce(value => value + 1) + const budgetReport = usePolled(() => codeburn.getPlans(period), [period, refreshToken]) + const manualPlans = budgetReport.data ? manualPlanSummaries(budgetReport.data) : [] + + return ( + <> +
+
Plans
+
+ +
+
+ {budgetReport.data && budgetReport.error && } + {renderQuota(quota.data, quota.error, reconnect)} + {renderBudgetPlans(budgetReport.data, budgetReport.error, manualPlans)} +
+ + ) +} + +function renderQuota(data: QuotaProvider[] | null, error: ReturnType>['error'], onReconnect: () => void) { + if (!data) { + if (error) { + return ( + +

Live quota is unavailable.

+
+ ) + } + return + } + + if (data.length === 0) { + return ( + +

No quota providers available.

+
+ ) + } + + return data.map(provider => ) +} + +function renderBudgetPlans(data: StatusJson | null, error: ReturnType>['error'], plans: JsonPlanSummary[]) { + if (!data && error) { + return ( +
+

Budget plans

+ +
+ ) + } + if (plans.length === 0) return null + + return ( +
+

Budget plans

+ {plans.map(plan => )} +
+ ) +} + +function QuotaPanel({ quota, onReconnect }: { quota: QuotaProvider; onReconnect: () => void }) { + const providerName = quota.provider === 'claude' ? 'Claude' : 'Codex' + return ( + {providerName}{quota.planLabel ? {quota.planLabel} : null}} + right={} + > + + + ) +} + +function ConnectionIndicator({ connection }: { connection: QuotaProvider['connection'] }) { + const label = connection === 'transientFailure' ? 'waiting' + : connection === 'terminalFailure' ? 'error' + : connection === 'accessDenied' ? 'locked' + : connection + return {label} +} + +function QuotaContent({ quota, onReconnect }: { quota: QuotaProvider; onReconnect: () => void }) { + if (quota.connection === 'disconnected' || quota.connection === 'accessDenied') { + return + } + if (quota.connection === 'loading') return

Loading quota…

+ if (quota.connection === 'stale' || quota.connection === 'transientFailure') { + return

waiting on the CLI…

+ } + if (quota.connection === 'terminalFailure') { + return

Quota is currently unavailable.

+ } + + return ( + <> +
+ {quota.details.map((window, index) => )} +
+ {quota.footerLines.length > 0 ? ( +
{quota.footerLines.map((line, index) => {line})}
+ ) : null} + + ) +} + +function QuotaMeter({ window }: { window: QuotaWindow }) { + const percent = Math.round(window.percent * 100) + const severity = window.percent >= 0.9 ? 'bad' : window.percent >= 0.7 ? 'warn' : 'accent' + const reset = formatResetTime(window.resetsAt) + return ( +
+
+ {window.label} + {percent}% used{reset ? ` · resets ${reset}` : ''} +
+
+ +
+
+ ) +} + +function formatResetTime(resetsAt: string | null): string | null { + if (!resetsAt) return null + const reset = Date.parse(resetsAt) + if (!Number.isFinite(reset)) return null + const remainingMinutes = Math.floor((reset - Date.now()) / 60_000) + if (remainingMinutes <= 0) return 'now' + const days = Math.floor(remainingMinutes / (24 * 60)) + const hours = Math.floor((remainingMinutes % (24 * 60)) / 60) + const minutes = remainingMinutes % 60 + if (days > 0) return `in ${days}d${hours > 0 ? ` ${hours}h` : ''}` + if (hours > 0) return `in ${hours}h${minutes > 0 ? ` ${minutes}m` : ''}` + return `in ${minutes}m` +} + +function PlanPanel({ plan }: { plan: JsonPlanSummary }) { + const hasBudget = plan.budget > 0 + const displayPercent = Math.min(100, Math.max(0, plan.percentUsed)) + const over = plan.status === 'over' || plan.percentUsed > 100 + const trackClass = hasBudget ? (over ? 'over' : undefined) : 'mut' + const overage = Math.max(0, plan.spent - plan.budget) + const right = hasBudget + ? `${formatConverted(plan.spent)} · ${fmtPct(plan.percentUsed)}${overage > 0 ? ` · ${formatConverted(overage)} over` : ''}` + : `${formatConverted(plan.spent)} this cycle` + const detail = hasBudget ? `${formatConverted(plan.budget)} / month · ${plan.provider}` : `${plan.provider} · pay as you go, no plan` + + return ( + +
+ {PLAN_NAMES[plan.id]} + {detail} + {right} +
+
+ +
+ {hasBudget ? : null} +
+ ) +} + +function PaceLine({ plan }: { plan: JsonPlanSummary }) { + const end = cycleEndDate(plan) + const endLabel = end ? formatShortDate(end) : 'unknown' + if (plan.status === 'over' || plan.projectedMonthEnd > plan.budget) { + return ( +
+ On pace to exceed; projected {formatConverted(plan.projectedMonthEnd)} by {endLabel} +
+ ) + } + if (plan.status === 'near') { + return ( +
+ {fmtPct(plan.percentUsed)} of budget used; projected {formatConverted(plan.projectedMonthEnd)} by {endLabel} +
+ ) + } + return
On track
+} diff --git a/app/renderer/sections/Sessions.test.tsx b/app/renderer/sections/Sessions.test.tsx new file mode 100644 index 0000000..8ff8302 --- /dev/null +++ b/app/renderer/sections/Sessions.test.tsx @@ -0,0 +1,286 @@ +// @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 { SessionRow } from '../lib/types' +import { INITIAL_VISIBLE, Sessions } from './Sessions' + +const { getSessions } = vi.hoisted(() => ({ + getSessions: vi.fn<(period: string, provider: string) => Promise>(), +})) +vi.mock('../lib/ipc', async orig => { + const actual = await orig() + return { ...actual, codeburn: { getSessions } } +}) + +function session(overrides: Partial & Pick): SessionRow { + return { + models: ['Default model'], + cost: 0, + savingsUSD: 0, + calls: 1, + turns: 1, + inputTokens: 1_000, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + startedAt: '2026-07-01T10:00:00.000Z', + endedAt: '2026-07-01T10:01:00.000Z', + durationMs: 60_000, + ...overrides, + } +} + +const rows: SessionRow[] = [ + session({ + sessionId: 'claude-session-123456789', + project: '-Users-torukmakto-Projects-codeburn', + provider: 'claude', + models: ['Opus 4.8'], + cost: 8.41, + savingsUSD: 1.25, + calls: 44, + turns: 41, + inputTokens: 1_420_000, + outputTokens: 64_000, + cacheReadTokens: 1_130_000, + cacheWriteTokens: 12_000, + startedAt: '2026-07-11T10:00:00.000Z', + endedAt: '2026-07-11T11:35:00.000Z', + durationMs: 5_700_000, + }), + session({ + sessionId: 'codex-session-987654321', + project: 'client-api', + provider: 'codex', + models: ['GPT-5.5 Codex'], + cost: 3.92, + calls: 25, + turns: 22, + inputTokens: 120_000, + outputTokens: 16_000, + cacheReadTokens: 40_000, + cacheWriteTokens: 4_000, + endedAt: '2026-07-10T10:30:00.000Z', + durationMs: 1_800_000, + }), + session({ + sessionId: 'claude-alpha-session', + project: 'alpha-worker', + provider: 'claude', + models: ['Haiku 4.5'], + cost: 1.10, + turns: 80, + inputTokens: 45_000, + outputTokens: 5_000, + endedAt: '2026-07-09T08:00:00.000Z', + }), + session({ + sessionId: 'codex-zeta-session', + project: 'zeta-search', + provider: 'codex', + models: ['GPT-5.5 Codex'], + cost: 6, + turns: 10, + inputTokens: 1_900_000, + outputTokens: 100_000, + endedAt: '2026-07-12T08:00:00.000Z', + }), + session({ + sessionId: 'claude-docs-session', + project: 'docs-site', + provider: 'claude', + models: ['Sonnet 4.6'], + cost: 0.50, + turns: 5, + inputTokens: 8_000, + outputTokens: 2_000, + endedAt: '2026-07-08T08:00:00.000Z', + }), + session({ + sessionId: 'codex-tools-session', + project: 'tools-service', + provider: 'codex', + models: ['GPT-5.4 Mini'], + cost: 2, + turns: 30, + inputTokens: 450_000, + outputTokens: 50_000, + endedAt: '2026-07-07T08:00:00.000Z', + }), +] + +describe('Sessions', () => { + beforeEach(() => getSessions.mockReset()) + + it('shows the first-load skeleton, then yields to the session list', async () => { + let resolve!: (value: SessionRow[]) => void + getSessions.mockReturnValue(new Promise(r => { resolve = r })) + const { container } = render() + + expect(container.querySelector('.skel')).toBeInTheDocument() + expect(screen.getByText('Scanning sessions…')).toHaveClass('sr-only') + + resolve(rows) + await waitFor(() => expect(container.querySelector('.session-list')).toBeInTheDocument()) + expect(container.querySelector('.skel')).not.toBeInTheDocument() + }) + + it('shows a summary of every filtered session and groups providers', async () => { + getSessions.mockResolvedValue(rows) + const { container } = render() + + expect(await screen.findByText('6 sessions · $21.93 · 4.2M tokens')).toBeInTheDocument() + expect(screen.getByText('Claude').closest('.provider-h')).toHaveTextContent('Claude3 sessions$10.01') + expect(screen.getByText('Codex').closest('.provider-h')).toHaveTextContent('Codex3 sessions$11.92') + expect(container.querySelectorAll('.session-row')).toHaveLength(6) + expect(screen.getByText('projects/codeburn')).toBeInTheDocument() + expect(screen.queryByText('-Users-torukmakto-Projects-codeburn')).not.toBeInTheDocument() + }) + + it('filters by project and offers to clear a search with no matches', async () => { + const user = userEvent.setup() + getSessions.mockResolvedValue(rows) + const { container } = render() + const search = await screen.findByRole('textbox', { name: 'Search sessions' }) + + await user.type(search, 'codeb') + expect(screen.getByText('1 sessions · $8.41 · 1.5M tokens')).toBeInTheDocument() + expect(container.querySelectorAll('.session-row')).toHaveLength(1) + expect(screen.getByText('projects/codeburn')).toBeInTheDocument() + expect(screen.queryByText('client-api')).not.toBeInTheDocument() + + await user.clear(search) + await user.type(search, 'nothing-here') + expect(screen.getByText('No sessions match "nothing-here".')).toBeInTheDocument() + expect(container.querySelectorAll('.session-row')).toHaveLength(0) + + await user.click(screen.getByRole('button', { name: 'Clear search' })) + expect(screen.getByText('6 sessions · $21.93 · 4.2M tokens')).toBeInTheDocument() + expect(container.querySelectorAll('.session-row')).toHaveLength(6) + }) + + it('reorders rows when the sort changes', async () => { + const user = userEvent.setup() + getSessions.mockResolvedValue(rows) + const { container } = render() + await screen.findByText('6 sessions · $21.93 · 4.2M tokens') + + expect(container.querySelector('.session-row .session-title')).toHaveTextContent('zeta/search') + await user.click(screen.getByRole('tab', { name: 'Turns' })) + expect(container.querySelector('.session-row .session-title')).toHaveTextContent('alpha/worker') + }) + + it('turns provider grouping off and back on', async () => { + const user = userEvent.setup() + getSessions.mockResolvedValue(rows) + const { container } = render() + const toggle = await screen.findByRole('button', { name: 'Group by provider' }) + + expect(container.querySelectorAll('.provider-h')).toHaveLength(2) + await user.click(toggle) + expect(toggle).toHaveAttribute('aria-pressed', 'false') + expect(container.querySelectorAll('.provider-h')).toHaveLength(0) + await user.click(toggle) + expect(toggle).toHaveAttribute('aria-pressed', 'true') + expect(container.querySelectorAll('.provider-h')).toHaveLength(2) + }) + + it('caps a large list and reveals the remaining rows without another fetch', async () => { + const user = userEvent.setup() + const largeRows = Array.from({ length: INITIAL_VISIBLE + 5 }, (_, index) => session({ + sessionId: `session-${index}`, + project: `project-${index}`, + provider: 'codex', + cost: INITIAL_VISIBLE + 5 - index, + })) + getSessions.mockResolvedValue(largeRows) + const { container } = render() + + expect(await screen.findByText(`Showing ${INITIAL_VISIBLE} of ${INITIAL_VISIBLE + 5}`)).toBeInTheDocument() + expect(container.querySelectorAll('.session-row')).toHaveLength(INITIAL_VISIBLE) + await user.click(screen.getByRole('button', { name: 'Show 5 more · 5 remaining' })) + expect(screen.getByText(`Showing ${INITIAL_VISIBLE + 5} of ${INITIAL_VISIBLE + 5}`)).toBeInTheDocument() + expect(container.querySelectorAll('.session-row')).toHaveLength(INITIAL_VISIBLE + 5) + expect(screen.queryByRole('button', { name: /remaining/ })).not.toBeInTheDocument() + expect(getSessions).toHaveBeenCalledTimes(1) + }) + + it('expands the live eight-stat detail inline and collapses the row in place', async () => { + const user = userEvent.setup() + getSessions.mockResolvedValue(rows) + const { container } = render() + await screen.findByText('6 sessions · $21.93 · 4.2M tokens') + + const row = screen.getByRole('button', { name: /projects\/codeburn/ }) + await user.click(row) + + expect(row).toHaveAttribute('aria-expanded', 'true') + const detail = screen.getByRole('region', { name: 'projects/codeburn session details' }) + expect(detail).toBeInTheDocument() + expect(screen.queryByRole('button', { name: '← Back to sessions' })).not.toBeInTheDocument() + expect(screen.getByText('claude · Opus 4.8')).toBeInTheDocument() + expect(screen.getByText(/Jul 11, 2026 → Jul 11, 2026 · 1h 35m/)).toBeInTheDocument() + expect(container.querySelectorAll('.stat')).toHaveLength(8) + expect(container.querySelectorAll('.session-row')).toHaveLength(6) + for (const label of ['Cost', 'Calls', 'Turns', 'Saved', 'Input', 'Output', 'Cache read', 'Cache write']) { + expect(within(detail).getByText(label)).toBeInTheDocument() + } + expect(screen.getByText('44')).toBeInTheDocument() + expect(screen.getByText('44% hit')).toBeInTheDocument() + expect(screen.queryByText('Context window')).not.toBeInTheDocument() + + await user.click(row) + expect(row).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByRole('region', { name: 'projects/codeburn session details' })).not.toBeInTheDocument() + expect(container.querySelectorAll('.session-row')).toHaveLength(6) + }) + + it('renders the honest empty state', async () => { + getSessions.mockResolvedValue([]) + render() + expect(await screen.findByText('No sessions in this range yet.')).toBeInTheDocument() + }) + + it('lifts provider quick-filter clicks to the app callback with the internal id', async () => { + const user = userEvent.setup() + getSessions.mockResolvedValue(rows) + const onProviderChange = vi.fn() + const detected = [ + { id: 'claude', label: 'Claude' }, + { id: 'codex', label: 'Codex' }, + ] + render() + await screen.findByText('6 sessions · $21.93 · 4.2M tokens') + + const filter = screen.getByRole('group', { name: /provider/i }) + expect(within(filter).getAllByRole('button')).toHaveLength(3) + + await user.click(within(filter).getByRole('button', { name: 'Codex' })) + expect(onProviderChange).toHaveBeenCalledWith('codex') + + await user.click(within(filter).getByRole('button', { name: 'All' })) + expect(onProviderChange).toHaveBeenLastCalledWith('all') + }) + + it('renders quick-filter buttons only for cost>0 providers and presses the active one', async () => { + getSessions.mockResolvedValue(rows) + // Mirror App.tsx: detectedProviders are built by dropping cost=0 entries. + const providerDetails = [ + { id: 'claude', label: 'Claude', cost: 12 }, + { id: 'codex', label: 'Codex', cost: 4 }, + { id: 'gemini', label: 'Gemini', cost: 0 }, + ] + const detected = providerDetails.filter(p => p.cost > 0).map(({ id, label }) => ({ id, label })) + render( {}} />) + await screen.findByText('6 sessions · $21.93 · 4.2M tokens') + + const filter = screen.getByRole('group', { name: /provider/i }) + expect(within(filter).getByRole('button', { name: 'Claude' })).toBeInTheDocument() + expect(within(filter).getByRole('button', { name: 'Codex' })).toBeInTheDocument() + expect(within(filter).queryByRole('button', { name: 'Gemini' })).not.toBeInTheDocument() + expect(within(filter).getByRole('button', { name: 'Codex' })).toHaveAttribute('aria-pressed', 'true') + expect(within(filter).getByRole('button', { name: 'All' })).toHaveAttribute('aria-pressed', 'false') + }) +}) diff --git a/app/renderer/sections/Sessions.tsx b/app/renderer/sections/Sessions.tsx new file mode 100644 index 0000000..1558879 --- /dev/null +++ b/app/renderer/sections/Sessions.tsx @@ -0,0 +1,298 @@ +import { Fragment, useEffect, useMemo, useState } from 'react' + +import { CliErrorPanel } from '../components/CliErrorPanel' +import { EmptyNote } from '../components/EmptyState' +import { Panel } from '../components/Panel' +import { ProviderLogo } from '../components/ProviderLogo' +import { SectionSkeleton } from '../components/Skeleton' +import { SegTabs } from '../components/SegTabs' +import { StaleBanner } from '../components/StaleBanner' +import { Stat } from '../components/Stat' +import { usePolled } from '../hooks/usePolled' +import { formatCompact, formatDayLong, formatDayShort, formatDuration, formatUsd, shortenProjectPath } from '../lib/format' +import { codeburn } from '../lib/ipc' +import type { DateRange, Period, SessionRow } from '../lib/types' + +export const INITIAL_VISIBLE = 120 +const STEP = 120 + +type SessionSort = 'cost' | 'recent' | 'turns' | 'tokens' +type SequenceEntry = + | { type: 'header'; provider: string; count: number; cost: number } + | { type: 'row'; row: SessionRow } + +const SORT_OPTIONS = [ + { value: 'cost', label: 'Cost' }, + { value: 'recent', label: 'Recent' }, + { value: 'turns', label: 'Turns' }, + { value: 'tokens', label: 'Tokens' }, +] + +function providerName(provider: string): string { + return provider + .split(/[-\s]+/) + .filter(Boolean) + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') +} + +function endedAtTime(row: SessionRow): number { + const time = new Date(row.endedAt).getTime() + return Number.isNaN(time) ? 0 : time +} + +function compareRows(sort: SessionSort, a: SessionRow, b: SessionRow): number { + if (sort === 'cost') return b.cost - a.cost + if (sort === 'turns') return b.turns - a.turns + if (sort === 'tokens') { + return (b.inputTokens + b.outputTokens) - (a.inputTokens + a.outputTokens) + } + return endedAtTime(b) - endedAtTime(a) +} + +function groupSortValue(sort: SessionSort, rows: SessionRow[]): number { + if (sort === 'cost') return rows.reduce((sum, row) => sum + row.cost, 0) + if (sort === 'turns') return rows.reduce((sum, row) => sum + row.turns, 0) + if (sort === 'tokens') { + return rows.reduce((sum, row) => sum + row.inputTokens + row.outputTokens, 0) + } + return rows.reduce((latest, row) => Math.max(latest, endedAtTime(row)), 0) +} + +export function Sessions({ + period, + provider, + range = null, + refreshToken = 0, + detectedProviders = [], + onProviderChange = () => {}, +}: { + period: Period + provider: string + range?: DateRange | null + refreshToken?: number + detectedProviders?: Array<{ id: string; label: string }> + onProviderChange?: (value: string) => void +}) { + const [selectedId, setSelectedId] = useState(null) + const [query, setQuery] = useState('') + const [sort, setSort] = useState('cost') + const [grouped, setGrouped] = useState(true) + const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE) + const report = usePolled( + () => range ? codeburn.getSessions(period, provider, range) : codeburn.getSessions(period, provider), + [period, provider, range?.from, range?.to, refreshToken], + ) + const rows = report.data ?? [] + const q = query.trim().toLowerCase() + const filtered = rows.filter(row => q === '' || [ + row.project, + row.sessionId, + row.models.join(' '), + ].some(value => value.toLowerCase().includes(q))) + + useEffect(() => { + setVisibleCount(INITIAL_VISIBLE) + }, [query, sort, grouped, report.data]) + + const sequence = useMemo(() => { + if (!grouped) { + return [...filtered] + .sort((a, b) => compareRows(sort, a, b)) + .map(row => ({ type: 'row' as const, row })) + } + + const byProvider = filtered.reduce((map, row) => { + const providerRows = map.get(row.provider) ?? [] + providerRows.push(row) + map.set(row.provider, providerRows) + return map + }, new Map()) + + return [...byProvider.entries()] + .map(([providerName, providerRows]) => ({ + provider: providerName, + rows: [...providerRows].sort((a, b) => compareRows(sort, a, b)), + cost: providerRows.reduce((sum, row) => sum + row.cost, 0), + sortValue: groupSortValue(sort, providerRows), + })) + .sort((a, b) => b.sortValue - a.sortValue || a.provider.localeCompare(b.provider)) + .flatMap(group => [ + { type: 'header' as const, provider: group.provider, count: group.rows.length, cost: group.cost }, + ...group.rows.map(row => ({ type: 'row' as const, row })), + ]) + }, [filtered, grouped, sort]) + + const renderedSequence: SequenceEntry[] = [] + let renderedRows = 0 + let pendingHeader: SequenceEntry | null = null + for (const entry of sequence) { + if (entry.type === 'header') { + pendingHeader = entry + continue + } + if (renderedRows >= visibleCount) break + if (pendingHeader) { + renderedSequence.push(pendingHeader) + pendingHeader = null + } + renderedSequence.push(entry) + renderedRows++ + } + + if (!report.data) { + if (report.error) return + return + } + + if (!report.data.length) { + return ( + + No sessions in this range yet. + + ) + } + + const totalCost = filtered.reduce((sum, row) => sum + row.cost, 0) + const totalTokens = filtered.reduce((sum, row) => sum + row.inputTokens + row.outputTokens, 0) + const remaining = filtered.length - renderedRows + + return ( +
+ {report.error && } + {detectedProviders.length > 0 && ( +
+ + {detectedProviders.map(entry => ( + + ))} +
+ )} +
+ setQuery(event.target.value)} + /> + setSort(value as SessionSort)} + /> + +
+
+ {filtered.length} sessions · {formatUsd(totalCost)} · {formatCompact(totalTokens)} tokens +
+ {filtered.length === 0 ? ( +
+ No sessions match "{query}". + +
+ ) : ( + <> +
+ {renderedSequence.map(entry => entry.type === 'header' ? ( +
+ {providerName(entry.provider)} + {entry.count.toLocaleString('en-US')} sessions + {formatUsd(entry.cost)} +
+ ) : ( + + + {selectedId === entry.row.sessionId && ( + setSelectedId(null)} /> + )} + + ))} +
+
Showing {renderedRows} of {filtered.length}
+ {remaining > 0 && ( + + )} + + )} +
+ ) +} + +function SessionDetail({ session, onCollapse }: { session: SessionRow; onCollapse: () => void }) { + const cacheTotal = session.inputTokens + session.cacheReadTokens + const cacheHit = cacheTotal > 0 ? Math.round(session.cacheReadTokens / cacheTotal * 100) : 0 + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') onCollapse() + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [onCollapse]) + + return ( +
+
+

{shortenProjectPath(session.project)}

+
{session.provider} · {session.models.join(', ')}
+
+ {formatDayLong(session.startedAt)} → {formatDayLong(session.endedAt)} · {formatDuration(session.durationMs)} +
+
+
+ + + + + + + + +
+
+ ) +} diff --git a/app/renderer/sections/Settings.test.tsx b/app/renderer/sections/Settings.test.tsx new file mode 100644 index 0000000..ef9fce9 --- /dev/null +++ b/app/renderer/sections/Settings.test.tsx @@ -0,0 +1,315 @@ +// @vitest-environment jsdom +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { ActionResult, AliasRow, CombinedUsage, DeviceScanResult, Identity, MenubarPayload, PriceOverrideList, PriceRates, QuotaProvider, ShareStatus, StatusJson } from '../lib/types' +import { Settings } from './Settings' + +const mocks = vi.hoisted(() => ({ + getIdentity: vi.fn<() => Promise>(), + getDevices: vi.fn<(period: string) => Promise>(), + getDevicesScan: vi.fn<() => Promise>(), + getShareStatus: vi.fn<() => Promise>(), + getQuota: vi.fn<(force?: boolean) => Promise>(), + getPlans: vi.fn<(period: string) => Promise>(), + getOverview: vi.fn<(period: string, provider: string) => Promise>(), + getAliases: vi.fn<() => Promise>(), + getPriceOverrides: vi.fn<() => Promise>(), + setPriceOverride: vi.fn<(model: string, rates: PriceRates) => Promise>(), + removePriceOverride: vi.fn<(model: string) => Promise>(), + setCurrency: vi.fn<(code: string) => Promise>(), + resetCurrency: vi.fn<() => Promise>(), + addAlias: vi.fn<(from: string, to: string) => Promise>(), + removeAlias: vi.fn<(from: string) => Promise>(), + removeDevice: vi.fn<(name: string) => Promise>(), + setPlan: vi.fn<(id: string, provider: string) => Promise>(), + resetPlan: vi.fn<(provider: string) => Promise>(), + chooseDirectory: vi.fn<() => Promise>(), + exportData: vi.fn<(format: string, provider: string, path: string) => Promise>(), +})) +vi.mock('../lib/ipc', async orig => { + const actual = await orig() + return { ...actual, codeburn: mocks } +}) + +const identity: Identity = { name: 'Toruk MacBook Pro', fingerprint: 'AA:11:22:33:44:55:66:77' } +const actionOk: ActionResult = { ok: true, stdout: 'updated', stderr: '', code: 0 } +const devices: CombinedUsage = { + perDevice: [ + { id: 'local', name: 'Toruk MacBook Pro', local: true, cost: 120.1, calls: 100, sessions: 10, inputTokens: 1, outputTokens: 2, cacheCreateTokens: 3, cacheReadTokens: 4, totalTokens: 10 }, + { id: 'mini', name: 'toruk-mini', local: false, cost: 41.2, calls: 680, sessions: 34, inputTokens: 11, outputTokens: 12, cacheCreateTokens: 13, cacheReadTokens: 14, totalTokens: 50 }, + ], + combined: { cost: 161.3, calls: 780, sessions: 44, inputTokens: 12, outputTokens: 14, cacheCreateTokens: 16, cacheReadTokens: 18, totalTokens: 60, deviceCount: 2, reachableCount: 2 }, +} +const scan: DeviceScanResult = { found: [{ name: 'Mac Studio', host: 'mac-studio.local', port: 9732, fingerprint: '7F:2A:19:88:55:44:33:C4', code: 'pair-1', paired: false }] } +const overview = { current: { providers: { claude: 12.34, codex: 4.5 } } } as unknown as MenubarPayload +const quotaProviders: QuotaProvider[] = [ + { provider: 'claude', connection: 'connected', primary: null, details: [], planLabel: 'Max 20x', footerLines: [] }, + { provider: 'codex', connection: 'disconnected', primary: null, details: [], planLabel: null, footerLines: [] }, +] +const stored = new Map() +vi.stubGlobal('localStorage', { + getItem: (key: string) => stored.get(key) ?? null, + setItem: (key: string, value: string) => stored.set(key, value), + clear: () => stored.clear(), +}) + +describe('Settings', () => { + beforeEach(() => { + Object.values(mocks).forEach(mock => mock.mockReset()) + mocks.getIdentity.mockResolvedValue(identity) + mocks.getDevices.mockResolvedValue(devices) + mocks.getDevicesScan.mockResolvedValue(scan) + mocks.getShareStatus.mockResolvedValue({ sharing: true, name: 'Toruk MacBook Pro', port: 9732, always: false, peers: 1, pending: [] }) + mocks.getQuota.mockResolvedValue(quotaProviders) + mocks.getPlans.mockResolvedValue({ currency: 'EUR', today: { cost: 0, savings: 0, calls: 0 }, month: { cost: 0, savings: 0, calls: 0 }, plans: { claude: { id: 'claude-max', provider: 'claude', budget: 200, spent: 48, percentUsed: 24, status: 'under', projectedMonthEnd: 120, daysUntilReset: 19, periodStart: '2026-07-01', periodEnd: '2026-08-01' } } }) + mocks.getOverview.mockResolvedValue(overview) + mocks.getAliases.mockResolvedValue([{ from: 'proxy-opus', to: 'claude-opus-4-6' }]) + mocks.getPriceOverrides.mockResolvedValue({ overrides: [{ model: 'local/llama', inputPerM: 0.2, outputPerM: 0.6, cacheReadPerM: 0.05 }], configPath: '/home/user/.config/codeburn/config.json' }) + mocks.setPriceOverride.mockResolvedValue(actionOk) + mocks.removePriceOverride.mockResolvedValue(actionOk) + mocks.setCurrency.mockResolvedValue(actionOk) + mocks.resetCurrency.mockResolvedValue(actionOk) + mocks.addAlias.mockResolvedValue(actionOk) + mocks.removeAlias.mockResolvedValue(actionOk) + mocks.removeDevice.mockResolvedValue(actionOk) + mocks.setPlan.mockResolvedValue(actionOk) + mocks.resetPlan.mockResolvedValue(actionOk) + mocks.chooseDirectory.mockResolvedValue('/Users/toruk/Exports') + mocks.exportData.mockResolvedValue(actionOk) + localStorage.clear() + document.documentElement.removeAttribute('data-theme') + }) + + it('switches panes from the rail and renders the completed Plans pane', async () => { + const user = userEvent.setup() + render() + expect(screen.getByRole('heading', { name: 'General' })).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Plans' })) + expect(screen.getByRole('heading', { name: 'Plans' })).toBeInTheDocument() + expect((await screen.findAllByText('Claude Max 20x')).length).toBeGreaterThan(0) + }) + + it('shows current currency and sends currency changes to the CLI', async () => { + const user = userEvent.setup() + render() + const currency = await screen.findByLabelText('Currency') + expect(currency).toHaveTextContent('EUR') + await user.click(currency) + expect(screen.getByRole('option', { name: 'CZK' })).toBeInTheDocument() + await user.click(screen.getByRole('option', { name: 'CNY' })) + expect(mocks.setCurrency).toHaveBeenCalledWith('CNY') + expect(await screen.findByText('Updated')).toBeInTheDocument() + }) + + it('persists theme choices and applies forced themes to the root', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'Dark' })) + expect(document.documentElement).toHaveAttribute('data-theme', 'dark') + expect(localStorage.getItem('codeburn.theme')).toBe('dark') + await user.click(screen.getByRole('button', { name: 'System' })) + expect(document.documentElement).not.toHaveAttribute('data-theme') + }) + + it('shows the active Claude config as a read-only line in General when multiple configs exist', async () => { + render() + expect(await screen.findByText('Claude config')).toBeInTheDocument() + expect(screen.getByText('Claude Desktop')).toBeInTheDocument() + expect(screen.getByText('Applies to the overview data. Manage config folders with the codeburn CLI.')).toBeInTheDocument() + }) + + it('omits the Claude config line when no multi-config selector is present', async () => { + render() + expect(await screen.findByRole('heading', { name: 'General' })).toBeInTheDocument() + expect(screen.queryByText('Claude config')).not.toBeInTheDocument() + }) + + it('stores a positive daily budget from General', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByLabelText('Daily budget')) + await user.click(screen.getByRole('option', { name: 'USD amount' })) + await user.type(screen.getByLabelText('Daily budget amount'), '25') + expect(JSON.parse(localStorage.getItem('codeburn.dailyBudget')!)).toEqual({ kind: 'usd', value: 25 }) + }) + + it('rejects a non-positive daily budget without persisting it', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByLabelText('Daily budget')) + await user.click(screen.getByRole('option', { name: 'Tokens' })) + await user.type(screen.getByLabelText('Daily budget amount'), '-5') + expect(screen.getByText('Enter a positive number.')).toBeInTheDocument() + expect(localStorage.getItem('codeburn.dailyBudget')).toBeFalsy() + }) + + it('lists providers from the real overview payload', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'Providers' })) + expect(await screen.findByText('Claude')).toBeInTheDocument() + expect(screen.getByText('Detected · $12.34')).toBeInTheDocument() + expect(screen.getByText('Codex')).toBeInTheDocument() + expect(mocks.getOverview).toHaveBeenCalledWith('week', 'all') + }) + + it('lists, adds, and removes model aliases through the action bridge', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'Model aliases' })) + expect(await screen.findByText('proxy-opus')).toBeInTheDocument() + expect(screen.getByText('claude-opus-4-6')).toBeInTheDocument() + await user.type(screen.getByLabelText('Unrecognized model'), 'proxy-sonnet') + await user.type(screen.getByLabelText('Priced model'), 'claude-sonnet-4-5') + await user.click(screen.getByRole('button', { name: 'Add' })) + expect(mocks.addAlias).toHaveBeenCalledWith('proxy-sonnet', 'claude-sonnet-4-5') + await user.click(screen.getByRole('button', { name: 'Remove' })) + expect(mocks.removeAlias).toHaveBeenCalledWith('proxy-opus') + }) + + it('lists, adds, and removes price overrides through the action bridge', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'Pricing' })) + expect(await screen.findByText('local/llama')).toBeInTheDocument() + expect(screen.getByText('in 0.2 · out 0.6 · read 0.05')).toBeInTheDocument() + await user.type(screen.getByLabelText('Override model'), 'ollama/qwen') + await user.type(screen.getByLabelText('Input rate'), '0.1') + await user.type(screen.getByLabelText('Output rate'), '0.4') + await user.click(screen.getByRole('button', { name: 'Add' })) + // Only the two filled rates are sent; cache fields stay out of the payload. + expect(mocks.setPriceOverride).toHaveBeenCalledWith('ollama/qwen', { input: 0.1, output: 0.4 }) + await user.click(screen.getByRole('button', { name: 'Remove' })) + await user.click(screen.getByRole('button', { name: 'Confirm' })) + expect(mocks.removePriceOverride).toHaveBeenCalledWith('local/llama') + }) + + it('rejects an invalid price rate client-side without calling the bridge', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'Pricing' })) + await user.type(screen.getByLabelText('Override model'), 'ollama/qwen') + await user.type(screen.getByLabelText('Input rate'), '0') + await user.type(screen.getByLabelText('Output rate'), '0.4') + await user.click(screen.getByRole('button', { name: 'Add' })) + expect(screen.getByText('Rates must be positive numbers (USD per 1M tokens).')).toBeInTheDocument() + expect(mocks.setPriceOverride).not.toHaveBeenCalled() + }) + + it('lists, removes, and adds plans through the action bridge', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'Plans' })) + expect((await screen.findAllByText('Claude Max 20x')).length).toBeGreaterThan(0) + expect(screen.getByText('$200.00/month · claude · 24% used')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Remove' })) + await user.click(screen.getByRole('button', { name: 'Confirm' })) + expect(mocks.resetPlan).toHaveBeenCalledWith('claude') + await user.click(screen.getByLabelText('Add a plan')) + await user.click(screen.getByRole('option', { name: 'Cursor Pro' })) + await user.click(screen.getByRole('button', { name: 'Add' })) + expect(mocks.setPlan).toHaveBeenCalledWith('cursor-pro', 'cursor') + }) + + it('shows detected subscriptions with an auto-detected tier and a disconnected hint', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'Plans' })) + expect(await screen.findByText('Detected subscriptions')).toBeInTheDocument() + expect(screen.getByText('Max 20x')).toBeInTheDocument() + expect(screen.getByText('Not connected. Log in with the Codex CLI.')).toBeInTheDocument() + expect(mocks.getQuota).toHaveBeenCalledWith(false) + }) + + it('expands the DetectedRow Connect affordance and forces a keychain refresh', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'Plans' })) + await screen.findByText('Detected subscriptions') + await user.click(screen.getByRole('button', { name: 'Connect' })) + expect(screen.getByText('codex login')).toBeInTheDocument() + mocks.getQuota.mockClear() + await user.click(screen.getByRole('button', { name: 'Refresh' })) + await waitFor(() => expect(mocks.getQuota).toHaveBeenCalledWith(true)) + }) + + it('offers only non-OAuth budget presets; Claude and Codex are excluded', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'Plans' })) + await user.click(await screen.findByLabelText('Add a plan')) + expect(screen.getByRole('option', { name: 'Cursor Pro' })).toBeInTheDocument() + expect(screen.getByRole('option', { name: 'SuperGrok' })).toBeInTheDocument() + expect(screen.queryByRole('option', { name: 'Claude Pro' })).not.toBeInTheDocument() + expect(screen.queryByRole('option', { name: 'Claude Max 20x' })).not.toBeInTheDocument() + expect(screen.queryByRole('option', { name: 'Claude Max 5x' })).not.toBeInTheDocument() + }) + + it('still lists a configured Claude manual plan with Remove and a superseded note', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'Plans' })) + expect((await screen.findAllByText('Claude Max 20x')).length).toBeGreaterThan(0) + expect(screen.getByText('superseded by the detected subscription')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument() + }) + + it('chooses an export folder and exports the selected format and provider', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getAllByRole('button', { name: 'Export' }).at(-1)!) + await user.click(screen.getByRole('button', { name: 'Choose folder…' })) + expect(mocks.chooseDirectory).toHaveBeenCalledOnce() + expect(await screen.findByText('/Users/toruk/Exports')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'JSON' })) + await user.click(screen.getByLabelText('Provider')) + await user.click(screen.getByRole('option', { name: 'Claude' })) + await user.click(screen.getAllByRole('button', { name: 'Export' }).at(-1)!) + expect(mocks.exportData).toHaveBeenCalledWith('json', 'claude', '/Users/toruk/Exports') + expect(await screen.findByText('Exported to /Users/toruk/Exports')).toBeInTheDocument() + }) + + it('renders real device status and removes paired devices without fake pairing controls', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'Devices' })) + expect(await screen.findByText('Toruk MacBook Pro')).toBeInTheDocument() + expect(screen.getByText('Local device name: Toruk MacBook Pro')).toBeInTheDocument() + expect(await screen.findByText('Mac Studio')).toBeInTheDocument() + expect(screen.getByText('fingerprint 7F:2A:…:C4')).toBeInTheDocument() + expect(await screen.findByText('toruk-mini')).toBeInTheDocument() + expect(screen.getByText('34 sessions · $41.20 this month')).toBeInTheDocument() + expect(screen.getByText('Visible')).toBeInTheDocument() + expect(screen.getByText(/Pairing is interactive/)).toBeInTheDocument() + expect(screen.queryByText('Approve')).not.toBeInTheDocument() + expect(screen.queryByText('Pull now')).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Remove' })) + await user.click(screen.getByRole('button', { name: 'Confirm' })) + expect(mocks.removeDevice).toHaveBeenCalledWith('toruk-mini') + expect(screen.getByText('Combined view active · 2 devices')).toBeInTheDocument() + }) + + it('excludes already-paired scans and renders empty device states', async () => { + const user = userEvent.setup() + mocks.getDevicesScan.mockResolvedValue({ found: [{ ...scan.found[0]!, paired: true }] }) + mocks.getDevices.mockResolvedValue({ perDevice: [devices.perDevice[0]!], combined: { ...devices.combined, deviceCount: 1, reachableCount: 1 } }) + render() + await user.click(screen.getByRole('button', { name: 'Devices' })) + expect(await screen.findByText('No nearby devices found.')).toBeInTheDocument() + expect(screen.getByText('No paired devices yet.')).toBeInTheDocument() + expect(screen.queryByText('Mac Studio')).not.toBeInTheDocument() + }) + + it('renders not-found and permission states for device reads', async () => { + const user = userEvent.setup() + mocks.getIdentity.mockRejectedValue({ kind: 'not-found', message: 'codeburn not found' }) + mocks.getDevicesScan.mockRejectedValue({ kind: 'nonzero', message: 'Cursor permission denied: Full Disk Access required' }) + mocks.getDevices.mockRejectedValue({ kind: 'not-found', message: 'codeburn not found' }) + render() + await user.click(screen.getByRole('button', { name: 'Devices' })) + await waitFor(() => expect(screen.getAllByText('Locate the codeburn CLI')).toHaveLength(2)) + expect(screen.getByText('permission denied; grant Full Disk Access')).toHaveStyle({ color: 'var(--warn)' }) + }) +}) diff --git a/app/renderer/sections/Settings.tsx b/app/renderer/sections/Settings.tsx new file mode 100644 index 0000000..341a036 --- /dev/null +++ b/app/renderer/sections/Settings.tsx @@ -0,0 +1,448 @@ +import { useEffect, useRef, useState } from 'react' + +import { Hint } from '../components/Hint' +import { CliErrorText, cliErrorDisplay } from '../components/CliErrorPanel' +import { ConnectAffordance } from '../components/ConnectAffordance' +import { Dropdown } from '../components/Dropdown' +import { Panel } from '../components/Panel' +import { ProviderLogo } from '../components/ProviderLogo' +import type { Section } from '../components/Sidebar' +import { usePolled } from '../hooks/usePolled' +import { readDailyBudget } from '../lib/budget' +import { formatConverted, formatUsd } from '../lib/format' +import { codeburn } from '../lib/ipc' +import { motionClass } from '../lib/motion' +import { showToast } from '../lib/toast' +import { ToastHost } from '../components/ToastHost' +import type { ActionResult, AliasRow, ClaudeConfigSelector, CliError, CombinedUsage, DeviceScanResult, Identity, JsonPlanSummary, MenubarPayload, Period, PlanId, PlanProvider, PriceOverrideList, PriceOverrideRow, PriceRates, QuotaProvider, ShareStatus, StatusJson } from '../lib/types' + +export type SettingsPane = 'general' | 'providers' | 'aliases' | 'pricing' | 'plans' | 'devices' | 'export' | 'privacy' +type Pane = SettingsPane +type Theme = 'system' | 'light' | 'dark' + +type PlanPreset = { id: Exclude; label: string; provider: Exclude } + +const PLAN_PRESETS: PlanPreset[] = [ + { id: 'claude-pro', label: 'Claude Pro', provider: 'claude' }, + { id: 'claude-max', label: 'Claude Max 20x', provider: 'claude' }, + { id: 'claude-max-5x', label: 'Claude Max 5x', provider: 'claude' }, + { id: 'cursor-pro', label: 'Cursor Pro', provider: 'cursor' }, + { id: 'supergrok', label: 'SuperGrok', provider: 'grok' }, + { id: 'supergrok-heavy', label: 'SuperGrok Heavy', provider: 'grok' }, +] + +// Claude and Codex subscriptions are detected from the CLI login (see the +// detected-subscriptions list), so only non-OAuth providers get a manual preset. +const MANUAL_PLAN_PRESETS = PLAN_PRESETS.filter(preset => preset.provider !== 'claude') + +const CURRENCIES = [ + 'USD', 'EUR', 'GBP', 'JPY', 'CNY', 'CAD', 'AUD', 'CHF', 'HKD', 'SGD', 'INR', 'NZD', 'SEK', 'NOK', 'DKK', + 'KRW', 'BRL', 'MXN', 'ZAR', 'AED', 'SAR', 'TRY', 'PLN', 'THB', 'IDR', 'MYR', 'PHP', 'RUB', 'ILS', 'CZK', +] + +function readSetting(key: string): string | null { + try { return globalThis.localStorage?.getItem(key) ?? null } catch { return null } +} + +function writeSetting(key: string, value: string): void { + try { globalThis.localStorage?.setItem(key, value) } catch { /* storage can be unavailable in hardened contexts */ } +} + +const RAIL_ITEMS: Array<{ id: Pane; label: string; icon: React.ReactNode }> = [ + { id: 'general', label: 'General', icon: <> }, + { id: 'providers', label: 'Providers', icon: <> }, + { id: 'aliases', label: 'Model aliases', icon: <> }, + { id: 'pricing', label: 'Pricing', icon: <> }, + { id: 'plans', label: 'Plans', icon: <> }, + { id: 'devices', label: 'Devices', icon: <> }, + { id: 'export', label: 'Export', icon: <> }, + { id: 'privacy', label: 'Privacy & data', icon: }, +] + +function periodLabel(period: Period): string { + if (period === 'today') return 'today' + if (period === 'week') return 'last 7 days' + if (period === 'month') return 'this month' + if (period === '30days') return 'last 30 days' + return 'all time' +} + +function shortFingerprint(fingerprint: string): string { + const parts = fingerprint.split(':').filter(Boolean) + if (parts.length < 3) return fingerprint + return `${parts[0]}:${parts[1]}:…:${parts[parts.length - 1]}` +} + +/** Inline destructive confirm: the button swaps to a prompt + Confirm/Cancel in + * place (no OS dialog). Auto-cancels on Escape or when focus leaves the group. */ +function ConfirmButton({ label, prompt, onConfirm }: { label: string; prompt: string; onConfirm: () => void }) { + const [confirming, setConfirming] = useState(false) + if (!confirming) { + return + } + return ( + { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setConfirming(false) }} + onKeyDown={event => { if (event.key === 'Escape') setConfirming(false) }} + > + {prompt} + + + + ) +} + +export function Settings({ period, refreshToken = 0, onNavigate, initialPane, claudeConfigs, claudeConfigSource = null }: { period: Period; refreshToken?: number; onNavigate?: (section: Section) => void; initialPane?: SettingsPane; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource?: string | null }) { + const [pane, setPane] = useState(initialPane ?? 'general') + + return ( + <> +
Settings
+ +
+ +
+ {pane === 'general' && } + {pane === 'providers' && } + {pane === 'aliases' && } + {pane === 'pricing' && } + {pane === 'plans' && } + {pane === 'devices' && } + {pane === 'export' && } + {pane === 'privacy' && } +
+
+ + + ) +} + +function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource }: { period: Period; refreshToken: number; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource: string | null }) { + const [currencyNonce, setCurrencyNonce] = useState(0) + const plans = usePolled(() => codeburn.getPlans(period), [period, refreshToken, currencyNonce]) + const [theme, setTheme] = useState(() => { + const saved = readSetting('codeburn.theme') + return saved === 'light' || saved === 'dark' ? saved : 'system' + }) + const [defaultPeriod, setDefaultPeriod] = useState(() => readSetting('codeburn.defaultPeriod') ?? 'today') + const [budgetKind, setBudgetKind] = useState<'off' | 'usd' | 'tokens'>(() => readDailyBudget()?.kind ?? 'off') + const [budgetInput, setBudgetInput] = useState(() => { const budget = readDailyBudget(); return budget ? String(budget.value) : '' }) + const [budgetError, setBudgetError] = useState('') + + useEffect(() => { + if (theme === 'system') document.documentElement.removeAttribute('data-theme') + else document.documentElement.setAttribute('data-theme', theme) + }, [theme]) + + // Store on change; a positive finite amount persists, anything else clears the + // cap (so the banner turns off) and, when non-empty, flags a validation error. + const persistBudget = (kind: 'off' | 'usd' | 'tokens', input: string) => { + const trimmed = input.trim() + if (kind === 'off' || trimmed === '') { setBudgetError(''); writeSetting('codeburn.dailyBudget', ''); return } + const value = Number(trimmed) + if (!Number.isFinite(value) || value <= 0) { setBudgetError('Enter a positive number.'); return } + setBudgetError('') + writeSetting('codeburn.dailyBudget', JSON.stringify({ kind, value })) + } + + const chooseTheme = (next: Theme) => { + setTheme(next) + writeSetting('codeburn.theme', next) + } + const finishCurrency = (result: ActionResult) => { + showToast(result.ok ? 'Updated' : result.stderr || 'Unable to update currency', result.ok ? 'ok' : 'error') + if (result.ok) setCurrencyNonce(value => value + 1) + } + const currencies = [...CURRENCIES] + if (plans.data?.currency && !currencies.includes(plans.data.currency)) currencies.push(plans.data.currency) + const hasConfigs = Boolean(claudeConfigs && claudeConfigs.options.length > 0) + const activeConfigLabel = claudeConfigSource + ? claudeConfigs?.options.find(option => option.id === claudeConfigSource)?.label ?? claudeConfigSource + : 'All Claude configs' + + return ( +
+

General

Display and appearance for the whole app.

+
+
+
Appearance
+
ThemeMatch your system or force a mode + {(['system', 'light', 'dark'] as Theme[]).map(value => )} +
+
+ {hasConfigs && ( +
+
Claude config
+
Active configApplies to the overview data. Manage config folders with the codeburn CLI.{activeConfigLabel}
+
+ )} +
+
Display
+
+ {plans.data ? ({ value: code, label: code }))} onChange={value => void codeburn.setCurrency(value).then(finishCurrency)} width={92} /> : plans.error ? : Loading…} + +
+
{ setDefaultPeriod(value); writeSetting('codeburn.defaultPeriod', value) }} width={92} />
+
{ const kind = value as 'off' | 'usd' | 'tokens'; setBudgetKind(kind); persistBudget(kind, budgetInput) }} width={120} />{budgetKind !== 'off' && { setBudgetInput(event.target.value); persistBudget(budgetKind, event.target.value) }} style={{ width: 90 }} />}
+ {budgetError &&

{budgetError}

} +
+
+
+ ) +} + +function ProvidersPane({ period, refreshToken }: { period: Period; refreshToken: number }) { + const overview = usePolled(() => codeburn.getOverview(period, 'all'), [period, refreshToken]) + const providers = Object.entries(overview.data?.current.providers ?? {}) + return
+

Providers

codeburn auto-detects coding tools from local session files. No setup needed.

+ {overview.error ? : !overview.data ?

Loading detected providers…

: providers.length === 0 ?

No providers detected.

: providers.map(([name, cost]) =>
{name.charAt(0).toUpperCase() + name.slice(1)}Detected · {formatUsd(cost)}
)} +
+} + +function AliasesPane({ refreshToken }: { refreshToken: number }) { + const [actionNonce, setActionNonce] = useState(0) + const aliases = usePolled(() => codeburn.getAliases(), [refreshToken, actionNonce]) + const [from, setFrom] = useState('') + const [to, setTo] = useState('') + const [error, setError] = useState('') + const complete = (result: ActionResult, added = false) => { + if (!result.ok) { setError(result.stderr || 'Alias action failed'); return } + setError('') + if (added) { setFrom(''); setTo('') } + setActionNonce(value => value + 1) + } + return
+

Model aliases

Map an unrecognized model name to a priced model so its cost shows up.

+
+ {aliases.error ? : !aliases.data ?

Loading aliases…

: aliases.data.length === 0 ?

No aliases configured. Unknown models are priced at $0 until aliased.

: aliases.data.map(alias =>
{alias.from}{alias.to}
)} +
setFrom(event.target.value)} /> setTo(event.target.value)} />
+ {error &&

{error}

} +
+

Unknown models are priced at $0 until aliased. A local model can instead be credited with what it would have cost via model-savings.

+
+} + +function priceRateSummary(o: PriceOverrideRow): string { + const parts = [`in ${o.inputPerM}`, `out ${o.outputPerM}`] + if (typeof o.cacheReadPerM === 'number') parts.push(`read ${o.cacheReadPerM}`) + if (typeof o.cacheCreationPerM === 'number') parts.push(`create ${o.cacheCreationPerM}`) + return parts.join(' · ') +} + +// '' -> not provided; a positive finite number -> a rate; 'invalid' otherwise. +function parseRate(raw: string): number | undefined | 'invalid' { + const trimmed = raw.trim() + if (trimmed === '') return undefined + const value = Number(trimmed) + if (!Number.isFinite(value) || value <= 0) return 'invalid' + return value +} + +function PricingPane({ refreshToken }: { refreshToken: number }) { + const [actionNonce, setActionNonce] = useState(0) + const overrides = usePolled(() => codeburn.getPriceOverrides(), [refreshToken, actionNonce]) + const [model, setModel] = useState('') + const [input, setInput] = useState('') + const [output, setOutput] = useState('') + const [cacheRead, setCacheRead] = useState('') + const [cacheCreation, setCacheCreation] = useState('') + const [error, setError] = useState('') + + const complete = (result: ActionResult, added = false) => { + if (!result.ok) { setError(result.stderr || 'Price override action failed'); return } + setError('') + if (added) { setModel(''); setInput(''); setOutput(''); setCacheRead(''); setCacheCreation('') } + setActionNonce(value => value + 1) + } + + const add = () => { + const fields: Array<[keyof PriceRates, string]> = [['input', input], ['output', output], ['cacheRead', cacheRead], ['cacheCreation', cacheCreation]] + const rates: PriceRates = {} + for (const [key, raw] of fields) { + const parsed = parseRate(raw) + if (parsed === 'invalid') { setError('Rates must be positive numbers (USD per 1M tokens).'); return } + if (parsed !== undefined) rates[key] = parsed + } + if (!model.trim()) { setError('Enter a model name.'); return } + if (rates.input === undefined || rates.output === undefined) { setError('Input and output rates are required.'); return } + setError('') + void codeburn.setPriceOverride(model.trim(), rates).then(result => complete(result, true)) + } + + return
+

Pricing

Override or add per-model rates so local or self-hosted models are priced. Rates are USD per 1,000,000 tokens.

+
+ {overrides.error ? : !overrides.data ?

Loading price overrides…

: overrides.data.overrides.length === 0 ?

No price overrides configured. Add one below to price an unrecognized or local model.

: overrides.data.overrides.map(override =>
{override.model}{priceRateSummary(override)} void codeburn.removePriceOverride(override.model).then(result => complete(result))} />
)} +
+ setModel(event.target.value)} /> + setInput(event.target.value)} /> + setOutput(event.target.value)} /> + setCacheRead(event.target.value)} /> + setCacheCreation(event.target.value)} /> + +
+ {error &&

{error}

} +
+

Rates are USD per 1,000,000 tokens. Input and output are required; cache read and cache creation are optional. A configured model is overridden; an unknown one is added.

+
+} + +function planSummaries(status: StatusJson): JsonPlanSummary[] { + if (status.plans) return Object.values(status.plans).filter((plan): plan is JsonPlanSummary => Boolean(plan)) + return status.plan ? [status.plan] : [] +} + +function DetectedRow({ quota, onReconnect }: { quota: QuotaProvider; onReconnect: () => void }) { + const name = quota.provider === 'claude' ? 'Claude' : 'Codex' + return
+ + {name} + {quota.connection === 'disconnected' || quota.connection === 'accessDenied' + ?
+ : {quota.planLabel ?? 'Connected'}} +
+} + +function PlansPane({ period, refreshToken, onNavigate }: { period: Period; refreshToken: number; onNavigate?: (section: Section) => void }) { + const [nonce, setNonce] = useState(0) + // Steady poll serves cached quota (force=false); the Connect affordance's + // Refresh forces a keychain-allowed fetch via the same path as Plans.tsx. + const [reconnectNonce, setReconnectNonce] = useState(0) + const lastForced = useRef(`${refreshToken}:${reconnectNonce}`) + const quota = usePolled(() => { + const key = `${refreshToken}:${reconnectNonce}` + const force = key !== lastForced.current + lastForced.current = key + return codeburn.getQuota(force) + }, [refreshToken, reconnectNonce]) + const plans = usePolled(() => codeburn.getPlans(period), [period, refreshToken, nonce]) + const [presetId, setPresetId] = useState(MANUAL_PLAN_PRESETS[0]!.id) + const configured = plans.data ? planSummaries(plans.data) : [] + + const finish = (result: ActionResult) => { + showToast(result.ok ? (result.stdout.trim() || 'Plan updated') : (result.stderr || 'Plan action failed'), result.ok ? 'ok' : 'error') + if (result.ok) setNonce(value => value + 1) + } + const remove = (plan: JsonPlanSummary) => { + void codeburn.resetPlan(plan.provider).then(finish) + } + const add = () => { + const preset = MANUAL_PLAN_PRESETS.find(item => item.id === presetId)! + void codeburn.setPlan(preset.id, preset.provider).then(finish) + } + + return
+

Plans

Claude and Codex subscriptions connect and auto-detect your tier. Set a manual budget plan for any other provider.

+
+
+
Detected subscriptions
+ {quota.error && !quota.data ? : !quota.data ?

Detecting subscriptions…

: quota.data.length === 0 ?

No detectable subscriptions.

: quota.data.map(provider => setReconnectNonce(value => value + 1)} />)} +
+
+
+
+
Budget plans (manual)
+ {plans.error ? : !plans.data ?

Loading plans…

: configured.length === 0 ?

No manual plans configured.

: configured.map(plan =>
{PLAN_PRESETS.find(item => item.id === plan.id)?.label ?? plan.id}{formatConverted(plan.budget)}/month · {plan.provider} · {plan.percentUsed}% used{(plan.provider === 'claude' || plan.provider === 'codex') && superseded by the detected subscription} remove(plan)} />
)} +
+
+
({ value: preset.id, label: preset.label }))} onChange={value => setPresetId(value as PlanPreset['id'])} width={160} />
+
+
+

Claude and Codex plans are detected automatically from your login.

+
+} + +function ExportPane({ period, refreshToken }: { period: Period; refreshToken: number }) { + const overview = usePolled(() => codeburn.getOverview(period, 'all'), [period, refreshToken]) + const [format, setFormat] = useState<'csv' | 'json'>('csv') + const [provider, setProvider] = useState('all') + const [destination, setDestination] = useState(null) + const [exporting, setExporting] = useState(false) + const providers = Object.keys(overview.data?.current.providers ?? {}) + + const chooseDirectory = async () => { + const selected = await codeburn.chooseDirectory() + if (selected) setDestination(selected) + } + const exportNow = async () => { + if (!destination) return + setExporting(true) + try { + const result = await codeburn.exportData(format, provider, destination) + showToast(result.ok ? `Exported to ${destination}` : (result.stderr || 'Export failed'), result.ok ? 'ok' : 'error') + } finally { + setExporting(false) + } + } + + return
+

Export

Save your usage as CSV or JSON. Everything stays on your machine.

+
+
+
Format
+
({ value, label: value.charAt(0).toUpperCase() + value.slice(1) }))]} onChange={setProvider} width={150} />
+
Destination{destination ?? 'Choose a folder…'}
+
+
+
+

CSV writes a folder (summary, daily, models, projects, sessions, tools, mcp). JSON writes one file (schema codeburn.export.v2).

+
+} + +function DevicesPane({ period, refreshToken }: { period: Period; refreshToken: number }) { + const [nonce, setNonce] = useState(0) + const identity = usePolled(() => codeburn.getIdentity(), [refreshToken]) + const shareStatus = usePolled(() => codeburn.getShareStatus(), [refreshToken]) + const scan = usePolled(() => codeburn.getDevicesScan(), [refreshToken, nonce]) + const devices = usePolled(() => codeburn.getDevices(period), [period, refreshToken, nonce]) + const refresh = () => setNonce(value => value + 1) + return

Devices

Combine usage across your machines.

+} + +function PrivacyPane() { + return

Privacy & data

What codeburn does, and does not do, with your data.

+ } /> + } /> + } /> +
+} + +function PrivacyClaim({ title, detail, icon }: { title: string; detail: string; icon: React.ReactNode }) { + return
{title}
{detail}
+} + +function ThisDevicePanel({ identity, shareStatus }: { identity: ReturnType>; shareStatus: ReturnType> }) { + const status = shareStatus.data ? {shareStatus.data.sharing ? 'Visible' : 'Not sharing'} : null + return {identity.data ?
{identity.data.name}Local device name: {identity.data.name}{identity.data.fingerprint}
: identity.error ? :

Reading this device identity…

}{shareStatus.error && }
+} + +function DiscoveredPanel({ scan }: { scan: ReturnType> }) { + const found = scan.data?.found.filter(device => !device.paired) ?? [] + return {!scan.data && scan.error ? : !scan.data ?

listening…

: found.length === 0 ?

No nearby devices found.

: found.map(device =>
{device.name}fingerprint {shortFingerprint(device.fingerprint)}
)}

To pair a device, run codeburn devices add in a terminal. Pairing is interactive (approve on the other device).

+} + +function PairedPanel({ devices, period, onRefresh }: { devices: ReturnType>; period: Period; onRefresh: () => void }) { + const [error, setError] = useState('') + const paired = devices.data?.perDevice.filter(device => !device.local) ?? [] + const remove = (name: string) => { + void codeburn.removeDevice(name).then(result => { + if (!result.ok) { setError(result.stderr || 'Unable to remove device'); return } + setError('') + onRefresh() + }) + } + return Refresh}>{!devices.data && devices.error ? : !devices.data ?

Loading paired devices…

: paired.length === 0 ?

No paired devices yet.

: paired.map(device =>
{device.name}{device.sessions.toLocaleString('en-US')} sessions · {formatUsd(device.cost)} {periodLabel(period)}
remove(device.name)} />
)}{devices.data && devices.data.combined.deviceCount > 1 &&
Combined view active · {devices.data.combined.deviceCount} devices
}{error &&

{error}

}
+} + +function SettingsErrorText({ error }: { error: CliError }) { + if (error.kind === 'not-found') { const display = cliErrorDisplay(error); return

{display.title}

} + return +} diff --git a/app/renderer/sections/Spend.test.tsx b/app/renderer/sections/Spend.test.tsx new file mode 100644 index 0000000..b79fe54 --- /dev/null +++ b/app/renderer/sections/Spend.test.tsx @@ -0,0 +1,386 @@ +// @vitest-environment jsdom +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { MenubarPayload, SpendFlow } from '../lib/types' +import { Spend } from './Spend' + +const { getOverview, getSpendFlow } = vi.hoisted(() => ({ + getOverview: vi.fn<(period: string, provider: string) => Promise>(), + getSpendFlow: vi.fn<(period: string, provider: string) => Promise>(), +})) +vi.mock('../lib/ipc', async orig => { + const actual = await orig() + return { ...actual, codeburn: { getOverview, getSpendFlow } } +}) + +function daily(date: string, cost: number, models: Array<{ name: string; cost: number }>) { + return { + date, + cost, + savingsUSD: 0, + calls: 10, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + topModels: models.map(m => ({ + name: m.name, + cost: m.cost, + savingsUSD: 0, + calls: 5, + inputTokens: 0, + outputTokens: 0, + })), + } +} + +function makePayload(now: Date): MenubarPayload { + return { + generated: now.toISOString(), + 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: [{ name: 'coding', cost: 42, savingsUSD: 0, turns: 12, oneShotRate: null }], + topModels: [], + localModelSavings: { totalUSD: 0, calls: 0, byModel: [], byProvider: [] }, + providers: {}, + topProjects: [ + { + name: 'codeburn', + cost: 246.1, + savingsUSD: 0, + sessions: 124, + avgCostPerSession: 1.98, + sessionDetails: [], + }, + { + name: 'agentseal-dash', + cost: 141.3, + savingsUSD: 0, + sessions: 74, + avgCostPerSession: 1.91, + sessionDetails: [], + }, + ], + modelEfficiency: [], + topSessions: [], + retryTax: { totalUSD: 0, retries: 0, editTurns: 0, byModel: [] }, + routingWaste: { totalSavingsUSD: 0, baselineModel: '', baselineCostPerEdit: 0, byModel: [] }, + tools: [{ name: 'Read', calls: 30 }], + skills: [{ name: 'imagegen', turns: 3, cost: 1.25 }], + subagents: [{ name: 'reviewer', calls: 2, cost: 2.5 }], + mcpServers: [{ name: 'filesystem', calls: 9 }], + }, + optimize: { findingCount: 0, savingsUSD: 0, topFindings: [] }, + history: { + daily: [ + daily('2026-06-30', 11, [{ name: 'claude-opus-4', cost: 11 }]), + daily('2026-07-01', 12, [{ name: 'gpt-5.5-codex', cost: 12 }]), + daily('2026-07-04', 13, [{ name: 'claude-opus-4', cost: 9 }, { name: 'claude-sonnet-5', cost: 4 }]), + daily('2026-07-06', 8, [{ name: 'claude-haiku-4', cost: 8 }]), + daily('2026-07-10', 15, [{ name: 'gpt-5.5-codex', cost: 15 }]), + ], + }, + } +} + +function makeFlow(): SpendFlow { + return { + period: { label: 'Last 7 days', start: '2026-07-04', end: '2026-07-10' }, + models: [ + { id: 'claude-opus-4-20260701', label: 'claude-opus-4-20260701', cost: 22 }, + { id: 'gpt-5.5-codex', label: 'gpt-5.5-codex', cost: 18 }, + ], + projects: [ + { id: '/Users/me/src/mobile-app', label: '/Users/me/src/mobile-app', cost: 30 }, + { id: '__other__', label: '__other__', cost: 10 }, + ], + links: [ + { model: 'claude-opus-4-20260701', project: '/Users/me/src/mobile-app', cost: 18 }, + { model: 'claude-opus-4-20260701', project: '__other__', cost: 4 }, + { model: 'gpt-5.5-codex', project: '/Users/me/src/mobile-app', cost: 12 }, + { model: 'gpt-5.5-codex', project: '__other__', cost: 6 }, + ], + } +} + +function emptyFlow(): SpendFlow { + return { + period: { label: 'Last 7 days', start: '2026-07-04', end: '2026-07-10' }, + models: [], + projects: [], + links: [], + } +} + +describe('Spend', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date(2026, 6, 10, 12, 0, 0)) + getOverview.mockReset() + getSpendFlow.mockReset() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('zero-fills a contiguous 15-day calendar window with a real date axis, projects, and Sankey ribbons', async () => { + getOverview.mockResolvedValue(makePayload(new Date())) + getSpendFlow.mockResolvedValue(makeFlow()) + + const { container } = render() + + expect(await screen.findByText('codeburn')).toBeInTheDocument() + expect(screen.getByText('$246.10')).toBeInTheDocument() + expect(screen.getByText('agentseal-dash')).toBeInTheDocument() + expect(screen.getByText('top 2')).toBeInTheDocument() + + // Sparse history is zero-filled into 15 contiguous days ending today (Jul 10), + // so the axis reflects real calendar spacing rather than compressing gaps. + const barColumns = container.querySelectorAll('.sbars .c') + expect(barColumns).toHaveLength(15) + expect([...barColumns].map(col => col.getAttribute('data-date'))).toEqual([ + '2026-06-26', '2026-06-27', '2026-06-28', '2026-06-29', '2026-06-30', + '2026-07-01', '2026-07-02', '2026-07-03', '2026-07-04', '2026-07-05', + '2026-07-06', '2026-07-07', '2026-07-08', '2026-07-09', '2026-07-10', + ]) + const ticks = container.querySelectorAll('.sbars-wrap > .ov-xax span') + expect([...ticks].map(tick => tick.textContent)).toEqual(['Jun 26', 'Jun 30', 'Jul 4', 'Jul 8', 'Jul 10']) + + expect(container.querySelectorAll('[data-testid="sankey-ribbon"]')).toHaveLength(makeFlow().links.length) + }) + + it('renders the chart, projects, Sankey, and all non-empty breakdowns on one page', async () => { + getOverview.mockResolvedValue(makePayload(new Date())) + getSpendFlow.mockResolvedValue(makeFlow()) + + render() + expect(await screen.findByLabelText('Daily spend by model')).toBeInTheDocument() + expect(screen.getByText('By project')).toBeInTheDocument() + expect(screen.getByText('Cost flow · model → project')).toBeInTheDocument() + expect(screen.getByText('Activity')).toBeInTheDocument() + expect(screen.getByText('coding')).toBeInTheDocument() + expect(screen.getByText('imagegen')).toBeInTheDocument() + expect(screen.getByText('Tools')).toBeInTheDocument() + expect(screen.getByText('Read')).toBeInTheDocument() + expect(screen.getByText('MCP')).toBeInTheDocument() + expect(screen.getByText('filesystem')).toBeInTheDocument() + expect(screen.getByText('Subagents')).toBeInTheDocument() + expect(screen.getByText('reviewer')).toBeInTheDocument() + }) + + it.each(['Activity', 'Tools', 'MCP', 'Subagents'])('hides an empty %s breakdown', async title => { + const payload = makePayload(new Date()) + if (title === 'Activity') { + payload.current.topActivities = [] + payload.current.skills = [] + } else if (title === 'Tools') { + payload.current.tools = [] + } else if (title === 'MCP') { + payload.current.mcpServers = [] + } else { + payload.current.subagents = [] + } + getOverview.mockResolvedValue(payload) + getSpendFlow.mockResolvedValue(makeFlow()) + + render() + expect(await screen.findByText('codeburn')).toBeInTheDocument() + expect(screen.queryByText(title)).not.toBeInTheDocument() + }) + + it('shows one compact empty state when every breakdown is empty', async () => { + const payload = makePayload(new Date()) + payload.current.topActivities = [] + payload.current.skills = [] + payload.current.tools = [] + payload.current.mcpServers = [] + payload.current.subagents = [] + getOverview.mockResolvedValue(payload) + getSpendFlow.mockResolvedValue(makeFlow()) + + render() + + expect(await screen.findByText('No activity, tool, MCP, or subagent data in this range yet.')).toBeInTheDocument() + expect(screen.queryByText('Activity')).not.toBeInTheDocument() + expect(screen.queryByText('Tools')).not.toBeInTheDocument() + expect(screen.queryByText('MCP')).not.toBeInTheDocument() + expect(screen.queryByText('Subagents')).not.toBeInTheDocument() + }) + + it('does not render the removed lens tabs', async () => { + getOverview.mockResolvedValue(makePayload(new Date())) + getSpendFlow.mockResolvedValue(makeFlow()) + + render() + expect(await screen.findByText('codeburn')).toBeInTheDocument() + + for (const name of ['Projects', 'Activity', 'Tools', 'MCP', 'Subagents']) { + expect(screen.queryByRole('button', { name })).not.toBeInTheDocument() + } + }) + + it('groups the top panels and breakdown panels in their page grids', async () => { + getOverview.mockResolvedValue(makePayload(new Date())) + getSpendFlow.mockResolvedValue(makeFlow()) + + const { container } = render() + expect(await screen.findByText('codeburn')).toBeInTheDocument() + + expect(container.querySelector('.spend-top-row')?.children).toHaveLength(2) + expect(container.querySelector('.spend-breakdowns')?.children).toHaveLength(4) + }) + + it('renders empty chart and flow states when no daily spend exists', async () => { + const payload = makePayload(new Date()) + payload.history.daily = [] + getOverview.mockResolvedValue(payload) + getSpendFlow.mockResolvedValue(emptyFlow()) + + const { container } = render() + + expect(await screen.findByText('No model spend in this range yet.')).toBeInTheDocument() + expect(container.querySelector('.sbars')).not.toBeInTheDocument() + expect(await screen.findByText('No model-project flow in this range yet.')).toBeInTheDocument() + }) + + it('renders the flow error path without hiding the rest of spend', async () => { + getOverview.mockResolvedValue(makePayload(new Date())) + getSpendFlow.mockRejectedValue({ kind: 'nonzero', message: 'flow command failed' }) + + render() + + expect(await screen.findByText('codeburn')).toBeInTheDocument() + expect(await screen.findByText('flow command failed')).toBeInTheDocument() + }) + + it('renders the not-found panel when codeburn is not on PATH', async () => { + getOverview.mockRejectedValue({ kind: 'not-found', message: 'codeburn not found' }) + getSpendFlow.mockResolvedValue(emptyFlow()) + + render() + + expect(await screen.findByText('Locate the codeburn CLI')).toBeInTheDocument() + expect(screen.getByText(/isn't on your PATH yet/)).toBeInTheDocument() + }) + + it('maps stacked segments to the expected model series classes', async () => { + const payload = makePayload(new Date()) + payload.history.daily = [ + daily('2026-07-10', 25, [ + { name: 'claude-opus-4', cost: 5 }, + { name: 'claude-sonnet-5', cost: 5 }, + { name: 'claude-haiku-4', cost: 5 }, + { name: 'gpt-5.5-codex', cost: 5 }, + { name: 'mystery-model', cost: 5 }, + ]), + ] + getOverview.mockResolvedValue(payload) + getSpendFlow.mockResolvedValue(makeFlow()) + + const { container } = render() + expect(await screen.findByLabelText('Daily spend by model')).toBeInTheDocument() + + expect(container.querySelector('.sbars .s-opus')).toBeInTheDocument() + expect(container.querySelector('.sbars .s-son')).toBeInTheDocument() + expect(container.querySelector('.sbars .s-hai')).toBeInTheDocument() + expect(container.querySelector('.sbars .s-gpt')).toBeInTheDocument() + expect(container.querySelector('.sbars .s-other')).toBeInTheDocument() + expect(screen.getByText('Opus')).toBeInTheDocument() + expect(screen.getByText('Sonnet')).toBeInTheDocument() + expect(screen.getByText('Haiku')).toBeInTheDocument() + expect(screen.getByText('GPT / Codex')).toBeInTheDocument() + expect(screen.getByText('Other')).toBeInTheDocument() + expect(screen.queryByText('Opus 4.8')).not.toBeInTheDocument() + expect(screen.queryByText('Sonnet 5')).not.toBeInTheDocument() + expect(screen.queryByText('Haiku 4.5')).not.toBeInTheDocument() + expect(screen.queryByText('GPT-5.5 Codex')).not.toBeInTheDocument() + }) + + it('renders Sankey ribbons with model gradients, neutral other nodes, and shortened labels', async () => { + getOverview.mockResolvedValue(makePayload(new Date())) + getSpendFlow.mockResolvedValue(makeFlow()) + + const { container } = render() + + expect(await screen.findByText(/opus-4-20260701/)).toBeInTheDocument() + expect(screen.getByText(/gpt-5.5-codex/)).toBeInTheDocument() + expect(screen.queryByText(/Opus 4.8/)).not.toBeInTheDocument() + expect(screen.queryByText(/GPT-5.5 Codex/)).not.toBeInTheDocument() + expect(screen.getByText(/src\/mobile-app/)).toBeInTheDocument() + expect(screen.queryByText(/Users\/me\/src\/mobile-app/)).not.toBeInTheDocument() + + const opusRibbon = container.querySelector('[data-testid="sankey-ribbon"][data-model="claude-opus-4-20260701"]') + expect(opusRibbon?.getAttribute('stroke')).toBe('url(#sankey-claude-opus-4-20260701)') + const opusStop = container.querySelector('linearGradient[id="sankey-claude-opus-4-20260701"] stop') + expect(opusStop?.getAttribute('stop-color')).toBe('var(--s-opus)') + const otherNode = container.querySelector('[data-testid="sankey-node"][data-node-id="__other__"]') + expect(otherNode?.getAttribute('fill')).toBe('var(--s-other)') + }) + + it('expands a project row inline to reveal its sessions, one open row at a time', async () => { + const user = userEvent.setup() + const payload = makePayload(new Date()) + payload.current.topProjects[0].sessionDetails = [ + { + cost: 120.5, savingsUSD: 0, calls: 40, inputTokens: 0, outputTokens: 0, date: '2026-07-09', + models: [ + { name: 'claude-opus-4', cost: 100, savingsUSD: 0 }, + { name: 'claude-haiku-4', cost: 20.5, savingsUSD: 0 }, + ], + }, + { + cost: 44.25, savingsUSD: 0, calls: 12, inputTokens: 0, outputTokens: 0, date: '2026-07-05', + models: [{ name: 'gpt-5.5-codex', cost: 44.25, savingsUSD: 0 }], + }, + ] + payload.current.topProjects[1].sessionDetails = [ + { + cost: 9.9, savingsUSD: 0, calls: 3, inputTokens: 0, outputTokens: 0, date: '2026-07-02', + models: [{ name: 'claude-sonnet-5', cost: 9.9, savingsUSD: 0 }], + }, + ] + getOverview.mockResolvedValue(payload) + getSpendFlow.mockResolvedValue(makeFlow()) + + render() + + const first = await screen.findByRole('button', { name: /codeburn/ }) + const second = screen.getByRole('button', { name: /agentseal-dash/ }) + expect(first).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByRole('region', { name: 'codeburn sessions' })).not.toBeInTheDocument() + + await user.click(first) + expect(first).toHaveAttribute('aria-expanded', 'true') + const detail = screen.getByRole('region', { name: 'codeburn sessions' }) + expect(within(detail).getByText('Jul 9')).toBeInTheDocument() + expect(within(detail).getByText('$120.50')).toBeInTheDocument() + expect(within(detail).getByText('claude-opus-4')).toBeInTheDocument() + expect(within(detail).getByText('Jul 5')).toBeInTheDocument() + expect(within(detail).getByText('$44.25')).toBeInTheDocument() + + // Expanding another project collapses the first (single open row). + await user.click(second) + expect(second).toHaveAttribute('aria-expanded', 'true') + expect(first).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByRole('region', { name: 'codeburn sessions' })).not.toBeInTheDocument() + expect(within(screen.getByRole('region', { name: 'agentseal-dash sessions' })).getByText('$9.90')).toBeInTheDocument() + + // Clicking the open row collapses it in place. + await user.click(second) + expect(second).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByRole('region', { name: 'agentseal-dash sessions' })).not.toBeInTheDocument() + }) +}) diff --git a/app/renderer/sections/Spend.tsx b/app/renderer/sections/Spend.tsx new file mode 100644 index 0000000..a9dc3b3 --- /dev/null +++ b/app/renderer/sections/Spend.tsx @@ -0,0 +1,235 @@ +import { Fragment, useState } from 'react' + +import { CliErrorPanel, CliErrorText } from '../components/CliErrorPanel' +import { EmptyNote } from '../components/EmptyState' +import { ListRow } from '../components/ListRow' +import { Panel } from '../components/Panel' +import { Sankey } from '../components/Sankey' +import { SectionSkeleton } from '../components/Skeleton' +import { StackedBars } from '../components/StackedBars' +import { StaleBanner } from '../components/StaleBanner' +import { type Polled, usePolled } from '../hooks/usePolled' +import { formatUsd } from '../lib/format' +import { codeburn } from '../lib/ipc' +import { contiguousDailyWindow, localDateKey } from '../lib/period' +import type { CliError, DateRange, MenubarPayload, Period, SpendFlow } from '../lib/types' + +type Project = MenubarPayload['current']['topProjects'][number] + +/** Date-only CLI strings ("2026-07-11") formatted at local noon so the calendar day never rolls across time zones. */ +function formatProjectDay(date: string): string { + const d = new Date(`${date}T12:00:00`) + return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) +} + +const SPEND_CHART_DAYS = 15 + +function providerLabel(provider: string): string { + if (provider === 'all') return 'All models' + return provider + .split(/[-\s]+/) + .filter(Boolean) + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') +} + +export function Spend({ period, provider, range = null }: { period: Period; provider: string; range?: DateRange | null }) { + const overview = usePolled( + () => range ? codeburn.getOverview(period, provider, range) : codeburn.getOverview(period, provider), + [period, provider, range?.from, range?.to], + ) + return +} + +export function SpendContent({ + period, + provider, + range = null, + overview, + refreshToken = 0, +}: { + period: Period + provider: string + range?: DateRange | null + overview: Polled + refreshToken?: number +}) { + const flow = usePolled( + () => range ? codeburn.getSpendFlow(period, provider, range) : codeburn.getSpendFlow(period, provider), + [period, provider, range?.from, range?.to, refreshToken], + ) + + if (!overview.data) { + if (overview.error) return + return + } + + const animateKey = `${period}|${provider}|${range?.from ?? ''}|${range?.to ?? ''}` + return +} + +function SpendPage({ + data, + flow, + provider, + range, + staleError, + animateKey, +}: { + data: MenubarPayload + flow: ReturnType> + provider: string + range: DateRange | null + staleError: CliError | null + animateKey: string +}) { + // `history.daily` is SPARSE (active days only), so zero-fill a contiguous + // calendar window client-side; date keys are localDateKey / the CLI dateKey, + // which match exactly, so real days always land in place. + const now = new Date() + const chartDaily = range + ? contiguousDailyWindow(data.history.daily, range.from, range.to) + : contiguousDailyWindow( + data.history.daily, + localDateKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - (SPEND_CHART_DAYS - 1))), + localDateKey(now), + ) + const chartHasSpend = chartDaily.some(day => day.cost > 0) + const projects = data.current.topProjects + const breakdowns = [ + { + title: 'Activity', + rows: [ + ...data.current.topActivities.map(row => ({ + key: `activity-${row.name}`, + title: row.name, + sub: `${row.turns.toLocaleString('en-US')} turns`, + value: formatUsd(row.cost), + })), + ...data.current.skills.map(row => ({ + key: `skill-${row.name}`, + title: row.name, + sub: `${row.turns.toLocaleString('en-US')} turns · skill`, + value: formatUsd(row.cost), + })), + ], + }, + { + title: 'Tools', + rows: data.current.tools.map(row => ({ + key: row.name, + title: row.name, + sub: `${row.calls.toLocaleString('en-US')} calls`, + value: undefined, + })), + }, + { + title: 'MCP', + rows: data.current.mcpServers.map(row => ({ + key: row.name, + title: row.name, + sub: `${row.calls.toLocaleString('en-US')} calls`, + value: undefined, + })), + }, + { + title: 'Subagents', + rows: data.current.subagents.map(row => ({ + key: row.name, + title: row.name, + sub: `${row.calls.toLocaleString('en-US')} calls`, + value: formatUsd(row.cost), + })), + }, + ].filter(section => section.rows.length) + + return ( + <> + {staleError && } +
+ + {chartHasSpend ? : No model spend in this range yet.} + + +
+ + + {flow.data && flow.data.links.length ? ( + + ) : flow.error ? ( + + ) : ( + {flow.loading ? 'Loading cost flow…' : 'No model-project flow in this range yet.'} + )} + + +
+ {breakdowns.length ? ( + breakdowns.map(section => ) + ) : ( + No activity, tool, MCP, or subagent data in this range yet. + )} +
+ + ) +} + +function ProjectBreakdown({ projects }: { projects: Project[] }) { + const [expanded, setExpanded] = useState(null) + + return ( + + {projects.length ? ( + projects.map((project, i) => { + const open = expanded === project.name + return ( + + setExpanded(current => current === project.name ? null : project.name)} + /> + {open && ( +
+ {project.sessionDetails.length ? ( + project.sessionDetails.map((session, j) => ( +
+ {formatProjectDay(session.date)} + {session.models[0]?.name ?? '—'} + {session.calls.toLocaleString('en-US')} calls + {formatUsd(session.cost)} +
+ )) + ) : ( +
No session detail for this project.
+ )} +
+ )} +
+ ) + }) + ) : ( + No project spend in this range yet. + )} +
+ ) +} + +function RowsPanel({ + title, + rows, +}: { + title: string + rows: Array<{ key: string; title: string; sub: string; value?: string }> +}) { + return ( + + {rows.map((row, i) => ( + + ))} + + ) +} diff --git a/app/renderer/styles/indigo.css b/app/renderer/styles/indigo.css new file mode 100644 index 0000000..60022cd --- /dev/null +++ b/app/renderer/styles/indigo.css @@ -0,0 +1,150 @@ +/* Ported verbatim from codeburn-desktop-wireframes.html + +
+
+

CodeBurn Desktop — indigo instrument

+

Wireframes v6. Cool near-black system with clean gradients: indigo→violet as the single UI gradient, blue/purple/lavender/cyan as the model-series palette, capsule gradient bars, and a Sankey cost-flow as the premium signature. Machined base kept: hairlines, mono digits, keycaps, scope labels.

+

v6.1 · 2026-07-10 · references: Dash0 AI-coding dashboard (series palette, card headers, Sankey), gradient-capsule chart, ambient glow · 30-day default period (7 bars never balloon again) · shiny orange CodeBurn brand mark · full period vocabulary: Today / 7D / 30D / Month / 6M / Custom

+
+ +
+

Design language

+

One gradient for the UI (indigo→violet: active rail, primary button, plan tracks, toggle). Data gets the series palette. Trouble is text color only — amber pace, red overage, mint savings.

+
+
Canvas#0B0D13 + ambient glow
+
Panel#12151F / head #161A27
+
UI gradient#5B8CFF → #8B7CF6
+
Opus#5B8CFF
+
Sonnet#8B7CF6
+
Haiku#B5A8FF
+
GPT / Codex#4DD8E6
+
Savings#3ECF8E
+
Pace#E8B93E
+
Overage#F26D6D
+
+
    +
  • One UI gradientIndigo→violet marks what's interactive or active. It never colors data.
  • +
  • Series palette for dataEach model owns a hue everywhere — chart, legend, Sankey, table dot. Recognition over decoration.
  • +
  • Capsule barsHistory in muted slate capsules; the emphasized bar gets the blue→cyan gradient and a soft glow.
  • +
  • Card = header + bodyDash0-style header strip carries the title and controls, keeping bodies pure content.
  • +
  • Status is textAmber pace, red overage, mint savings. No dots, no pills, no gradient alarms.
  • +
+
+ + +
+

01 · Overview

+

Ten-second triage. Big clean numbers, one glowing capsule chart, the damage list.

+
+
+ +
+
+
Overview
+ Last 30 days · All providers · 2 devices +
+
Today7D30DMonth6MCustom
+
All providers
+
+
+
+
Today
$6.20
12 sessions
+
Month to date
$312.40
+24% vs June pace
+
Projected month
$968 est
$748 over plans
+
Waste found
$23.60 /wk
3 fixes ready
+
+
+
Daily spendbiggest driver: Opus 4.8 in codeburn, +61% — why ›
+
+
+
32
+
16
+
0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Jun 11Jun 18Jun 25Jul 2Jul 10
+
+
+
+
Most expensive sessionsSee all ›
+
+
01
Refactor parser OOM buffer readercodeburn · Wed 14:32 · Opus 4.8 · 41 turns
$8.41
+
02
Device pairing e2e + mTLS handshakecodeburn · Tue 10:05 · Opus 4.8 · 33 turns
$6.12
+
03
Honeycomb logo iterationsagentseal-dash · Mon 16:48 · Sonnet 5 · 27 turns
$4.98
+
+
+
+
⌘KCommand⌘EExport viewDrill inrefreshed 30s ago
+
+
+
+

Notes. Capsule bars: slate history, gradient + glow on the peak, plain blue on second-highest — the eye finds Monday instantly. Session rows carry the model's series dot. Deltas: blue = neutral info, amber = pace, red = overage, mint = good.

+
+ + +
+

02 · Spend

+

Stacked series by model, then the signature: cost flowing model → project as a Sankey. Every ribbon is a filterable claim.

+
+
+ +
+
+
Spend
+ Last 30 days · All providers +
+
Today7D30DMonth6MCustom
+
All providers
+
+
+
ProjectsActivityToolsMCPSubagents
+
+
+
Daily spend by model
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Opus 4.8 + Sonnet 5 + Haiku 4.5 + GPT-5.5 Codex +
+
+
+
+
By project16 projects
+
+
01
codeburn124 sessions
$246.10
+
02
agentseal-dash74 sessions
$141.30
+
03
hermes-eval38 sessions
$77.85
+
04
client-api est29 sessions · Cursor
$58.60
+
05
dotfiles21 sessions
$34.20
+
+
+
+
+
Cost flow · model → projectclick a ribbon to filter
+
+ + + + + + + + + + + + + + + + + + + + + + + + Opus 4.8 · $331.20 + GPT-5.5 · $137.90 + Sonnet 5 · $108.63 + Haiku 4.5 · $34.75 + codeburn · $246.10 + agentseal-dash · $141.30 + hermes-eval · $77.85 + others · $147.23 + +
+
+
+
⌘KCommand⌘EExport viewSwitch lens
+
+
+
+

Notes. The Sankey is the premium signature — each ribbon inherits its model's hue and fades toward the project side; clicking one scopes every panel to that model × project pair. Stacked bars share the same series palette, so the legend teaches colors once for the whole app.

+
+ + +
+

03 · Optimize

+

Waste, reverts, abandoned, fixes. Findings ranked by recoverable dollars, evidence in mono, savings in mint.

+
+
+ +
+
+
Optimize
+ Last 30 days · All providers +
+
Today7D30DMonth6MCustom
+
All providers
+
+
+
Waste $94.40Reverts $107.00Abandoned $65.40Fixes 3
+
+
+
+ 01 +
+ Opus is doing your small talk + 38% of Opus 4.8 turns had zero tool calls — pure conversation. Sonnet answers the same turns at 1/5 the price. +
856 turns · 124 sessions › · 856 × $0.052 − sonnet $0.011
+
/model sonnetCopy fix
+
+ $9.10/wk +
+
+ 02 +
+ Cache hit is low in agentseal-dash + 41% vs your 68% average — sessions restart context instead of resuming. +
72 sessions › · 37.6M uncached reads × $0.93/M delta
+
claude --continueCopy fix
+
+ $8.70/wk +
+
+ 03 +
+ Twelve sessions went nowhere + $39.20 across 12 abandoned sessions with no surviving edits — nearly all started without a stated goal. +
no surviving edits vs git · 12 sessions › · full list under Abandoned
+
+ $5.80/wk +
+
+
+
+
⌘CCopy fixOpen sessions
+
+
+
+

Notes. The old Yield analysis lives under Reverts and Abandoned (git-correlated, per codeburn yield). Segments carry their dollar totals so the tab row itself is a summary.

+
+ + +
+

04 · Models

+

The pricing table with each model's series dot. Compare is a mode; unpriced rows carry their own fix.

+
+
+ +
+
+
Models
+ Last 30 days · All providers +
+
By modelBy task
+ Compare… +
+
+
+
+ + + + + + + + + +
ModelCallsInputOutputCache readCostSaved
Claude Opus 4.84,812152.6M9.64M119.4M$331.20$86.40
GPT-5.5 Codex2,70486.9M7.52M45.1M$137.90$35.10
Claude Sonnet 53,31877.7M6.08M63.3M$108.63$27.90
Claude Haiku 4.51,27032.5M2.84M16.9M$34.75$5.85
my-proxy-model add alias ›1764.8M0.4M$0.00
+
+
+
+
⌘EExport tableSpaceSelect for comparefriendly names · raw IDs on hover
+
+
+
+

Notes. Series dots make the table, charts, and Sankey one continuous vocabulary. By-task nests category rows under each model (models --by-task); Compare… slides a sheet over the table for the selected rows.

+
+ + +
+

05 · Plans

+

Vendor clocks, not analytics periods. The gradient track is UI; the warning is text.

+
+
+ +
+
+
Plans
+ Cycle Jun 15 – Jul 14 · day 26 of 30 +
+
Cycle: Jun 15 – Jul 14
+ Add plan… +
+
+
+
Claude Max$200 / month · claude$186.20 · 93%
+
+
On pace to exceed in 4 days — projected $214 by Jul 14
+
+
+
Cursor Pro$20 / month · cursor$8.20 · 41%
+
+
On track
+
+
+
API usagecodex · pay as you go, no plan$31.02 this cycle
+
+
+
+
Edit planpresets: Claude Max/Pro · Cursor Pro · custom
+
+
+
+

Notes. One overage line per provider plan, mirroring the CLI. Past 100% the fill switches to the red gradient and the overage renders in dollars beside it.

+
+ + +
+

06 · Settings — with Devices inside

+

Own rail, deep-linkable. Pairing is the only amber on screen; Approve is the view's single gradient button.

+
+
+ +
+
Settings
+
+ +
+
+
This device
+
Toruk's MacBook ProVisible on the local network as codeburn-mbp.local
Visibility: on
+
+
+
Discovered nearbylistening…
+
Mac Studiowants to pair · fingerprint 7F:2A:…:C4
Approve
+
+
+
Paired
+
+
toruk-minilast pull 2h ago · 34 sessions · $41.20 this month
Pull now
+
Combine usage from paired devicesscope captions gain “· 2 devices” when on
+
+
+
+
+
escBackpairing uses mutual TLS · approve-style, no PIN
+
+
+
+

Notes. No period chrome — configuration is timeless. Approve-style pairing mirrors the CLI: a human verifies the fingerprint, no PIN.

+
+ + +
+

07 · States & trust

+

Missing tools, denied permissions, empty data are normal for local-first scanning. Text status only.

+
+
+
Empty / permission
+
Claude1,412 sessions
+
Codex688 sessions
+
Cursorpermission denied — grant Full Disk Access
Fix…
+
Devinnot installed
+
+
+
Loading
+ Scanning 30 providers… +

First scan reads every session file; later scans are incremental via the daily cache.

+
+
+
+
+
+
Provenance
+ $31.02 est +

Cursor reports messages without token counts; cost is estimated from message sizes and LiteLLM prices.

+ + +
+
+
+ +
+

Behavior spec

+

+ Inactive window: gradient rail and selection dim to slate. + Focus: indigo focus rings, never browser outlines. + Hover: row fills at 5% lavender; chart hover = column highlight + mono value popover; Sankey ribbons brighten on hover and filter on click. + Scrolling: native overlay scrollbars. + ⌘K palette: filters, export, view jumps, session search. + Series rule: each model's hue is global — chart, Sankey, dots, legends. UI gradient never colors data. + Light theme: same ladder on paper (#FAFBFE, panels white, hairlines 6% indigo-black) — implementation deliverable, same tokens. + Export: ⌘E everywhere, scoped to current filters. + Motion (codex): bars rise on view entry, Sankey ribbons draw left→right, active rail glides — 120–180ms, no bounce. + Depth (codex): cards carry a 1px top-edge light and faint bottom occlusion; active controls get inner highlight + subtle bloom, centers stay dark; 1–2% canvas noise to kill flat Electron darkness. + Rendering (codex): charts locked to device pixel ratio, fixed bar geometry, legends exactly matching series colors — test at 1×/2×. + Numerals: aligned decimals, muted currency symbols, deltas enlarged only when meaningful. + Period control: all six options shown per product decision; codex's alternative (segmented Today·7D·30D·Month + “More ▾” holding 6M/Custom) is on file if the toolbar ever feels crowded. + Orange discipline (codex): the flame mark stays the single warm object — never data, progress, status, nav, or chart highlights. +

+
+ + +
diff --git a/docs/design/codeburn-desktop-plan.md b/docs/design/codeburn-desktop-plan.md new file mode 100644 index 0000000..d81fb5d --- /dev/null +++ b/docs/design/codeburn-desktop-plan.md @@ -0,0 +1,255 @@ +# CodeBurn Desktop Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Each task runs its own TDD cycle (write failing test → confirm red → implement → confirm green → commit). + +**Goal:** A standalone Electron desktop app that renders the approved v6 "indigo instrument" wireframes, fed entirely by spawning the `codeburn` CLI for JSON. + +**Architecture:** New `app/` workspace. Electron **main** process resolves + spawns `codeburn --format json|menubar-json` (plain argv, no shell), decodes typed payloads, pushes to the **renderer** (React 19 + Vite) over a typed `contextBridge` IPC surface, and re-polls on a 30s timer + on demand. All aggregation stays CLI-side; the renderer is a pure view and never fabricates data. Wireframe CSS is ported verbatim as the design system. + +**Tech Stack:** Electron, TypeScript, React 19, Vite 6, Vitest + React Testing Library. Data via `codeburn` CLI subprocess. No new runtime deps in the CLI beyond the two small JSON emitters (T1). + +**Design source of truth:** `codeburn-desktop-wireframes.html` (repo root). Each renderer section is a near-verbatim port of the matching `
` block; do not redesign. + +**Spec:** `docs/design/codeburn-desktop.md`. + +## Global Constraints + +- **Do not modify** `dash/`, `mac/`, `gnome/`, `appstore/`, or the gitignored `desktop/` Tauri experiment. New code lives in `app/` (renderer/main) and `src/` (only the two T1 emitters + their tests). +- **Electron security:** `contextIsolation: true`, `nodeIntegration: false`, `sandbox: true` where feasible; renderer gets Node access ONLY through the typed `preload` bridge. No remote content loaded — renderer is local files only. +- **Data contract = spawn CLI, decode JSON, poll.** No HTTP server, no daemon, no importing `src/` into the app. Never render fabricated numbers; missing/failed data → the wireframe's honest loading/empty/permission states. +- **Binary resolution** mirrors the mac app: resolve `codeburn` via a persisted path file, then `PATH` search (brew/nvm/volta/asdf), then a first-run "locate CLI" state. Reference: `mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift`. +- **Poll cadence:** 30s timer + immediate refresh on period/provider change and manual refresh; hard per-spawn timeout ~45s; cap concurrent spawns. +- **Theme:** dark only this milestone (light is M2). +- **Packaging:** none this milestone — app runs via `npm --prefix app run dev` (Vite + Electron). Signing/notarization/`codeburn app` launcher are M2. +- **Repo pattern:** `app/` is a standalone package (own `package.json`), like `dash/`. No npm workspaces exist. Hook into root `build` via a `build:app` script only in M2; M1 stays self-contained. +- **CLI subcommand flags are `--format json`, not `--json`.** Exact commands are in the Data Contract table below. + +--- + +## Shared Interface Contracts + +These are defined once and referenced by every task. Implementers must use these exact names/types. + +### CLI data contract (what main spawns) + +| Need | Command (argv) | Payload type | Source | New? | +|---|---|---|---|---| +| Overview + Spend bars | `codeburn status --format menubar-json --period

[--provider ]` | `MenubarPayload` | `src/menubar-json.ts:80` | existing | +| Plans pacing | `codeburn status --format json --period

` | JSON incl. `plan` summaries (`PlanUsage`) | `src/plan-usage.ts:110`, wired `src/main.ts:773` | existing | +| Models table | `codeburn models --format json --period

[--provider ] [--by-task]` | `ModelReportRow[]` | `src/models-report.ts:9,551` | existing | +| Optimize reverts/abandoned | `codeburn yield --format json --period

` | `YieldJsonReport` | `src/yield.ts:247` | existing | +| Spend Sankey (model×project) | `codeburn spend --format flow-json --period

[--provider ]` | `SpendFlow` (T1a) | new — `src/spend-flow.ts` | **T1a** | +| Settings/Devices | `codeburn devices --format json --period

` / `codeburn share status --format json` | `CombinedUsage` / `ShareStatus` / `Identity` / scan list | `src/menubar-json.ts:99`, `src/sharing/*` | **T1b** | + +`

` ∈ `today | week | 30days | month | all` (wireframe "Today/7D/30D/Month/6M/Custom" maps to these; `6M`/`Custom` use `--from/--to`). + +Types to mirror verbatim into `app/renderer/lib/types.ts` (copy from the cited files — do not invent): +- `MenubarPayload`, `DailyHistoryEntry`, `DailyModelBreakdown`, `CombinedUsage`, `DeviceSummary` — `src/menubar-json.ts`. +- `ModelReportRow` — `src/models-report.ts:9`. +- `YieldJsonReport` — `src/yield.ts:247`. +- `PlanUsage` — `src/plan-usage.ts` (only the fields the Plans section renders: `plan, periodStart, periodEnd, spentApiEquivalentUsd, budgetUsd, percentUsed, status, projectedMonthUsd, daysUntilReset`). +- `SpendFlow`, `ShareStatus`, `Identity`, scan entry — defined by T1a/T1b below. + +### IPC surface (preload `contextBridge`) + +`app/electron/preload.ts` exposes exactly this on `window.codeburn`; `app/renderer/lib/ipc.ts` wraps it 1:1 with the mirrored types: + +```ts +export interface CodeburnBridge { + getOverview(period: Period, provider: string): Promise // status --format menubar-json + getPlans(period: Period): Promise // status --format json (plan summaries) + getModels(period: Period, provider: string, byTask: boolean): Promise + getYield(period: Period): Promise + getSpendFlow(period: Period, provider: string): Promise // T1a + getDevices(period: Period): Promise // T1b + getShareStatus(): Promise // T1b + getIdentity(): Promise // T1b + cliStatus(): Promise<{ found: boolean; path: string | null; error?: string }> +} +export type Period = 'today' | 'week' | '30days' | 'month' | 'all' +``` + +Each method rejects with a structured `CliError { kind: 'not-found' | 'nonzero' | 'bad-json' | 'timeout'; message: string }` so sections can render the right empty/permission state. Channel names: `codeburn:getOverview`, etc. (one per method). + +### Section → data map (for the six section tasks) + +| Section (wireframe `

`) | Bridge call(s) | +|---|---| +| 01 Overview | `getOverview` | +| 02 Spend | `getOverview` (bars, projects) + `getSpendFlow` (Sankey) | +| 03 Optimize | `getOverview` (`optimize` waste) + `getYield` (reverts/abandoned) | +| 04 Models | `getModels` | +| 05 Plans | `getPlans` | +| 06 Settings/Devices | `getIdentity` + `getDevices` + `getShareStatus` | + +--- + +## File Structure + +``` +app/ +├── package.json # electron, vite, @vitejs/plugin-react, react, react-dom, typescript, vitest, @testing-library/react, jsdom +├── tsconfig.json +├── vite.config.ts # base './', react plugin, build → app/dist/renderer +├── electron/ +│ ├── main.ts # BrowserWindow, 30s timer, ipcMain handlers → spawnCli +│ ├── cli.ts # resolveCodeburnPath() + spawnCli(args): argv, no shell, timeout +│ └── preload.ts # contextBridge → window.codeburn (CodeburnBridge) +├── renderer/ +│ ├── index.html +│ ├── main.tsx +│ ├── App.tsx # sidebar nav + window chrome + section switch (local state) +│ ├── styles/indigo.css # ported wireframe CSS (tokens + classes) +│ ├── lib/{ipc.ts,types.ts} +│ ├── hooks/usePolled.ts # generic 30s poll + loading/error state +│ ├── components/ # Window, Sidebar, TopBar, Panel, Stat, CapsuleChart, +│ │ # StackedBars, Sankey, ListRow, Track, SegTabs, ProviderPop, Hint +│ └── sections/ # Overview, Spend, Optimize, Models, Plans, Settings (+ tests co-located) +│ └── test/setup.ts # RTL + jsdom +└── (no changes to root package.json this milestone) + +src/ # CLI — T1 only +├── spend-flow.ts # T1a: computeSpendFlow() + SpendFlow type +├── main.ts # T1a: register `spend` command; T1b: add --format json to devices/share +└── sharing/… # T1b: json serializers (thin) +tests/ +├── spend-flow.test.ts # T1a +└── devices-json.test.ts # T1b +``` + +--- + +## Task 0 — Scaffold + shell (BLOCKS ALL) · **Opus 4.8** + +**Files:** +- Create: `app/package.json`, `app/tsconfig.json`, `app/vite.config.ts`, `app/renderer/index.html`, `app/renderer/main.tsx`, `app/renderer/App.tsx`, `app/renderer/styles/indigo.css`, `app/electron/{main,cli,preload}.ts`, `app/renderer/lib/{ipc,types}.ts`, `app/renderer/hooks/usePolled.ts`, `app/renderer/test/setup.ts` +- Create components: `app/renderer/components/{Window,Sidebar,TopBar,Panel,Stat,SegTabs,ProviderPop,Hint}.tsx` +- Test: `app/electron/cli.test.ts`, `app/renderer/components/Sidebar.test.tsx` + +**Interfaces:** +- Produces: the full `CodeburnBridge` (stubbed handlers that actually spawn for `getOverview`/`cliStatus`; other methods wired but may throw `not-found` until their emitter lands), `resolveCodeburnPath()`, `spawnCli(args, {timeoutMs}): Promise`, `usePolled(fetcher, deps)`, and all shared `components/` + `styles/indigo.css`. Every later task consumes these. + +**Steps:** +- [ ] **1. Port CSS.** Copy the `