diff --git a/.github/workflows/build-windows-store.yml b/.github/workflows/build-windows-store.yml new file mode 100644 index 00000000..a7e6f034 --- /dev/null +++ b/.github/workflows/build-windows-store.yml @@ -0,0 +1,45 @@ +name: Build Windows Store package + +on: + workflow_dispatch: + pull_request: + paths: + - .github/workflows/build-windows-store.yml + - app/** + - src/** + - package.json + - package-lock.json + +permissions: + contents: read + +jobs: + appx: + runs-on: windows-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 22.13.0 + cache: npm + cache-dependency-path: | + package-lock.json + app/package-lock.json + + - name: Install CLI dependencies + run: npm ci + + - name: Install desktop dependencies + run: npm ci --prefix app + + - name: Build Microsoft Store package + run: npm --prefix app run package:store + + - name: Upload Store package + uses: actions/upload-artifact@v6 + with: + name: CodeBurn-Microsoft-Store + path: app/release/CodeBurn-Store-*.appx + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..d81667d5 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,36 @@ +name: Tests + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v4 + with: + node-version: 22.13.0 + cache: npm + - run: npm ci + - name: Typecheck + run: npx tsc --noEmit + # The cache-refresh-lock files exercise a cross-process file lock and are + # parallelism-sensitive (they fail under full worker pressure and pass serially - + # reproduced repeatedly on unmodified main), so they run in their own serial step + # below instead of making every PR roll dice. + # Scoping (tests/ only, app/ excluded) lives in the package.json test + # script since #948, so CI and a contributor's `npm test` can never drift. + - name: Test suite (parallel) + run: npm test + # Single forked worker, so lock contention comes only from the child processes the + # tests spawn deliberately. Quarantined (reports, never gates): the process + # suite still races its own takeover window even serially on slow runners - + # tracked in #904; drop continue-on-error once that race is settled. + - name: Cache-lock suite (serial, quarantined) + continue-on-error: true + run: npm run test:locks diff --git a/.gitignore b/.gitignore index b71c1592..b733eabe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ -node_modules/ +# No trailing slash: the slash form only ignores directories, so a +# node_modules SYMLINK (common in linked worktrees) slips into git add -A. +# One did exactly that in db018f7 and had to be removed again in c642787. +node_modules dist/ *.tgz diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 00000000..5c5a5255 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,41 @@ +# gitleaks config for codeburn. +# +# Extends the default ruleset and allowlists the cases a full-history audit +# (2026-08-04) confirmed are NOT secrets, so scans stay green and a real leak +# is never buried under recurring false positives. See each entry for why the +# match is safe; nothing here suppresses a live credential. +[extend] +useDefault = true + +[[allowlists]] +description = "Test fixtures: obviously-fake API keys used as parser/validator input." +# sk-live-0123456789abcdef... and sk-live-AKIA1234567890SECRETKEY live in +# packages/core and root test suites purely as decode/redaction fixtures. +condition = "AND" +regexes = [ + '''sk-live-0123456789abcdef''', + '''sk-live-AKIA1234567890SECRETKEY''', +] +paths = ['''(^|/)tests?/'''] + +[[allowlists]] +description = "Public OAuth client IDs (PKCE public-client flow, public by design, no client_secret)." +# Claude Code and Codex OAuth client identifiers. Client IDs travel in the +# authorization request and are not credentials; the flows carry no secret. +regexes = [ + '''9d1c250a-e61b-44d9-88ed-5944d1962f5e''', + '''app_EMoamEEZ73f0CkXaXp7hrann''', +] + +[[allowlists]] +description = "Non-secret identifiers the generic-api-key rule mis-fires on." +# e.g. dedup keys like 'synth-retain-89d' in parser fixtures. +condition = "AND" +regexes = ['''synth-[a-z0-9-]+'''] +paths = ['''(^|/)tests?/'''] + +[[allowlists]] +description = "Canonical example JWT header used as a redaction/parse fixture (decodes to {\"alg\":\"HS256\",\"typ\":\"JWT\"}, carries no claims or signature)." +condition = "AND" +regexes = ['''eyJhbGciOiJIUzI1NiIsIn'''] +paths = ['''(^|/)tests?/'''] diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..6fa8dec4 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.13.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cfdf1fa..b7f1ff94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## Unreleased + +### Added +- **Credit-metered ChatGPT workspaces (Business / Edu / Enterprise) now show their limit.** These plans report no rate-limit windows, so the admin-set monthly allowance from `spend_control.individual_limit` is shown as a "Monthly usage limit" bar in the desktop app and the menubar. (#833) +- **Combined-device scope in the desktop Dashboard**, mirroring the menu bar. A Local / Combined toggle aggregates paired-device usage in the Overview hero and the menu bar badge, degrading gracefully to the local figure when a peer is unreachable; the badge then shows a dimmed `reachable/total` marker so a momentary drop to the local number reads as "a peer is unreachable" rather than a glitch. (#866, #867, thanks @marcreynolds) + +### Added (CLI) +- **Cline CLI provider.** The Cline command-line agent (npm `cline`, 3.x) stores sessions as `~/.cline/data/sessions//.json` + `.messages.json`, a layout the existing Cline provider never scanned — it requires `tasks//ui_messages.json` — so every CLI session was silently reported as $0.00, with no warning even under `--verbose`. Added as its own `cline-cli` provider so the shared Cline-family parser (Roo Code, KiloCode, IBM Bob) is untouched; it mirrors the CLI's own root resolution (`CLINE_SESSION_DATA_DIR` → `CLINE_DATA_DIR` → `CLINE_DIR` → `~/.cline`) and reports its probed root through `codeburn doctor`. Per-message cost is metered by the CLI, so `cline-cli` joins the reported-cost pass-through allowlist rather than being re-priced from tokens. (#874) +- **Codex throughput tracking**: per-model Tok/s in the dashboard and report, active time excludes tool wait. (#805, thanks @ihearttokyo) +- `codeburn sync push --attribution` (opt-in): sends git attribution spans — the session→commit correlation from `codeburn yield` (`codeburn.session.attribution` and `codeburn.commit` span types with normalized repo remote, commit SHAs, merged/reverted state, and PR links). Nothing new is sent without the flag; local-only repos and Windows filesystem paths are never emitted as repo identities, and sessions whose project path no longer resolves never inherit the push-time working directory's repo. See docs/sync/README.md "Git attribution". + +### Fixed (CLI) +- **Copilot CLI sessions report their input and cache tokens.** The Copilot CLI writes the same `producer: 'copilot-agent'` in its `session.start` events that VS Code transcripts carry, so content-based detection classified every CLI session as a transcript and skipped its `session.shutdown` rollup — the only place the CLI records input, cache-read and cache-write tokens — leaving cache hit rate at 0.0% and dramatically underreporting cost. Whether a file is a transcript is now decided by where discovery found it, never by its contents. Resumed sessions, whose legs each append a cumulative rollup, are billed as per-leg deltas so a growing session never double-counts or goes stale; the GitHub Copilot desktop app writes the same session store, so its usage is covered by the same fix. The copilot session cache takes a parse-version bump and the daily cache bumps from v16 to v17 for the one-time re-parse that heals already-recorded days whose logs still exist. (#944) +- **Copilot CLI subagent runs are attributed to their agent.** Newer CLIs announce delegation with `subagent.started`/`subagent.completed` rather than `subagent.selected`, so delegated turns lost their agent label; the label now also clears when the subagent completes instead of bleeding onto the parent's later turns. Rides the #944 re-parse, so already-cached sessions gain the attribution. (#944) +- **`--project` / `--exclude` now apply to the headline totals, not just the detail panels.** The durable headline unions the carry-forward daily cache with today's live parse, and the cached days were sliced to the requested provider but never to the requested project — so the Overview panel counted excluded projects while By Project / By Activity / By Model (built from the name-filtered parse) left them out, and the two could not be reconciled. Cost, calls, sessions and savings are now sliced out of the per-project day stats the cache has carried since v15. Tokens, models and categories have no per-project split in the cache, so under a project filter they come from the (project-filtered) live parse instead; cached days — or provider slices — carried from before v15 have no project split at all, so they cannot be attributed to a filtered project, and the terminal overview now states how much was set aside rather than folding it into the total. (#864) +- **Codex parser corrections**: fork-replay no longer double-counts `patch_apply_end` and `mcp_tool_call_end`; `exec` is normalized to Bash; `custom_tool_call` events are handled; token_count lines larger than 32 KiB now parse exact token counts instead of estimating. Codex session cache bumps from v7 to v8 for a one-time re-parse. Only tool attribution changes for ordinary sessions, leaving their cost identical; sessions that logged an oversized token_count line are repriced from exact counts instead of an estimate. (#805) + +- **Midnight-straddling turns keep both halves.** A turn whose calls span local midnight was attributed whole to its start day, so `codeburn today` under-reported until the turn ended and multi-day totals mis-split it. Calls are now range-filtered inside the turn so each day gets the calls that belong to it, and By Activity and the daily turn counts reconcile with the headline. (#853, thanks @KENSHI601) +- **`--provider ` no longer leaks Claude spend into the detail panels.** A provider-filtered run still ran the Claude scan, whose orphan pass re-injected every cached Claude session, so By Project / By Model / By Activity showed Claude usage under, e.g., `--provider cursor` while the headline was correct. (#872, thanks @ozymandiashh) +- **A degraded session parse no longer freezes daily history.** A read-only parse that served a stale or missing session file was treated as complete and finalized days it never covered, freezing warm-cache ingestion; a corrupt refresh lock is now recovered rather than ending ingestion, and a legitimately idle tail is no longer re-derived on every launch. (#856, thanks @avs-io) +- **Pi / Oh My Pi transcripts with a leading title record are discovered.** OMP writes a `type: "title"` line before the session header; discovery now scans a bounded number of leading lines for the first session record instead of requiring it on the first physical line. (#846, #859, thanks @jbspeakr, @avs-io) +- **Nine providers served silently stale numbers after you pointed their env override at a different profile or root.** Kiro, Grok, Kimi, Mux, Mistral Vibe, Zerostack, Codebuff, Goose and Crush each honor an env var that relocates where discovery looks, but the var was never declared in the provider env fingerprint, so the cache section survived the change and kept reporting sessions parsed from the old root — with no diagnostic anywhere. The fix declares those vars, the adjacent OS-set path variables that resolve a discovery root for Claude, IBM Bob, Open Design and Kilo Code on Windows and Linux, Cursor's parse-budget override, and the Vercel AI Gateway credential — which must invalidate the fingerprint because a read-only refresh serves the cached report and would otherwise keep reporting the previous account's usage after a swap. Your next run re-parses the fourteen file-backed providers whose declarations changed — the nine above plus Claude, Cursor, Open Design, IBM Bob and Kilo Code — once, and only once; the Vercel AI Gateway declaration is a read-only-path correction, not a migration (its report is re-fetched on every writable run anyway); Copilot is deliberately NOT included, because declaring its overrides would force a re-parse that can drop OTel history only the cache still holds; `codeburn doctor` names deliberate overrides including the XDG_* vars, never the Windows ambient APPDATA / LOCALAPPDATA, and redacts credential values. (#920) + +### Fixed +- Claude Desktop and Cowork sessions are discovered for Windows Microsoft Store (MSIX) installs. (#611) +- Cline tasks are discovered in every VS Code variant (VS Code, VS Code Insiders, VSCodium), not just stable VS Code. (#874) + ## 0.9.19 - 2026-07-20 One version across every surface: CLI, macOS menubar, and the desktop app all ship as 0.9.19. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aebe0f23..67dfc280 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,17 +24,24 @@ There is no separate build step required to run the dev CLI. `npm run dev` runs | Command | What it does | |---|---| -| `npm test` | Runs the vitest suite (42 test files, 568 tests). | +| `npm test` | Runs the vitest suite under `tests/` (189 of the 192 files, 2,494 tests). | +| `npm run test:locks` | Runs the three parallelism-sensitive `cache-refresh-lock` suites serially. | +| `npm run test:watch` | Same scope as `npm test`, in watch mode. | | `npm run dev -- status` | Runs the CLI in dev mode against your real data. | | `npm run build` | Bundles the litellm pricing snapshot, then runs `tsup` to produce `dist/cli.js`. | | `npm run bundle-litellm` | Refreshes `src/data/litellm-snapshot.json` from the upstream litellm repo. | -To test a specific suite, pass a path: +To test a specific suite, run vitest directly with a path: ```bash -npm test -- tests/providers/codex.test.ts +npx vitest run tests/providers/codex.test.ts ``` +`npm test` is scoped to `tests/` on purpose. The Electron app under `app/` carries its +own vitest config and its own `jsdom` dependency in `app/node_modules`; letting vitest's +default glob reach those specs from a root install fails with +`ERR_MODULE_NOT_FOUND: jsdom`. To run the app's tests, install and run them from `app/`. + ## What to Read Before Editing - `docs/architecture.md` for the high-level codebase map. @@ -65,9 +72,14 @@ See `docs/architecture.md` for a fuller map. ## Tests -- Each new provider should ship with a fixture-based test under `tests/providers/`. The five providers without test files today (claude, gemini, goose, qwen, antigravity) are a known gap; new code should not add to that list. +- Each new provider should ship with a fixture-based test under `tests/providers/`. The three providers without test files today (claude, goose, qwen) are a known gap; new code should not add to that list. - Each new optimize detector in `src/optimize.ts` needs at least one positive and one negative case in `tests/optimize.test.ts`. - If your change affects the menubar JSON contract, update `tests/menubar-json.test.ts`. +- A new test that exercises the cross-process refresh lock must be named + `tests/cache-refresh-lock-.test.ts` **and** added to the `test:locks` script in + `package.json`. `npm test` excludes that prefix, so a lock test placed anywhere else + runs under the full worker pool and fails intermittently; one that matches the prefix + but is missing from `test:locks` never runs at all. ## Commit Message Format diff --git a/README.md b/README.md index 1a5db9c8..24af85ae 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Claude for Open Source Recipient + Codex and Claude for Open Source Recipient

@@ -27,7 +27,7 @@ CodeBurn Desktop
Download for macOS (Apple Silicon) Download for macOS (Intel) - Download for Windows + Get CodeBurn from the Microsoft Store Download for Linux (.deb) Download for Linux (.rpm) Download for Linux (AppImage) @@ -306,6 +306,14 @@ defaults write org.agentseal.codeburn-menubar CodeBurnMenubarRefreshSeconds -int Seconds between refreshes: `60`, `300`, or `900`; `0` is Manual and `-1` is Auto. Takes effect on the next refresh tick, no relaunch needed. +**Preferred terminal** decides where Full Report and Optimize open. Set it in Settings → General → Terminal, or from Terminal: + +```bash +defaults write org.agentseal.codeburn-menubar CodeBurnPreferredTerminal -string iterm2 +``` + +Allowed values are `terminal` (macOS Terminal.app, the default) and `iterm2`. Anything else falls back to `terminal`. Only terminals that can script a command into a live window are offered; if the chosen app is missing or fails to accept the command, CodeBurn tries Terminal.app and then runs the command in the background, logging each step to Console.app. Takes effect on the next launch of a command, no relaunch needed. + ### Linux (GNOME) Linux gets the same ambient view through a GNOME Shell extension (GNOME 45+): spend in the top panel, period switcher, compact mode, and daily budget alerts. It lives in [`gnome/`](gnome/): @@ -401,7 +409,7 @@ Run `codeburn` for the dashboard, or use a subcommand below. Most commands also | `codeburn report -p all` | Every recorded session | | `codeburn report --from 2026-04-01 --to 2026-04-10` | An exact date range | | `codeburn report --format json` | Full dashboard data as JSON, printed to stdout | -| `codeburn report --refresh 60` | Auto-refresh every 60s (default 30s; `--refresh 0` disables) | +| `codeburn report --refresh 60` | Auto-refresh every 60s (the minimum and default; `--refresh 0` disables) | **Status & export** @@ -473,7 +481,7 @@ Sync sends token counts, costs, models, and projects, never prompts or code. Thi | `codeburn models --task feature` | Filter to feature-development work | | `codeburn models --provider claude` | Filter to a single provider | -Left/right arrow keys switch between Today, 7 Days, 30 Days, Month, 6 Months, and Lifetime (use `--from` / `--to` for an exact historical window). The main Daily Activity panel always shows scrollable full history: use up/down to move one day, Page Up/Page Down (or Shift+Space/Space) to page, and `g`/`G` to jump to either end. These keys update the panel in place instead of moving terminal scrollback. Press `q` to quit, `1` `2` `3` `4` `5` `6` as period shortcuts, `c` to open model comparison, or `o` to open optimize. The dashboard auto-refreshes every 30 seconds by default (`--refresh 0` to disable). It also shows average cost per session and the five most expensive sessions across all projects. +Left/right arrow keys switch between Today, 7 Days, 30 Days, Month, 6 Months, and Lifetime (use `--from` / `--to` for an exact historical window). Up/down scroll the full dashboard one line, Page Up/Page Down move one screen, and Home/End jump to either end. The main Daily Activity panel shows at least 10 dates from scrollable full history: use `j`/`k` to move one day, Shift+Space/Space to page, and `g`/`G` to jump to either end. Panels flow in the same order across three columns at maximum width, two at medium width, and one when narrow. In the three-column layout, all panels widen equally by one character for every three additional terminal columns until the dashboard reaches the lesser of 256 characters or the widest renderable source row. Press `q` to quit, `1` `2` `3` `4` `5` `6` as period shortcuts, `c` to open model comparison, or `o` to open optimize. Today, 7 Days, and concrete-day views refresh in place at most once per minute by default (`--refresh 0` to disable) without changing the active view or scroll position. The heavier aggregate views remain static between deliberate navigation changes. The dashboard also shows average cost per session and the five most expensive sessions across all projects. @@ -659,12 +667,14 @@ These are starting points, not verdicts. A 60% cache hit on a single experimenta | **Kiro** | `.chat` JSON files | Token counts are estimated from content length. The model is not exposed, so sessions are labeled `kiro-auto` and costed at Sonnet rates. | | **Mistral Vibe** | `~/.vibe/logs/session/` (or `$VIBE_HOME/logs/session/`); each folder has `meta.json` + `messages.jsonl` | Reads cumulative prompt/completion totals and model pricing from `meta.json`, then the first user prompt and tool calls from `messages.jsonl`. Emits one record per session (source data is cumulative, not per turn); subagent sessions under `agents/` are counted separately. | | **OpenClaw** | `~/.openclaw/agents/*.jsonl` (legacy `.clawdbot`, `.moltbot`, `.moldbot`) | Token usage comes from assistant message `usage` blocks; the model from `modelId` or `message.model`. | +| **OpenClaude** | `~/.openclaude/projects//*.jsonl` (or `$CODEBURN_OPENCLAUDE_DIR/projects/`) | Claude Code fork routed to any LLM backend; transcripts are Claude-Code schema. Only usage-bearing assistant lines become calls; no cost field exists, so every call is priced from the shared tables and flagged estimated. Sidechain (subagent) lines are counted as real spend. | | **Warp** | `~/Library/Group Containers/2BBY89MBSN.dev.warp/Library/Application Support/dev.warp.Warp-Stable/warp.sqlite` (Preview fallback) | Reads `agent_conversations`, `ai_queries`, and `blocks`, emitting one call per finalized exchange. Exchange token share is estimated from prompt-size weighting normalized to conversation totals; `run_command` blocks attach to the nearest preceding exchange by timestamp. | | **Zed** | SQLite `~/Library/Application Support/Zed/threads/threads.db` (Linux `~/.local/share/zed/threads/`) | One row per agent thread; the blob is zstd-compressed JSON with per-request token usage (input, output, cache read, cache write) and the thread's model. Threads are topped up to the exact cumulative counter so totals match the store. Needs Node 22.15+ for built-in zstd. | | **Forge** | SQLite `~/.forge/.forge.db` | Queries `conversations` read-only and parses `context.messages`. Assistant usage entries provide prompt, completion, and cached counts; CodeBurn subtracts cached from prompt for input pricing, emits one call per assistant message, and extracts tool calls plus shell commands. | | **Pi / OMP** | `~/.pi/agent/sessions//*.jsonl` (Pi), `~/.omp/agent/sessions//*.jsonl` (OMP) | Each assistant message carries usage (input, output, cacheRead, cacheWrite) plus inline `toolCall` blocks. Tool names normalize to the standard set (`bash` → `Bash`, `dispatch_agent` → `Agent`); bash commands come from `toolCall.arguments.command`. | | **Codebuff** (formerly Manicode) | `~/.config/manicode/projects//chats//chat-messages.json` (honors `CODEBUFF_DATA_DIR`; walks `manicode-dev` / `manicode-staging`) | Bills in credits, so each completed assistant message is costed at the public rate of $0.01/credit via `msg.credits`. When an upstream provider's stashed RunState records token-level usage (`message.metadata.runState.sessionState.mainAgentState.messageHistory[*].providerOptions`), the real tokens and LiteLLM cost take precedence. Native tool names (`read_files`, `str_replace`, `run_terminal_command`, `spawn_agents`) normalize to `Read`, `Edit`, `Bash`, `Agent`. | -| **Cline / Roo Code / KiloCode** | VS Code `globalStorage`: Cline at `saoudrizwan.claude-dev` and `~/.cline/data`; Roo Code and KiloCode across VS Code, VS Code Insiders, and VSCodium | Cline-family agents. CodeBurn reads `ui_messages.json` from each task directory, extracting token counts from `type: "say"` entries with `say: "api_req_started"`. | +| **Cline / Roo Code / KiloCode** | VS Code `globalStorage` across VS Code, VS Code Insiders, and VSCodium (Cline at `saoudrizwan.claude-dev`, plus `~/.cline/data`) | Cline-family agents. CodeBurn reads `ui_messages.json` from each task directory, extracting token counts from `type: "say"` entries with `say: "api_req_started"`. | +| **Cline CLI** | `~/.cline/data/sessions//` (honors `CLINE_SESSION_DATA_DIR`, `CLINE_DATA_DIR`, `CLINE_DIR`) | The Cline command-line agent, whose layout is unrelated to the VS Code extension's. Reads `.json` for session metadata and the rolled-up `usage`, and `.messages.json` for the per-message `metrics` block (input, output, cacheRead, cacheWrite, cost) that becomes one call each. | | **CodeWhale** | `~/.codewhale/sessions/*.json` plus unmigrated legacy `~/.deepseek/sessions/*.json`; `$CODEWHALE_HOME/sessions` is an exact override | Emits one cumulative record per saved session. CodeWhale exposes only `total_tokens`, so CodeBurn preserves that aggregate in the input column rather than inventing an input/output split. Cost is the exact stored parent-session plus subagent USD total; model pricing is used only when the cost snapshot is absent. Tool blocks, shell commands, skills, and subagent types are retained. | | **IBM Bob** | `User/globalStorage/ibm.bob-code/tasks//` (GA `IBM Bob` and preview `Bob-IDE` app folders) | Reads `ui_messages.json` for API request token/cost records and `api_conversation_history.json` for the selected model. | | **Kimi Code CLI** | `$KIMI_SHARE_DIR/sessions///` or `~/.kimi/sessions///` | Reads `wire.jsonl` `StatusUpdate.token_usage` records, mapping `input_other`, `input_cache_read`, `input_cache_creation`, and `output` into the standard token columns; includes subagents under each session's `subagents/` folder. | diff --git a/RELEASING.md b/RELEASING.md index 8f1f0113..df1c6754 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -14,8 +14,13 @@ Run the test suite to catch any regressions: ```bash npm test +npm run test:locks ``` +`npm test` covers `tests/`. `npm run test:locks` runs the three parallelism-sensitive +`cache-refresh-lock` suites serially; CI treats them as reporting-only, so check them by +hand here. + Verify that the build completes without errors: ```bash diff --git a/SUBMISSION.md b/SUBMISSION.md new file mode 100644 index 00000000..9adbbc17 --- /dev/null +++ b/SUBMISSION.md @@ -0,0 +1,111 @@ +# Submission Statement + +## Proposed title + +Stabilize TUI refresh, scrolling, responsive layout, and dashboard data density + +## Summary + +This pull request repairs the terminal dashboard as one coherent rendering surface. Background refresh no longer replaces the active Optimize view or resets the viewport. The full application can scroll. The eight dashboard panels retain their order while reflowing through one, two, and three columns. Metric headings and values remain visible before labels are shortened, and Daily Activity grows to match the relevant neighboring panels. + +The branch is rebased on upstream `main` at `2c3319b`. The implementation reuses Ink and the existing dashboard state rather than adding a dependency or a second layout engine. + +## Maintainer review reconciliation + +The maintainer review identified a Windows ConPTY risk in the branch's custom synchronized-update write. A maintainer supplied a narrower escape-chunk fix in `7716f95`; this reconciliation preserves its intended Windows safety while removing the application-owned terminal protocol entirely: + +- `src/ink-win.ts` is restored to the upstream implementation. +- The dashboard emits no manual begin/end synchronized-update sequence and no manual clear-and-home write. +- Ink remains the sole owner of terminal synchronization. +- CodeBurn's prepended resize handler only captures the new column count and rerenders React before Ink's ordinary resize listener paints. + +This removes the reviewed ConPTY failure path instead of maintaining another platform-specific escape protocol. The Windows filter was checked with a mocked `win32` source-path test, and the pull request's AppX job remains the authoritative Windows package gate because no physical Windows host was available locally. + +The same reconciliation restored the existing heavy-period refresh policy and made the CLI help truthful: Today, 7 Days, and concrete-day views may refresh automatically; 30 Days, Month, All, and Lifetime remain static between deliberate navigation changes. Every enabled interval is clamped to at least 60 seconds, and `--refresh 0` disables it. + +## User-visible behavior + +### Stable refresh and navigation + +- A background result cannot replace the Optimize view after the user enters it. +- Background work retains the current frame instead of replacing it with a loading or blank screen. +- Refresh and resize rerenders preserve the application scroll offset. +- Up and down move one application row, Page Up and Page Down move one viewport, and Home and End jump to the bounds. +- Deliberate navigation to a different view, period, provider, or day begins at the top. + +### Responsive dashboard + +- The eight panels retain source order through one column at 89 characters or fewer, two columns from 90 through 134, and three columns from 135 upward. +- Three-column rows use the requested 3/3/2 arrangement. +- All three panels in a row widen equally by one character for every three additional terminal characters. +- Growth stops at the lesser of 256 characters or the widest row the current source data can render. +- Windows wider than 256 characters retain a populated capped dashboard. +- Colored bars remain at the left edge of every data section; Daily Activity places its bar before the date. + +### Complete, compact data rows + +- Metric widths are derived from their full headings and rendered values. +- Adjacent metric cells use exactly one separating character. +- `Tok/s` and every other metric column always render; unavailable values display `-`. +- Costs, including the estimated-cost `~` marker, render in full whenever the panel can hold them. +- The project heading spells out `session`. +- Project labels yield space before any heading or metric. Shortening removes the parent-folder prefix first, then the year in a date folder, and only then truncates the project title with a macOS-style ellipsis. + +### Adaptive Daily Activity history + +- One-column layout displays 10 dates. +- Two-column layout displays `MAX(10, visible By Project rows)`. +- Three-column layout displays `MAX(10, visible By Project rows, visible By Activity rows)`. +- Day mode remains one date, and available history remains the upper bound. +- Rendering, `j`/`k`, Space paging, `g`/`G`, final-page clamping, and the `Showing X-Y of Z` status share the same page-size calculation. +- By Activity row counting and rendering share the same aggregation, so the calculated height cannot drift from the displayed panel. + +## TDDRGR and post-implementation bug-fix rounds + +The adaptive-row contract first failed for the intended reason: a two-column lifetime fixture with 14 visible projects rendered 10 dates. The smallest production change introduced one shared page-size calculation. After the first green run, the refactor reused the existing project-row limit and Activity aggregation, and the focused contract stayed green. + +The maintainer reconciliation also began red. Tests proved that the maintainer head still contained application-owned synchronized writes, scheduled refreshes for four heavy periods, and advertised a 30-second interval in three CLI help surfaces. Removing the writes, restoring the period gate, and updating the help produced 59 passing focused tests. + +Dedicated bug-fix rounds then repeated the relevant regression checks and real user path: + +1. Daily Activity paging and bounds used the calculated 10/14/18-row sizes. +2. Full-application End scrolling remained at the bottom after a live 89-to-100-column resize. +3. Optimize remained mounted across live 100-to-89-column reflow, while its fake-timer refresh regression retained the view with no loading frame. +4. An unsuccessful `incrementalRendering` experiment was removed after measurement showed no improvement; the smaller Ink-owned design remained. + +Correctness review found no issue in the final production diff. Ponytail review concluded: `Lean already. Ship.` + +## Validation + +### Deterministic and build gates + +- Focused refresh, resize, layout, scrolling, metric, and CLI-help matrix: **59/59**. +- Relevant dashboard, model, overview, and CLI-help matrix: **72/72**. +- Complete dashboard suite: **56/56**. +- Desktop application suite: **462/462**. +- Root `tests/` suite: **2,481 passed**, **3 failed**, and **5 skipped**. The same three failures reproduce at unmodified upstream `2c3319b`: two Copilot durable-orphan assertions and one provider-filter durable-total assertion. None touches this dashboard diff. +- TypeScript checks for the CLI and desktop application: passed. +- CLI, browser dashboard, and desktop application production builds: passed. The existing Vite warning for a browser chunk above 500 KB is unchanged. +- `git diff --check`: passed. + +Running root Vitest without limiting it to `tests/` also discovers the nested desktop tests under the root configuration. That unsupported combined invocation lacks the desktop setup and produces matcher/environment failures; the canonical desktop command above passes all 462 tests. + +### Native Ghostty inspection + +- **241** deterministic width frames from 60 through 300 columns confirmed the 89/90 and 134/135 breakpoints, symmetric three-column growth, the 256-character cap, and populated frames above the cap. +- **40** window-bounded Ghostty captures covered two font zoom levels, multiple window shapes, top, scrolled, and Optimize states, with most captures below 260 columns as requested. +- **105** final settled captures shrank one column at a time from 146 through 42. All contained rendered content; no settled frame was blank. +- **20** repeated 120-to-110-column shrink cycles rendered successfully. +- Final live screenshots confirmed scroll-position preservation across a one-to-two-column resize and Optimize preservation across the reverse breakpoint. + +All visual evidence used the Ghostty window ID with native `screencapture -l`; no full-display capture and no Computer Use session was used. The user's Ghostty shell was returned to its original `~` prompt, size, and position after validation. + +## Deliberate non-changes + +- Compare keeps its existing two-column composition; redesigning it is outside this dashboard repair. +- The status/help bar remains part of the scrollable content, as requested during review. +- Existing aggregation memoization and viewport-measurement behavior remain unchanged where the accepted design did not require them. + +## Reviewer focus + +The highest-value review is the interaction among the shared metric row, the calculated Daily Activity page size, and existing scroll state. Acceptance requires that background refresh never changes the active view or position, supported widths never lose a metric, each settled resize preserves panel order and content, and Daily Activity navigation uses the same page size shown on screen. diff --git a/app/DISTRIBUTION.md b/app/DISTRIBUTION.md index 7bd4bd0e..69165022 100644 --- a/app/DISTRIBUTION.md +++ b/app/DISTRIBUTION.md @@ -66,6 +66,7 @@ npm --prefix app run package # macOS, both arm64 and x64 npm --prefix app run package:arm64 # macOS arm64 only (faster on Apple Silicon) npm --prefix app run package:x64 # macOS x64 only npm --prefix app run package:win # Windows NSIS installer, x64 +npm --prefix app run package:store # Microsoft Store AppX, x64 (Windows host only) npm --prefix app run package:linux # Linux AppImage, x64 ``` @@ -184,6 +185,27 @@ shows **"Windows protected your PC"**. Users click **"More info" → "Run anyway"** to launch it. This is expected for an unsigned build; the only fix is a purchased code-signing (Authenticode/EV) certificate. +### Microsoft Store (`package:store`) + +The Store build is a separate AppX target so the GitHub NSIS installer remains +unchanged. AppX packaging requires Windows 10 or newer and is built by the +manual `Build Windows Store package` GitHub Actions workflow on +`windows-latest`. Download its `CodeBurn-Microsoft-Store` workflow artifact and +upload the contained `CodeBurn-Store--x64.appx` file in Partner Center. + +The manifest identity must exactly match the reserved Partner Center product: + +- Identity name: `Codeburn.CodeBurn` +- Publisher: `CN=3EFA3336-87E1-46F2-9DFA-2EB5A7693F89` +- Publisher display name: `Codeburn` +- Store ID: `9P0R4ZL5XMB8` + +The Store package is intentionally unsigned: Microsoft signs it during Store +submission. Direct sideloading requires a separate trusted or development +certificate. The AppX declares `runFullTrust` (electron-builder's required +default for Electron apps), so CodeBurn retains access to the user's local +provider session files rather than running in a UWP application sandbox. + ### Linux (`package:linux`) `electron-builder --linux` produces a single artifact in `app/release/`: diff --git a/app/build/appx/Square150x150Logo.png b/app/build/appx/Square150x150Logo.png new file mode 100644 index 00000000..f80200bd Binary files /dev/null and b/app/build/appx/Square150x150Logo.png differ diff --git a/app/build/appx/Square44x44Logo.png b/app/build/appx/Square44x44Logo.png new file mode 100644 index 00000000..33d78894 Binary files /dev/null and b/app/build/appx/Square44x44Logo.png differ diff --git a/app/build/appx/StoreLogo.png b/app/build/appx/StoreLogo.png new file mode 100644 index 00000000..bc440fed Binary files /dev/null and b/app/build/appx/StoreLogo.png differ diff --git a/app/build/appx/Wide310x150Logo.png b/app/build/appx/Wide310x150Logo.png new file mode 100644 index 00000000..909773a2 Binary files /dev/null and b/app/build/appx/Wide310x150Logo.png differ diff --git a/app/electron/main.test.ts b/app/electron/main.test.ts index 30dfe17e..208bd865 100644 --- a/app/electron/main.test.ts +++ b/app/electron/main.test.ts @@ -89,6 +89,12 @@ const ARGV_CASES: Array<{ channel: string; args: unknown[]; argv: string[] }> = { channel: 'codeburn:getOverview', args: ['30days', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--from', '2026-07-01', '--to', '2026-07-11'] }, { channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, 'claude-config:91dda17e8cf35193'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--claude-config-source', 'claude-config:91dda17e8cf35193'] }, { channel: 'codeburn:getOverview', args: ['month', 'claude', { from: '2026-07-01', to: '2026-07-11' }, 'claude-desktop:980e1e488a654830'], argv: ['status', '--format', 'menubar-json', '--period', 'month', '--no-timeline', '--provider', 'claude', '--from', '2026-07-01', '--to', '2026-07-11', '--claude-config-source', 'claude-desktop:980e1e488a654830'] }, + // Combined scope emits --scope combined; an explicit local scope is identical + // to the default (no flag). The CLI rejects --scope with --provider, so a + // provider passed alongside combined is dropped (the renderer forces 'all'). + { channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, undefined, undefined, 'combined'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--scope', 'combined'] }, + { channel: 'codeburn:getOverview', args: ['30days', 'claude', undefined, undefined, undefined, 'combined'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--scope', 'combined'] }, + { channel: 'codeburn:getOverview', args: ['30days', 'claude', undefined, undefined, undefined, 'local'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--provider', 'claude'] }, { channel: 'codeburn:getModels', args: ['week', 'claude', true, { from: '2026-07-01', to: '2026-07-11' }], argv: ['models', '--format', 'json', '--period', 'week', '--provider', 'claude', '--by-task', '--from', '2026-07-01', '--to', '2026-07-11'] }, { channel: 'codeburn:getYield', args: ['today', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['yield', '--format', 'json', '--period', 'today', '--from', '2026-07-01', '--to', '2026-07-11'] }, { channel: 'codeburn:getSpendFlow', args: ['month', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['spend', '--format', 'flow-json', '--period', 'month', '--from', '2026-07-01', '--to', '2026-07-11'] }, @@ -212,6 +218,7 @@ describe('createBridgeHandlers (IPC input validation)', () => { { name: 'remove price override model that looks like a flag', channel: 'codeburn:removePriceOverride', args: ['--all'] }, { name: 'claude config source that looks like a flag', channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, '-rf'] }, { name: 'claude config source with shell metacharacters', channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, 'id; rm -rf'] }, + { name: 'unknown scope', channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, undefined, undefined, 'everything'] }, ] it.each(REJECTIONS)('rejects $name with a bad-args envelope and never spawns', async ({ channel, args }) => { diff --git a/app/electron/main.ts b/app/electron/main.ts index 82a95c0d..5eddbdca 100644 --- a/app/electron/main.ts +++ b/app/electron/main.ts @@ -165,6 +165,11 @@ function vConfigSource(source: string | null | undefined): string | null { if (!/^[A-Za-z0-9][A-Za-z0-9:_-]*$/.test(source)) throw new CliError('bad-args', 'invalid claude config source') return source } +function vScope(scope: string | undefined): 'local' | 'combined' { + if (scope === 'combined') return 'combined' + if (scope === undefined || scope === 'local') return 'local' + throw new CliError('bad-args', 'invalid scope') +} function vOutPath(outPath: string): string { if (outPath.startsWith('-') || !path.isAbsolute(outPath)) throw new CliError('bad-args', 'export path must be absolute') return outPath @@ -269,19 +274,28 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re // The desktop never renders the granular timeline, so it always passes // --no-timeline (skips buildGranularHistory on every poll). The Swift menubar // omits the flag and keeps the timeline unchanged. - const buildOverviewArgs = (period: string, provider: string, range?: DateRange, configSource?: string | null): string[] => [ - 'status', '--format', 'menubar-json', '--period', vPeriod(period), '--no-timeline', - ...providerArgs(vProvider(provider)), ...rangeArgs(vRange(range)), ...configSourceArgs(vConfigSource(configSource)), - ] + // + // Combined scope aggregates paired-device usage: the CLI rejects --scope + // combined alongside --provider/--project/--exclude (paired devices report + // unfiltered usage), so the provider filter is dropped in that mode. The + // caller (renderer) forces provider='all' when combined, so nothing is lost. + const buildOverviewArgs = (period: string, provider: string, range?: DateRange, configSource?: string | null, scope?: string): string[] => { + const vScopeValue = vScope(scope) + return [ + 'status', '--format', 'menubar-json', '--period', vPeriod(period), '--no-timeline', + ...(vScopeValue === 'combined' ? ['--scope', 'combined'] : providerArgs(vProvider(provider))), + ...rangeArgs(vRange(range)), ...configSourceArgs(vConfigSource(configSource)), + ] + } // `background` (renderer prefetch only) drops this fetch to background priority // so it yields the CLI's run slots to any interactive poll or click. Optional // and defaulting to interactive, so an older preload that omits it is unchanged. - const getOverview: Handler = async (period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean) => { + const getOverview: Handler = async (period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean, scope?: string) => { coldStartBegan ??= Date.now() const priority: SpawnPriority | undefined = background ? 'background' : undefined try { - const args = buildOverviewArgs(period, provider, range, configSource) + const args = buildOverviewArgs(period, provider, range, configSource, scope) if (overviewWarmed) return { ok: true, value: await deps.spawnCli(args, priority ? { priority } : undefined) } const value = await deps.spawnCli(args, { timeoutMs: WARMUP_TIMEOUT_MS, diff --git a/app/electron/preload.ts b/app/electron/preload.ts index 39426ebd..1c4bc3e9 100644 --- a/app/electron/preload.ts +++ b/app/electron/preload.ts @@ -20,7 +20,7 @@ async function invoke(channel: string, ...args: unknown[]): Promise { // renderer-side where `window.codeburn` is declared as CodeburnBridge. const bridge = { getQuota: (force?: boolean) => invoke('codeburn:getQuota', force), - getOverview: (period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean) => invoke('codeburn:getOverview', period, provider, range, configSource, background), + getOverview: (period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean, scope?: string) => invoke('codeburn:getOverview', period, provider, range, configSource, background, scope), getPlans: (period: string) => invoke('codeburn:getPlans', period), getActReport: () => invoke('codeburn:getActReport'), getModels: (period: string, provider: string, byTask: boolean, range?: DateRange) => invoke('codeburn:getModels', period, provider, byTask, range), diff --git a/app/electron/quota/codex.test.ts b/app/electron/quota/codex.test.ts index b8e2de45..4e429d68 100644 --- a/app/electron/quota/codex.test.ts +++ b/app/electron/quota/codex.test.ts @@ -38,6 +38,153 @@ describe('Codex quota', () => { expect(quota.details).toHaveLength(1) }) + // Shape captured from a live ChatGPT Enterprise workspace. + const enterpriseBody = { + plan_type: 'business', + rate_limit: null, + additional_rate_limits: null, + credits: { has_credits: false, unlimited: false, balance: null }, + spend_control: { + reached: false, + individual_limit: { + source: 'workspace_spend_controls', + limit: '10000', + used: '3028.9909675121307', + remaining: '6971.009032487869', + used_percent: 30, + remaining_percent: 70, + reset_after_seconds: 441_896, + reset_at: 1_785_542_400, + }, + }, + rate_limit_reset_credits: { available_count: 0 }, + } + + it('surfaces the spend-control credit limit when there are no rate windows', () => { + const quota = decodeCodexUsage(enterpriseBody) + expect(quota.primary).toEqual({ + label: 'Monthly usage limit · 3,029 / 10,000 credits', + percent: 0.3, + resetsAt: new Date(1_785_542_400 * 1000).toISOString(), + }) + expect(quota.details).toEqual([quota.primary]) + expect(quota.planLabel).toBe('Business') + expect(quota.footerLines).toEqual([]) + }) + + it('keeps rate windows primary and appends the credit limit alongside them', () => { + const quota = decodeCodexUsage({ + ...enterpriseBody, + rate_limit: { primary_window: { used_percent: 20, reset_at: 1_800_000_000, limit_window_seconds: 18_000 } }, + }) + expect(quota.primary?.label).toBe('5-hour') + expect(quota.details.map(row => row.label)).toEqual([ + '5-hour', + 'Monthly usage limit · 3,029 / 10,000 credits', + ]) + }) + + it.each([ + ['top level', (limit: unknown) => ({ individual_limit: limit })], + ['camelCase key', (limit: unknown) => ({ spend_control: { individualLimit: limit } })], + ['nested in rate_limit', (limit: unknown) => ({ rate_limit: { individual_limit: limit } })], + ])('reads the credit limit positioned at %s', (_name, wrap) => { + const quota = decodeCodexUsage(wrap({ limit: 10_000, used: 2500, used_percent: 25 })) + expect(quota.primary?.percent).toBe(0.25) + expect(quota.primary?.label).toBe('Monthly usage limit · 2,500 / 10,000 credits') + }) + + it('derives the percent from remaining_percent, then from used/limit', () => { + const fromRemaining = decodeCodexUsage({ spend_control: { individual_limit: { limit: 10_000, remaining_percent: 70 } } }) + expect(fromRemaining.primary?.percent).toBeCloseTo(0.3) + expect(fromRemaining.primary?.label).toBe('Monthly usage limit · 3,000 / 10,000 credits') + + const fromRatio = decodeCodexUsage({ spend_control: { individual_limit: { limit: 400, used: 100 } } }) + expect(fromRatio.primary?.percent).toBeCloseTo(0.25) + }) + + it('ignores a spend control with no usable limit', () => { + for (const individual_limit of [{ limit: 0, used: 5 }, { limit: null }, { used_percent: 40 }, null]) { + const quota = decodeCodexUsage({ spend_control: { individual_limit } }) + expect(quota.primary).toBeNull() + expect(quota.details).toEqual([]) + } + }) + + it('renders no row when the allowance is known but the draw on it is not', () => { + const quota = decodeCodexUsage({ spend_control: { individual_limit: { limit: 10_000, reset_at: 1_785_542_400 } } }) + expect(quota.primary).toBeNull() + expect(quota.details).toEqual([]) + }) + + it('treats a blank numeric string as absent, not as zero', () => { + const quota = decodeCodexUsage({ spend_control: { individual_limit: { limit: '10000', used: ' ', used_percent: 30 } } }) + expect(quota.primary?.label).toBe('Monthly usage limit · 3,000 / 10,000 credits') + expect(decodeCodexUsage({ spend_control: { individual_limit: { limit: '' } } }).primary).toBeNull() + }) + + it('marks a spent-out allowance as reached', () => { + const quota = decodeCodexUsage({ + spend_control: { reached: true, individual_limit: { limit: 10_000, used: 10_000, used_percent: 100 } }, + }) + expect(quota.primary?.label).toBe('Monthly usage limit · 10,000 / 10,000 credits · limit reached') + expect(quota.primary?.percent).toBe(1) + }) + + it('keeps overage counts truthful while clamping the bar', () => { + const quota = decodeCodexUsage({ spend_control: { individual_limit: { limit: 10_000, used: 12_000, used_percent: 120 } } }) + expect(quota.primary?.label).toBe('Monthly usage limit · 12,000 / 10,000 credits') + expect(quota.primary?.percent).toBe(1) + }) + + it('keeps the implied overage when only the percent is given', () => { + const quota = decodeCodexUsage({ spend_control: { individual_limit: { limit: 10_000, used_percent: 120 } } }) + expect(quota.primary?.label).toBe('Monthly usage limit · 12,000 / 10,000 credits') + expect(quota.primary?.percent).toBe(1) + }) + + it('skips a garbage alias instead of letting it mask a valid one', () => { + const quota = decodeCodexUsage({ + spend_control: { individual_limit: 'bad', individualLimit: { limit: 100, usedPercent: 25 } }, + }) + expect(quota.primary?.label).toBe('Monthly usage limit · 25 / 100 credits') + const perField = decodeCodexUsage({ + spend_control: { individual_limit: { limit: 100, used_percent: 'bad', usedPercent: 25 } }, + }) + expect(perField.primary?.percent).toBe(0.25) + }) + + it('survives a reset timestamp beyond the Date range', () => { + const quota = decodeCodexUsage({ spend_control: { individual_limit: { limit: 100, used_percent: 10, reset_at: 9_000_000_000_000 } } }) + expect(quota.primary?.resetsAt).toBeNull() + expect(quota.primary?.percent).toBe(0.1) + }) + + it('says so when the account is credit-metered but uncapped', () => { + const quota = decodeCodexUsage({ plan_type: 'business', credits: { has_credits: true, unlimited: true } }) + expect(quota.footerLines).toEqual(['Credits · Unlimited']) + const capped = decodeCodexUsage({ credits: { unlimited: true }, spend_control: { individual_limit: { limit: 10_000, used_percent: 30 } } }) + expect(capped.footerLines).toEqual([]) + }) + + it('normalizes credit-based-pricing plan tiers', () => { + const label = (plan_type: string) => decodeCodexUsage({ plan_type }).planLabel + expect(label('enterprise_cbp_usage_based')).toBe('Enterprise') + expect(label('self_serve_business_usage_based')).toBe('Business') + expect(label('enterprise')).toBe('Enterprise') + expect(label('some_future_tier')).toBe('Some Future Tier') + }) + + it('labels a credit-settled balance in credits, not dollars', () => { + const inCredits = decodeCodexUsage({ credits: { has_credits: true, balance: 3410.4 } }) + expect(inCredits.footerLines).toEqual(['Credits remaining · 3,410']) + const inDollars = decodeCodexUsage({ credits: { has_credits: false, balance: 3.5 } }) + expect(inDollars.footerLines).toEqual(['Credits remaining · $3.50']) + // Thousands separators must match the menubar's en_US currency formatter. + const inDollarsLarge = decodeCodexUsage({ credits: { has_credits: false, balance: 12500 } }) + expect(inDollarsLarge.footerLines).toEqual(['Credits remaining · $12,500.00']) + }) + it('returns disconnected without credentials', async () => { const fetchMock = vi.fn() const result = await fetchCodexQuota({ fetch: fetchMock, readFile: vi.fn(async () => null) }) diff --git a/app/electron/quota/codex.ts b/app/electron/quota/codex.ts index b339d503..42b32df3 100644 --- a/app/electron/quota/codex.ts +++ b/app/electron/quota/codex.ts @@ -133,10 +133,27 @@ function windowOf(value: unknown, override?: string): QuotaWindow | null { return { label: override ?? labelForSeconds(row.limit_window_seconds), percent, resetsAt: reset } } +// chatgpt.com mixes encodings inside one payload. `Number('')` is 0, not NaN, +// so blank is rejected or an absent `used` decodes as a confident zero. +function num(value: unknown): number | null { + if (typeof value === 'string' && !value.trim()) return null + const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Number(value.trim()) : NaN + return Number.isFinite(parsed) ? parsed : null +} + +// Credit-based-pricing tiers arrive composite (`enterprise_cbp_usage_based`). +function normalizePlanType(value: string): string { + return value + .replace(/[_-]usage[_-]based$/, '') + .replace(/^self[_-]serve[_-]/, '') + .replace(/[_-]cbp$/, '') + .replace(/[_-]cbp[_-]/g, '_') +} + function planLabel(value: unknown): string | null { if (typeof value !== 'string' || !value.trim()) return null const raw = value.trim() - const lower = raw.toLowerCase() + const lower = normalizePlanType(raw.toLowerCase()) const known: Record = { guest: 'Guest', free: 'Free', go: 'Go', plus: 'Plus', pro: 'Pro', prolite: 'Pro Lite', pro_lite: 'Pro Lite', 'pro-lite': 'Pro Lite', @@ -146,6 +163,44 @@ function planLabel(value: unknown): string | null { return known[lower] ?? lower.replace(/(^|[_-])\w/g, match => match.replace(/[_-]/, ' ').toUpperCase()) } +// The admin-set monthly allowance, the only limit a credit-metered workspace +// has. `spend_control` is the live position, the others forward-compat. `any` +// because this walks six optional-chained hops, all validated by `num()`. +function spendControlWindow(data: Record): QuotaWindow | null { + // `find`/`num` per alias, not `??`: a non-null garbage value would stop `??` + // and mask a valid alias further down. Object-shaped garbage still wins the + // position, matching Swift, which likewise commits to the first that decodes. + const row = [ + data.spend_control?.individual_limit, + data.spend_control?.individualLimit, + data.individual_limit, + data.individualLimit, + data.rate_limit?.individual_limit, + data.rate_limit?.individualLimit, + ].find(candidate => candidate && typeof candidate === 'object') + if (!row) return null + const limit = num(row.limit) + if (limit === null || limit <= 0) return null + const remainingPercent = num(row.remaining_percent) ?? num(row.remainingPercent) + const used = num(row.used) + const rawPercent = num(row.used_percent) ?? num(row.usedPercent) + ?? (remainingPercent === null ? null : 100 - remainingPercent) + ?? (used === null ? null : (used / limit) * 100) + if (rawPercent === null) return null + const percent = Math.min(1, Math.max(0, rawPercent / 100)) + const resetRaw = num(row.reset_at) ?? num(row.resets_at) ?? num(row.resetsAt) + // Past 8.64e15 ms `toISOString()` throws RangeError. + const resetsAt = resetRaw !== null && resetRaw > 0 && resetRaw * 1000 <= 8.64e15 + ? new Date(resetRaw * 1000).toISOString() + : null + // Unclamped percent, so a 120% draw still reports 12,000 of 10,000. + const spent = used ?? limit * Math.max(0, rawPercent) / 100 + const round = (n: number) => Math.round(n).toLocaleString('en-US') + const reached = data.spend_control?.reached === true + const label = `Monthly usage limit · ${round(spent)} / ${round(limit)} credits` + return { label: reached ? `${label} · limit reached` : label, percent, resetsAt } +} + export function decodeCodexUsage(body: unknown): QuotaProvider { const data = body && typeof body === 'object' ? body as Record : {} const primaryRaw = windowOf(data.rate_limit?.primary_window) @@ -165,12 +220,21 @@ export function decodeCodexUsage(body: unknown): QuotaProvider { } } } - const rawBalance = data.credits?.balance - const balance = typeof rawBalance === 'number' ? rawBalance : typeof rawBalance === 'string' ? Number(rawBalance) : NaN + const credits = spendControlWindow(data) + if (credits) details.push(credits) + const balance = num(data.credits?.balance) + // Credit-settled accounts denominate in credits, so no currency symbol. + const hasCredits = data.credits?.has_credits === true + const footerLines: string[] = [] + if (balance !== null && balance > 0) { + footerLines.push(`Credits remaining · ${hasCredits ? Math.round(balance).toLocaleString('en-US') : balance.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}`) + } + // Uncapped on purpose, so a bar-less card does not read as a failed fetch. + if (!credits && data.credits?.unlimited === true) footerLines.push('Credits · Unlimited') return { - provider: 'codex', connection: 'connected', primary, details, + provider: 'codex', connection: 'connected', primary: primary ?? credits, details, planLabel: planLabel(data.plan_type), - footerLines: Number.isFinite(balance) && balance > 0 ? [`Credits remaining · $${balance.toFixed(2)}`] : [], + footerLines, } } diff --git a/app/package.json b/app/package.json index 3301a041..d9e7b424 100644 --- a/app/package.json +++ b/app/package.json @@ -16,6 +16,7 @@ "package:arm64": "npm run stage-cli && npm run build && electron-builder --mac --arm64", "package:x64": "npm run stage-cli && npm run build && electron-builder --mac --x64", "package:win": "npm run stage-cli && npm run build && electron-builder --win", + "package:store": "npm run stage-cli && npm run build && electron-builder --win appx --x64", "package:linux": "npm run stage-cli && npm run build && electron-builder --linux" }, "dependencies": { @@ -100,6 +101,20 @@ "perMachine": false, "artifactName": "CodeBurn-Setup-${version}.${ext}" }, + "appx": { + "applicationId": "CodeBurn", + "identityName": "Codeburn.CodeBurn", + "publisher": "CN=3EFA3336-87E1-46F2-9DFA-2EB5A7693F89", + "publisherDisplayName": "Codeburn", + "displayName": "CodeBurn", + "artifactName": "CodeBurn-Store-${version}-${arch}.${ext}", + "backgroundColor": "#15100D", + "languages": [ + "en-US" + ], + "minVersion": "10.0.17763.0", + "maxVersionTested": "10.0.26100.0" + }, "linux": { "target": [ { diff --git a/app/renderer/App.test.tsx b/app/renderer/App.test.tsx index ef3fd993..c1957a3e 100644 --- a/app/renderer/App.test.tsx +++ b/app/renderer/App.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { App, overviewMemoKey, topCategoryByModel, usageSnapshotProps } from './App' import { sanitizeProps } from '../electron/telemetry' @@ -17,7 +17,7 @@ vi.stubGlobal('localStorage', { }) const mocks = vi.hoisted(() => ({ - getOverview: vi.fn<(period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean) => Promise>(), + getOverview: vi.fn<(period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean, scope?: string) => Promise>(), getSpendFlow: vi.fn<(period: string, provider: string, range?: DateRange) => Promise>(), getOptimizeReport: vi.fn<(period: string, provider: string, range?: DateRange) => Promise>(), getModels: vi.fn(), @@ -52,6 +52,16 @@ function setVisibility(state: 'visible' | 'hidden') { Object.defineProperty(document, 'hidden', { configurable: true, get: () => state === 'hidden' }) } +// The shortcut code (lib/platform.ts) reads `window.codeburn.platform` at call +// time; stub it per test and always restore so no test leaks platform state. +function setPlatform(platform: string): void { + ;(window as unknown as { codeburn?: { platform?: string } }).codeburn = { platform } +} + +function clearPlatform(): void { + delete (window as unknown as { codeburn?: { platform?: string } }).codeburn +} + function overviewPayload(): MenubarPayload { const now = new Date() return { @@ -181,6 +191,11 @@ describe('App shortcuts', () => { // the app-wide default ('today'); tests that exercise the default set it. localStorage.setItem('codeburn.defaultPeriod', '30days') document.documentElement.removeAttribute('data-theme') + setPlatform('darwin') + }) + + afterEach(() => { + clearPlatform() }) it('applies the persisted theme on app boot before Settings mounts', async () => { @@ -211,47 +226,63 @@ describe('App shortcuts', () => { expect(await screen.findByText('No sessions in this range yet.')).toBeInTheDocument() }) - it('keeps command navigation, settings, and refresh shortcuts active without stale hints', async () => { + it.each([ + ['darwin', { metaKey: true }, '⌘'], + ['win32', { ctrlKey: true }, 'Ctrl+'], + ] as const)('keeps %s navigation, settings, and refresh shortcuts active without stale hints', async (platform, chord, mod) => { + setPlatform(platform) render() expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument() - expect(screen.getByText('⌘1-7')).toBeInTheDocument() - expect(screen.getAllByText('⌘,').length).toBeGreaterThan(0) - expect(screen.getByText('⌘R')).toBeInTheDocument() + expect(screen.getByText(`${mod}1-8`)).toBeInTheDocument() + expect(screen.getAllByText(`${mod},`).length).toBeGreaterThan(0) + expect(screen.getByText(`${mod}R`)).toBeInTheDocument() expect(screen.queryByText('Command')).not.toBeInTheDocument() expect(screen.queryByText('Export view')).not.toBeInTheDocument() - fireEvent.keyDown(document, { key: '2', metaKey: true }) + fireEvent.keyDown(document, { key: '2', ...chord }) expect(await screen.findByText('No sessions in this range yet.')).toBeInTheDocument() - fireEvent.keyDown(document, { key: '3', metaKey: true }) + fireEvent.keyDown(document, { key: '3', ...chord }) + expect(await screen.findByText(/PR links are captured as sessions are parsed/)).toBeInTheDocument() + + fireEvent.keyDown(document, { key: '4', ...chord }) expect(await screen.findByText('Cost flow · model → project')).toBeInTheDocument() - fireEvent.keyDown(document, { key: '4', metaKey: true }) + fireEvent.keyDown(document, { key: '5', ...chord }) expect(await screen.findByText('No waste findings in this range yet.')).toBeInTheDocument() - fireEvent.keyDown(document, { key: '5', metaKey: true }) + fireEvent.keyDown(document, { key: '6', ...chord }) expect(await screen.findByText('No model usage in this range yet.')).toBeInTheDocument() - fireEvent.keyDown(document, { key: '6', metaKey: true }) + fireEvent.keyDown(document, { key: '7', ...chord }) expect(await screen.findByText('Need at least two models with usage in this range to compare.')).toBeInTheDocument() - fireEvent.keyDown(document, { key: '7', metaKey: true }) + fireEvent.keyDown(document, { key: '8', ...chord }) expect(await screen.findByText('Not connected. Log in with the Claude CLI.')).toBeInTheDocument() - fireEvent.keyDown(document, { key: ',', metaKey: true }) + fireEvent.keyDown(document, { key: ',', ...chord }) expect((await screen.findAllByText('Settings')).length).toBeGreaterThan(0) expect(screen.queryByText('Back')).not.toBeInTheDocument() const overviewCalls = mocks.getOverview.mock.calls.length - fireEvent.keyDown(document, { key: 'r', metaKey: true }) + fireEvent.keyDown(document, { key: 'r', ...chord }) await waitFor(() => expect(mocks.getOverview.mock.calls.length).toBeGreaterThan(overviewCalls)) }) + it('ignores Ctrl+2 on mac', async () => { + render() + + expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument() + + fireEvent.keyDown(document, { key: '2', ctrlKey: true }) + expect(screen.queryByText('No sessions in this range yet.')).not.toBeInTheDocument() + }) + it('re-polls visible section data when period or provider changes', async () => { render() - fireEvent.keyDown(document, { key: '3', metaKey: true }) + fireEvent.keyDown(document, { key: '4', metaKey: true }) expect(await screen.findByText('Cost flow · model → project')).toBeInTheDocument() fireEvent.click(screen.getByText('Today')) @@ -270,6 +301,25 @@ describe('App shortcuts', () => { }) }) + it('drives combined-scope overview fetches and persists the Scope setting', async () => { + render() + await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all')) + + fireEvent.keyDown(document, { key: ',', metaKey: true }) + fireEvent.click(await screen.findByLabelText('Scope')) + fireEvent.click(await screen.findByRole('option', { name: 'Combined' })) + + // Combined scope forces provider='all' and passes --scope combined (6th arg). + await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, undefined, undefined, 'combined')) + expect(localStorage.getItem('codeburn.scope')).toBe('combined') + }) + + it('boots in combined scope from the persisted Scope setting', async () => { + localStorage.setItem('codeburn.scope', 'combined') + render() + await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, undefined, undefined, 'combined')) + }) + it('builds the provider picker from providerDetails so display-name providers round-trip their internal id', async () => { // grok's display name is "Grok Build"; the picker must show the label but // send the internal id `grok` as --provider (which assertProvider accepts). @@ -389,7 +439,7 @@ describe('App shortcuts', () => { it('applies a calendar range to overview and visible section polls', async () => { render() - fireEvent.keyDown(document, { key: '3', metaKey: true }) + fireEvent.keyDown(document, { key: '4', metaKey: true }) expect(await screen.findByText('Cost flow · model → project')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Choose date range' })) @@ -459,6 +509,48 @@ describe('App shortcuts', () => { }) }) +describe('win32 shortcut chords', () => { + beforeEach(() => { + installDefaultMocks() + localStorage.clear() + localStorage.setItem('codeburn.defaultPeriod', '30days') + document.documentElement.removeAttribute('data-theme') + setPlatform('win32') + }) + + afterEach(() => { + clearPlatform() + }) + + it('navigates with Ctrl+2 and refreshes with Ctrl+R', async () => { + render() + expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument() + + fireEvent.keyDown(document, { key: '2', ctrlKey: true }) + expect(await screen.findByText('No sessions in this range yet.')).toBeInTheDocument() + + const overviewCalls = mocks.getOverview.mock.calls.length + fireEvent.keyDown(document, { key: 'r', ctrlKey: true }) + await waitFor(() => expect(mocks.getOverview.mock.calls.length).toBeGreaterThan(overviewCalls)) + }) + + it('ignores Meta+2 on win32', async () => { + render() + expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument() + + fireEvent.keyDown(document, { key: '2', metaKey: true }) + expect(screen.queryByText('No sessions in this range yet.')).not.toBeInTheDocument() + }) + + it('ignores Ctrl+Alt+2 (the AltGr shape) on win32', async () => { + render() + expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument() + + fireEvent.keyDown(document, { key: '2', ctrlKey: true, altKey: true }) + expect(screen.queryByText('No sessions in this range yet.')).not.toBeInTheDocument() + }) +}) + describe('provider prefetch storm', () => { const PROVIDERS = [ 'claude', 'codex', 'gemini', 'grok', 'copilot', 'droid', @@ -596,6 +688,11 @@ describe('currency correctness', () => { // independent of the app-wide default ('today'). localStorage.setItem('codeburn.defaultPeriod', '30days') __resetPolledMemo() + setPlatform('darwin') + }) + + afterEach(() => { + clearPlatform() }) it('never regresses the applied currency to a memo-served (stale) payload during a switch', async () => { diff --git a/app/renderer/App.tsx b/app/renderer/App.tsx index 8dae38a9..48295d6b 100644 --- a/app/renderer/App.tsx +++ b/app/renderer/App.tsx @@ -16,17 +16,19 @@ import { readDailyBudget } from './lib/budget' import { formatCompact, formatUsd, setActiveCurrency } from './lib/format' import { motionClass } from './lib/motion' import { codeburn } from './lib/ipc' +import { isModifierChord, shortcutLabel } from './lib/platform' import { localDateKey } from './lib/period' import { persistRefreshValue, readRefreshValue, refreshValueToMs, RefreshCadenceContext, type RefreshCadence } from './lib/refreshCadence' import { OverviewContent } from './sections/Overview' import { OptimizeContent } from './sections/Optimize' import { Models } from './sections/Models' import { Sessions } from './sections/Sessions' +import { PullRequestsContent } from './sections/PullRequests' import { Compare } from './sections/Compare' import { Plans } from './sections/Plans' import { Settings, type SettingsPane } from './sections/Settings' import { SpendContent } from './sections/Spend' -import type { DateRange, MenubarPayload, ModelReportRow, Period, TelemetryStatus } from './lib/types' +import type { DateRange, MenubarPayload, ModelReportRow, Period, Scope, TelemetryStatus } from './lib/types' // Bucket raw dollar amounts before they leave the machine: telemetry carries // coarse ranges, never exact spend. @@ -106,6 +108,7 @@ export function usageSnapshotProps(payload: MenubarPayload, modelCategories?: Ma const SECTION_TITLES: Record = { overview: 'Overview', sessions: 'Sessions', + pullRequests: 'Pull requests', spend: 'Spend', optimize: 'Optimize', models: 'Models', @@ -128,8 +131,8 @@ const STANDARD_PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all', ' // Instant-switch memo key for an overview result. Shared by the overview poll // and the provider prefetcher so the two never drift out of sync. Exported so // the prefetch-storm test can assert warmed keys survive between polls. -export function overviewMemoKey(provider: string, period: Period, range: DateRange | null, configSource: string | null): string { - return `overview|${provider}|${period}|${range?.from ?? ''}-${range?.to ?? ''}|${configSource ?? ''}` +export function overviewMemoKey(provider: string, period: Period, range: DateRange | null, configSource: string | null, scope: Scope = 'local'): string { + return `overview|${provider}|${period}|${range?.from ?? ''}-${range?.to ?? ''}|${configSource ?? ''}|${scope}` } // Prefetch pacing: wait a short idle after the first paint, then warm one @@ -170,6 +173,15 @@ function persistConfigSource(id: string | null): void { } catch { /* storage can be unavailable */ } } +/** Boot scope = the persisted dashboard Scope setting, else local. */ +function initialScope(): Scope { + try { return globalThis.localStorage?.getItem('codeburn.scope') === 'combined' ? 'combined' : 'local' } catch { return 'local' } +} + +function persistScope(scope: Scope): void { + try { globalThis.localStorage?.setItem('codeburn.scope', scope) } catch { /* storage can be unavailable */ } +} + function providerName(provider: string): string { if (provider === 'all') return 'All providers' return provider @@ -216,20 +228,27 @@ function AppMain() { const [detectedProviders, setDetectedProviders] = useState>([]) const [customRange, setCustomRange] = useState(null) const [claudeConfigSource, setClaudeConfigSource] = useState(initialConfigSource) + const [scope, setScopeState] = useState(initialScope) const [refreshToken, setRefreshToken] = useState(0) const [now, setNow] = useState(() => Date.now()) const [, setCurrencyTick] = useState(0) // Preserve the 2/3-arg call shapes when no config is scoped so the CLI argv // stays flag-free; only add --claude-config-source once a config is picked. + // Combined scope aggregates paired-device usage; the CLI rejects it alongside + // a provider/config filter, so onScopeChange forces provider='all' and clears + // the config scope before this poll runs. Passing scope='local' produces the + // same flag-free argv as before, so local users are unaffected. const overview = usePolled( - () => claudeConfigSource + () => scope === 'combined' + ? codeburn.getOverview(period, 'all', customRange ?? undefined, undefined, undefined, 'combined') + : claudeConfigSource ? codeburn.getOverview(period, provider, customRange ?? undefined, claudeConfigSource) : customRange ? codeburn.getOverview(period, provider, customRange) : codeburn.getOverview(period, provider), - [period, provider, customRange?.from, customRange?.to, claudeConfigSource], - { memoKey: overviewMemoKey(provider, period, customRange, claudeConfigSource) }, + [period, provider, customRange?.from, customRange?.to, claudeConfigSource, scope], + { memoKey: overviewMemoKey(provider, period, customRange, claudeConfigSource, scope) }, ) const refreshOverview = overview.refresh @@ -271,7 +290,7 @@ function AppMain() { // fails we still emit the snapshot, just without the model x category cross. const snapshotDayRef = useRef(null) useEffect(() => { - if (!overview.data || provider !== 'all' || customRange || claudeConfigSource) return + if (!overview.data || provider !== 'all' || customRange || claudeConfigSource || scope !== 'local') return const today = localDateKey(new Date()) if (snapshotDayRef.current === today) return snapshotDayRef.current = today @@ -283,7 +302,7 @@ function AppMain() { } catch { /* degrade: emit the snapshot without per-model topCategory */ } trackEvent('usage_snapshot', usageSnapshotProps(payload, modelCategories)) })() - }, [overview.data, provider, customRange, claudeConfigSource, period, trackEvent]) + }, [overview.data, provider, customRange, claudeConfigSource, scope, period, trackEvent]) useEffect(() => { let saved: string | null = null @@ -358,7 +377,9 @@ function AppMain() { overviewBusyRef.current = overview.loading const warmedKeys = useRef>(new Set()) useEffect(() => { - if (!ready || overview.data == null || customRange || claudeConfigSource) return + // Combined scope has no provider picker to warm — it always shows unfiltered + // all-device usage — so the per-provider prefetch is local-scope only. + if (!ready || overview.data == null || customRange || claudeConfigSource || scope !== 'local') return const targets = detectedProviders.map(entry => entry.id).filter(id => id !== provider) if (targets.length === 0) return let cancelled = false @@ -389,7 +410,7 @@ function AppMain() { // `overview.data == null` (a boolean) gates on first-resolution without // re-running every poll; the data content itself is intentionally not a dep. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ready, period, provider, customRange, claudeConfigSource, detectedProviders, overview.data == null]) + }, [ready, period, provider, customRange, claudeConfigSource, scope, detectedProviders, overview.data == null]) useEffect(() => { const id = window.setInterval(() => setNow(Date.now()), 1000) @@ -420,15 +441,16 @@ function AppMain() { useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { - if (!event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return + if (!isModifierChord(event)) return const key = event.key.toLowerCase() if (key === '1') navigate('overview') else if (key === '2') navigate('sessions') - else if (key === '3') navigate('spend') - else if (key === '4') navigate('optimize') - else if (key === '5') navigate('models') - else if (key === '6') navigate('compare') - else if (key === '7') navigate('plans') + else if (key === '3') navigate('pullRequests') + else if (key === '4') navigate('spend') + else if (key === '5') navigate('optimize') + else if (key === '6') navigate('models') + else if (key === '7') navigate('compare') + else if (key === '8') navigate('plans') else if (key === ',') navigate('settings') else if (key === 'r') refreshVisible() else return @@ -448,17 +470,22 @@ function AppMain() { // A Claude config scopes Claude usage only, so a non-Claude provider filter // would make the CLI reject the flag: reset it to 'all' first (a 'claude' - // filter is already compatible and is left alone). + // filter is already compatible and is left alone). Picking a config also + // implies a device-specific view, so drop combined scope back to local. const onConfigSelect = (id: string) => { const next = id || null if (next && provider !== 'all' && provider !== 'claude') setProvider('all') + if (next && scope === 'combined') { setScopeState('local'); persistScope('local') } setClaudeConfigSource(next) persistConfigSource(next) } // Symmetric direction: picking a non-Claude provider while a config is - // scoped would hit the same CLI rejection, so drop the config scope. + // scoped would hit the same CLI rejection, so drop the config scope. A + // specific provider filter is a device-specific view, so it also drops + // combined scope back to local (combined reports unfiltered usage). const onProviderSelect = (value: string) => { + if (value !== 'all' && scope === 'combined') { setScopeState('local'); persistScope('local') } if (claudeConfigSource && value !== 'all' && value !== 'claude') { setClaudeConfigSource(null) persistConfigSource(null) @@ -466,6 +493,19 @@ function AppMain() { setProvider(value) } + // Combined scope reports unfiltered, all-provider usage across paired devices, + // so switching to it resets the provider filter and Claude-config scope (which + // the CLI would otherwise reject), mirroring the menubar's setMenubarScope. + const onScopeChange = (value: string) => { + const next: Scope = value === 'combined' ? 'combined' : 'local' + if (next === 'combined') { + if (provider !== 'all') setProvider('all') + if (claudeConfigSource) { setClaudeConfigSource(null); persistConfigSource(null) } + } + setScopeState(next) + persistScope(next) + } + const claudeConfigs = overview.data?.claudeConfigs const providerOptions = [ { value: 'all', label: 'All providers' }, @@ -475,7 +515,11 @@ function AppMain() { const activeConfigLabel = claudeConfigSource ? claudeConfigs?.options.find(option => option.id === claudeConfigSource)?.label ?? null : null - const scope = `${customRange ? rangeLabel(customRange) : PERIOD_LABELS[period]} · ${providerLabel}${activeConfigLabel ? ` · ${activeConfigLabel}` : ''}` + // Combined scope reports unfiltered all-device usage, so the caption reads + // "Combined" in place of the (forced-'all') provider label. + const scopeCaption = scope === 'combined' + ? `${customRange ? rangeLabel(customRange) : PERIOD_LABELS[period]} · Combined` + : `${customRange ? rangeLabel(customRange) : PERIOD_LABELS[period]} · ${providerLabel}${activeConfigLabel ? ` · ${activeConfigLabel}` : ''}` return ( @@ -491,12 +535,12 @@ function AppMain() { {section === 'plans' ? ( ) : section === 'settings' ? ( - + ) : ( <>

{section === 'overview' ? ( - + ) : section === 'sessions' ? ( + ) : section === 'pullRequests' ? ( + ) : section === 'spend' ? ( ) : section === 'optimize' ? ( @@ -532,9 +578,9 @@ function AppMain() { {section !== 'settings' && ( diff --git a/app/renderer/components/Sidebar.test.tsx b/app/renderer/components/Sidebar.test.tsx index 853c2393..64454053 100644 --- a/app/renderer/components/Sidebar.test.tsx +++ b/app/renderer/components/Sidebar.test.tsx @@ -1,17 +1,31 @@ // @vitest-environment jsdom -import { describe, it, expect, vi } from 'vitest' +import { afterEach, describe, it, expect, vi } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' import { Sidebar } from './Sidebar' +function setPlatform(platform: string): void { + ;(window as unknown as { codeburn?: { platform?: string } }).codeburn = { platform } +} + describe('Sidebar', () => { - it('renders all eight nav items in the desktop order', () => { + afterEach(() => { + delete (window as unknown as { codeburn?: { platform?: string } }).codeburn + }) + + it.each([ + ['darwin', '⌘'], + ['win32', 'Ctrl+'], + ] as const)('renders all nine nav items in the desktop order with %s keycaps', (platform, mod) => { + setPlatform(platform) render( {}} />) - const labels = screen.getAllByRole('button').map(item => item.textContent?.replace(/⌘[\d,]/, '')) - expect(labels).toEqual(['Overview', 'Sessions', 'Spend', 'Optimize', 'Models', 'Compare', 'Plans', 'Settings']) - expect(screen.getByRole('button', { name: /Sessions.*⌘2/ })).toBeInTheDocument() - expect(screen.getByRole('button', { name: /Compare.*⌘6/ })).toBeInTheDocument() - expect(screen.getByRole('button', { name: /Plans.*⌘7/ })).toBeInTheDocument() + const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const labels = screen.getAllByRole('button').map(item => item.textContent?.replace(/(⌘|Ctrl\+)[\d,]/, '')) + expect(labels).toEqual(['Overview', 'Sessions', 'Pull requests', 'Spend', 'Optimize', 'Models', 'Compare', 'Plans', 'Settings']) + expect(screen.getByRole('button', { name: new RegExp(`Sessions.*${esc(mod)}2`) })).toBeInTheDocument() + expect(screen.getByRole('button', { name: new RegExp(`Pull requests.*${esc(mod)}3`) })).toBeInTheDocument() + expect(screen.getByRole('button', { name: new RegExp(`Compare.*${esc(mod)}7`) })).toBeInTheDocument() + expect(screen.getByRole('button', { name: new RegExp(`Plans.*${esc(mod)}8`) })).toBeInTheDocument() }) it('calls onNavigate with the section id when a nav item is clicked', () => { diff --git a/app/renderer/components/Sidebar.tsx b/app/renderer/components/Sidebar.tsx index 8c493a55..ebbd5eed 100644 --- a/app/renderer/components/Sidebar.tsx +++ b/app/renderer/components/Sidebar.tsx @@ -1,34 +1,38 @@ import { useState, type ReactNode } from 'react' import { codeburn } from '../lib/ipc' +import { shortcutLabel } from '../lib/platform' import { AboutModal, type SocialLink } from './AboutModal' import { FlameMark } from './FlameMark' -export type Section = 'overview' | 'sessions' | 'spend' | 'optimize' | 'models' | 'compare' | 'plans' | 'settings' +export type Section = 'overview' | 'sessions' | 'pullRequests' | 'spend' | 'optimize' | 'models' | 'compare' | 'plans' | 'settings' export const NAV_ITEMS: Array<{ id: Section; label: string; key: string; icon: ReactNode }> = [ - { id: 'overview', label: 'Overview', key: '⌘1', icon: ( + { id: 'overview', label: 'Overview', key: '1', icon: ( ) }, - { id: 'sessions', label: 'Sessions', key: '⌘2', icon: ( + { id: 'sessions', label: 'Sessions', key: '2', icon: ( ) }, - { id: 'spend', label: 'Spend', key: '⌘3', icon: ( + { id: 'pullRequests', label: 'Pull requests', key: '3', icon: ( + + ) }, + { id: 'spend', label: 'Spend', key: '4', icon: ( ) }, - { id: 'optimize', label: 'Optimize', key: '⌘4', icon: ( + { id: 'optimize', label: 'Optimize', key: '5', icon: ( ) }, - { id: 'models', label: 'Models', key: '⌘5', icon: ( + { id: 'models', label: 'Models', key: '6', icon: ( ) }, - { id: 'compare', label: 'Compare', key: '⌘6', icon: ( + { id: 'compare', label: 'Compare', key: '7', icon: ( ) }, - { id: 'plans', label: 'Plans', key: '⌘7', icon: ( + { id: 'plans', label: 'Plans', key: '8', icon: ( ) }, - { id: 'settings', label: 'Settings', key: '⌘,', icon: ( + { id: 'settings', label: 'Settings', key: ',', icon: ( ) }, ] @@ -72,7 +76,7 @@ export function Sidebar({ > {item.icon} {item.label} - {item.key} + {shortcutLabel(item.key)}
))}
diff --git a/app/renderer/hooks/useUpdateStatus.test.ts b/app/renderer/hooks/useUpdateStatus.test.ts index c20d4261..f48b75f2 100644 --- a/app/renderer/hooks/useUpdateStatus.test.ts +++ b/app/renderer/hooks/useUpdateStatus.test.ts @@ -4,7 +4,7 @@ vi.mock('../lib/ipc', () => ({ codeburn: { platform: 'linux', arch: 'x64' }, })) -import { directDownloadUrl, releasePageUrl, updateDownloadUrl } from './useUpdateStatus' +import { directDownloadUrl, MICROSOFT_STORE_URL, releasePageUrl, updateDownloadUrl } from './useUpdateStatus' const TAG = 'desktop-v0.9.19' const BASE = 'https://github.com/getagentseal/codeburn/releases/download/desktop-v0.9.19' @@ -18,9 +18,9 @@ describe('directDownloadUrl', () => { expect(directDownloadUrl(TAG, 'darwin', 'x64')).toBe(`${BASE}/CodeBurn-0.9.19.dmg`) }) - it('maps Windows to the Setup exe regardless of arch', () => { - expect(directDownloadUrl(TAG, 'win32', 'x64')).toBe(`${BASE}/CodeBurn-Setup-0.9.19.exe`) - expect(directDownloadUrl(TAG, 'win32', undefined)).toBe(`${BASE}/CodeBurn-Setup-0.9.19.exe`) + it('maps Windows to the official Microsoft Store regardless of arch', () => { + expect(directDownloadUrl(TAG, 'win32', 'x64')).toBe(MICROSOFT_STORE_URL) + expect(directDownloadUrl(TAG, 'win32', undefined)).toBe(MICROSOFT_STORE_URL) }) it('returns null for Linux (three formats, the user picks on the page)', () => { diff --git a/app/renderer/hooks/useUpdateStatus.ts b/app/renderer/hooks/useUpdateStatus.ts index dd90c9d9..f72ac8b4 100644 --- a/app/renderer/hooks/useUpdateStatus.ts +++ b/app/renderer/hooks/useUpdateStatus.ts @@ -30,12 +30,16 @@ export function releasePageUrl(tag: string): string { return `https://github.com/getagentseal/codeburn/releases/tag/${tag}` } +/** Official signed Windows distribution. The Store handles installation and + * updates, avoiding the unsigned GitHub installer and SmartScreen warning. */ +export const MICROSOFT_STORE_URL = 'https://apps.microsoft.com/detail/9P0R4ZL5XMB8' + /** - * Direct asset download for the running platform, so Download saves the file - * instead of landing on a GitHub page. Filenames mirror the electron-builder - * output (app/DISTRIBUTION.md) and are version-derived from the tag. Returns - * null (callers fall back to the release page) for Linux — three formats, the - * user picks — and for unknown platforms or a preload without `arch`. + * Preferred install target for the running platform. macOS downloads the + * matching release asset; Windows opens the signed Microsoft Store listing. + * Returns null (callers fall back to the release page) for Linux — three + * formats, the user picks — and for unknown platforms or a preload without + * `arch`. */ export function directDownloadUrl(tag: string, platform: string | undefined, arch: string | undefined): string | null { const version = tag.startsWith('desktop-v') ? tag.slice('desktop-v'.length) : null @@ -45,7 +49,7 @@ export function directDownloadUrl(tag: string, platform: string | undefined, arc if (!arch) return null file = arch === 'arm64' ? `CodeBurn-${version}-arm64.dmg` : `CodeBurn-${version}.dmg` } else if (platform === 'win32') { - file = `CodeBurn-Setup-${version}.exe` + return MICROSOFT_STORE_URL } if (!file) return null return `https://github.com/getagentseal/codeburn/releases/download/${tag}/${file}` diff --git a/app/renderer/lib/platform.ts b/app/renderer/lib/platform.ts new file mode 100644 index 00000000..f0c62b88 --- /dev/null +++ b/app/renderer/lib/platform.ts @@ -0,0 +1,46 @@ +// Single source of truth for platform-aware shortcut behaviour. The preload +// exposes `window.codeburn.platform` (process.platform); when the bridge is +// absent (unit tests, vite in a plain browser) fall back to the user agent. +// All functions read platform state at call time, never at module load, so +// the preload bridge may appear after this module is imported. + +function bridgePlatform(): string | undefined { + if (typeof window === 'undefined') return undefined + return (window as unknown as { codeburn?: { platform?: string } }).codeburn?.platform +} + +function userAgentPlatform(): string | undefined { + if (typeof navigator === 'undefined') return undefined + if (/mac/i.test(navigator.userAgent)) return 'darwin' + const platform = navigator.platform + if (typeof platform === 'string' && /mac/i.test(platform)) return 'darwin' + return undefined +} + +/** True when the Electron preload reports darwin (or the UA matches a Mac). */ +export function isMacPlatform(): boolean { + const platform = bridgePlatform() + if (platform) return platform === 'darwin' + return userAgentPlatform() === 'darwin' +} + +/** The modifier keycap label: '⌘' on mac, 'Ctrl+' elsewhere. */ +export function modKeyLabel(): string { + return isMacPlatform() ? '⌘' : 'Ctrl+' +} + +/** A full shortcut label, e.g. '⌘R' on mac, 'Ctrl+R' on Windows. */ +export function shortcutLabel(key: string): string { + return modKeyLabel() + key +} + +/** + * True when the event is the platform's modifier chord and no other modifier + * is held. On mac: Meta (Cmd) without Ctrl. Elsewhere: Ctrl without Meta. + * altKey stays rejected on every platform: AltGr on European layouts arrives + * as Ctrl+Alt, and Ctrl+Alt+ must not hijack a typed character. + */ +export function isModifierChord(event: { metaKey: boolean; ctrlKey: boolean; altKey: boolean; shiftKey: boolean }): boolean { + if (event.altKey || event.shiftKey) return false + return isMacPlatform() ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey +} diff --git a/app/renderer/lib/types.ts b/app/renderer/lib/types.ts index 9a87f25f..faf00a92 100644 --- a/app/renderer/lib/types.ts +++ b/app/renderer/lib/types.ts @@ -6,6 +6,10 @@ export type Period = 'today' | 'week' | '30days' | 'month' | 'all' | 'lifetime' +// Dashboard usage scope: this device only ('local') or the aggregate across +// every paired device ('combined'). Mirrors the macOS menubar's Scope setting. +export type Scope = 'local' | 'combined' + export type DateRange = { from: string; to: string } export type CliErrorKind = 'not-found' | 'nonzero' | 'bad-json' | 'timeout' | 'too-large' | 'bad-args' @@ -178,6 +182,20 @@ export type MenubarPayload = { calls: number date: string }> + // Workflow-intelligence rollups (src/menubar-json.ts buildWorkflow / + // buildTopReworkedFiles). Optional: older CLIs omit them, so the Overview + // workflow card renders only when they are present with real signal. + workflow?: { + corrections: number + correctionRate: number | null + medianTimeToFirstEditMs: number | null + } + // Files most reworked by edit-family calls, basename-only, ranked by + // distinct sessions then edits (src/menubar-json.ts buildTopReworkedFiles). + topReworkedFiles?: Array<{ path: string; sessions: number; edits: number }> + // Share (0-1) of cost-bearing calls that resolved a price. Below 1 means some + // usage priced against no table entry; null when not computable. + pricingCoverage?: number | null retryTax: { totalUSD: number retries: number @@ -206,6 +224,37 @@ export type MenubarPayload = { skills: Array<{ name: string; turns: number; cost: number }> subagents: Array<{ name: string; calls: number; cost: number }> mcpServers: Array<{ name: string; calls: number }> + // Spend by referenced pull request (every PR, cost-descending), attributed at turn + // granularity. Optional: older CLIs omit it, and it is absent when no PR links + // were observed. Rows carry attributed cost/calls and ARE summable; + // `attributedCost + unattributedCost === distinctCost`. `approx` marks a row + // fed by the legacy whole-session even split (transcript expired). `models` is + // the short model names that processed the PR (cost-desc); `categories` is the + // per-task-category attributed cost (cost-desc), omitted for legacy rows. + // `attributedCost`/`unattributedCost` are optional so a payload from an older + // CLI (by-reference rows, not summable) still type-checks and can be detected. + pullRequests?: { + rows: Array<{ + url: string + label: string + cost: number + savingsUSD: number + sessions: number + calls: number + firstStarted: string + lastEnded: string + approx?: boolean + models?: string[] + categories?: Array<{ name: string; cost: number }> + }> + distinctCost: number + distinctSessions: number + // Count of subagent (sidechain) runs folded into the PR-linked parent + // sessions. Optional (absent when none folded, or from an older producer). + subagentSessions?: number + attributedCost?: number + unattributedCost?: number + } } optimize: { findingCount: number @@ -421,6 +470,10 @@ export type ActReportJson = { // ————— src/sessions-report.ts ————— export type SessionRow = { sessionId: string + // Captured human title (src/sessions-report.ts). Empty string when the + // transcript produced none; optional so older CLIs that predate the field + // render unchanged (the row falls back to the project as its primary label). + title?: string project: string provider: string models: string[] @@ -588,7 +641,9 @@ export interface CodeburnBridge { getQuota(force?: boolean): Promise // `background` (prefetch only) requests background CLI-spawn priority; optional // so an older preload that ignores it degrades to interactive priority. - getOverview(period: Period, provider: string, range?: DateRange, configSource?: string | null, background?: boolean): Promise + // `scope` selects local-device usage ('local', default) or paired-device + // aggregate ('combined'); optional so an older preload degrades to local. + getOverview(period: Period, provider: string, range?: DateRange, configSource?: string | null, background?: boolean, scope?: string): Promise getPlans(period: Period): Promise getActReport(): Promise readonly platform: string diff --git a/app/renderer/sections/Overview.test.tsx b/app/renderer/sections/Overview.test.tsx index f76e4984..1093eebe 100644 --- a/app/renderer/sections/Overview.test.tsx +++ b/app/renderer/sections/Overview.test.tsx @@ -520,6 +520,49 @@ describe('Overview', () => { expect(screen.queryByText('Saved to date')).not.toBeInTheDocument() }) + it('shows paired-device aggregate totals in the hero under combined scope', async () => { + const now = new Date() + const payload = makePayload(now) + // Local device: $312.40 / 4200 calls / 88 sessions (from makePayload). + // Combined swaps the hero to the cross-device aggregate and lists devices. + payload.combined = { + perDevice: [ + { id: 'local', name: 'laptop', local: true, cost: 312.4, calls: 4200, sessions: 88, inputTokens: 0, outputTokens: 0, cacheCreateTokens: 0, cacheReadTokens: 0, totalTokens: 0 }, + { id: 'fp-workstation', name: 'workstation', local: false, cost: 187.6, calls: 2100, sessions: 40, inputTokens: 0, outputTokens: 0, cacheCreateTokens: 0, cacheReadTokens: 0, totalTokens: 0 }, + ], + combined: { cost: 500, calls: 6300, sessions: 128, inputTokens: 0, outputTokens: 0, cacheCreateTokens: 0, cacheReadTokens: 0, totalTokens: 0, deviceCount: 2, reachableCount: 2 }, + } + + const { container } = render() + + const kpis = container.querySelector('.ov-hero-main') as HTMLElement + // Hero cost is the combined $500, not the local $312.40. + expect(within(kpis).getByText('$500.00')).toBeInTheDocument() + expect(within(kpis).getByText(/6,300 calls · 128 sessions/)).toBeInTheDocument() + expect(within(kpis).getByText('Combined · Last 30 days')).toBeInTheDocument() + expect(within(kpis).getByText('2 of 2 devices')).toBeInTheDocument() + expect(within(kpis).getByText('workstation')).toBeInTheDocument() + expect(within(kpis).getByText('laptop · this device')).toBeInTheDocument() + // Combined mode hides the local savings lines (they are device-specific). + expect(within(kpis).queryByText('Saved via local models')).not.toBeInTheDocument() + }) + + it('keeps local hero totals when scope is local even if a combined payload is present', async () => { + const now = new Date() + const payload = makePayload(now) + payload.combined = { + perDevice: [], + combined: { cost: 999, calls: 1, sessions: 1, inputTokens: 0, outputTokens: 0, cacheCreateTokens: 0, cacheReadTokens: 0, totalTokens: 0, deviceCount: 2, reachableCount: 2 }, + } + + const { container } = render() + + const kpis = container.querySelector('.ov-hero-main') as HTMLElement + expect(within(kpis).getByText('$312.40')).toBeInTheDocument() + expect(within(kpis).queryByText('$999.00')).not.toBeInTheDocument() + expect(within(kpis).queryByText(/devices/)).not.toBeInTheDocument() + }) + it('shows a stale banner when last-good data is present but the latest poll failed', async () => { const now = new Date() const overview: Polled = { @@ -700,3 +743,113 @@ describe('Overview', () => { expect(within(outcome).getByText('€36.00')).toBeInTheDocument() }) }) + +type WorkflowOverrides = { + workflow?: MenubarPayload['current']['workflow'] + topReworkedFiles?: MenubarPayload['current']['topReworkedFiles'] + pricingCoverage?: MenubarPayload['current']['pricingCoverage'] +} + +function workflowPayload(now: Date, over: WorkflowOverrides): MenubarPayload { + const base = makePayload(now) + return { ...base, current: { ...base.current, ...over } } +} + +describe('Overview workflow card', () => { + beforeEach(() => { + setActiveCurrency({ code: 'USD', symbol: '$', rate: 1 }) + getOverview.mockReset() + getActReport.mockReset() + getYield.mockReset() + getActReport.mockResolvedValue({ totals: { realizedCostUSD: 0, measuredActions: 0 } }) + getYield.mockResolvedValue(makeYieldReport()) + }) + afterEach(() => vi.useRealTimers()) + + function workflowRegion(): HTMLElement { + return screen.getByRole('heading', { name: 'Workflow' }).closest('.ov-workflow-widget') as HTMLElement + } + + it('renders correction rate, time to first edit, top rework, coverage chip, and a coaching note', async () => { + const now = new Date() + getOverview.mockResolvedValue(workflowPayload(now, { + workflow: { corrections: 7, correctionRate: 0.2, medianTimeToFirstEditMs: 45_000 }, + topReworkedFiles: [{ path: 'parser.ts', sessions: 4, edits: 12 }], + pricingCoverage: 0.92, + })) + + render() + + const card = await waitFor(() => workflowRegion()) + expect(within(card).getByText('Correction rate')).toBeInTheDocument() + expect(within(card).getByText('20%')).toBeInTheDocument() + expect(within(card).getByText('7 corrections')).toBeInTheDocument() + expect(within(card).getByText('Time to first edit')).toBeInTheDocument() + // Under 60s renders as seconds, not minutes. + expect(within(card).getByText('45s')).toBeInTheDocument() + expect(within(card).getByText(/Top rework:/)).toHaveTextContent('Top rework: parser.ts · 4 sessions · 12 edits') + // pricingCoverage 0.92 → a "92% priced" caveat chip. + expect(within(card).getByText('92% priced')).toBeInTheDocument() + // Corrections clears its bar first, so its coaching line wins. + expect(within(card).getByText(/You corrected the assistant on 20% of prompts \(7 times\)/)).toBeInTheDocument() + }) + + it('does not render at all when the payload carries no workflow signal', async () => { + const now = new Date() + // makePayload omits workflow/topReworkedFiles/pricingCoverage entirely. + getOverview.mockResolvedValue(makePayload(now)) + + render() + + await screen.findByText('$312.40') + expect(screen.queryByRole('heading', { name: 'Workflow' })).not.toBeInTheDocument() + }) + + it('stays hidden when workflow exists but every metric is empty', async () => { + const now = new Date() + getOverview.mockResolvedValue(workflowPayload(now, { + workflow: { corrections: 0, correctionRate: null, medianTimeToFirstEditMs: null }, + topReworkedFiles: [], + pricingCoverage: 1, + })) + + render() + + await screen.findByText('$312.40') + expect(screen.queryByRole('heading', { name: 'Workflow' })).not.toBeInTheDocument() + }) + + it('picks the churn note and formats minutes when corrections are below the bar', async () => { + const now = new Date() + getOverview.mockResolvedValue(workflowPayload(now, { + workflow: { corrections: 1, correctionRate: 0.05, medianTimeToFirstEditMs: 8 * 60 * 1000 }, + topReworkedFiles: [{ path: 'router.ts', sessions: 5, edits: 30 }], + pricingCoverage: null, + })) + + render() + + const card = await waitFor(() => workflowRegion()) + // >= 60s renders as whole minutes. + expect(within(card).getByText('8m')).toBeInTheDocument() + // Corrections (5%) is below 0.15, so the churn note wins over TTFE. + expect(within(card).getByText(/router\.ts was reworked across 5 sessions \(30 edits\)/)).toBeInTheDocument() + // pricingCoverage null → no chip. + expect(within(card).queryByText(/priced/)).not.toBeInTheDocument() + }) + + it('falls back to a neutral caption and hides the chip at full coverage when no note fires', async () => { + const now = new Date() + getOverview.mockResolvedValue(workflowPayload(now, { + workflow: { corrections: 1, correctionRate: 0.05, medianTimeToFirstEditMs: 30_000 }, + topReworkedFiles: [{ path: 'small.ts', sessions: 1, edits: 2 }], + pricingCoverage: 1, + })) + + render() + + const card = await waitFor(() => workflowRegion()) + expect(within(card).getByText('Corrections, first-edit latency, and file churn across your sessions.')).toBeInTheDocument() + expect(within(card).queryByText(/priced/)).not.toBeInTheDocument() + }) +}) diff --git a/app/renderer/sections/Overview.tsx b/app/renderer/sections/Overview.tsx index 66ac77ed..b2a9dd20 100644 --- a/app/renderer/sections/Overview.tsx +++ b/app/renderer/sections/Overview.tsx @@ -15,10 +15,12 @@ import { codeburn } from '../lib/ipc' import { contiguousDailyWindow, dataStartKey, formatChartDate, localDateKey, sliceDailyToPeriod, sliceDailyToRange } from '../lib/period' import type { ActReportJson, + CombinedUsage, DailyHistoryEntry, DateRange, MenubarPayload, Period, + Scope, YieldJsonReport, } from '../lib/types' @@ -129,6 +131,88 @@ function CostPerOutcome({ outcome }: { outcome: Polled }) { ) } +// Coaching-note thresholds, mirrored from the CLI so the card and the CLI never +// disagree (src/workflow-insights.ts buildCoachingNotes). +const WORKFLOW_CORRECTION_RATE = 0.15 +const WORKFLOW_CORRECTION_COUNT = 3 +const WORKFLOW_CHURN_SESSIONS = 3 +const WORKFLOW_TTFE_SLOW_MS = 5 * 60 * 1000 + +/** Median time to first edit: `<60s → Ns`, else `Nm` (src/workflow-insights.ts formatDurationShort). */ +function formatWorkflowDuration(ms: number): string { + if (ms >= 60_000) return `${Math.round(ms / 60_000)}m` + return `${Math.round(ms / 1000)}s` +} + +type WorkflowRollup = NonNullable +type ReworkedFile = { path: string; sessions: number; edits: number } + +/** + * One coaching line derived with the CLI's thresholds and dry copy voice + * (src/workflow-insights.ts buildCoachingNotes): corrections, then file churn, + * then time-to-first-edit; the first that fires. Null when none clears its bar. + */ +function workflowCoachingNote(workflow: WorkflowRollup, topReworked?: ReworkedFile): string | null { + const { correctionRate, corrections, medianTimeToFirstEditMs } = workflow + if (correctionRate !== null && correctionRate >= WORKFLOW_CORRECTION_RATE && corrections >= WORKFLOW_CORRECTION_COUNT) { + return `You corrected the assistant on ${Math.round(correctionRate * 100)}% of prompts (${corrections} times). State the requirements in the first message to cut the back and forth.` + } + if (topReworked && topReworked.sessions >= WORKFLOW_CHURN_SESSIONS) { + return `${topReworked.path} was reworked across ${topReworked.sessions} sessions (${topReworked.edits} edits). A focused pass on it may cost less than the repeated churn.` + } + if (medianTimeToFirstEditMs !== null && medianTimeToFirstEditMs >= WORKFLOW_TTFE_SLOW_MS) { + return `Median time to first edit is ${formatWorkflowDuration(medianTimeToFirstEditMs)}. Point the assistant at the target file to cut the exploration before it starts editing.` + } + return null +} + +function WorkflowCard({ current }: { current: MenubarPayload['current'] }) { + const workflow = current.workflow + const topReworked = current.topReworkedFiles?.[0] + // Hide when there is no real signal: never show a card of zeros. + const hasSignal = !!workflow && ( + workflow.correctionRate !== null || + workflow.medianTimeToFirstEditMs !== null || + workflow.corrections > 0 || + !!topReworked + ) + if (!workflow || !hasSignal) return null + + const coverage = current.pricingCoverage + const showCoverage = typeof coverage === 'number' && coverage < 1 + const note = workflowCoachingNote(workflow, topReworked) + const { correctionRate, corrections, medianTimeToFirstEditMs } = workflow + + return ( +
+
+

Workflow

+ {showCoverage && {Math.min(99, Math.round(coverage * 100))}% priced} +
+
+
+
+ Correction rate + {correctionRate === null ? '—' : `${Math.round(correctionRate * 100)}%`} + {correctionRate !== null && {corrections} {corrections === 1 ? 'correction' : 'corrections'}} +
+
+ Time to first edit + {medianTimeToFirstEditMs === null ? '—' : formatWorkflowDuration(medianTimeToFirstEditMs)} + median +
+
+ {topReworked && ( +
+ Top rework: {topReworked.path} · {topReworked.sessions} {topReworked.sessions === 1 ? 'session' : 'sessions'} · {topReworked.edits} {topReworked.edits === 1 ? 'edit' : 'edits'} +
+ )} +

{note ?? 'Corrections, first-edit latency, and file churn across your sessions.'}

+
+
+ ) +} + export type Signal = { text: string; trailing?: string } export type SignalGroups = { wins: Signal[]; improvements: Signal[]; risks: Signal[] } @@ -568,6 +652,23 @@ export function Overview({ period, provider }: { period: Period; provider: strin return } +/** Combined-scope hero footer: a per-device cost breakdown plus a reachable/ + * total device count, mirroring the menubar's combined view. An unreachable + * device (powered off, off-network) shows its error in place of a cost. */ +function CombinedDevices({ usage }: { usage: CombinedUsage }) { + return ( +
+
{usage.combined.reachableCount} of {usage.combined.deviceCount} devices
+ {usage.perDevice.map(device => ( +
+ {device.local ? `${device.name} · this device` : device.name} + {device.error ?? formatUsd(device.cost)} +
+ ))} +
+ ) +} + export function OverviewContent({ period, provider = 'all', @@ -575,6 +676,7 @@ export function OverviewContent({ overview, onNavigate, ready = true, + scope = 'local', }: { period: Period provider?: string @@ -582,6 +684,7 @@ export function OverviewContent({ overview: Polled onNavigate?: (section: 'optimize' | 'sessions') => void ready?: boolean + scope?: Scope }) { // Gate secondary spawns on the app-level readiness (first overview resolved), // so the cold hydration runs once (via overview) rather than 3 parses at once @@ -598,7 +701,14 @@ export function OverviewContent({ const now = new Date() const rangeActive = !!range - const animateKey = `${period}|${provider}|${range?.from ?? ''}|${range?.to ?? ''}` + // Combined scope shows the paired-device aggregate in the hero KPIs, mirroring + // the menubar. Only the hero totals are aggregated; the detailed panels below + // (daily chart, models) stay local — the combined payload carries totals only. + const combined = scope === 'combined' ? data.combined : undefined + const heroCost = combined ? combined.combined.cost : data.current.cost + const heroCalls = combined ? combined.combined.calls : data.current.calls + const heroSessions = combined ? combined.combined.sessions : data.current.sessions + const animateKey = `${period}|${provider}|${range?.from ?? ''}|${range?.to ?? ''}|${scope}` const stats = deriveStats(data, now) const periodDaily = sliceDailyToPeriod(data.history.daily, period, now) // Daily chart: contiguous zero-filled calendar window. A custom range spans @@ -633,15 +743,21 @@ export function OverviewContent({ {error && }
-
{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 > 0 && ( -
Saved by applied fixes{formatUsd(saved)}across {applied} {applied === 1 ? 'fix' : 'fixes'}
- )} - {localSaved > 0 && ( -
Saved via local models{formatUsd(localSaved)}local-model routing
- )} +
{combined ? `Combined · ${data.current.label}` : data.current.label}{streakDays(data.history.daily, now)}-day streak
+ +
{heroCalls.toLocaleString('en-US')} calls · {heroSessions.toLocaleString('en-US')} sessions
+ {combined + ? + : ( + <> + {saved > 0 && ( +
Saved by applied fixes{formatUsd(saved)}across {applied} {applied === 1 ? 'fix' : 'fixes'}
+ )} + {localSaved > 0 && ( +
Saved via local models{formatUsd(localSaved)}local-model routing
+ )} + + )}
@@ -659,6 +775,8 @@ export function OverviewContent({
{data.history.daily.length ? : No spend yet.}
+ +
diff --git a/app/renderer/sections/PullRequests.test.tsx b/app/renderer/sections/PullRequests.test.tsx new file mode 100644 index 00000000..69efc422 --- /dev/null +++ b/app/renderer/sections/PullRequests.test.tsx @@ -0,0 +1,298 @@ +// @vitest-environment jsdom +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { formatDayShort } from '../lib/format' +import type { MenubarPayload } from '../lib/types' +import { PullRequests } from './PullRequests' + +type PrPayload = NonNullable + +const { getOverview, openExternal } = vi.hoisted(() => ({ + getOverview: vi.fn<(period: string, provider: string) => Promise>(), + openExternal: vi.fn<(url: string) => Promise>(), +})) +vi.mock('../lib/ipc', async orig => { + const actual = await orig() + return { ...actual, codeburn: { getOverview, openExternal } } +}) + +// Mirror the component's span rule so the assertion stays timezone-safe. +function expectedSpan(first: string, last: string): string { + const start = formatDayShort(first) + const end = formatDayShort(last) + return start === end ? start : `${start} - ${end}` +} + +function makePayload(pullRequests?: PrPayload): MenubarPayload { + return { + generated: '2026-07-20T00:00:00Z', + current: { + label: 'Lifetime', cost: 0, calls: 0, sessions: 0, oneShotRate: null, + inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, cacheHitPercent: 0, + codexCredits: 0, topActivities: [], topModels: [], + localModelSavings: { totalUSD: 0, calls: 0, byModel: [], byProvider: [] }, + providers: {}, topProjects: [], modelEfficiency: [], topSessions: [], + retryTax: { totalUSD: 0, retries: 0, editTurns: 0, byModel: [] }, + routingWaste: { totalSavingsUSD: 0, baselineModel: '', baselineCostPerEdit: 0, byModel: [] }, + tools: [], skills: [], subagents: [], mcpServers: [], + ...(pullRequests ? { pullRequests } : {}), + }, + optimize: { findingCount: 0, savingsUSD: 0, topFindings: [] }, + history: { daily: [] }, + } +} + +const SAMPLE: PrPayload = { + rows: [ + { url: 'https://github.com/getagentseal/codeburn/pull/780', label: 'getagentseal/codeburn#780', cost: 240.5, savingsUSD: 0, sessions: 3, calls: 512, firstStarted: '2026-07-01T10:00:00Z', lastEnded: '2026-07-03T18:00:00Z', models: ['fable', 'opus', 'haiku'], categories: [{ name: 'Feature work', cost: 180.25 }, { name: 'Debugging', cost: 60.25 }] }, + { url: 'https://github.com/getagentseal/codeburn/pull/781', label: 'getagentseal/codeburn#781', cost: 90.25, savingsUSD: 0, sessions: 1, calls: 120, firstStarted: '2026-07-05T13:00:00Z', lastEnded: '2026-07-05T15:00:00Z', models: ['sonnet'], categories: [{ name: 'Refactoring', cost: 90.25 }] }, + ], + distinctCost: 376.05, + distinctSessions: 3, + attributedCost: 330.75, + unattributedCost: 45.3, +} + +// Get the button-role row wrapping a given PR link, for click/keyboard toggling. +function rowForLink(link: HTMLElement): HTMLElement { + const row = link.closest('[role="button"]') + if (!row) throw new Error('expected a button-role row around the PR link') + return row as HTMLElement +} + +describe('PullRequests', () => { + beforeEach(() => { + getOverview.mockReset() + openExternal.mockReset() + openExternal.mockResolvedValue(undefined) + }) + + it('renders PR cards with linked labels, cost, activity, and a date span', async () => { + getOverview.mockResolvedValue(makePayload(SAMPLE)) + render() + + const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' }) + expect(link).toHaveAttribute('href', 'https://github.com/getagentseal/codeburn/pull/780') + expect(screen.getByText('$240.50')).toBeInTheDocument() + expect(screen.getByText('512 calls')).toBeInTheDocument() + expect(screen.getByText(expectedSpan(SAMPLE.rows[0]!.firstStarted, SAMPLE.rows[0]!.lastEnded))).toBeInTheDocument() + // A same-day PR collapses its span to a single label. + expect(screen.getByText(expectedSpan(SAMPLE.rows[1]!.firstStarted, SAMPLE.rows[1]!.lastEnded))).toBeInTheDocument() + }) + + it('renders every model explicitly instead of hiding models behind an overflow count', async () => { + getOverview.mockResolvedValue(makePayload(SAMPLE)) + render() + + expect(await screen.findByText('fable')).toBeInTheDocument() + expect(screen.getByText('opus')).toBeInTheDocument() + expect(screen.getByText('haiku')).toBeInTheDocument() + expect(screen.queryByText('+1')).toBeNull() + expect(screen.getByText('sonnet')).toBeInTheDocument() + }) + + it('opens the PR URL externally without navigating or toggling the row', async () => { + getOverview.mockResolvedValue(makePayload(SAMPLE)) + render() + + const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' }) + await userEvent.click(link) + expect(openExternal).toHaveBeenCalledWith('https://github.com/getagentseal/codeburn/pull/780') + // Clicking the link must not expand its row. + expect(rowForLink(link)).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByText('Feature work')).toBeNull() + }) + + it('expands a row to its category breakdown on click, then collapses', async () => { + getOverview.mockResolvedValue(makePayload(SAMPLE)) + render() + + const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' }) + const row = rowForLink(link) + expect(row).toHaveAttribute('aria-expanded', 'false') + + await userEvent.click(row) + expect(row).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByText('Feature work')).toBeInTheDocument() + expect(screen.getByText('$180.25')).toBeInTheDocument() + expect(screen.getByText('Debugging')).toBeInTheDocument() + + await userEvent.click(row) + expect(row).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByText('Feature work')).toBeNull() + }) + + it('closes an open expansion when the period changes the PR set', async () => { + const changed: PrPayload = { ...SAMPLE, rows: [SAMPLE.rows[0]!] } // #781 dropped + getOverview.mockImplementation((period: string) => Promise.resolve(makePayload(period === 'lifetime' ? SAMPLE : changed))) + const { rerender } = render() + + const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' }) + await userEvent.click(rowForLink(link)) + expect(rowForLink(link)).toHaveAttribute('aria-expanded', 'true') + + rerender() + // The new period drops #781, so the PR set changes and the stale expansion + // resets once the new data lands (wait for the breakdown to disappear). + await waitFor(() => expect(screen.queryByText('Feature work')).toBeNull()) + const link2 = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' }) + expect(rowForLink(link2)).toHaveAttribute('aria-expanded', 'false') + }) + + it('closes an open expansion on a period switch even when the PR set is identical', async () => { + getOverview.mockResolvedValue(makePayload(SAMPLE)) + const { rerender } = render() + + const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' }) + await userEvent.click(rowForLink(link)) + expect(rowForLink(link)).toHaveAttribute('aria-expanded', 'true') + + // Same rows come back for the new period; the expansion must still reset, + // since the row's underlying numbers may differ across periods. + rerender() + await waitFor(() => expect(screen.queryByText('Feature work')).toBeNull()) + const link2 = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' }) + expect(rowForLink(link2)).toHaveAttribute('aria-expanded', 'false') + }) + + it('toggles expansion from the keyboard with Enter', async () => { + getOverview.mockResolvedValue(makePayload(SAMPLE)) + render() + + const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' }) + const row = rowForLink(link) + row.focus() + + await userEvent.keyboard('{Enter}') + expect(row).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByText('Feature work')).toBeInTheDocument() + + await userEvent.keyboard('{Enter}') + expect(row).toHaveAttribute('aria-expanded', 'false') + }) + + it('states the attributed-total footer and the summable framing', async () => { + getOverview.mockResolvedValue(makePayload(SAMPLE)) + render() + + expect(await screen.findByText('Attributed spend')).toBeInTheDocument() + expect(screen.getByText('$330.75')).toBeInTheDocument() + expect(screen.getByLabelText('Pull request attribution summary')).toHaveTextContent('Linked sessions3') + const note = screen.getByText(/attributed turn by turn/) + expect(note.textContent).toContain('without double counting') + expect(screen.getByText(/Not tied to a specific PR/).textContent).toContain('$45.30') + }) + + it('notes folded-in subagent runs in the footer when present', async () => { + getOverview.mockResolvedValue(makePayload({ ...SAMPLE, subagentSessions: 32 })) + render() + + expect(await screen.findByText('Folded agent runs')).toBeInTheDocument() + expect(screen.getByText('32')).toBeInTheDocument() + const note = screen.getByText(/attributed turn by turn/) + expect(note.textContent).toContain('32 subagent runs are included') + }) + + it('omits the subagent note when none were folded', async () => { + getOverview.mockResolvedValue(makePayload(SAMPLE)) + render() + + const note = await screen.findByText(/attributed turn by turn/) + expect(note.textContent).not.toContain('subagent') + }) + + it('marks an approximate (legacy) row with a ~ prefix and a tooltip', async () => { + const approxPayload: PrPayload = { + rows: [ + { url: 'https://github.com/getagentseal/codeburn/pull/900', label: 'getagentseal/codeburn#900', cost: 12.5, savingsUSD: 0, sessions: 1, calls: 30, firstStarted: '2026-07-10T10:00:00Z', lastEnded: '2026-07-10T11:00:00Z', approx: true }, + ], + distinctCost: 12.5, + distinctSessions: 1, + attributedCost: 12.5, + unattributedCost: 0, + } + getOverview.mockResolvedValue(makePayload(approxPayload)) + render() + + const cost = await screen.findByText('~$12.50') + expect(cost).toHaveAttribute('title') + // A zero unattributed remainder hides the muted line. + expect(screen.queryByText(/Not tied to a specific PR/)).toBeNull() + }) + + it('expands a category-less (legacy) row to a muted note, not an empty box', async () => { + const approxPayload: PrPayload = { + rows: [ + { url: 'https://github.com/getagentseal/codeburn/pull/900', label: 'getagentseal/codeburn#900', cost: 12.5, savingsUSD: 0, sessions: 1, calls: 30, firstStarted: '2026-07-10T10:00:00Z', lastEnded: '2026-07-10T11:00:00Z', approx: true }, + ], + distinctCost: 12.5, + distinctSessions: 1, + attributedCost: 12.5, + unattributedCost: 0, + } + getOverview.mockResolvedValue(makePayload(approxPayload)) + render() + + const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#900' }) + await userEvent.click(rowForLink(link)) + expect(screen.getByText(/No per-turn detail/)).toBeInTheDocument() + }) + + it('renders the old-CLI by-reference footer without NaN and never claims summable', async () => { + const oldPayload: PrPayload = { + rows: [ + { url: 'https://github.com/getagentseal/codeburn/pull/500', label: 'getagentseal/codeburn#500', cost: 120.4, savingsUSD: 0, sessions: 2, calls: 300, firstStarted: '2026-06-01T10:00:00Z', lastEnded: '2026-06-02T12:00:00Z' }, + ], + distinctCost: 120.4, + distinctSessions: 2, + } + getOverview.mockResolvedValue(makePayload(oldPayload)) + render() + + const note = await screen.findByText(/produced pull requests/) + expect(note.textContent).toContain('$120.40') + expect(note.textContent).toContain('by reference') + expect(note.textContent).toContain('not summed') + expect(note.textContent).not.toContain('summable') + // No optional field renders as NaN and no unattributed line appears. + expect(screen.queryByText(/NaN/)).toBeNull() + expect(screen.queryByText(/Not tied to a specific PR/)).toBeNull() + }) + + it('renders the complete PR list without an opaque Other row', async () => { + const manyRows = Array.from({ length: 32 }, (_, index) => ({ + ...SAMPLE.rows[0]!, + url: `https://github.com/getagentseal/codeburn/pull/${800 + index}`, + label: `getagentseal/codeburn#${800 + index}`, + })) + getOverview.mockResolvedValue(makePayload({ + ...SAMPLE, + rows: manyRows, + attributedCost: manyRows.reduce((sum, row) => sum + row.cost, 0), + })) + render() + + expect(await screen.findByText('getagentseal/codeburn#800')).toBeInTheDocument() + expect(screen.getByText('getagentseal/codeburn#831')).toBeInTheDocument() + expect(screen.getByText('32 total')).toBeInTheDocument() + expect(screen.queryByText(/Other \(/)).toBeNull() + }) + + it('shows the quiet empty state (never a fake table) when no PR links exist', async () => { + getOverview.mockResolvedValue(makePayload()) + render() + + expect(await screen.findByText(/PR links are captured as sessions are parsed/)).toBeInTheDocument() + expect(screen.queryByRole('table')).toBeNull() + }) + + it('shows the empty state when the PR array is present but empty', async () => { + getOverview.mockResolvedValue(makePayload({ rows: [], distinctCost: 0, distinctSessions: 0, attributedCost: 0, unattributedCost: 0 })) + render() + + expect(await screen.findByText(/PR links are captured as sessions are parsed/)).toBeInTheDocument() + expect(screen.queryByRole('table')).toBeNull() + }) +}) diff --git a/app/renderer/sections/PullRequests.tsx b/app/renderer/sections/PullRequests.tsx new file mode 100644 index 00000000..c14aa8e8 --- /dev/null +++ b/app/renderer/sections/PullRequests.tsx @@ -0,0 +1,226 @@ +import type { KeyboardEvent, MouseEvent } from 'react' +import { useEffect, useState } from 'react' + +import { CliErrorPanel } from '../components/CliErrorPanel' +import { EmptyNote } from '../components/EmptyState' +import { Panel } from '../components/Panel' +import { SectionSkeleton } from '../components/Skeleton' +import { StaleBanner } from '../components/StaleBanner' +import { type Polled, usePolled } from '../hooks/usePolled' +import { formatDayShort, formatUsd } from '../lib/format' +import { codeburn } from '../lib/ipc' +import type { CliError, DateRange, MenubarPayload, Period } from '../lib/types' + +type PullRequests = NonNullable +type PrRow = PullRequests['rows'][number] + +// A PR's active window: one day collapses to a single label, otherwise the two +// endpoints joined with a hyphen (never an en/em dash, per repo copy rules). +function spanLabel(firstStarted: string, lastEnded: string): string { + const start = formatDayShort(firstStarted) + const end = formatDayShort(lastEnded) + if (start === '—' && end === '—') return '—' + return start === end ? start : `${start} - ${end}` +} + +function sessionWord(n: number): string { + return n === 1 ? 'session' : 'sessions' +} + +function ModelChips({ models }: { models: string[] }) { + return ( +
+ {models.map(model => {model})} +
+ ) +} + +function openPr(event: MouseEvent, url: string): void { + event.preventDefault() + event.stopPropagation() + void codeburn.openExternal(url) +} + +// Keyboard activation for the button-role row, guarded so Enter/Space fired on +// the inner link (its own control) never doubles up as a row toggle. +function rowKeyDown(event: KeyboardEvent, toggle: () => void): void { + if (event.target !== event.currentTarget) return + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + toggle() + } +} + +/** Standalone entry: self-fetches the overview payload (used in tests). The App + * passes its shared overview poll straight into PullRequestsContent instead. */ +export function PullRequests({ period, provider, range = null }: { period: Period; provider: string; range?: DateRange | null }) { + const overview = usePolled( + () => range ? codeburn.getOverview(period, provider, range) : codeburn.getOverview(period, provider), + [period, provider, range?.from, range?.to], + ) + // The key remounts the content on a period/provider/range switch so row state + // (an open expansion) never survives onto the same PR rendered from new data. + return +} + +export function PullRequestsContent({ overview }: { overview: Polled }) { + if (!overview.data) { + if (overview.error) return + return + } + return +} + +function PullRequestsPage({ pullRequests, staleError }: { pullRequests?: PullRequests; staleError: CliError | null }) { + return ( + <> + {staleError && } + + {pullRequests && pullRequests.rows.length > 0 + ? + : PR links are captured as sessions are parsed. Once a session references a pull request, it appears here.} + + + ) +} + +function PrTable({ pullRequests }: { pullRequests: PullRequests }) { + const { rows, distinctCost, distinctSessions, subagentSessions, attributedCost, unattributedCost } = pullRequests + const [expandedUrl, setExpandedUrl] = useState(null) + // Reset any open expansion when the PR set changes (a period/provider switch or + // a refresh that alters the list): a stale expandedUrl would otherwise linger + // pointing at a row that is no longer present. + const rowKey = rows.map(row => row.url).join('|') + useEffect(() => { setExpandedUrl(null) }, [rowKey]) + + // A new-attribution payload carries `attributedCost`; an older by-reference + // payload omits it, so the rows are not summable and the footer must differ. + const summable = attributedCost !== undefined + const unattributed = unattributedCost ?? 0 + // Reconcile to the visible numbers: every PR is present, so the summary is + // exactly the sum of the rounded cards a person can inspect below. + const displayedAttributed = rows.reduce((sum, row) => sum + Number(row.cost.toFixed(2)), 0) + + return ( + <> +
+
+ Attributed spend + {formatUsd(summable ? displayedAttributed : distinctCost)} +
+
+ Pull requests + {rows.length.toLocaleString('en-US')} +
+
+ Linked sessions + {distinctSessions.toLocaleString('en-US')} +
+
+ Folded agent runs + {(subagentSessions ?? 0).toLocaleString('en-US')} +
+
+
+
+ Attributed pull requests + Sorted by spend, highest first +
+ {rows.length.toLocaleString('en-US')} total +
+
+ {rows.map(pr => ( + setExpandedUrl(current => current === pr.url ? null : pr.url)} + /> + ))} +
+ {summable ? ( +

+ Costs are attributed turn by turn, so every row adds up without double counting. + {subagentSessions ? ` ${subagentSessions.toLocaleString('en-US')} subagent ${subagentSessions === 1 ? 'run is' : 'runs are'} included in the PR where the work happened.` : ''} +

+ ) : ( +

+ {formatUsd(distinctCost)} across {distinctSessions.toLocaleString('en-US')} distinct {sessionWord(distinctSessions)} produced pull requests. + {' '}Attribution is by reference: a session referencing several PRs counts toward each, so the rows above are not summed. +

+ )} + {unattributed > 0 && ( +

Not tied to a specific PR: {formatUsd(unattributed)}

+ )} + + ) +} + +const APPROX_TITLE = 'Approximate: the transcript expired before per-turn capture, so this PR’s share is an even split of the whole session.' + +function PrRowView({ pr, expanded, onToggle }: { pr: PrRow; expanded: boolean; onToggle: () => void }) { + const models = pr.models ?? [] + const categories = pr.categories ?? [] + const catMax = categories.length ? Math.max(...categories.map(cat => cat.cost)) : 0 + + return ( +
+
rowKeyDown(event, onToggle)} + > +
+ +
+ openPr(event, pr.url)}>{pr.label} +
+ {spanLabel(pr.firstStarted, pr.lastEnded)} + {pr.sessions.toLocaleString('en-US')} {sessionWord(pr.sessions)} + {pr.calls.toLocaleString('en-US')} calls +
+
+
+
+ Models + +
+
+ Spend + {pr.approx ? '~' : ''}{formatUsd(pr.cost)} +
+ +
+ {expanded && ( +
+ {categories.length > 0 ? ( +
+
+ Work breakdown + {formatUsd(pr.cost)} total +
+
+ {categories.map(cat => ( +
+ {cat.name} + + {formatUsd(cat.cost)} +
+ ))} +
+
+ ) : ( +

No per-turn detail (estimated from a whole-session split).

+ )} +
+ )} +
+ ) +} diff --git a/app/renderer/sections/Sessions.test.tsx b/app/renderer/sections/Sessions.test.tsx index 8b38ed0a..710daf02 100644 --- a/app/renderer/sections/Sessions.test.tsx +++ b/app/renderer/sections/Sessions.test.tsx @@ -299,4 +299,36 @@ describe('Sessions', () => { expect(within(filter).getByRole('button', { name: 'Codex' })).toHaveAttribute('aria-pressed', 'true') expect(within(filter).getByRole('button', { name: 'All' })).toHaveAttribute('aria-pressed', 'false') }) + + it('headlines a captured title, demotes the id, and falls back to the project when untitled', async () => { + getSessions.mockResolvedValue([ + session({ sessionId: 'claude-abc123456789xyz', project: 'codeburn', provider: 'claude', title: 'Fix lifetime period in menubar labels', cost: 5 }), + session({ sessionId: 'claude-untitled-000000', project: 'docs-site', provider: 'claude', title: '', cost: 3 }), + ]) + const { container } = render() + await screen.findByText('2 sessions · $8.00 · 2K tokens') + + const titles = [...container.querySelectorAll('.session-row .session-title')] + const ids = [...container.querySelectorAll('.session-row .session-project')] + // Titled row (higher cost, first): the title is the headline, the id its mono secondary line. + expect(titles[0]).toHaveTextContent('Fix lifetime period in menubar labels') + expect(ids[0]).toHaveTextContent('claude-abc') + // Untitled row: unchanged behavior, the project (via shortenProjectPath) stays the headline. + expect(titles[1]).toHaveTextContent('docs/site') + expect(ids[1]).toHaveTextContent('claude-untitled') + }) + + it('matches sessions by their captured title', async () => { + const user = userEvent.setup() + getSessions.mockResolvedValue([ + session({ sessionId: 'claude-1', project: 'codeburn', provider: 'claude', title: 'Refactor the parser cache', cost: 5 }), + session({ sessionId: 'codex-2', project: 'client-api', provider: 'codex', title: 'Add billing webhook', cost: 3 }), + ]) + const { container } = render() + const search = await screen.findByRole('textbox', { name: 'Search sessions' }) + + await user.type(search, 'webhook') + expect(container.querySelectorAll('.session-row')).toHaveLength(1) + expect(container.querySelector('.session-row .session-title')).toHaveTextContent('Add billing webhook') + }) }) diff --git a/app/renderer/sections/Sessions.tsx b/app/renderer/sections/Sessions.tsx index 4baea6e9..3d0297e8 100644 --- a/app/renderer/sections/Sessions.tsx +++ b/app/renderer/sections/Sessions.tsx @@ -125,6 +125,7 @@ export function Sessions({ const rows = report.data ?? [] const q = query.trim().toLowerCase() const filtered = rows.filter(row => q === '' || [ + row.title ?? '', row.project, row.sessionId, row.models.join(' '), @@ -251,7 +252,7 @@ export function Sessions({ - {shortenProjectPath(entry.row.project)} + {entry.row.title || shortenProjectPath(entry.row.project)} {entry.row.sessionId.slice(0, 18)} diff --git a/app/renderer/sections/Settings.test.tsx b/app/renderer/sections/Settings.test.tsx index c2d12827..58aa987e 100644 --- a/app/renderer/sections/Settings.test.tsx +++ b/app/renderer/sections/Settings.test.tsx @@ -145,6 +145,17 @@ describe('Settings', () => { expect(localStorage.getItem('codeburn.dailyBudget')).toBeFalsy() }) + it('reflects the current scope and reports a change through onScopeChange', async () => { + const user = userEvent.setup() + const onScopeChange = vi.fn() + render() + const scope = screen.getByLabelText('Scope') + expect(scope).toHaveTextContent('Local') + await user.click(scope) + await user.click(screen.getByRole('option', { name: 'Combined' })) + expect(onScopeChange).toHaveBeenCalledWith('combined') + }) + it('lists providers from the real overview payload', async () => { const user = userEvent.setup() render() diff --git a/app/renderer/sections/Settings.tsx b/app/renderer/sections/Settings.tsx index 17666f20..41ddcf46 100644 --- a/app/renderer/sections/Settings.tsx +++ b/app/renderer/sections/Settings.tsx @@ -13,12 +13,13 @@ import { version as appVersion } from '../../package.json' import { readDailyBudget } from '../lib/budget' import { formatConverted, formatUsd } from '../lib/format' import { codeburn } from '../lib/ipc' +import { shortcutLabel } from '../lib/platform' import { motionClass } from '../lib/motion' import { REFRESH_OPTIONS, useRefreshCadence } from '../lib/refreshCadence' import { showToast } from '../lib/toast' import { ToastHost } from '../components/ToastHost' import { rateLimitedNote } from './Plans' -import type { ActionResult, AliasRow, ClaudeConfigSelector, CliError, CombinedUsage, DeviceScanResult, Identity, JsonPlanSummary, MenubarPayload, Period, PlanId, PlanProvider, PriceOverrideList, PriceOverrideRow, PriceRates, QuotaProvider, ShareStatus, StatusJson, TelemetryStatus } from '../lib/types' +import type { ActionResult, AliasRow, ClaudeConfigSelector, CliError, CombinedUsage, DeviceScanResult, Identity, JsonPlanSummary, MenubarPayload, Period, PlanId, PlanProvider, PriceOverrideList, PriceOverrideRow, PriceRates, QuotaProvider, Scope, ShareStatus, StatusJson, TelemetryStatus } from '../lib/types' export type SettingsPane = 'general' | 'providers' | 'aliases' | 'pricing' | 'plans' | 'devices' | 'export' | 'privacy' type Pane = SettingsPane @@ -97,7 +98,7 @@ function ConfirmButton({ label, prompt, onConfirm }: { label: string; prompt: st ) } -export function Settings({ period, refreshToken = 0, onNavigate, initialPane, claudeConfigs, claudeConfigSource = null, onConfigMutated }: { period: Period; refreshToken?: number; onNavigate?: (section: Section) => void; initialPane?: SettingsPane; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource?: string | null; onConfigMutated?: () => void }) { +export function Settings({ period, refreshToken = 0, onNavigate, initialPane, claudeConfigs, claudeConfigSource = null, onConfigMutated, scope = 'local', onScopeChange }: { period: Period; refreshToken?: number; onNavigate?: (section: Section) => void; initialPane?: SettingsPane; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource?: string | null; onConfigMutated?: () => void; scope?: Scope; onScopeChange?: (scope: string) => void }) { const [pane, setPane] = useState(initialPane ?? 'general') return ( @@ -113,7 +114,7 @@ export function Settings({ period, refreshToken = 0, onNavigate, initialPane, cl ))}
- {pane === 'general' && } + {pane === 'general' && } {pane === 'providers' && } {pane === 'aliases' && } {pane === 'pricing' && } @@ -123,12 +124,12 @@ export function Settings({ period, refreshToken = 0, onNavigate, initialPane, cl {pane === 'privacy' && }
- + ) } -function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource, onConfigMutated }: { period: Period; refreshToken: number; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource: string | null; onConfigMutated?: () => void }) { +function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource, onConfigMutated, scope = 'local', onScopeChange }: { period: Period; refreshToken: number; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource: string | null; onConfigMutated?: () => void; scope?: Scope; onScopeChange?: (scope: string) => void }) { const [currencyNonce, setCurrencyNonce] = useState(0) const plans = usePolled(() => codeburn.getPlans(period), [period, refreshToken, currencyNonce]) const [theme, setTheme] = useState(() => { @@ -202,7 +203,8 @@ function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource,
{ setDefaultPeriod(value); writeSetting('codeburn.defaultPeriod', value) }} width={92} />
-
({ value: option.value, label: option.label }))} onChange={cadence.setValue} width={124} />
+
onScopeChange?.(value)} width={110} />
+
({ value: option.value, label: option.label }))} onChange={cadence.setValue} width={124} />
{ 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}

}
diff --git a/app/renderer/styles/plain.css b/app/renderer/styles/plain.css index f666e056..f82172b9 100644 --- a/app/renderer/styles/plain.css +++ b/app/renderer/styles/plain.css @@ -512,6 +512,12 @@ td:first-child { font-size: var(--fs-body); font-weight: var(--fw-body); } .ov-saved-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: 3px 7px; margin-top: 5px; padding-top: 9px; border-top: 1px solid var(--line2); color: var(--mut2); font-size: 10.5px; } .ov-saved-line strong { color: var(--ok); font-family: var(--mono); font-size: 13px; font-weight: 650; font-variant-numeric: tabular-nums; } .ov-saved-line small { color: var(--mut2); font-size: 10px; } +.ov-combined-devices { width: 100%; margin-top: 5px; padding-top: 9px; border-top: 1px solid var(--line2); display: flex; flex-direction: column; gap: 3px; } +.ov-combined-head { color: var(--mut2); font-size: 10.5px; font-weight: 560; text-transform: uppercase; letter-spacing: 0.03em; margin-bottom: 2px; } +.ov-combined-row { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; font-size: 11.5px; color: var(--mut); } +.ov-combined-row .ov-combined-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ov-combined-row .ov-combined-val { font-family: var(--mono); font-variant-numeric: tabular-nums; color: var(--ink); } +.ov-combined-row.err .ov-combined-val { color: var(--warn); font-family: inherit; } .ov-hero-split .ov-heatmap-bare { display: flex; flex-direction: column; justify-content: space-between; gap: 8px; } .ov-activity-head { display: flex; align-items: baseline; gap: 8px; } .ov-hero-sub .neutral { color: var(--mut); font-weight: 560; } @@ -580,6 +586,57 @@ td:first-child { font-size: var(--fs-body); font-weight: var(--fw-body); } .ov-models th:first-child, .ov-models td:first-child { width: 100%; text-align: left; } .ov-models .ov-model-name { overflow: hidden; color: var(--ink); font-weight: var(--fw-medium); text-overflow: ellipsis; } .ov-models td.mono { font-family: var(--mono); color: var(--ink); } +.pr-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-bottom: 20px; } +.pr-summary-item { display: flex; min-width: 0; flex-direction: column; gap: 7px; padding: 14px 16px; border: 1px solid var(--line); border-radius: 9px; background: color-mix(in srgb, var(--panel) 92%, var(--fill)); box-shadow: 0 1px 2px rgba(0,0,0,.08), inset 0 1px 0 rgba(255,255,255,.025); } +.pr-summary-item span { color: var(--mut2); font-size: var(--fs-label); font-weight: var(--fw-strong); letter-spacing: .055em; text-transform: uppercase; } +.pr-summary-item strong { overflow: hidden; color: var(--ink); font-family: var(--mono); font-size: 20px; font-weight: var(--fw-kpi); font-variant-numeric: tabular-nums; letter-spacing: -.02em; text-overflow: ellipsis; white-space: nowrap; } +.pr-list-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin: 0 2px 10px; } +.pr-list-head > div { display: flex; flex-direction: column; gap: 3px; } +.pr-list-head strong { color: var(--ink); font-size: var(--fs-body); font-weight: var(--fw-subhead); } +.pr-list-head span { color: var(--mut2); font-size: var(--fs-meta); } +.pr-list-count { padding: 4px 8px; border: 1px solid var(--line); border-radius: 999px; background: var(--fill); font-family: var(--mono); font-variant-numeric: tabular-nums; } +.pr-list { display: flex; flex-direction: column; gap: 8px; } +.pr-card { overflow: hidden; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); box-shadow: 0 1px 2px rgba(0,0,0,.07); transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease; } +.pr-card:hover { border-color: color-mix(in srgb, var(--line) 55%, var(--mut2)); box-shadow: 0 4px 12px rgba(0,0,0,.10); transform: translateY(-1px); } +.pr-card.is-open { border-color: color-mix(in srgb, var(--accent) 32%, var(--line)); box-shadow: 0 6px 18px rgba(0,0,0,.12); } +.pr-card-trigger { display: grid; grid-template-columns: minmax(280px, 1.35fr) minmax(230px, 1fr) 100px 28px; min-height: 74px; align-items: center; gap: 18px; padding: 12px 14px; cursor: pointer; } +.pr-card-trigger:focus-visible { outline: none; box-shadow: inset 0 0 0 1px var(--accent); } +.pr-card-identity { display: flex; min-width: 0; align-items: center; gap: 11px; } +.pr-card-identity > div { min-width: 0; } +.pr-icon { display: grid; width: 32px; height: 32px; flex: 0 0 auto; place-items: center; border: 1px solid color-mix(in srgb, var(--accent) 25%, var(--line)); border-radius: 8px; background: color-mix(in srgb, var(--accent) 9%, var(--panel)); color: var(--accent-text); } +.pr-icon svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } +.pr-link, .pr-link:visited { display: block; overflow: hidden; color: var(--ink); font-size: 12.5px; font-weight: var(--fw-subhead); text-decoration: none; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; } +.pr-link:hover, .pr-link:focus-visible { color: var(--ink); text-decoration: underline; text-decoration-color: var(--mut2); text-underline-offset: 3px; } +.pr-card-meta { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; gap: 0; margin-top: 6px; color: var(--mut2); font-size: var(--fs-meta); font-variant-numeric: tabular-nums; } +.pr-card-meta span + span::before { content: '•'; margin: 0 7px; color: var(--line); } +.pr-card-models { min-width: 0; } +.pr-card-label { display: block; margin-bottom: 6px; color: var(--mut2); font-size: var(--fs-micro); font-weight: var(--fw-strong); letter-spacing: .05em; text-transform: uppercase; } +.pr-model-list { display: flex; min-width: 0; flex-wrap: wrap; gap: 5px; } +.pr-model-chip { display: inline-flex; max-width: 100%; align-items: center; padding: 4px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--fill); color: var(--mut); font-family: var(--mono); font-size: 10.5px; line-height: 1; white-space: nowrap; } +.pr-card-cost { text-align: right; } +.pr-card-cost strong { color: var(--ink); font-family: var(--mono); font-size: 14px; font-weight: var(--fw-strong); font-variant-numeric: tabular-nums; } +.pr-chevron { display: grid; width: 26px; height: 26px; place-items: center; border: 1px solid var(--line); border-radius: 7px; background: var(--fill); color: var(--mut2); font-family: system-ui, sans-serif; font-size: 17px; line-height: 1; transition: transform 140ms ease, background 140ms ease; } +.pr-card.is-open .pr-chevron { transform: rotate(90deg); } +.pr-detail-cell { padding: 16px 18px 18px 57px; border-top: 1px solid var(--line2); background: color-mix(in srgb, var(--panel) 82%, var(--fill)); } +.pr-detail { max-width: 880px; } +.pr-detail-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; } +.pr-detail-head span { color: var(--ink); font-size: var(--fs-body); font-weight: var(--fw-subhead); } +.pr-detail-head strong { color: var(--mut); font-family: var(--mono); font-size: var(--fs-meta); font-weight: var(--fw-medium); font-variant-numeric: tabular-nums; } +.pr-cats { display: flex; flex-direction: column; gap: 9px; } +.pr-cat { display: grid; grid-template-columns: minmax(110px, 160px) minmax(120px, 1fr) 72px; align-items: center; gap: 12px; } +.pr-cat-bar { height: 5px; overflow: hidden; border-radius: 3px; background: color-mix(in srgb, var(--fill) 82%, var(--mut2)); } +.pr-cat-bar span { display: block; height: 100%; border-radius: inherit; background: color-mix(in srgb, var(--accent) 58%, white); } +.pr-cat-name { overflow: hidden; color: var(--mut); font-size: 12px; font-weight: var(--fw-medium); text-overflow: ellipsis; white-space: nowrap; } +.pr-cat > strong { color: var(--ink); font-family: var(--mono); font-size: 11.5px; font-variant-numeric: tabular-nums; text-align: right; } +.pr-cat-empty { margin: 0; color: var(--mut2); font-size: var(--fs-meta); } +.pr-footnote { margin: 14px 2px 2px; color: var(--mut); font-size: var(--fs-meta); line-height: 1.55; } +.pr-unattributed { margin: 4px 2px 2px; color: var(--mut2); font-size: var(--fs-meta); font-variant-numeric: tabular-nums; } +@media (max-width: 820px) { + .pr-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .pr-card-trigger { grid-template-columns: minmax(230px, 1fr) 96px 28px; } + .pr-card-models { grid-column: 1 / -1; grid-row: 2; padding-left: 43px; } + .pr-detail-cell { padding-left: 18px; } +} .opt-waste { min-width: 0; } .opt-summary { padding: 0 0 10px; color: var(--mut); font-size: 11.5px; font-variant-numeric: tabular-nums; } .opt-findings { display: grid; min-width: 0; } @@ -622,6 +679,9 @@ td:first-child { font-size: var(--fs-body); font-weight: var(--fw-body); } .ov-outcome-metrics span { color: var(--mut); font-size: 10px; line-height: 1.25; } .ov-outcome-metrics strong { overflow: hidden; color: var(--ink); font-family: var(--mono); font-size: var(--fs-kpi); font-weight: var(--fw-kpi); font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; } .ov-outcome-split { margin-top: 9px; color: var(--mut); font-size: 10.5px; font-variant-numeric: tabular-nums; line-height: 1.4; } +.ov-priced-chip { flex: 0 0 auto; margin-left: auto; display: inline-flex; align-items: center; border-radius: 999px; padding: 2px 8px; background: color-mix(in srgb, var(--warn) 14%, transparent); color: var(--warn); font-size: 10px; font-weight: 650; font-variant-numeric: tabular-nums; line-height: 1.4; } +.ov-workflow-rework { margin-top: 10px; color: var(--mut); font-size: 11.5px; font-variant-numeric: tabular-nums; line-height: 1.4; } +.ov-workflow-rework strong { color: var(--ink); font-family: var(--mono); font-weight: 600; } .ov-routing { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 11px 13px; } .ov-routing p { margin: 4px 0 0; color: var(--mut); font-size: 11.5px; line-height: 1.4; } .ov-routing p strong { color: var(--ink); font-weight: 620; } diff --git a/app/scripts/stage-cli.mjs b/app/scripts/stage-cli.mjs index 827776e1..69085678 100644 --- a/app/scripts/stage-cli.mjs +++ b/app/scripts/stage-cli.mjs @@ -68,7 +68,11 @@ writeFileSync( // extraneous warnings, so capture stdout regardless of exit code. let listed = '' try { - listed = execFileSync('npm', ['ls', '--omit=dev', '--all', '--parseable'], { + // Execute npm's JavaScript entry point with the current Node binary. Windows + // exposes npm as a .cmd shim, which execFile cannot launch without a shell. + const npmCli = process.env.npm_execpath + if (!npmCli) throw new Error('npm_execpath is unavailable') + listed = execFileSync(process.execPath, [npmCli, 'ls', '--omit=dev', '--all', '--parseable'], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, @@ -81,11 +85,17 @@ try { // Map each back to its top-level node_modules entry (`name` or `@scope/name`), // then copy those dirs whole — a package's own nested node_modules comes with // it, which is exactly the closure it needs at runtime. -const prefix = rootModules + '/' +// `npm ls --parseable` uses native separators on Windows. Normalize both sides +// before extracting the package name so Store builds do not treat a populated +// node_modules tree as empty merely because it uses `\\` instead of `/`. +const prefix = rootModules.replaceAll('\\', '/') + '/' +const comparisonPrefix = process.platform === 'win32' ? prefix.toLowerCase() : prefix const topLevel = new Set() for (const line of listed.split('\n')) { - if (!line.startsWith(prefix)) continue - const rest = line.slice(prefix.length) + const normalizedLine = line.trim().replaceAll('\\', '/') + const comparisonLine = process.platform === 'win32' ? normalizedLine.toLowerCase() : normalizedLine + if (!comparisonLine.startsWith(comparisonPrefix)) continue + const rest = normalizedLine.slice(prefix.length) const match = rest.match(/^(@[^/]+\/[^/]+|[^/]+)/) if (match) topLevel.add(match[1]) } diff --git a/assets/open-source-recipient.png b/assets/open-source-recipient.png new file mode 100644 index 00000000..9c54891a Binary files /dev/null and b/assets/open-source-recipient.png differ diff --git a/dash/index.html b/dash/index.html index 978f7b92..70231095 100644 --- a/dash/index.html +++ b/dash/index.html @@ -5,6 +5,19 @@ CodeBurn - Local Dashboard +
diff --git a/dash/src/App.tsx b/dash/src/App.tsx index 6b1e5a75..27117618 100644 --- a/dash/src/App.tsx +++ b/dash/src/App.tsx @@ -77,7 +77,7 @@ function DeviceView({ payload, isRemote, unit }: { payload?: Payload; isRemote: const c = payload?.current // Cache cards read the period-scoped `current` totals, matching Cost/Calls/ // Tokens. `history.daily` is the 365-day backfill that feeds the trend chart - // only; summing it here over-counted the cards for shorter periods (#583). + // only; summing it here over-counted the cards for shorter periods (issue 583). const cacheWrite = c?.cacheWriteTokens ?? 0 const cacheRead = c?.cacheReadTokens ?? 0 const toolBars: BarItem[] = c @@ -290,7 +290,7 @@ function CombinedView({ devices, unit }: { devices: DeviceUsage[]; unit: Unit }) if (!c) continue inTok += c.inputTokens outTok += c.outputTokens - // Period-scoped per device (was summing each device's 365-day backfill, #583). + // Period-scoped per device (was summing each device's 365-day backfill, issue 583). // `?? 0` mirrors DeviceView and guards the un-normalized bootstrap payload, // where an older peer may not carry these fields yet (avoids NaN). cacheWrite += c.cacheWriteTokens ?? 0 @@ -376,6 +376,42 @@ function CombinedView({ devices, unit }: { devices: DeviceUsage[]; unit: Unit }) ) } +// Theme toggle: mirrors the .dark class set by the index.html pre-paint script +// and persists the choice to the same localStorage key. +function ThemeToggle() { + const [dark, setDark] = useState(() => document.documentElement.classList.contains('dark')) + const toggle = () => { + const next = !dark + setDark(next) + document.documentElement.classList.toggle('dark', next) + try { + localStorage.setItem('codeburn-theme', next ? 'dark' : 'light') + } catch { + // storage disabled (some embeds/webviews): persist nothing, OS theme wins next load + } + } + return ( + + ) +} + export function App() { const [page, setPage] = useState<'usage' | 'context'>('usage') const [period, setPeriod] = useState('today') @@ -459,6 +495,25 @@ export function App() { if (provider !== 'all' && c0 && !providerOptions.includes(provider)) setProvider('all') }, [provider, providerOptions, c0]) + // Follow the OS theme live while the user has no explicit preference, so + // flipping the system theme updates the dashboard without a reload. + useEffect(() => { + const mql = window.matchMedia('(prefers-color-scheme: dark)') + const apply = () => { + let saved: string | null = null + try { + saved = localStorage.getItem('codeburn-theme') + } catch { + // storage disabled: OS theme only + } + if (saved !== 'dark' && saved !== 'light') { + document.documentElement.classList.toggle('dark', mql.matches) + } + } + mql.addEventListener('change', apply) + return () => mql.removeEventListener('change', apply) + }, []) + const showCombined = multi && view === 'all' const viewTitle = showCombined ? 'All devices' : (primary ? primary.name + (primary.local ? ' · this Mac' : '') : 'Loading…') const label = local?.payload?.current?.label ?? '' @@ -466,7 +521,7 @@ export function App() { return (
-
+
@@ -638,7 +694,7 @@ export function App() { type="checkbox" checked={shareInfo.always} onChange={() => void toggleAlways()} - className="h-3.5 w-3.5 accent-[#1f8a5b]" + className="h-3.5 w-3.5 accent-primary" /> Keep sharing always diff --git a/dash/src/components/ContextExplorer.tsx b/dash/src/components/ContextExplorer.tsx index 2fa67afb..7538fd5f 100644 --- a/dash/src/components/ContextExplorer.tsx +++ b/dash/src/components/ContextExplorer.tsx @@ -106,7 +106,7 @@ function SessionDetails({ provider, id }: { provider: ContextProvider; id: strin {pct}%
-
= 80 ? 'bg-[#c8541f]' : 'bg-primary')} style={{ width: `${pct}%` }} /> +
= 80 ? 'bg-chart-5' : 'bg-primary')} style={{ width: `${pct}%` }} />
)} diff --git a/dash/src/components/DeviceSearchModal.tsx b/dash/src/components/DeviceSearchModal.tsx index 8181c371..6eacb3b5 100644 --- a/dash/src/components/DeviceSearchModal.tsx +++ b/dash/src/components/DeviceSearchModal.tsx @@ -115,7 +115,7 @@ export function DeviceSearchModal({ onClose, onPaired }: { onClose: () => void; )} {status &&

{status}

} - {error &&

{error}

} + {error &&

{error}

}
diff --git a/dash/src/components/UsageChart.tsx b/dash/src/components/UsageChart.tsx index 1c1ce1b1..926409e8 100644 --- a/dash/src/components/UsageChart.tsx +++ b/dash/src/components/UsageChart.tsx @@ -26,7 +26,7 @@ function makeTooltip(labels: Record, fmt: (n: number) => string, // eslint-disable-next-line @typescript-eslint/no-explicit-any const total = items.reduce((s: number, p: any) => s + p.value, 0) return ( -
+
{formatPeriod(String(lbl))}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} @@ -252,7 +252,7 @@ function StackedBars({ tick={{ fontSize: 11, fill: 'var(--color-tertiary-foreground)' }} tickFormatter={axisFmt} /> - } /> + } /> {series.map((s, i) => ( by index.html before first paint and toggled in the header. + */ +.dark { + color-scheme: dark; + + --background: #0e1013; + --outer-background: #0e1013; + --foreground: #e8eaee; + --card: #16181d; + --card-foreground: #e8eaee; + --popover: #16181d; + --popover-foreground: #e8eaee; + --muted: #1a1d22; + --muted-foreground: #959ca8; + --tertiary-foreground: #8b93a1; + --heading: #7aa86f; + --border: #282c33; + --input: #282c33; + --interactive-secondary: rgba(255, 255, 255, 0.06); + --interactive-secondary-hover: rgba(255, 255, 255, 0.1); + --active-primary: #2a2f38; + --accent: #1a1d22; + --accent-foreground: #e8eaee; + --subtle: #8b93a1; + --primary: #3ecf8e; + --primary-foreground: #0e1013; + --ring: #3ecf8e; + --positive: #3ecf8e; + --brand: #f2701c; + + --chart-1: #3ecf8e; + --chart-2: #7ce0b0; + --chart-3: #2f9e6e; + --chart-4: #e8b93e; + --chart-5: #f2701c; + --chart-6: #5b9bef; + --chart-7: #8bb6a0; + --chart-8: #f26d6d; + --chart-9: #4fbf93; + --chart-10: #d5b26a; + --chart-grid-stroke: rgba(255, 255, 255, 0.08); + --chart-hover-cursor: rgba(255, 255, 255, 0.06); +} + @theme inline { --color-background: var(--background); --color-outer-background: var(--outer-background); @@ -75,6 +125,7 @@ --color-primary-foreground: var(--primary-foreground); --color-ring: var(--ring); --color-positive: var(--positive); + --color-brand: var(--brand); --color-chart-1: var(--chart-1); --color-chart-2: var(--chart-2); diff --git a/dash/src/lib/utils.ts b/dash/src/lib/utils.ts index 95f28c15..1a6de0db 100644 --- a/dash/src/lib/utils.ts +++ b/dash/src/lib/utils.ts @@ -36,11 +36,11 @@ export function compactUsd(n: number): string { return sign + '$' + Math.round(a) } -// Forest green -> gold -> terracotta ramp for stacked series (mirrors the -// --chart-* tokens). Warm and on-brand, distinct enough to read when stacked. +// Forest green -> gold -> terracotta ramp for stacked series. Referenced as CSS +// custom properties so the palette follows the active theme (light or dark). export const CHART_COLORS = [ - '#1f8a5b', '#4fd394', '#2c5242', '#d99a3c', '#c8541f', - '#2f5fd0', '#7aa86f', '#b5403a', '#3f8f6b', '#a98b4f', + 'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)', + 'var(--chart-6)', 'var(--chart-7)', 'var(--chart-8)', 'var(--chart-9)', 'var(--chart-10)', ] const MODEL_LABELS: Record = { diff --git a/docs/architecture.md b/docs/architecture.md index 8131aa4e..088e46f8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -177,13 +177,21 @@ The `prepublishOnly` hook in `package.json` runs `npm run build` so `npm publish ## Tests -`npm test` runs vitest. Forty-two test files live under `tests/`: +`npm test` runs vitest, scoped to `tests/`. 192 test files live there: -- `tests/` root (27 files) covers CLI, parser, optimize, cache, format, models, plans. +- `tests/` root (141 files) covers CLI, parser, optimize, cache, format, models, plans. - `tests/security/` (1 file) covers prototype-pollution guards. -- `tests/providers/` (15 files) covers per-provider parsing. +- `tests/providers/` (44 files) covers per-provider parsing. +- `tests/sharing/` (6 files) covers the share/export surface. +- `tests/setup/` holds the env-isolation setup file, not specs. - `tests/fixtures/` holds redacted real-world session data. -Five providers ship without dedicated test files today: `antigravity`, `claude`, `gemini`, `goose`, `qwen`. Closing this gap is a standing good-first-issue. +The scope is deliberate: the Electron app under `app/` has its own vitest config and its +own `jsdom` dependency, so vitest's default glob must not reach it from a root install. +The three `cache-refresh-lock` suites are excluded from `npm test` and run serially via +`npm run test:locks`, because they exercise a cross-process file lock and fail under full +worker pressure. -CI runs Semgrep against `.semgrep/rules/no-bracket-assign-hot-paths.yml` over `src/providers/` and `src/parser.ts` (`.github/workflows/ci.yml`). It does not run vitest in CI today; tests run locally before publish. +Three providers ship without dedicated test files today: `claude`, `goose`, `qwen`. Closing this gap is a standing good-first-issue. + +CI runs Semgrep against `.semgrep/rules/no-bracket-assign-hot-paths.yml` over `src/providers/` and `src/parser.ts` (`.github/workflows/ci.yml`). The vitest suite runs in CI too, via `.github/workflows/tests.yml`, on every pull request and every push to `main`. diff --git a/docs/design/codeburn-mcp-plan.md b/docs/design/codeburn-mcp-plan.md index 83e2caa1..4d35bb7d 100644 --- a/docs/design/codeburn-mcp-plan.md +++ b/docs/design/codeburn-mcp-plan.md @@ -184,7 +184,7 @@ Keep `main.ts:477–484` (the `daysSelection`/`customRange`/`daySelection`/`peri - [ ] **Step 4: Typecheck + parity test** -Run: `npx tsc --noEmit && npm test -- cli-status-menubar` +Run: `npx tsc --noEmit && npx vitest run cli-status-menubar` Expected: clean typecheck; `tests/cli-status-menubar.test.ts` passes — i.e. `status --format menubar-json` output is unchanged (parity). - [ ] **Step 5: Add a direct unit test for the aggregator** @@ -207,7 +207,7 @@ describe('buildMenubarPayloadForRange', () => { }) ``` -Run: `npm test -- usage-aggregator` +Run: `npx vitest run usage-aggregator` Expected: PASS (uses the empty real environment; `scanAndDetect` not called because `optimize:false`). - [ ] **Step 6: Commit** @@ -272,7 +272,7 @@ describe('redact', () => { - [ ] **Step 2: Run to verify failure** -Run: `npm test -- mcp-redact` +Run: `npx vitest run mcp-redact` Expected: FAIL ("Cannot find module '../src/mcp/redact.js'"). - [ ] **Step 3: Implement** @@ -302,7 +302,7 @@ export function redactProjectNames(payload: MenubarPayload, includeNames: boolea - [ ] **Step 4: Run to verify pass** -Run: `npm test -- mcp-redact` +Run: `npx vitest run mcp-redact` Expected: PASS (3 tests). - [ ] **Step 5: Commit** @@ -370,7 +370,7 @@ describe('tables', () => { - [ ] **Step 2: Run to verify failure** -Run: `npm test -- mcp-tables` +Run: `npx vitest run mcp-tables` Expected: FAIL ("Cannot find module '../src/mcp/tables.js'"). - [ ] **Step 3: Implement** @@ -434,7 +434,7 @@ export function renderSavingsTable(p: MenubarPayload): string { - [ ] **Step 4: Run to verify pass** -Run: `npm test -- mcp-tables` +Run: `npx vitest run mcp-tables` Expected: PASS (4 tests). - [ ] **Step 5: Commit** @@ -521,7 +521,7 @@ describe('mcp server', () => { - [ ] **Step 2: Run to verify failure** -Run: `npm test -- mcp-server` +Run: `npx vitest run mcp-server` Expected: FAIL ("Cannot find module '../src/mcp/server.js'"). - [ ] **Step 3: Implement the server** @@ -660,7 +660,7 @@ export async function startStdioServer(version: string): Promise { - [ ] **Step 4: Run to verify pass** -Run: `npm test -- mcp-server` +Run: `npx vitest run mcp-server` Expected: PASS (5 tests). - [ ] **Step 5: Commit** diff --git a/docs/providers/NEW_PROVIDER.md b/docs/providers/NEW_PROVIDER.md new file mode 100644 index 00000000..a13c071d --- /dev/null +++ b/docs/providers/NEW_PROVIDER.md @@ -0,0 +1,46 @@ +# New provider checklist + +Guide for adding a new session-discovery provider to codeburn. Follow every item; most exist because a past provider broke without them. + +## One provider, one product + +- [ ] A provider is one product. Same-vendor IDE and CLI products get separate providers. +- [ ] Precedents: `kimi` / `kimicode`, `cursor` / `cursor-agent`, `cline` / `cline-cli`. +- [ ] Do not merge products under one provider name. `PROVIDER_PARSE_VERSIONS` and `PROVIDER_ENV_VARS` are keyed by provider name, so merging couples cache invalidation across products. +- [ ] Separate providers also keep `codeburn doctor` output legible. + +## Required pieces + +- [ ] `src/providers/.ts` implementing the Provider contract. +- [ ] Registration in `src/providers/index.ts` `coreProviders` (or the lazy list). +- [ ] `PROVIDER_ENV_VARS` entry in `src/session-cache.ts` when discovery reads env overrides. +- [ ] `PROVIDER_PARSE_VERSIONS` entry when cached entries must re-parse after parser changes. +- [ ] `probeRoots()` is required for new providers, not optional. `codeburn doctor` uses it to tell "not installed" from "override points somewhere empty" - the silent-$0.00 class (#874, #899). + +## Cost rules + +- [ ] If the tool meters its own per-message cost, add the provider to the reported-cost allowlist in `src/parser.ts` (`providerCallToCachedCall`). +- [ ] Cost presence is a PRESENCE check, not truthiness: a metered $0 stays reported (free/cached calls). +- [ ] Computed costs go through `calculateCost` and set `costIsEstimated: true`. + +## Parsing rules + +- [ ] Defensive reads on every field - records may be any JSON. +- [ ] Dedup keys namespaced as `::`. +- [ ] Timestamps guard against seconds-vs-milliseconds: promote and reject implausible values (see `kiro.ts` / `cline-cli.ts`). +- [ ] Never let one corrupt file throw - skip it. + +## Tests + +- [ ] Fixture-based tests under `tests/providers/.test.ts` covering discovery, parsing, cost semantics, and `probeRoots` resolution. +- [ ] The suite scrubs env in `tests/setup/env-isolation.ts`, so tests set their own overrides. + +## PR expectations + +- [ ] Proof of real local testing in the PR body: generated sessions from the actual tool, not only fixtures. +- [ ] No Claude/Anthropic co-author trailers - CI rejects them. +- [ ] Docs page under `docs/providers/.md` describing the storage layout and any quirks. + +## Fastest path + +Read `src/providers/cline-cli.ts` end to end first. It is the most recent provider and demonstrates every rule above. diff --git a/docs/providers/README.md b/docs/providers/README.md index f4d2aa4c..971ae2b4 100644 --- a/docs/providers/README.md +++ b/docs/providers/README.md @@ -12,6 +12,7 @@ For the architectural picture, see `../architecture.md`. |---|---|---|---| | [Claude](claude.md) | JSONL (no parser) | `src/providers/claude.ts` | none (covered indirectly) | | [Cline](cline.md) | JSON | `src/providers/cline.ts` | `tests/providers/cline.test.ts` | +| [Cline CLI](cline-cli.md) | JSON | `src/providers/cline-cli.ts` | `tests/providers/cline-cli.test.ts` | | [CodeWhale](codewhale.md) | JSON | `src/providers/codewhale.ts` | `tests/providers/codewhale.test.ts` | | [Codex](codex.md) | JSONL | `src/providers/codex.ts` | `tests/providers/codex.test.ts` | | [Copilot](copilot.md) | JSONL + SQLite (OTel) + Nitrite .db (JetBrains) | `src/providers/copilot.ts` | `tests/providers/copilot.test.ts` | @@ -27,6 +28,7 @@ For the architectural picture, see `../architecture.md`. | [LingTai TUI](lingtai-tui.md) | JSONL | `src/providers/lingtai-tui.ts` | `tests/providers/lingtai-tui.test.ts` | | [Mistral Vibe](mistral-vibe.md) | JSON / JSONL | `src/providers/mistral-vibe.ts` | `tests/providers/mistral-vibe.test.ts` | | [OpenClaw](openclaw.md) | JSONL | `src/providers/openclaw.ts` | `tests/providers/openclaw.test.ts` | +| [OpenClaude](openclaude.md) | JSONL | `src/providers/openclaude.ts` | `tests/providers/openclaude.test.ts` | | [Pi](pi.md) | JSONL | `src/providers/pi.ts` | `tests/providers/pi.test.ts` | | [OMP](omp.md) | JSONL | `src/providers/pi.ts` | `tests/providers/omp.test.ts` | | [Qwen](qwen.md) | JSONL | `src/providers/qwen.ts` | none | diff --git a/docs/providers/claude.md b/docs/providers/claude.md index b5954c1f..34128977 100644 --- a/docs/providers/claude.md +++ b/docs/providers/claude.md @@ -12,11 +12,26 @@ Anthropic Claude Code CLI and Claude Desktop's local agent mode. |---|---| | Claude Code CLI | `$CLAUDE_CONFIG_DIR` if set, otherwise `~/.claude/projects/` | | Claude Desktop (macOS) | `~/Library/Application Support/Claude/local-agent-mode-sessions/` | -| Claude Desktop (Windows) | `%APPDATA%/Claude/local-agent-mode-sessions/` | +| Claude Desktop (Windows, classic) | `%APPDATA%/Claude/local-agent-mode-sessions/` | +| Claude Desktop (Windows, MSIX) | `%LOCALAPPDATA%/Packages//LocalCache/Roaming/Claude/local-agent-mode-sessions/` | | Claude Desktop (Linux) | `~/.config/Claude/local-agent-mode-sessions/` | For Desktop, `findDesktopProjectDirs` walks up to 8 levels deep looking for `projects/` subdirectories, skipping `node_modules` and `.git`. +Desktop session roots are resolved in this order: + +1. A non-empty `CODEBURN_DESKTOP_SESSIONS_DIR` overrides discovery and is the + only returned root. +2. macOS uses the single path shown above. +3. Windows always includes the classic path first. It then scans + `%LOCALAPPDATA%/Packages` for package directories whose names start with + `Claude_` or contain `.Claude_`, sorted by package name, and includes only + packages whose full MSIX sessions path exists as a directory. +4. Other platforms use the single Linux path shown above. + +All returned roots are absolute, resolved, and deduplicated. Missing or +unreadable Windows package directories are ignored. + ## Storage format JSONL, one event per line, per session file. Sessions live under `/.jsonl`. diff --git a/docs/providers/cline-cli.md b/docs/providers/cline-cli.md new file mode 100644 index 00000000..69a7b7d2 --- /dev/null +++ b/docs/providers/cline-cli.md @@ -0,0 +1,58 @@ +# Cline CLI + +The Cline command-line agent (npm `cline`, 3.x). Separate from the [Cline](cline.md) provider, which reads the VS Code extension's task tree. + +- **Source:** `src/providers/cline-cli.ts` +- **Loading:** eager (`src/providers/index.ts`) +- **Test:** `tests/providers/cline-cli.test.ts` + +## Where it reads from + +One root, resolved exactly as the CLI resolves it — each level independently overridable: + +| Level | Env var | Default | +|---|---|---| +| sessions | `CLINE_SESSION_DATA_DIR` | `/sessions` | +| data | `CLINE_DATA_DIR` | `/data` | +| root | `CLINE_DIR` | `~/.cline` | + +A directory is a session only when it contains `/.json`. `probeRoots()` reports the resolved sessions dir, so `codeburn doctor` distinguishes "CLI not installed" from "override pointing somewhere else". + +## Storage format + +``` +sessions// + .json metadata + rolled-up usage + .messages.json per-message metrics +``` + +`.json` carries `session_id`, `provider`, `model`, `cwd`, `workspace_root`, `started_at` / `ended_at`, `messages_path`, and a `metadata.usage` rollup (`inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheWriteTokens`, `totalCost`). + +`.messages.json` holds `{ version, updated_at, agent, sessionId, messages[], system_prompt }`. Assistant messages carry Anthropic-style content blocks (`thinking` / `text` / `tool_use`) plus: + +```jsonc +"modelInfo": { "id": "z-ai/glm-5.2", "provider": "cline-pass" }, +"metrics": { "inputTokens": 6937, "outputTokens": 213, + "cacheReadTokens": 0, "cacheWriteTokens": 0, "cost": 0.002108502 } +``` + +One `metrics` block becomes one parsed call. Dedup key: `cline-cli::`. + +## Caching + +None at the provider level; the metadata file is the cached source path and the normal parser/cache layers apply. + +## Quirks + +- **`provider` in the session file is the upstream LLM route** (e.g. `cline-pass`), not the tool. The codeburn provider name is always `cline-cli`. +- **Model strings are not normalized by the CLI.** The same model appears as `z-ai/glm-5.2`, `cline-pass/glm-5.2`, and `GLM-5.2` across sessions, so pricing lookups may need a `model-alias`. +- **Cost is reported per message**, so `costIsEstimated` is false on the normal path; it falls back to `calculateCost` only when a message omits `cost`. +- **Rollup fallback.** A session whose messages carry no metrics (interrupted, or an older layout) emits a single call from `metadata.usage`. This reads `usage`, deliberately *not* `aggregateUsage` / `aggregatedAgentsCost`, which fold in spawned subagents that are themselves separate session directories and would double count. +- **`messages_path` is absolute** and goes stale when a session directory is copied between machines, so the co-located `.messages.json` is preferred and `messages_path` is only the fallback. +- **Tool names differ from the extension's.** `run_commands`, `read_files`, `search_codebase`, `editor`, `apply_patch`, `fetch_web_content`, `skills`, `spawn_agent`, and the `team_*` family. `run_commands` carries a JSON-encoded array of command lines in a single string field. + +## When fixing a bug here + +1. Reproduce with a minimal session directory: `.json` plus `.messages.json`. +2. Run `tests/providers/cline-cli.test.ts`. +3. This provider shares no code with `vscode-cline-parser.ts` — changes here cannot affect Cline, Roo Code, KiloCode, or IBM Bob. diff --git a/docs/providers/cline.md b/docs/providers/cline.md index 65f27eae..ec8e9d2f 100644 --- a/docs/providers/cline.md +++ b/docs/providers/cline.md @@ -2,18 +2,20 @@ Cline VS Code extension and Cline home-data task storage. +Sessions from the Cline **command-line** agent use an unrelated layout and are handled by [Cline CLI](cline-cli.md); this provider does not see them. + - **Source:** `src/providers/cline.ts` - **Loading:** eager (`src/providers/index.ts:2`) - **Test:** `tests/providers/cline.test.ts` ## Where it reads from -Two task roots are scanned: +These task roots are scanned: -1. VS Code extension globalStorage for `saoudrizwan.claude-dev`. +1. VS Code extension globalStorage for `saoudrizwan.claude-dev`, in every supported VS Code variant: stable (`Code`), Insiders (`Code - Insiders`), and VSCodium. The per-platform paths come from `getVSCodeGlobalStoragePaths` in `src/providers/vscode-cline-parser.ts`, the same helper Roo Code and KiloCode use. 2. Cline's home-data root at `~/.cline/data`. -Both roots are expected to contain a `tasks/` child directory. Discovery is delegated to `discoverClineTasks` in `src/providers/vscode-cline-parser.ts`, so a task is only included when it has a `ui_messages.json` file. +Every root is expected to contain a `tasks/` child directory. Discovery is delegated to `discoverClineTasks` in `src/providers/vscode-cline-parser.ts`, so a task is only included when it has a `ui_messages.json` file. ## Storage format @@ -35,12 +37,13 @@ None at the provider level; delegates to the shared helper and normal parser/cac ## Deduplication -Discovery deduplicates by task id across the two Cline roots so a migrated task is not scanned twice. If the same task id exists in multiple roots, the one with the newest `ui_messages.json` wins. Parsing still uses the shared per-call key: `::`. +Discovery deduplicates by task id across all Cline roots so a task that exists in more than one root (a migration, or the same extension storage seen by two VS Code variants) is not scanned twice. If the same task id exists in multiple roots, the one with the newest `ui_messages.json` wins. Parsing still uses the shared per-call key: `::`. ## Quirks - This provider is intentionally a thin wrapper over the shared Cline-family parser. - Cline can keep data in both VS Code globalStorage and `~/.cline/data`, depending on version and workflow. +- A user can run Cline in VS Code stable, Insiders, and VSCodium at the same time; each variant has its own globalStorage tree, so all of them must be scanned. - If Cline changes the JSON shape, fix `vscode-cline-parser.ts` only if Roo Code and KiloCode still pass. Branch provider-specific parsing rather than duplicating the whole parser. ## When fixing a bug here diff --git a/docs/providers/codewhale.md b/docs/providers/codewhale.md index bc9cdc48..d04f1535 100644 --- a/docs/providers/codewhale.md +++ b/docs/providers/codewhale.md @@ -104,5 +104,5 @@ parser key is `codewhale:`. 1. Reproduce with a minimal real-shape saved-session JSON fixture. 2. Verify aggregate tokens and parent-plus-subagent cost before checking UI totals; do not infer an input/output split CodeWhale does not store. -3. Run `npm test -- tests/providers/codewhale.test.ts --run` and - `npm test -- tests/provider-registry.test.ts tests/session-cache.test.ts --run`. +3. Run `npx vitest run tests/providers/codewhale.test.ts` and + `npx vitest run tests/provider-registry.test.ts tests/session-cache.test.ts`. diff --git a/docs/providers/codex.md b/docs/providers/codex.md index 505b3089..1ba46fdd 100644 --- a/docs/providers/codex.md +++ b/docs/providers/codex.md @@ -4,7 +4,7 @@ OpenAI Codex CLI. - **Source:** `src/providers/codex.ts` - **Loading:** eager (`src/providers/index.ts:2`) -- **Test:** `tests/providers/codex.test.ts` (374 lines) +- **Test:** `tests/providers/codex.test.ts` (1075 lines) ## Where it reads from @@ -24,7 +24,11 @@ The active-session discovery walk uses strict regex (`^\d{4}$`, `^\d{2}$`) on ea ## Storage format -JSONL. The first line must be a `session_meta` entry with `payload.originator` starting with `codex` (case-insensitive). Files that fail this check are silently skipped. +JSONL. Validation of the first line is **structural**: it must parse as JSON, have `type === "session_meta"`, and carry a `payload` that is a plain object (not missing, not a scalar, not an array). Files that fail this check are silently skipped. + +`payload.originator` is deliberately **not** part of the check. It is a free-form client identity string, not a format marker: Codex CLI writes `codex-tui` / `codex_exec` / `codex_cli_rs`, Codex Desktop writes `Codex Desktop`, and third-party frontends driving `codex app-server` write their own values (`t3code_desktop`, `JetBrains.IntelliJ IDEA`, ...) into structurally identical rollouts. Gating discovery on the spelling silently dropped those sessions from every report and required a new allowlist entry per client (issues #626, #873). Directory ownership decides the provider instead: `codex.ts` is the only provider that reads `~/.codex`, and the walk only visits `rollout-*.jsonl` under the strict `YYYY/MM/DD` path or `archived_sessions/`. `originator` is still parsed into the session meta entry, but nothing downstream reads it. + +Because admission no longer implies a known client, every payload field is treated as untrusted JSON. `payload.cwd` in particular is type-guarded before it reaches `sanitizeProject` (discovery) or `projectPath`/`workingDirectory` (parse): a non-string `cwd` falls back to the `unknown` project instead of throwing out of `discoverSessions`, which `safeDiscoverSessions` would have turned into an empty session list for the *entire* provider. The first line read is capped at 1 MB (`FIRST_LINE_READ_CAP`). Codex CLI 0.128+ embeds the full system prompt in `session_meta`, which can run 20-27 KB; the cap leaves headroom while bounding memory if a corrupt file has no newline. @@ -48,6 +52,110 @@ A session that yielded zero parseable lines does **not** write to the cache (`co - `prev*` token counters are advanced on **every** event, including ones that used `last_token_usage`. Earlier code only updated them on the fallback branch, which double-counted any session that mixed modes. - OpenAI counts cached tokens **inside** `input_tokens`. The parser subtracts them so the rest of the codebase can assume Anthropic semantics (cached are separate). +## Live quota (ChatGPT subscription) + +Separate from the log parser above: the desktop app and the macOS menubar read +live quota from `GET https://chatgpt.com/backend-api/wham/usage` using the Codex +OAuth token. Two independent implementations of the same decoder, which must be +kept in sync: + +- `app/electron/quota/codex.ts`: `decodeCodexUsage()` is the pure, exported decoder. +- `mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift`: `decodeUsage()`. + +### Seat-based plans (Plus, Pro, Team) + +`rate_limit.primary_window` / `secondary_window` carry `used_percent`, +`reset_at` and `limit_window_seconds`. The window *label* is inferred from the +duration (5-hour, Weekly, …), never from the plan, because window size is dynamic per +account. `additional_rate_limits[]` holds per-model limits (Codex Spark, etc.) +and is only surfaced when utilization is non-zero. + +### Credit-metered plans (Business, Edu, Enterprise on flexible pricing) + +These workspaces have **no rate-limit windows**: `rate_limit` comes back +`null`. Usage scales with credits, and an admin sets a monthly per-user credit +allowance. That allowance is the account's only limit and lives in +`spend_control`: + +```jsonc +"spend_control": { + "reached": false, + "individual_limit": { + "source": "workspace_spend_controls", + "limit": "10000", // string + "used": "3028.9909675121307", // string + "used_percent": 30, // number + "remaining_percent": 70, + "reset_after_seconds": 441896, // time *remaining*, not window length + "reset_at": 1785542400 + } +} +``` + +Notes that have bitten us: + +- **Number encodings are mixed within the same object**: `limit` and `used` + arrive as strings while `used_percent` arrives as a number. Every numeric + field is decoded flexibly (number | string) on both sides. +- **`reset_after_seconds` is not the window length.** Pace projection needs the + whole-window duration, so it is derived as the calendar month preceding + `reset_at`, resolved in **UTC**: `reset_at` is a UTC boundary, and a local + calendar would make the month length depend on the viewer's timezone (a + 2026-03-01Z reset spans 28 days in UTC but 31 in Toronto). +- Two other positions for this object have been observed in other clients + (top-level `individual_limit`, and nested under `rate_limit`), in both + snake_case and camelCase. All are accepted; `spend_control` wins. +- `credits.has_credits` means the account settles in **credits, not dollars**, so + `credits.balance` must not be rendered with a currency symbol in that case. + `credits.unlimited` means credit-metered but deliberately uncapped. +- **`has_credits` is not "is credit-metered".** The live Enterprise workspace + above is credit-metered (it has a `spend_control` allowance) yet reports + `has_credits: false` with a `null` balance, so the flag tracks whether the + account holds a *credit balance*, which is orthogonal to the allowance. Do not + derive one from the other. The `has_credits: true` rendering path has not been + observed against a real account; if a seat-based account ever reports it + alongside a dollar balance, the footer would drop the `$` and round to whole + units. + +### `plan_type` cannot distinguish Business from Enterprise + +A live ChatGPT **Enterprise** workspace reports `plan_type: "business"` on this +endpoint, and the `id_token`'s `https://api.openai.com/auth → chatgpt_plan_type` +claim says `"business"` too, even though ChatGPT's own workspace switcher +displays "Enterprise". Neither source carries the distinction, so the label +CodeBurn shows is faithfully what OpenAI returns. Do not try to infer a tier +from the presence of a spend control. + +The switcher renders from the accounts endpoints, and **those are not reachable +with a Codex token**, verified against a live Enterprise workspace: + +| Endpoint | Result | +| --- | --- | +| `/backend-api/accounts/check/v4-2023-04-27` | 403 | +| `/backend-api/accounts/check` | 403 | +| `/backend-api/me` | 403 | +| `/backend-api/settings/account_user_setting` | 403 | + +Not an expiry or a missing-header problem: the same token returns 200 on +`/wham/usage` (and on `/backend-api/gizmo_creator_profile`) in the same run. The +Codex OAuth access token carries scopes `openid profile email offline_access +api.connectors.read api.connectors.invoke` with audience +`https://api.openai.com/v1`, with no ChatGPT web-app account scope, so the accounts +surfaces reject it by design. Adding a `ChatGPT-Account-Id` header does not +change this. **Business is therefore the correct label to display**; closing +this gap would need a different credential, not a different endpoint. + +Composite tiers (`enterprise_cbp_usage_based`, `self_serve_business_usage_based`) +*are* normalized down to their base tier before lookup. + +### Reset credits + +`rate_limit_reset_credits` is carried inline on the usage payload +(`available_count`). The dedicated `GET /wham/rate-limit-reset-credits` +endpoint is only called when the inline block is absent. It is the sole source +of per-credit `expires_at` values, so the "next expires" caption is omitted on +the inline path. + ## When fixing a bug here 1. Reproduce against a real `rollout-*.jsonl` if you can. Drop a redacted copy under `tests/fixtures/codex/` and reference it from `tests/providers/codex.test.ts`. diff --git a/docs/providers/hermes.md b/docs/providers/hermes.md index 9300ef94..32ec51e1 100644 --- a/docs/providers/hermes.md +++ b/docs/providers/hermes.md @@ -62,6 +62,6 @@ The shared session cache fingerprints Hermes state DB files. `HERMES_HOME` is in ## When fixing a bug here 1. Reproduce against a real Hermes `state.db` or a minimal SQLite fixture. -2. Run `npm test -- tests/providers/hermes.test.ts --run`. +2. Run `npx vitest run tests/providers/hermes.test.ts`. 3. For local smoke testing, use an isolated cache directory, for example: `CODEBURN_CACHE_DIR=/tmp/codeburn-hermes-cache node --import tsx -e "import { parseAllSessions } from './src/parser.ts'; console.log(await parseAllSessions(undefined, 'hermes'))"`. diff --git a/docs/providers/kimicode.md b/docs/providers/kimicode.md index 80300883..a92356bb 100644 --- a/docs/providers/kimicode.md +++ b/docs/providers/kimicode.md @@ -8,14 +8,23 @@ MoonshotAI Kimi Code local session usage and tool activity. ## Where it reads from -The provider reads `~/.kimi-code` by default and honors the Kimi Code CLI's `KIMI_CODE_HOME` environment variable. It scans: +By default the provider scans every known Kimi Code runtime store: ```text -$KIMI_CODE_HOME/sessions/wd_*/session_*/ +~/.kimi-code +~/Library/Application Support/kimi-desktop/daimon-share/daimon/runtime/kimi-code/home (Kimi desktop / IDE embedded runtime) +``` + +Setting `KIMI_CODE_HOME` (or passing a home override) narrows the scan to that single home. Inside each home it scans: + +```text +$HOME/sessions/wd_*// ├── state.json └── agents//wire.jsonl ``` +Session directory naming depends on the host product: the CLI uses `session_*`, embedded runtimes use `conv-*` / `ctitle-*`. Any directory is accepted; the `agents/*/wire.jsonl` probe gates real sessions. + Every agent wire is a cache source. Main-agent and subagent calls share the session ID from the `session_*` directory. `state.json.workDir` supplies the project name and path. `probeRoots()` reports the resolved Kimi Code home for `codeburn doctor` even when there are no sessions. ## Storage format diff --git a/docs/providers/lingtai-tui.md b/docs/providers/lingtai-tui.md index b34681a5..6634d3e3 100644 --- a/docs/providers/lingtai-tui.md +++ b/docs/providers/lingtai-tui.md @@ -85,4 +85,4 @@ The ledger is append-only, so line number is stable for normal operation. 1. Prefer a minimal redacted `token_ledger.jsonl` fixture over full `chat_history.jsonl`. 2. Check whether a daemon entry is already mirrored into the parent ledger before adding new discovery paths. -3. Run `npm test -- tests/providers/lingtai-tui.test.ts --run`. +3. Run `npx vitest run tests/providers/lingtai-tui.test.ts`. diff --git a/docs/providers/openclaude.md b/docs/providers/openclaude.md new file mode 100644 index 00000000..d365b793 --- /dev/null +++ b/docs/providers/openclaude.md @@ -0,0 +1,33 @@ +# OpenClaude + +OpenClaude (npm `@gitlawb/openclaude`) is a Claude Code fork that runs the same +agent loop against any LLM backend (DeepSeek, OpenAI-compatible endpoints, +Gemini, Ollama, ...). Because it is a fork, its transcripts are Claude-Code +schema and its tool names are already codeburn-canonical. + +## Storage layout + +``` +~/.openclaude/projects//.jsonl transcript +~/.openclaude/projects//.replay.json replay state (skipped) +``` + +`CODEBURN_OPENCLAUDE_DIR` overrides the root (projects live under +`/projects`). + +## Quirks + +- Only `assistant` lines carrying `message.usage` become calls; the + `queue-operation` / `last-prompt` bookkeeping lines and user lines are + skipped. +- Usage is Anthropic-shaped; there is NO cost field, so every call is priced + through the shared tables and always carries `costIsEstimated: true`. +- `isSidechain: true` lines are subagent traffic inside the same transcript + and are counted: their usage is real spend. +- The model id is whatever the routed backend reports (e.g. `deepseek-chat`), + so pricing accuracy tracks the shared litellm tables. +- Tool attribution is partial by construction: streamed responses can split + tool_use blocks across assistant events, and only usage-bearing events are + parsed. Cost and token accounting are exact; tool breakdowns are a floor. +- The project name prefers the basename of the first `cwd` seen in the + transcript; the project-slug directory is the fallback. diff --git a/docs/providers/opencode.md b/docs/providers/opencode.md index 0236db94..470eadf1 100644 --- a/docs/providers/opencode.md +++ b/docs/providers/opencode.md @@ -57,6 +57,6 @@ Per `:`. ## When fixing a bug here -1. The 558-line test suite catches a lot. Run `npm test -- tests/providers/opencode.test.ts` before and after any change. +1. The 558-line test suite catches a lot. Run `npx vitest run tests/providers/opencode.test.ts` before and after any change. 2. If the bug is "missing table" warning, do not catch and silence it. Either upgrade the version expectation in the parser or document the breaking schema change. 3. If the bug is "reasoning tokens off by one", check the parts index ordering. diff --git a/docs/sync/README.md b/docs/sync/README.md index 5f1343ed..64540b83 100644 --- a/docs/sync/README.md +++ b/docs/sync/README.md @@ -48,6 +48,9 @@ codeburn sync push --since 30d # Preview what would be sent codeburn sync push --dry-run + +# Also push git attribution (opt-in — see "Git attribution" below) +codeburn sync push --attribution ``` ### `codeburn sync status` @@ -95,6 +98,36 @@ Each AI interaction becomes one OTLP span with these attributes: A pseudonymous `device_id` distinguishes your machines without revealing hostnames. +### Git attribution (opt-in: `--attribution`) + +`codeburn sync push --attribution` additionally sends the session→commit correlation that `codeburn yield` computes locally, so the backend can join AI usage to git activity without git hooks. Two extra span types are emitted: + +**`codeburn.session.attribution`** — one per session with joinable evidence: + +| Field | Example | Description | +|---|---|---| +| `ai.session_id` | `abc123…` | Session (shares the usage spans' traceId) | +| `ai.project` | `my-app` | Project name | +| `git.repo` | `github.com/acme/widget` | Normalized `origin` remote (credentials and ports stripped) | +| `git.pr_links` | `["…/pull/12"]` | PR URLs captured for the session | +| `git.commit_count` | `2` | Number of attributed commits | + +**`codeburn.commit`** — one per commit attributed to a session: + +| Field | Example | Description | +|---|---|---| +| `git.sha` | `4f2a…` | Commit SHA | +| `git.in_main` | `true` | Whether the commit landed in the main branch | +| `git.was_reverted` | `false` | Whether a later commit reverted it | + +Attribution is **inferred** (timestamp-window correlation, the same heuristic as `codeburn yield`); the resource attribute `codeburn.attribution_methodology: timestamp-window` marks it as such. State transitions (a commit merging to main, or being reverted) are re-sent automatically on later pushes — receivers should upsert commits by `(git.repo, git.sha)` and session spans by `ai.session_id` (latest state wins). When a commit migrates to a later-parsed session with a tighter window, the losing session re-emits with `git.commit_count: 0` (a retraction), so summing `git.commit_count` across upserted session rows never double-counts. Retractions fire only when the commit was won by another session — commits that merely age out of the `--since` window are not retracted, so a previously-synced count stays correct. Session spans also re-emit when an ongoing session's window grows, keeping the span end time current. + +With `--attribution`, normalized repo remote URLs, commit SHAs, commit timestamps (span start times), PR URLs, and the merged/reverted booleans leave your machine — plus the same pseudonymous `codeburn.device_id` resource attribute the usage spans carry. PR links are rebuilt client-side from scheme + host + path only (userinfo, query strings, and fragments are dropped; https, `/org/repo/pull/N` path, bounded length, max 20 per session), and the repo identity itself passes a strict hostname/path allow-list before sending — malformed or transport-helper remotes (`ext::…`, `codecommit::…`) are rejected outright rather than parsed. Precisely what is and is not sent: + +- **Commits**: only from repos with a network `origin` remote, and only for sessions whose own project path resolved to that repo. Local-only repos, `file://` remotes, and Windows filesystem paths are never emitted as repo identities. A session whose project path no longer resolves never inherits the repo of the directory you happen to push from. +- **PR links**: sent whenever a session captured them, even when the session's repo could not be identified — the PR URL itself names the repo, so this adds no information beyond the link the session already recorded. +- Without the flag, none of this is sent. + ### What is NOT sent - **Prompts** — your actual messages to AI are never included @@ -102,7 +135,7 @@ A pseudonymous `device_id` distinguishes your machines without revealing hostnam - **Bash commands** — may contain secrets, never sent - **Your name/email** — identity is derived server-side from your login token -There is no flag to override this. Privacy is structural, not configurable. +There is no flag to override this. Privacy is structural, not configurable. The only additive opt-in is `--attribution` (repo remotes, commit SHAs, and PR URLs — never code or prompts), described above. ## Authentication diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index fb3edbb9..005a7531 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -138,11 +138,19 @@ final class AppStore { var codexError: String? var codexLoadState: SubscriptionLoadState = CodexCredentialStore.isBootstrapCompleted ? .dormant : .notBootstrapped + var kimiUsage: KimiUsage? + var kimiError: String? + // No keychain dance for Kimi — "connected" just means the CLI's + // credential file exists, so we start dormant and auto-activate on the + // first refresh tick. + var kimiLoadState: SubscriptionLoadState = KimiSubscriptionService.hasCredential ? .dormant : .notBootstrapped + /// Generation tokens for the in-flight refresh tasks. Incremented on every /// disconnect / reset so a fetch that started before the disconnect cannot /// resume after the await and re-populate the freshly-cleared state. private var claudeRefreshGen: Int = 0 private var codexRefreshGen: Int = 0 + private var kimiRefreshGen: Int = 0 private var cache: [PayloadCacheKey: CachedPayload] = [:] private var cacheDate: String = "" @@ -263,6 +271,49 @@ final class AppStore { cache[menubarStatusKey]?.payload } + private var menubarCombinedKey: PayloadCacheKey { + PayloadCacheKey(scope: .combined, period: menubarPeriod, provider: .all, day: nil, claudeConfigSourceId: selectedClaudeConfigSourceId) + } + + /// Cross-device totals for the menubar badge's period, used so the badge + /// figure matches the popover hero under combined scope. `nil` under local + /// scope, or when no combined payload for the badge period is cached yet + /// (cold start, or the peer is unreachable) — the badge then falls back to + /// the local figure, exactly like the popover. + var menubarBadgeCombined: CombinedUsageTotals? { + guard effectiveSelectedScope == .combined else { return nil } + return cache[menubarCombinedKey]?.payload.combined?.combined + } + + /// `(reachable, total)` only when combined scope is active and fewer paired + /// devices reported than are paired — i.e. the badge total is degraded to + /// the reachable subset (a peer is asleep/off-network this cycle). The badge + /// shows this so a momentary drop to the local figure reads as "peer + /// unreachable", not a glitch. `nil` when every paired device reported (or + /// there is only one), and under local scope. + var menubarBadgeDeviceShortfall: (reachable: Int, total: Int)? { + guard let totals = menubarBadgeCombined, totals.reachableCount < totals.deviceCount else { return nil } + return (totals.reachableCount, totals.deviceCount) + } + + /// Refresh the payloads the badge renders for `period`: always the local + /// figure, plus the combined cross-device total when combined scope is + /// active. Combined is best-effort — a slow or unreachable peer degrades to + /// the local figure — so the local fetch alone determines success. + @discardableResult + func refreshMenubarBadge(period: Period, force: Bool = false, qualityOfService: QualityOfService = .userInitiated) async -> Bool { + async let local = refreshQuietly(period: period, force: force, qualityOfService: qualityOfService) + guard effectiveSelectedScope == .combined else { return await local } + async let combined = refreshQuietly( + key: PayloadCacheKey(scope: .combined, period: period, provider: .all, day: nil, claudeConfigSourceId: selectedClaudeConfigSourceId), + includeOptimize: false, + force: force, + qualityOfService: qualityOfService + ) + let (localSucceeded, _) = await (local, combined) + return localSucceeded + } + /// All-provider payload for the selected period. Used by the tab strip to show /// per-provider costs that match the active period, not just today. var periodAllPayload: MenubarPayload? { @@ -1104,6 +1155,90 @@ final class AppStore { } } + // MARK: - Kimi Code + + /// Unlike Claude/Codex there is no keychain bootstrap: reading the CLI's + /// credential file is prompt-free, so the first refresh tick activates + /// the dormant state automatically. + func bootstrapKimi() async { + // Capture the generation before the await so a disconnect that lands + // mid-fetch cannot be resurrected into .loaded when the fetch returns. + let gen = kimiRefreshGen + kimiLoadState = .bootstrapping + do { + let usage = try await KimiSubscriptionService.refresh() + guard gen == kimiRefreshGen else { return } + kimiUsage = usage + kimiError = nil + kimiLoadState = .loaded + } catch let err as KimiSubscriptionService.FetchError { + guard gen == kimiRefreshGen else { return } + applyKimiFetchError(err) + } catch { + guard gen == kimiRefreshGen else { return } + kimiError = sanitizeForUI(String(describing: error)) + kimiLoadState = .failed + } + } + + func refreshKimi() async { + _ = await refreshKimiReportingSuccess() + } + + @discardableResult + func refreshKimiReportingSuccess() async -> Bool { + if case .dormant = kimiLoadState { + await bootstrapKimi() + return kimiLoadState == .loaded + } + guard KimiSubscriptionService.hasCredential else { + if kimiLoadState != .notBootstrapped { kimiLoadState = .notBootstrapped } + return false + } + let gen = kimiRefreshGen + if kimiUsage == nil { kimiLoadState = .loading } + do { + let usage = try await KimiSubscriptionService.refresh() + guard gen == kimiRefreshGen else { return false } + kimiUsage = usage + kimiError = nil + kimiLoadState = .loaded + return true + } catch let err as KimiSubscriptionService.FetchError { + guard gen == kimiRefreshGen else { return false } + applyKimiFetchError(err) + return false + } catch { + guard gen == kimiRefreshGen else { return false } + kimiError = sanitizeForUI(String(describing: error)) + kimiLoadState = .failed + return false + } + } + + func disconnectKimi() { + KimiSubscriptionService.disconnect() + kimiRefreshGen &+= 1 + kimiUsage = nil + kimiError = nil + kimiLoadState = .notBootstrapped + NotificationCenter.default.post(name: .codeBurnSubscriptionDisconnected, object: nil) + } + + private func applyKimiFetchError(_ err: KimiSubscriptionService.FetchError) { + let sanitized = sanitizeForUI(err.errorDescription) + kimiError = sanitized + if case .noCredentials = err { + kimiLoadState = .noCredentials + } else if err.isTerminal { + kimiLoadState = .terminalFailure(reason: sanitized) + } else if let retryAt = err.rateLimitRetryAt { + kimiLoadState = .transientFailure(retryAt: retryAt) + } else { + kimiLoadState = .failed + } + } + private func applyFetchError(_ err: ClaudeSubscriptionService.FetchError) { let sanitized = sanitizeForUI(err.errorDescription) subscriptionError = sanitized @@ -1172,6 +1307,10 @@ final class AppStore { let worst = max(usage.primary?.usedPercent ?? 0, usage.secondary?.usedPercent ?? 0) if worst > 0 { providers.append(("Codex", worst)) } } + if let usage = kimiUsage, shouldIncludeCachedQuota(loadState: kimiLoadState) { + let worst = max(usage.primary?.usedPercent ?? 0, usage.details.map(\.usedPercent).max() ?? 0) + if worst > 0 { providers.append(("Kimi Code", worst)) } + } let worst = providers.map(\.percent).max() ?? 0 let severity = QuotaSummary.severity(for: worst / 100) let sorted = providers.sorted { $0.percent > $1.percent } @@ -1192,6 +1331,7 @@ final class AppStore { switch filter { case .claude: return claudeQuotaSummary(filter: filter) case .codex: return codexQuotaSummary(filter: filter) + case .kimiCode: return kimiQuotaSummary(filter: filter) default: return nil } } @@ -1281,22 +1421,78 @@ final class AppStore { details.append(.init(label: "\(extra.name) · \(s.windowLabel)", percent: s.usedPercent / 100, resetsAt: s.resetsAt)) } } + // No rate windows here, so the allowance feeds the bar and badge. + if let credits = usage.creditLimit { + let row = QuotaSummary.Window( + label: credits.shortLabel, + percent: credits.usedPercent / 100, + resetsAt: credits.resetsAt + ) + if primary == nil { primary = row } + details.append(row) + } } let plan = codexUsage?.plan.displayName var footerLines: [String] = [] if let balance = codexUsage?.creditsBalance, balance > 0 { - // Format as plain dollars; ChatGPT settles in USD regardless of - // the user's display-currency preference. + // Credit-settled accounts denominate in credits, so no symbol. + let inCredits = codexUsage?.hasCredits == true let formatter = NumberFormatter() - formatter.numberStyle = .currency - formatter.currencyCode = "USD" - formatter.maximumFractionDigits = 2 - let formatted = formatter.string(from: NSNumber(value: balance)) ?? "$\(balance)" + formatter.numberStyle = inCredits ? .decimal : .currency + formatter.maximumFractionDigits = inCredits ? 0 : 2 + // Half-up matches the desktop decoder's Math.round; the default is + // half-even, which disagrees on exact-half balances. + formatter.roundingMode = .halfUp + // `en_US`, not `en_US_POSIX`: the latter drops grouping entirely. + formatter.locale = Locale(identifier: "en_US") + if !inCredits { formatter.currencyCode = "USD" } + let fallback = inCredits ? "\(Int(balance.rounded()))" : "$\(balance)" + let formatted = formatter.string(from: NSNumber(value: balance)) ?? fallback footerLines.append("Credits remaining · \(formatted)") } + if codexUsage?.creditLimit == nil, codexUsage?.creditsUnlimited == true { + footerLines.append("Credits · Unlimited") + } return QuotaSummary(providerFilter: filter, connection: connection, primary: primary, details: details, planLabel: plan, footerLines: footerLines) } + private func kimiQuotaSummary(filter: ProviderFilter) -> QuotaSummary? { + if case .notBootstrapped = kimiLoadState { return nil } + if case .bootstrapping = kimiLoadState { return nil } + if case .noCredentials = kimiLoadState { return nil } + + let connection: QuotaSummary.Connection = { + switch kimiLoadState { + case .notBootstrapped, .dormant, .bootstrapping, .noCredentials: return .disconnected + case .loading: return kimiUsage == nil ? .loading : .stale + case .loaded: return .connected + case .failed: return kimiUsage == nil ? .loading : .stale + // Kimi tokens expire ~every 15 min and only the CLI renews them, so + // terminal is the steady state between CLI uses. Keep the last-known + // bars (marked stale) instead of flapping the chip to a reconnect + // card; the reconnect card is reserved for the genuinely-no-data case. + case let .terminalFailure(reason): return kimiUsage == nil ? .terminalFailure(reason: reason) : .stale + case .transientFailure: return .transientFailure + } + }() + + var primary: QuotaSummary.Window? + var details: [QuotaSummary.Window] = [] + if let usage = kimiUsage { + if let w = usage.primary { + let row = QuotaSummary.Window(label: w.label, percent: w.usedPercent / 100, resetsAt: w.resetsAt) + primary = row + details.append(row) + } + for w in usage.details { + let row = QuotaSummary.Window(label: w.label, percent: w.usedPercent / 100, resetsAt: w.resetsAt) + if primary == nil { primary = row } + details.append(row) + } + } + return QuotaSummary(providerFilter: filter, connection: connection, primary: primary, details: details, planLabel: kimiUsage?.plan ?? "Kimi Code", footerLines: []) + } + /// Persist one snapshot per window so we can answer "what did the prior cycle end at?" /// when the current window has just reset and projection from current data isn't meaningful. /// Also computes the effective_tokens consumed inside each 7-day window from local history, @@ -1400,9 +1596,11 @@ enum ProviderFilter: String, CaseIterable, Identifiable { case ibmBob = "IBM Bob" case kiro = "Kiro" case kimi = "Kimi" + case kimiCode = "Kimi Code" case lingtaiTui = "LingTai TUI" case kiloCode = "KiloCode" case openclaw = "OpenClaw" + case openclaude = "OpenClaude" case opencode = "OpenCode" case pi = "Pi" case qwen = "Qwen" @@ -1432,6 +1630,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable { case .grok: ["grok", "grok build"] case .hermes: ["hermes", "hermes agent"] case .lingtaiTui: ["lingtai-tui", "lingtai tui"] + case .kimiCode: ["kimicode", "kimi code"] default: [rawValue.lowercased()] } } @@ -1453,8 +1652,10 @@ enum ProviderFilter: String, CaseIterable, Identifiable { case .kiloCode: "kilo-code" case .kiro: "kiro" case .kimi: "kimi" + case .kimiCode: "kimicode" case .lingtaiTui: "lingtai-tui" case .openclaw: "openclaw" + case .openclaude: "openclaude" case .opencode: "opencode" case .pi: "pi" case .qwen: "qwen" diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index fefdffac..befaf027 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -49,11 +49,14 @@ struct CodeBurnApp: App { } @MainActor -final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { +final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSMenuDelegate { private var statusItem: NSStatusItem! private var popover: NSPopover! private var rightClickMonitor: Any? private var lastContextMenuPresentedAt: Date = .distantPast + /// Held only while the right-click menu is open. Cleared in menuDidClose so + /// left-click goes back to the popover action instead of re-showing the menu. + private var contextMenu: NSMenu? fileprivate let store = AppStore() let updateChecker = UpdateChecker() /// True while the displays are asleep. Refresh ticks skip spawning @@ -464,7 +467,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { // is refreshed by refreshPayloadForPopoverOpen the moment it opens, // so a closed-popover tick never pays for it (#647). if !(popover?.isShown ?? false) { - async let menubar = store.refreshQuietly( + async let menubar = store.refreshMenubarBadge( period: menubarPeriod, force: force, qualityOfService: qualityOfService @@ -486,7 +489,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { qualityOfService: qualityOfService ) async let menubar = needsMenubarPayload - ? store.refreshQuietly(period: menubarPeriod, force: force, qualityOfService: qualityOfService) + ? store.refreshMenubarBadge(period: menubarPeriod, force: force, qualityOfService: qualityOfService) : true async let today = needsTodayPayload ? store.refreshQuietly(period: .today, force: force, qualityOfService: qualityOfService) @@ -519,6 +522,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { fileprivate var lastSubscriptionRefreshAt: Date? fileprivate var lastCodexRefreshAt: Date? + fileprivate var lastKimiRefreshAt: Date? private var claudeQuotaFailureCount = 0 private var nextClaudeQuotaRefreshAt: Date? @@ -614,8 +618,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { if let task = codexQuotaRefreshTask { return await task.value } - let task = Task { [store] in - await store.refreshCodexReportingSuccess() + // Kimi Code rides the same tick, but with its own cadence anchor: + // when Codex is not connected its refresh returns false immediately, + // so lastCodexRefreshAt never advances — anchoring Kimi on it would + // poll api.kimi.com on every payload tick instead of the configured + // quota cadence. Anchor on attempt (not success) so a failing Kimi + // endpoint also respects the cadence. + let kimiDue: Bool = { + let cadence = SubscriptionRefreshCadence.current + guard cadence != .manual else { return false } + return Date().timeIntervalSince(lastKimiRefreshAt ?? .distantPast) >= TimeInterval(cadence.rawValue) + }() + if kimiDue { lastKimiRefreshAt = Date() } + let task = Task { [store, kimiDue] in + async let codex = store.refreshCodexReportingSuccess() + if kimiDue { + _ = await store.refreshKimiReportingSuccess() + } + return await codex } codexQuotaRefreshTask = task let result = await task.value @@ -833,6 +853,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { func resetSubscriptionCadenceAnchor() { lastSubscriptionRefreshAt = nil lastCodexRefreshAt = nil + lastKimiRefreshAt = nil claudeQuotaFailureCount = 0 nextClaudeQuotaRefreshAt = nil } @@ -849,6 +870,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { _ = self.store.payload _ = self.store.menubarPeriod _ = self.store.menubarPayload + // Combined-scope badge total: re-render the badge when the cross-device + // aggregate for the menubar period lands (or a peer goes reachable). + _ = self.store.menubarBadgeCombined // Track currency so the menubar title catches up immediately on // currency switch instead of waiting for the next 30s payload tick. _ = self.store.currency @@ -909,15 +933,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { button.sendAction(on: [.leftMouseUp, .rightMouseUp]) // macOS 27 no longer routes any right-mouse event to the status-item - // button's target/action. A global monitor still observes right-mouse-down; + // button's target/action. A global monitor still observes right-mouse-up; // we hit-test it against our own status-item window and present the menu - // ourselves. Harmless and stable on 15/26 too (the debounce in - // showContextMenu prevents a double-present if the legacy path also fires). - rightClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.rightMouseDown]) { [weak self] _ in + // ourselves. + // + // Must be mouse-*up*, not mouse-down: presenting on down starts menu + // tracking while the button is still held, so the matching rightMouseUp + // is treated as an outside click and the menu flashes then dismisses. + // Presenting on up (after the click completes) keeps it open. Harmless + // on 15/26 too (the debounce in showContextMenu prevents a double-present + // if the legacy path also fires). + rightClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: StatusItemContextMenuPolicy.presentEventMask) { [weak self] _ in guard let self, let button = self.statusItem.button, let window = button.window, window.frame.contains(NSEvent.mouseLocation) else { return } + // Defer one turn so menu presentation is not nested inside the + // monitor callback. Safe on mouse-*up* (the click is already + // complete); on mouse-down a deferred present was killed by the + // matching up event and the menu only flashed. DispatchQueue.main.async { self.showContextMenu(from: button) } } @@ -955,7 +989,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { button.image = nil button.imagePosition = .noImage - let font = NSFont.monospacedDigitSystemFont(ofSize: menubarTitleFontSize, weight: .medium) + let font = NSFont.monospacedDigitSystemFont(ofSize: menubarTitleFontSize, weight: .regular) let baseConfig = NSImage.SymbolConfiguration(pointSize: menubarTitleFontSize, weight: .medium) // Tint the flame based on the worst-affected connected provider's quota. // Normal (<70%) keeps the template (auto white-on-dark / black-on-light); @@ -992,23 +1026,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { if store.displayMetric != .iconOnly { let suffix = menubarPeriod.menubarSuffix(compact: compact) + // Under combined scope the badge shows the cross-device aggregate, so + // it matches the popover hero instead of trailing it with the local + // figure. Falls back to local when no combined payload is available + // (local scope, cold cache, or an unreachable peer). Credits have no + // combined total, so that metric always reflects the local device. + let badgeCombined = store.menubarBadgeCombined + let cost: Double? = badgeCombined?.cost ?? menubarPayload?.current.cost + let outputTokens: Int? = badgeCombined?.outputTokens ?? menubarPayload?.current.outputTokens + let inputTokens: Int? = badgeCombined?.inputTokens ?? menubarPayload?.current.inputTokens let valueText: String - if store.displayMetric == .tokens, let p = menubarPayload?.current { - let out = formatTokensMenubar(Double(p.outputTokens)) - let inp = formatTokensMenubar(Double(p.inputTokens)) - valueText = compact ? "↑\(out)↓\(inp)\(suffix)" : " ↑\(out) ↓\(inp)\(suffix)" - } else if store.displayMetric == .totalTokens, let p = menubarPayload?.current { - let total = formatTokensMenubar(Double(p.inputTokens + p.outputTokens)) + if store.displayMetric == .tokens, let out = outputTokens, let inp = inputTokens { + let outText = formatTokensMenubar(Double(out)) + let inpText = formatTokensMenubar(Double(inp)) + valueText = compact ? "↑\(outText)↓\(inpText)\(suffix)" : " ↑\(outText) ↓\(inpText)\(suffix)" + } else if store.displayMetric == .totalTokens, let out = outputTokens, let inp = inputTokens { + let total = formatTokensMenubar(Double(inp + out)) valueText = compact ? "\(total)\(suffix)" : " \(total)\(suffix)" } else if store.displayMetric == .credits, let p = menubarPayload?.current { let credits = formatTokensMenubar((p.codexCredits ?? 0).rounded()) valueText = compact ? "\(credits)cr\(suffix)" : " \(credits) credits\(suffix)" } else { let fallback = compact ? "$-" : "$—" - let formatted = menubarPayload?.current.cost valueText = compact - ? (formatted?.asCompactCurrencyWhole() ?? fallback) + suffix - : " " + (formatted?.asCompactCurrency() ?? fallback) + suffix + ? (cost?.asCompactCurrencyWhole() ?? fallback) + suffix + : " " + (cost?.asCompactCurrency() ?? fallback) + suffix } var textAttrs: [NSAttributedString.Key: Any] = [.font: font, .baselineOffset: -1.0] @@ -1016,10 +1058,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { textAttrs[.foregroundColor] = NSColor.secondaryLabelColor } composed.append(NSAttributedString(string: valueText, attributes: textAttrs)) + + // Combined scope, but a paired device didn't report this cycle: append + // a dimmed "reachable/total" so the reduced total reads as "peer + // unreachable" rather than a glitch (mirrors the popover's device list). + if let shortfall = store.menubarBadgeDeviceShortfall { + let marker = " · \(shortfall.reachable)/\(shortfall.total)" + let markerAttrs: [NSAttributedString.Key: Any] = [ + .font: font, + .baselineOffset: -1.0, + .foregroundColor: NSColor.secondaryLabelColor, + ] + composed.append(NSAttributedString(string: marker, attributes: markerAttrs)) + } } button.attributedTitle = composed - button.toolTip = "CodeBurn \(menubarPeriod.menubarMetricLabel)" + if let shortfall = store.menubarBadgeDeviceShortfall { + button.toolTip = "CodeBurn \(menubarPeriod.menubarMetricLabel) · \(shortfall.reachable) of \(shortfall.total) devices reporting" + } else { + button.toolTip = "CodeBurn \(menubarPeriod.menubarMetricLabel)" + } persistBadgeStatusFile() } @@ -1126,9 +1185,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { private func showContextMenu(from button: NSStatusBarButton) { // Debounce: on macOS <= 26 both the legacy action path and the global // monitor can fire for a single right-click. Present at most once per click. - let now = Date() - guard now.timeIntervalSince(lastContextMenuPresentedAt) > 0.3 else { return } - lastContextMenuPresentedAt = now + // Policy lives in StatusItemContextMenuPolicy so the gate is unit-tested (#802). + guard StatusItemContextMenuPolicy.acceptPresent( + now: Date(), + lastPresentedAt: &lastContextMenuPresentedAt + ) else { return } + + // Don't let an open popover steal the click / sit under the menu. + if popover?.isShown == true { + popover.performClose(nil) + } let menu = NSMenu() @@ -1160,12 +1226,39 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { quitItem.target = self menu.addItem(quitItem) - // Present directly. The previous `statusItem.menu = menu; button.performClick` - // trick relies on the click -> action path that macOS 27 changed; popUp is - // version-stable. Open a few px below the status item so the menu clears the - // menu bar: anchoring flush clips the top edge and makes macOS engage menu - // scrolling (a scroll chevron appears and the first row slides up on hover). - menu.popUp(positioning: nil, at: NSPoint(x: 0, y: button.bounds.height + 6), in: button) + // Present via the status item's own menu slot. AppKit positions and tracks + // that menu correctly under the status item (no scroll chevron / first-row + // jump). Manual NSMenu.popUp(at:in:) is what caused the jump: the menu was + // tracked against a point while the cursor still sat on the status item + // above it, so the first mouse move engaged scroll mode. + // + // #472 dropped this pattern because assigning `statusItem.menu` from the + // right-mouse *action* never ran on macOS 27 (right-clicks no longer reach + // the button action). We still open from our global rightMouseUp monitor + // (or the legacy action on ≤26); once we set `statusItem.menu` ourselves, + // performClick is just "open the attached menu" and works on 27 too. + // + // menuDidClose clears `statusItem.menu` so the next left-click hits our + // action (popover) instead of re-opening this menu. + menu.delegate = self + contextMenu = menu + statusItem.menu = menu + button.performClick(nil) + } + + // MARK: - NSMenuDelegate + + // AppKit invokes menu callbacks on the main thread. Clear the status-item + // menu slot so the next left-click hits our action (popover) again. + nonisolated func menuDidClose(_ menu: NSMenu) { + // Hop explicitly — don't assumeIsolated across the NSMenuDelegate boundary + // under Swift 6 strict concurrency (NSMenu isn't Sendable). + DispatchQueue.main.async { [weak self] in + guard let self else { return } + // Always clear: we only ever attach our own context menu to the item. + self.statusItem.menu = nil + self.contextMenu = nil + } } /// One-line "today" summary for the context menu's usage row. diff --git a/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift b/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift index 0eea71f2..d25637c1 100644 --- a/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift +++ b/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift @@ -111,10 +111,12 @@ enum CodexSubscriptionService { switch http.statusCode { case 200: clearUsageBlock() - // Companion fetch, strictly best-effort: any failure yields nil and - // the Plan view simply omits the row. This endpoint must never be - // able to break the quota display. - let resetCredits = await fetchResetCredits(token: token) + // Skip the companion request only when the inline block says zero. + // Best-effort either way: nil just omits the row. + var resetCredits = inlineResetCreditsShortcut(data: data) + if resetCredits == nil { + resetCredits = await fetchResetCredits(token: token) + } do { return try decodeUsage(data: data, resetCredits: resetCredits) } catch { @@ -144,15 +146,83 @@ enum CodexSubscriptionService { } } + /// chatgpt.com mixes encodings inside one payload: `"limit": "10000"` next + /// to `"used_percent": 30`. Every numeric field decodes through here. + private enum Flexible { + // `decode`, not `decodeIfPresent`: missing, null and wrong-typed all + // mean "not available", without the double-optional footgun. + // Int first keeps precision above 2^53. Infinity and NaN survive + // `Double(_ text:)`, so reject them here. + static func double(_ c: KeyedDecodingContainer, _ key: K) -> Double? { + if let v = try? c.decode(Int.self, forKey: key) { return Double(v) } + if let v = try? c.decode(Double.self, forKey: key) { return v.isFinite ? v : nil } + if let v = try? c.decode(String.self, forKey: key), + let d = Double(v.trimmingCharacters(in: .whitespacesAndNewlines)) { + return d.isFinite ? d : nil + } + return nil + } + // `Int(exactly:)`, never `Int(_:)`: the plain initializer traps on an + // out-of-range Double, and a trap is not a catchable DecodingError. + static func int(_ c: KeyedDecodingContainer, _ key: K) -> Int? { + double(c, key).flatMap { Int(exactly: $0.rounded()) } + } + static func bool(_ c: KeyedDecodingContainer, _ key: K) -> Bool { + (try? c.decode(Bool.self, forKey: key)) ?? false + } + } + + /// Decoding `[T]` is atomic, so one bad entry would discard every sibling. + private struct Lossy: Decodable { + let value: T? + init(from decoder: Decoder) throws { value = try? T(from: decoder) } + } + private struct UsageDTO: Decodable { let plan_type: String? let rate_limit: RateLimit? let additional_rate_limits: [AdditionalLimitDTO]? let credits: Credits? + let spend_control: SpendControl? + /// Forward-compat: some variants hoist this to the top level. + let individual_limit: IndividualLimit? + + enum CodingKeys: String, CodingKey { + case plan_type, rate_limit, additional_rate_limits, credits, spend_control + case individual_limit + case individualLimit + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + plan_type = try? c.decode(String.self, forKey: .plan_type) + rate_limit = try? c.decode(RateLimit.self, forKey: .rate_limit) + additional_rate_limits = (try? c.decode([Lossy].self, forKey: .additional_rate_limits))? + .compactMap(\.value) + credits = try? c.decode(Credits.self, forKey: .credits) + spend_control = try? c.decode(SpendControl.self, forKey: .spend_control) + individual_limit = (try? c.decode(IndividualLimit.self, forKey: .individual_limit)) + ?? (try? c.decode(IndividualLimit.self, forKey: .individualLimit)) + } struct RateLimit: Decodable { let primary_window: WindowDTO? let secondary_window: WindowDTO? + /// Forward-compat: another observed position for the spend control. + let individual_limit: IndividualLimit? + + enum CodingKeys: String, CodingKey { + case primary_window, secondary_window, individual_limit + case individualLimit + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + primary_window = try? c.decode(WindowDTO.self, forKey: .primary_window) + secondary_window = try? c.decode(WindowDTO.self, forKey: .secondary_window) + individual_limit = (try? c.decode(IndividualLimit.self, forKey: .individual_limit)) + ?? (try? c.decode(IndividualLimit.self, forKey: .individualLimit)) + } } struct AdditionalLimitDTO: Decodable { let limit_name: String? @@ -163,22 +233,72 @@ enum CodexSubscriptionService { let reset_at: Int? let limit_window_seconds: Int? } + /// Credit-metered workspaces report `rate_limit: null` and carry their + /// real limit here: the monthly allowance an admin sets. + struct SpendControl: Decodable { + let reached: Bool + let individualLimit: IndividualLimit? + + enum CodingKeys: String, CodingKey { + case reached + case individual_limit + case individualLimit + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + reached = Flexible.bool(c, .reached) + individualLimit = (try? c.decode(IndividualLimit.self, forKey: .individual_limit)) + ?? (try? c.decode(IndividualLimit.self, forKey: .individualLimit)) + } + } + struct IndividualLimit: Decodable { + let limit: Double? + let used: Double? + let usedPercent: Double? + let remainingPercent: Double? + let resetAt: Int? + + enum CodingKeys: String, CodingKey { + case limit, used + case used_percent, usedPercent + case remaining_percent, remainingPercent + case reset_at, resets_at, resetsAt + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + limit = Flexible.double(c, .limit) + used = Flexible.double(c, .used) + usedPercent = Flexible.double(c, .used_percent) ?? Flexible.double(c, .usedPercent) + remainingPercent = Flexible.double(c, .remaining_percent) + ?? Flexible.double(c, .remainingPercent) + resetAt = Flexible.int(c, .reset_at) + ?? Flexible.int(c, .resets_at) + ?? Flexible.int(c, .resetsAt) + } + } // chatgpt.com sometimes serializes balance as a Double ("balance": 0.0) // and other times as a String ("balance": "0.00"). Mirror CodexBar's // resilient decode so a schema drift on either shape doesn't blow up // the whole quota fetch. struct Credits: Decodable { let balance: Double? - enum CodingKeys: String, CodingKey { case balance } + /// Settles in credits, not dollars, which relabels `balance`. + let hasCredits: Bool + let unlimited: Bool + + enum CodingKeys: String, CodingKey { + case balance + case has_credits + case unlimited + } + init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) - if let n = try? c.decode(Double.self, forKey: .balance) { - balance = n - } else if let s = try? c.decode(String.self, forKey: .balance), let n = Double(s) { - balance = n - } else { - balance = nil - } + balance = Flexible.double(c, .balance) + hasCredits = Flexible.bool(c, .has_credits) + unlimited = Flexible.bool(c, .unlimited) } } } @@ -204,6 +324,28 @@ enum CodexSubscriptionService { return parseResetCredits(data: data) } + /// The inline block carries no per-credit expiry list, so it is only a safe + /// shortcut at zero, where there is no expiry to report. A non-zero count + /// still pays for the companion request rather than dropping the + /// "next expires" caption the popover would otherwise show. + static func inlineResetCreditsShortcut(data: Data) -> CodexUsage.ResetCredits? { + guard let inline = inlineResetCredits(data: data), inline.availableCount == 0 else { return nil } + return inline + } + + /// Reset-credit inventory carried inline on the usage payload. Nil means + /// absent. + static func inlineResetCredits(data: Data) -> CodexUsage.ResetCredits? { + struct InlineDTO: Decodable { + struct Block: Decodable { let available_count: Int? } + let rate_limit_reset_credits: Block? + } + guard let count = (try? JSONDecoder().decode(InlineDTO.self, from: data))? + .rate_limit_reset_credits?.available_count, count >= 0 + else { return nil } + return CodexUsage.ResetCredits(availableCount: count, nextExpiresAt: nil) + } + /// Internal (not private) so tests can drive it with fixture payloads. /// Returns nil on any unexpected shape — the caller treats nil as /// "feature unavailable", never as an error. @@ -239,7 +381,8 @@ enum CodexSubscriptionService { return plain.date(from: raw) } - private static func decodeUsage(data: Data, resetCredits: CodexUsage.ResetCredits? = nil) throws -> CodexUsage { + /// Internal (not private) so tests can drive it with fixture payloads. + static func decodeUsage(data: Data, resetCredits: CodexUsage.ResetCredits? = nil) throws -> CodexUsage { let root = try JSONDecoder().decode(UsageDTO.self, from: data) let additional: [CodexUsage.AdditionalLimit] = (root.additional_rate_limits ?? []).compactMap { dto in guard let name = dto.limit_name, !name.isEmpty else { return nil } @@ -249,17 +392,61 @@ enum CodexSubscriptionService { secondary: makeWindow(dto.rate_limit?.secondary_window) ) } + let limitDTO = root.spend_control?.individualLimit + ?? root.individual_limit + ?? root.rate_limit?.individual_limit return CodexUsage( plan: CodexUsage.planType(from: root.plan_type), primary: makeWindow(root.rate_limit?.primary_window), secondary: makeWindow(root.rate_limit?.secondary_window), additionalLimits: additional, creditsBalance: root.credits?.balance, + hasCredits: root.credits?.hasCredits ?? false, + creditsUnlimited: root.credits?.unlimited ?? false, + creditLimit: makeCreditLimit(limitDTO, reached: root.spend_control?.reached ?? false), resetCredits: resetCredits, fetchedAt: Date() ) } + private static func makeCreditLimit( + _ dto: UsageDTO.IndividualLimit?, + reached: Bool + ) -> CodexUsage.CreditLimit? { + guard let dto, let limit = dto.limit, limit > 0 else { return nil } + // Server percentage, then remaining_percent, then the raw ratio. No + // signal at all means the draw is unknown; a 0% bar would claim otherwise. + guard let raw = dto.usedPercent + ?? dto.remainingPercent.map({ 100 - $0 }) + ?? dto.used.map({ $0 / limit * 100 }) + else { return nil } + let percent = min(max(raw, 0), 100) + let resetsAt = dto.resetAt.flatMap { $0 > 0 ? Date(timeIntervalSince1970: TimeInterval($0)) : nil } + return CodexUsage.CreditLimit( + // Unclamped percent, so a 120% draw still reports 12,000 of 10,000. + used: dto.used ?? limit * max(raw, 0) / 100, + limit: limit, + usedPercent: percent, + resetsAt: resetsAt, + windowSeconds: monthlyWindowSeconds(endingAt: resetsAt), + reached: reached + ) + } + + /// Spend controls reset on a calendar-month boundary, so the window is the + /// month preceding the reset. Not `reset_after_seconds`, which is remaining. + /// UTC, not `Calendar.current`: a 2026-03-01Z reset spans 28 days in UTC + /// but 31 in Toronto, so a local calendar makes pace timezone-dependent. + private static func monthlyWindowSeconds(endingAt resetsAt: Date?) -> Int? { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt + guard let resetsAt, + let start = calendar.date(byAdding: .month, value: -1, to: resetsAt) + else { return nil } + let seconds = Int(resetsAt.timeIntervalSince(start)) + return seconds > 0 ? seconds : nil + } + private static func makeWindow(_ dto: UsageDTO.WindowDTO?) -> CodexUsage.Window? { guard let dto, let used = dto.used_percent, let windowSeconds = dto.limit_window_seconds else { return nil diff --git a/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift b/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift index 3a5d814d..2fbbdbf1 100644 --- a/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift +++ b/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift @@ -31,7 +31,12 @@ struct CodexUsage: Sendable, Equatable { case .k12: "K-12" case .enterprise: "Enterprise" case .edu: "Edu" - case let .unknown(raw): raw.isEmpty ? "Subscription" : raw.capitalized + case let .unknown(raw): + raw.isEmpty + ? "Subscription" + : raw.replacingOccurrences(of: "_", with: " ") + .replacingOccurrences(of: "-", with: " ") + .capitalized } } } @@ -76,16 +81,55 @@ struct CodexUsage: Sendable, Equatable { let nextExpiresAt: Date? } + /// The monthly allowance an admin sets. Credit-metered workspaces report + /// `rate_limit: null`, so this is their only limit. + struct CreditLimit: Sendable, Equatable { + let used: Double + let limit: Double + let usedPercent: Double // 0.0 ... 100.0 + let resetsAt: Date? + /// Calendar month the allowance resets on, for pace projection. Not the + /// payload's `reset_after_seconds`, which is the time remaining. + let windowSeconds: Int? + /// Allowance already spent: a hard stop, not a near-limit warning. + let reached: Bool + + /// `.halfUp` matches the desktop decoder's `Math.round`. + var displayLabel: String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 0 + formatter.roundingMode = .halfUp + // `en_US`, not `en_US_POSIX`: the latter drops grouping entirely. + formatter.locale = Locale(identifier: "en_US") + func text(_ value: Double) -> String { + formatter.string(from: NSNumber(value: value)) ?? "\(Int(value.rounded()))" + } + let base = "Monthly usage limit · \(text(used)) / \(text(limit)) credits" + return reached ? "\(base) · limit reached" : base + } + + var shortLabel: String { + reached ? "Monthly usage limit · limit reached" : "Monthly usage limit" + } + } + let plan: PlanType let primary: Window? let secondary: Window? let additionalLimits: [AdditionalLimit] let creditsBalance: Double? + /// Account settles in credits, not dollars, which changes `creditsBalance`. + let hasCredits: Bool + /// Uncapped on purpose, as distinct from a limit we failed to read. + let creditsUnlimited: Bool + let creditLimit: CreditLimit? let resetCredits: ResetCredits? let fetchedAt: Date static func planType(from raw: String?) -> PlanType { - guard let raw = raw?.lowercased() else { return .unknown("") } + guard let original = raw?.lowercased() else { return .unknown("") } + let raw = normalizePlanType(original) switch raw { case "guest": return .guest case "free": return .free @@ -101,7 +145,28 @@ struct CodexUsage: Sendable, Equatable { case "k12": return .k12 case "enterprise": return .enterprise case "edu": return .edu + // Normalized, so an unknown composite reads "Some Future Tier". default: return .unknown(raw) } } + + /// Credit-based-pricing tiers arrive composite (`enterprise_cbp_usage_based`). + private static func normalizePlanType(_ raw: String) -> String { + var value = raw.trimmingCharacters(in: .whitespacesAndNewlines) + for suffix in ["_usage_based", "-usage-based", "_usage-based", "-usage_based"] + where value.hasSuffix(suffix) { + value.removeLast(suffix.count) + } + for prefix in ["self_serve_", "self-serve-", "self_serve-", "self-serve_"] + where value.hasPrefix(prefix) { + value.removeFirst(prefix.count) + } + for suffix in ["_cbp", "-cbp"] where value.hasSuffix(suffix) { + value.removeLast(suffix.count) + } + for infix in ["_cbp_", "-cbp-", "_cbp-", "-cbp_"] { + value = value.replacingOccurrences(of: infix, with: "_") + } + return value + } } diff --git a/mac/Sources/CodeBurnMenubar/Data/KimiQuotaPresentation.swift b/mac/Sources/CodeBurnMenubar/Data/KimiQuotaPresentation.swift new file mode 100644 index 00000000..10aacd9b --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/KimiQuotaPresentation.swift @@ -0,0 +1,51 @@ +import Foundation + +/// Pure display-decision helpers for the Kimi Code quota surfaces. +/// +/// Kimi Code tokens live ~15 min and only the Kimi CLI renews them, so the +/// load state sits in `.terminalFailure` as its dominant steady state between +/// CLI uses. The always-visible surfaces (Plan tab, tab-strip chip) must keep +/// showing the last good snapshot with a quiet caption instead of flapping to +/// a reconnect screen every cycle. The reconnect screen is reserved for the +/// no-data case, where there is genuinely nothing to show. +enum KimiQuotaPresentation { + /// Which Plan-tab subview to render, given the load state and whether a + /// last-known snapshot exists. + enum PlanContent: Equatable { + case noCredentials + case loading + case failed + case transientFailed + case reconnect(reason: String?) + /// Render the loaded usage bars. `idle` is true when the login has + /// gone terminal but a snapshot is still on hand — the caller stamps a + /// quiet "run the CLI" caption instead of hiding the data. + case usage(idle: Bool) + } + + static func planContent(loadState: SubscriptionLoadState, hasUsage: Bool) -> PlanContent { + switch loadState { + case .notBootstrapped, .noCredentials: + return .noCredentials + case .dormant, .bootstrapping: + return .loading + case .loading, .loaded: + return hasUsage ? .usage(idle: false) : .loading + case .failed: + return .failed + case .transientFailure: + return hasUsage ? .usage(idle: false) : .transientFailed + case .terminalFailure(let reason): + return hasUsage ? .usage(idle: true) : .reconnect(reason: reason) + } + } + + /// Snapshot age past which a loaded view stamps an "as of