diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e8d75b6..3a6aafc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.9.21 - 2026-08-24 + ### Added - **Copilot AI Credit plans.** `codeburn plan set copilot-pro` (1,500 credits / $15 equivalent, not the $10 sticker), `copilot-pro-plus` (7,000), `copilot-max` (20,000), and `custom --credits N --provider copilot`. Spend is `sum(finite nanoAiu) / 1e9`. Token-priced `costUSD` does not fill the credits bar. Claude / cursor / grok / custom-USD plans stay on `costUSD`. (#943) - **`codeburn models --unpriced`.** The dashboard warns about models that price at $0 and points at `codeburn model-alias`, but the list itself was hard to get out of the TUI. This filters the plain-stdout `models` report to exactly those rows, reusing `findUnpricedModels` so local, free, aliased and price-overridden models are treated the same way the warning treats them, and defaulting that mode's min-cost to 0 so $0 rows are not pre-filtered away. Thanks @kocaemre. (#969) @@ -11,6 +13,8 @@ - **`CODEBURN_CACHE_SCOPE=all` forces a full session-cache read.** A ranged query reads only the month shards that can contribute a turn to it, which is a real behaviour change on a warm cache; this is the escape hatch for the case where a number looks wrong and you want to know whether the scoped read is why. Set it and every load ignores its scope and reads every shard, one-shot runs and the resident `codeburn serve` alike. It is a read policy, not an input to any cache fingerprint: setting or unsetting it re-parses nothing and invalidates nothing. +- **The interactive dashboard opens on Today, and falls back to 7 days when today is empty.** Only the CLI TUI ever defaulted to 7 days; the desktop app and the web dashboard already opened on Today and needed the fallback half alone. The TUI takes the auto path only when `-p` came from the default rather than from you, with no `--day` and no `--from`/`--to`, and only on a TTY — so piped runs, `--format json`/`csv`/`markdown`, `report`, `sessions` and `models` stay on week, byte for byte. The probe costs nothing: it is the today slice of the parse the dashboard already runs, and it counts sessions rather than projects, because a project can survive the slice on a subagent anchor with no sessions behind it. The period strip shows what actually opened, keys 1-6 and every explicit selection win over the fallback, and the app's persisted `defaultPeriod` preference always wins. On a cold cache an empty today costs one extra repaint — the first paint's file floor is Today's, so the 7-day view is re-painted once on the wider floor from the cache pass one just wrote, about 2.3s on a 16.7k-file corpus and only when the cache is empty and the day unused. (#1113) + ### Added (OrcaRouter) - **OrcaRouter sessions now price and label like the model they route to.** OrcaRouter is a gateway that exposes route ids (`orcarouter/auto`, `orcarouter/fusion`, …) plus plain upstream ids (`deepseek/deepseek-v4-pro`), and reports the upstream id that actually ran in the completion response's `model` field. The `orcarouter/` prefix is now a routing wrapper like `cmd/` / `antigravity/`, so a routed spelling prices at its upstream LiteLLM row instead of $0; `orcarouter/auto` stays unpriced (the smart route lands on a Qwen/Llama flash model — a Sonnet alias would overprice it) and the fusion routes alias to their current `openai/gpt-oss-120b` target (live completion `model` field, 2026-08); and an unknown vendor nested inside the route still fails closed. Daily cache v29 so warm days re-derive the new prices. @@ -22,6 +26,10 @@ - **Copilot input/cache tokens are read per request from `~/.copilot/session-store.db`.** Previously, codeburn relied on `session.shutdown` rollups from the Copilot CLI and GitHub Copilot desktop app. Those rollups are written only after a clean shutdown, stamp all usage on the shutdown day, and reset their counters at in-session compaction — so a crash could lose an entire session's input/cache usage, and even cleanly-closed long sessions were silently truncated. On one machine with long history, reading the per-request rows recovered about 35% of actual Copilot spend. Covered sessions now use per-request tokens with their real timestamps, counted exactly once against existing rollups and never added as extra calls or turns. Pre-store CLI sessions continue using the unchanged rollup path, and a locked or unreadable store defers only its own re-read instead of prematurely sealing daily history. Copilot reasoning tokens are also no longer double-billed: they are a subset of output already priced through the per-turn calls. This triggers a one-time re-parse, with the daily cache bumped to v26 to re-derive finalized days. Because that reconciliation keeps changing while a session is live, `codeburn sync push` holds a Copilot session back until it has been quiet for 24 hours and then sends it once, final — the sent-ledger is append-once, so a value sent mid-reconciliation could never be corrected at the receiver (#988). Sync also pins each Copilot session to whichever of the two shapes it was first synced in — the `session.shutdown` rollup, or per-request rows plus a residual — because a usage span cannot be retracted and sending the same tokens in the other shape would double them at the receiver permanently. That holds in both directions: a session synced before this release keeps its rollup and never sends rows, and a session synced as rows never sends the rollup that starts serving again once the 90-day age-out prunes them. `--dry-run` reports the frozen count, and `codeburn sync reset --confirm` re-pushes everything under the new breakdown for anyone who can clear the receiver too. (#946) - **DeepSeek Harness (`dsh`) is now a supported provider.** Reads DeepSeek's open-source agent harness from `~/.dsh/sessions` (`DSH_HOME` relocates the root), both the default zstd logs and the uncompressed `session.jsonl` variant. A `.zstd` log is a concatenation of independent zstd frames, one per write batch, so it is decoded frame by frame behind a structural frame scan and a torn trailing frame from a crashed writer is ignored rather than failing the file (needs Node 22.15+ for `zlib` zstd; below that dsh is skipped with a notice instead of counted as $0). One call per `(turn, step)`, with the step's final `assistant/message` usage superseding the streamed `assistant/chunk` sample of the same call rather than adding to it, the model taken from the message that served the step, and reasoning tokens billed at the output rate. DSH records tokens but no cost, so calls are priced from the shared tables. The events a forked session replays from its parent are skipped, since codeburn already counts the parent's own log. The session format is pinned at version 0 upstream with no compatibility implied, so a log stamped with any other version is skipped with a notice instead of read under today's assumptions. +### Added (Desktop & Menubar) +- **Live quota for Gemini, Copilot and Antigravity in the desktop app.** The app's quota module knew claude and codex only; it now carries three more providers, all read from local credentials the tools already wrote — no cookies, no logins, no credential writes anywhere. Gemini goes through Code Assist (`loadCodeAssist` / `retrieveUserQuota`) using the Gemini CLI's own on-disk OAuth material, refreshing a token only when the CLI's documented env overrides are set and keeping the refreshed token in memory; a retired consumer tier degrades to a terminal failure carrying migration guidance rather than a wrong number. Copilot reads the editor-plugin token from `~/.config/github-copilot/hosts.json` → `apps.json` and calls `copilot_internal/user`, re-reading once on a 401 — an internal API that may drift, so every failure degrades to an honest connection state. Antigravity probes the local language server over loopback only (`RetrieveUserQuotaSummary`, falling back to `GetUserStatus`), discovering it through `ps`/`lsof` with regex-validated pids and relaxing TLS strictly for 127.0.0.1 self-signed certificates. Settings gains a per-provider toggle, defaulting on for detected providers; a disabled provider's fetcher never runs, so it reads no credential and makes no request. Provider display names come from a map rather than hardcoded pairs, sign-in guidance is honest per provider, and error sanitization now redacts Google `ya29.` and GitHub `gh*_` token shapes. Kimi live quota already exists in the macOS menubar and was audited rather than duplicated. (#1114) +- **The macOS menubar reaches quota parity with the desktop app, and gets a System Settings-style Settings window.** The three providers above are ported into the menubar read-only from the app's Electron services, on the same load-state machine Kimi, Claude and Codex already use: Gemini through Code Assist and `~/.gemini/oauth_creds.json` (in-memory refresh only, handling Google's CLI-OAuth tier retirement), Copilot through `copilot_internal/user` and the editor plugin token in `~/.config/github-copilot` (preferring the github.com host), and Antigravity through `ps`+`lsof` discovery and Connect-RPC probes over loopback HTTPS with the self-signed-certificate trust override scoped strictly to 127.0.0.1. Settings is rebuilt as a System Settings-style window modeled on CodexBar (MIT, steipete/CodexBar): a fixed 260pt sidebar with native search, SF Symbol gradient chips, official provider marks (LobeHub icon set) with connected dots and an N-on counter, and a grouped About pane whose Updates row is wired to the existing update checker. The window is resizable and fullscreen-capable, titles itself after the visible pane, and recenters when it would be restored off-screen. Alongside it: an animated flame-gradient wordmark in the popover header at parity with the site, respecting Reduce Motion; the website's binary `0101` flame as the app icon, the full-colour flame in the About hero and a solid silhouette in the About sidebar row; one tagline everywhere; and em-dashes removed from UI copy. `package-app.sh` now ships the SwiftPM resource bundle inside the `.app` — without it `Bundle.module` aborts at the first icon render, which the release workflow would have shipped. (#1133) + ### Changed - **Report, sessions, overview, compare, export, report JSON and menubar `localModelSavings` now use `billableOutputTokens` for displayed output.** Cost already did. Exclusive providers (Grok and the rest) were under-reporting output by exactly their reasoning tokens; optimize added reasoning on top of output for the inclusive set `{claude, codex, copilot}` and double-counted it. Displayed aggregates now bill per call while the provider is known, joined onto an existing `modelBreakdown` key (parser short name first, then the raw id) so a leftover raw-id bucket cannot mint a $0 `Opus 4.8` Unpriced orphan. Daily cache v28 re-derives finalized days so `report` matches the live parse. Thanks @saulcanina. (#1115) - **The desktop app's cold start paints as soon as the selected period is readable, instead of after the whole history.** The resident `codeburn serve` child now does for its JSON clients what the TUI got in #1107: on a cold cache it answers the first `status --format menubar-json` from the files whose mtime allows them to hold data the requested period can show (the same floor and 48h clock-skew margin), then indexes the rest behind the answer. The payload says so in-band through a new optional `hydration: { complete, indexedFiles, totalFiles }` block, and the desktop app and web dashboard render `indexing history · N/M files · totals below cover what is indexed so far` until it reports `complete: true`. On a 21k-file corpus the app's first payload lands in 7.3s instead of 31.3s, fully indexed at 36.2s. Only the resident serve process ever emits `hydration`, and only it may answer partially — its clients poll, so they converge. Every one-shot output is unchanged and always a full parse: `--format json`/`csv`/`markdown`, `report`, `sessions`, `models`, MCP, the Swift menubar and GNOME clients, and the desktop app's own spawn fallback, none of which have a second poll to converge with. Absence of `hydration` therefore always means complete, which is also what an older CLI's payload means. It is not `stale` (#1100) and never sets it: a first paint is fresh but partial, where a stale payload is complete but old. Nothing is stamped complete until the fill has actually parsed everything, so a process killed mid-fill comes back cold and finishes the job. (#1110) @@ -38,6 +46,9 @@ - **A date-ranged report classifies only the turns it keeps.** Every cached turn went through the turn classifier — category, retries, edit detection, and a full reconstruction of its API calls — before the date slice discarded most of them, so a week view paid to classify all of history to keep a few percent of it. The keep/drop decision is now taken on the raw cached turn and only the survivors are classified, still from their complete call list, with the branch and pull-request carries still walking the full ordered turn list. Output is byte-identical. - **One rule for every cache file.** `CODEBURN_CACHE_DIR` when set, otherwise `~/.cache/codeburn`. `XDG_CACHE_HOME` is no longer consulted; the sync ledger, the only file that ever honored it, is merged into the canonical location on first read and the legacy copy is retired, so nothing is re-uploaded after the move. (#972) +- **A cold interactive launch paints the dated view first and indexes the rest behind it.** An empty cache parsed all ~21k files before painting a dashboard that shows 7 days. On a cold interactive TTY launch with a dated default view, the first paint now parses only the files that can hold in-range data — `fp.mtimeMs >= rangeStart - 48h`, the clock-skew margin — which is safe because a session log's last event is never later than its mtime, so an older file provably cannot move the dated view; a file that already has a cache entry is never deferred, and network sources always load. The deferred files are parsed by the existing background reload without blocking keys, writing the per-file cache exactly as a full cold parse would and refreshing the panels when it lands, with an `indexing history · N/M files · totals below cover what is indexed so far` banner until it converges. Nothing is marked seen without being parsed: a first paint that deferred anything cannot stamp the cache complete, so a run killed mid-fill re-enters cold and converges, and the floored parse's memo key carries a `:paint` suffix so it can never be served to an unfloored request. The path is gated on `isTTY` with no `--day` and no `--from`/`--to`, so one-shot JSON/CSV/markdown, `report`/`sessions`/`models` and every serve, menubar and app payload never enter it and never return partial data. On a real 21k-file corpus with an empty cache, time to first paint drops from 36.3s to 9.9s, and full indexing finishes at 47.5s instead of 36.1s — about eleven extra background seconds, paid while you are already looking at data. Two deliberate limits: a period switch during the fill queues behind it rather than slicing partially, and the plan-usage bar can read low until the fill lands, which the banner covers. (#1109) +- **The dashboard parses once per launch instead of up to three times.** Rendering the dashboard issued three `parseAllSessions` calls — the scan, the plan usages and the durable overview — whose ranges differed only in their ends, about a second apart, so the exact-key memo never hit and each one paid a full parse. The dashboard now declares its widest range up front, and any request inside it that is a pure narrowing — same start, an end no later, the same month-shard scope — is served by slicing that single parse. `month` goes from 2 parses to 1 and `today` from 3 to 2; the plan window starts on the 1st while the scan starts at midnight, and two different starts cannot be merged under the rule that makes this provably lossless, because a wider parse reads files a narrower one never sees and its dedup seeding can drop an in-range turn the narrower parse keeps. Alongside it the cross-provider PR correlation stops re-filtering the session list per child and the launch list per candidate, using a one-shot agent index and a windowed scan over a sorted array instead; the match sets are identical. On a frozen corpus, warm `today` in the TUI goes 5.12s to 3.81s and `month` 5.46s to 3.41s, with every checked warm and cold output byte-identical. (#1108) + ### Changed (Linux packaging) - **The snap asks for the log directories it reads, not each tool's whole home.** The first Snap Store submission declared a `personal-files` read of every AI tool's root — `$HOME/.claude`, `$HOME/.codex`, `$HOME/.cursor` and the rest — and that interface is recursive, so it granted read of every credential file those roots hold. Each entry now names the subdirectory the provider actually opens (`.claude/projects`, `.codex/sessions`, `.cline/data`, `.vibe/logs/session`, `.dsh/sessions`, `.kiro/sessions`, `.quickwork/{profiles.json,sessions,metrics}`, `.config/Claude/local-agent-mode-sessions`, `.config/Open Design/{runs,data/runs,namespaces}`), two are single files (`.forge/.forge.db`, `.zcode/cli/db/db.sqlite`), and the editor entries name only the extension folders holding transcripts instead of the editor's whole configuration. Five providers that were missing entirely and would have shown no data are declared — opencode, crush, goose, kilo, kimi-code — and four roots stay roots only because the file the provider opens sits directly in them (`.config/github-copilot`, `.local/share/{opencode,crush,kilo}`). One credential file is now requested openly rather than implicitly: `.claude/.credentials.json`, read-only, for the live plan gauge. Codex's equivalent would need write access to the Codex CLI's own `auth.json` to rotate the token, so neither it nor a Codex root is declared and the Codex live gauge is disabled under `$SNAP`; Codex usage and cost are unaffected, they come from the session rollouts. Two consequences inside the snap: `.lingtai` is dropped, because its per-agent log directory needs a wildcard the interface has no form for, and `optimize`, `context-budget` and `act` no longer see the user-scope `~/.claude/settings.json`, `agents/`, `skills/` and `commands/` — project-scope copies still work through the `home` plug. Nothing outside the snap changes. @@ -48,6 +59,8 @@ - **First launch no longer asks to control System Events.** The macOS menubar registered its login item by driving System Events over AppleScript, which made macOS put up an Automation consent dialog the first time the app ran. It now registers itself through `SMAppService.mainApp`, an in-process call that needs no Automation grant; there is no AppleScript fallback, so a failure logs and leaves the login item unset rather than bringing the prompt back. The same `codeburn.loginItemRegistered` guard still limits this to the first launch, so a login item you removed by hand stays removed. (#1026) - **The resident `codeburn serve` child.** The first real panel request is also the cache warm-up, so startup never runs an artificial warm-up query beside a duplicate one-shot child; each served command carries its own read-only option allowlist, and anything outside it falls back to a normal spawn; the child exits when its stdin closes, so it can never outlive the app. Requests whose response exceeds the 16 MiB frame limit still replace the child, but that deliberate kill no longer spends the resident's unexpected-death budget. (#972) +- **The desktop app's Pull requests tab says why it is empty.** A PR row exists only where a transcript actually contains a pull-request URL, and the default period is Today, so a week of work through providers that never write those URLs left the tab blank with copy that named neither the period nor the reason — indistinguishable from a broken extractor. The empty note now names the selected period, and when the All period does hold rows it says how many and points at the period control. Attribution itself is untouched: no link is inferred, no PR is invented, and the default period is unchanged. (#1098) + ### Fixed - **Hermes sessions that keep running after a day is sealed no longer lose later token and cost growth.** A sidecar ledger under the CodeBurn cache records last-seen lifetime totals per `(profile, sessionId)` and emits observation-time deltas (weight 0) so a sealed day stays put while today's growth still lands. An all-zero reset is visible to the cursor after discovery filtering, so a later 40 is `+40` rather than a silent shrink. Explicit `$0` is treated as recorded. Ledger publication failures are retryable and hold the daily watermark. (#916) - **`codeburn doctor` now probes the five remaining fixed-location providers.** `codebuff`, `devin`, `gemini`, `kiro`, and `mistral-vibe` implement `probeRoots()` through the same resolvers discovery uses, so a silent zero is distinguishable from a missing install. Codebuff reports all three manicode channels unless a factory or `CODEBUFF_DATA_DIR` pins one; Devin reports `transcripts` plus `sessions.db`, not the parent; Gemini reports only `~/.gemini/tmp`; Kiro reports pre-filter candidates (empty CLI/v2 skipped, empty agent/workspace fall back); Mistral Vibe reports the joined sessions dir. Missing defaults still appear. Thanks @therickfactr. (#899) @@ -75,6 +88,16 @@ - **Bash command splitting was quadratic on long whitespace-heavy commands.** The separator regex retried its leading `\s*` from every offset; matching the separator alone and widening over whitespace by hand makes cold parse ~24% and warm ~40% faster on large corpora, output unchanged. - **Cold parse no longer retains full message bodies through cached previews.** `flatSlice` skipped its Buffer round-trip for strings already within the bound, but provider adapters pre-truncate user-message previews with `.slice(0, 500)` before the cache-site call — those pre-sliced views are still V8 SlicedStrings pinning their large parent, so the retention that OOM'd cold parses of large histories survived. The round-trip now always runs. - **Kiro sessions carry the real `projectPath`** (CLI meta.cwd, v2 `workspacePaths[0]`, workspace sessions' `workspaceDirectory`), so git-repo attribution can resolve them; previously they were attribution-blind. Bumps the kiro parse version, so the first run after upgrade re-parses kiro history once, and kiro sessions in linked git worktrees now group under the main repo. +- **A ranged parse can no longer write a day outside the range it was asked for.** A turn that straddles local midnight keeps its original anchor through range slicing, so the day aggregator could emit a residue day just outside the parsed window — cost, calls and tokens all zero, category counts only — and both ranged call sites, the re-derive and the gap fill, wrote it into durable history. The merge guards below defuse the overwrite, but the residue should never reach the cache at all, so both ranged sites now filter to the days actually in range. The aggregator and the slicer are untouched, so whole-corpus callers are unaffected. (#1131) +- **The daily cache's gap-fill path can no longer overwrite a good day with a degraded parse, and a day left holding only midnight residue heals itself.** `ensureCacheHydrated`'s gap fill wrote days into durable history through a blind overwrite with no completeness gate, so a parse degraded by the refresh lock — the menubar refreshing concurrently is the ordinary way this happens — could permanently replace a settled day with its undercount; the re-derive path had completeness and partial-survival guards, the gap path had neither. A gap merge is now completeness-gated: a complete parse wins per (date, provider) through the same guarded merge the re-derive uses, while a partial parse can only fill gaps and never shrinks the baseline, and a `pendingRederive` is no longer silently dropped. A day holding only a straddling turn's category counts, with cost, calls and tokens at zero, now pulls the watermark back so the next launch re-derives it — bounded to the settle window and never the oldest cached day. `codeburn doctor` reports cache health read-only, listing residue-only dates and failed or empty session-cache entries, so the next report of this shape is one command rather than archaeology. There is no cache-version bump: every affected cache heals on its next launch. (#1129) +- **`codeburn sync` no longer puts a filesystem path or a raw output count on the wire.** Every synced span carried `ai.project` as the slugified absolute path (`-Users-you-Projects-thing`), contradicting both `docs/sync/README.md` ("paths stay local") and the compatibility contract's "safe project basename"; the wire name is now the leaf directory name, derived at the single choke point after the parse, and `codeburn yield`'s local output is untouched. Two directories with the same leaf collapse to one wire identity — that is what the contract's project identity means — and `git.repo` disambiguates them. Separately, `ai.output_tokens` was the raw output field, so exclusive-reasoning providers were under-reported on the wire by exactly their reasoning volume (measured on one corpus: an opencode session short by 37,848 tokens, its summed reasoning); it now goes through the same `billableOutputTokens` helper the display layer took, leaving the inclusive providers byte-identical. Cost is unaffected, being priced before export. Neither field is part of a span id, so nothing is re-sent and nothing duplicates at the receiver: usage span ids and commit attribution keys carry no project, and session attribution re-emits once per session under an upsert keyed on (org, trace). (#1126) +- **Outbound project metadata now requires proven provenance instead of failing open.** The basename mapper above still accepted whatever the cache held, so a Claude path slug, a generated session title, a home root, a relative path, a provider's own storage directory, a credential-shaped basename or a provider-owned container path could all become an outbound project label. The privacy decision moves to the OTLP boundary and demands explicit provenance: a usage span's `ai.project` comes only from a provider-recorded absolute cwd, attribution resolves repositories only from that same trusted cwd and derives its `ai.project` from the normalized `git.repo`, and a legacy cached cwd with no provenance fails closed. A relative cwd can never be resolved against the directory that happens to be running `sync`; Hermes prompt-derived paths, including the legacy Windows `Current working directory:` messages, stay local grouping labels and never become outbound provenance — a deliberate privacy correction; Goose propagates its own `sessions.working_dir` field while `/sessions/` paths are rejected centrally; and provider, model, tool and span-name identifiers are sanitized at serialization, with real routed identifiers still supported. `ai.project` remains optional, so a receiver must accept an unattributed span; where a trace carries both labels and they differ, the attribution repository basename is authoritative and the provisional usage basename must not be counted as a second project. (#1128) +- **Attribution spans no longer carry the session id in cleartext.** `codeburn sync push --attribution` copied `ai.session_id` onto session and commit spans even though `deriveTraceId(sessionId)` already keys both usage and attribution spans, and usage spans never carried the field; it is dropped from both, and the docs now match the wire. This is hygiene rather than a leak — the id existed on the wire only as hash input before attribution shipped — but a receiver that keyed session rows on `ai.session_id` must key on `traceId`, the same id usage spans already carry. The join to usage is unchanged, and the local sent-ledger keys still use the session id. (#1073) +- **`codeburn doctor` names Buzz as a launcher instead of showing it as a second Codex tree.** Buzz sits on top of Codex, so a Buzz usage parser or a walk of `~/.buzz/.codex` as a second Codex home would count the same billed seat twice — but doctor only knew providers, so a nest holding no usage store of its own read as "nothing found". Buzz is now listed as a launcher billed through Codex, with no session count, as is Grok Bot when `~/.grok` is the real store, and Codex discovery returns nothing for a home nested under `~/.buzz` while a distinct primary Codex home exists. A sole Codex home that does live under `.buzz` is still counted. Spend stays on the Codex row; no Buzz parser and no rates were added. (#1099) +- **`codeburn models` resolves raw ids the way `report` does, and stops splitting one SKU across two rows.** The report bucketed by raw id after asking each provider for a label, so a provider whose local table missed fell back to the raw id (`gpt-5.6-sol`, `accounts/fireworks/models/kimi-k2p6`) — and a later merge keyed on the display string, which summed genuinely distinct SKUs whose labels happen to collide into a single row. Provider-first resolution stays and local labels still win (the Cursor estimated suffix, provider overrides); a provider that merely echoes the raw id now falls through to the global short-name table, which `cursor-agent` and `vercel-gateway` also consult after their own transform. Rows merge on provider plus alias-resolved canonical id, so a path-form id merges with its bare slug and display names go back to being cosmetic: two SKUs that only share a label stay two rows. The first-seen raw id is kept, so `models --format json` is not rewritten to a lexically smallest spelling, and a Codex credit row mixing rated and unrated buckets partial-sums the rated ones and reports `creditsIncomplete` rather than a confident total. `audit` still buckets by raw id, because it recomputes rates per id. (#1053) +- **A resumed Copilot CLI session's stampless shutdown legs no longer collapse onto one day.** Each leg of a resumed session appends its own `session.shutdown` rollup, and a journal whose legs carry `sessionStartTime` but no per-leg timestamp fell back to that start time — identical across every leg — so all of them billed onto one date and, with that stamp in the dedup key, onto one row. The timestamp fallback is now the event's own stamp, then the last event seen, and only then the session start, so a stampless leg lands on its last stamped event; the occurrence keys (`copilot::shutdown::`) are unchanged, so nothing already synced is re-sent under a new name. (#1054) +- **`codeburn optimize --provider ` no longer tells you to ask Claude.** Provider filtering already skipped the Claude-only detectors, but the cross-provider ones — `retry-heavy-capabilities`, `low-worth-sessions`, `context-heavy-sessions`, `cost-outliers`, `mcp-project-scope` — baked "Ask Claude" and `CLAUDE.md` into their fix labels, and the CLI and TUI destination headers said Claude whatever the run was scoped to, with the JSON report carrying the same copy through. One remediation table now drives all of them: an unscoped run, `--provider all` and `--provider claude` keep the shipped Claude and `CLAUDE.md` copy; `codex` says Codex and `AGENTS.md`, the only other instruction file CodeBurn already names; every other provider gets its display name and a generic "project instructions" rather than an invented filename. Apply plans still write `CLAUDE.md` only for Claude-only findings, and the paste-destination values are unchanged. (#1049) +- **Resizing the terminal or zooming its font produces one settled reflow instead of a burst of intermediate widths.** CodeBurn re-rendered on every `resize` event from `process.stdout` with the live `columns`/`rows`, and debouncing that listener alone cannot stop Ink, which runs its own resize handler. During a burst the dashboard now sees a frozen stdout facade and a private resize emitter, and on settle it re-renders once and lets Ink see a single resize — no write interceptor, which was the earlier shape's trap: swallowing frames still advanced Ink's last-output state, so a settle producing the same string wrote nothing and lost a mid-burst update forever. One transient old-width frame during the burst is accepted. Static and non-interactive output and the periodic refresh are unchanged. (#1038) ## 0.9.20 - 2026-08-10 diff --git a/README.md b/README.md index c1ca15c2..5bc94035 100644 --- a/README.md +++ b/README.md @@ -32,12 +32,12 @@ Desktop
CodeBurn Desktop
- Download for macOS (Apple Silicon) - Download for macOS (Intel) + Download for macOS (Apple Silicon) + Download for macOS (Intel) Get CodeBurn from the Microsoft Store - Download for Linux (.deb) - Download for Linux (.rpm) - Download for Linux (AppImage) + Download for Linux (.deb) + Download for Linux (.rpm) + Download for Linux (AppImage) Web
@@ -55,7 +55,7 @@ Menubar
CodeBurn macOS menubar
codeburn menubar
- Download the CodeBurn Windows menubar + Download the CodeBurn Windows menubar @@ -347,7 +347,7 @@ Windows gets the same ambient view from the system tray, from the same one comma codeburn menubar ``` -It downloads the `.msi` for your CLI version, verifies its sha256, runs it through `msiexec /passive`, and launches the tray app. Re-run with `--force` to reinstall; an already-installed matching version is just launched. You can also download the `.msi` yourself from the [latest Windows Menubar release](https://github.com/getagentseal/codeburn/releases/tag/windows-v0.9.20). +It downloads the `.msi` for your CLI version, verifies its sha256, runs it through `msiexec /passive`, and launches the tray app. Re-run with `--force` to reinstall; an already-installed matching version is just launched. You can also download the `.msi` yourself from the [latest Windows Menubar release](https://github.com/getagentseal/codeburn/releases/tag/windows-v0.9.21). Today's spend sits in the tray as a number beside the flame icon (turn it off in Settings, and the tooltip always carries it). Click for the same popover the macOS app shows: agent tabs, period switcher, Trend, Forecast, Pulse, Stats and Plan insights, activity and model breakdowns, optimize findings, and CSV/JSON export. Settings covers launch at login, the tray number, theme, and currency. It refreshes every 60 seconds while the popover is open and every 2 minutes while it is closed. diff --git a/app/package.json b/app/package.json index 1c459862..8595f890 100644 --- a/app/package.json +++ b/app/package.json @@ -1,7 +1,7 @@ { "name": "codeburn-desktop", "private": true, - "version": "0.9.20", + "version": "0.9.21", "description": "CodeBurn Desktop \u2014 Electron app fed by the codeburn CLI", "main": "dist/electron/main.js", "scripts": { diff --git a/assets/menubar-logo.png b/assets/menubar-logo.png index d5921c90..6f8d05d7 100644 Binary files a/assets/menubar-logo.png and b/assets/menubar-logo.png differ diff --git a/mac/Package.swift b/mac/Package.swift index b525848e..b7e59d08 100644 --- a/mac/Package.swift +++ b/mac/Package.swift @@ -17,6 +17,9 @@ let package = Package( .executableTarget( name: "CodeBurnMenubar", path: "Sources/CodeBurnMenubar", + resources: [ + .process("Resources/ProviderIcons") + ], swiftSettings: [ .enableUpcomingFeature("StrictConcurrency") ] diff --git a/mac/Scripts/package-app.sh b/mac/Scripts/package-app.sh index db8c3b48..5b47c81b 100755 --- a/mac/Scripts/package-app.sh +++ b/mac/Scripts/package-app.sh @@ -48,6 +48,13 @@ mkdir -p "${BUNDLE}/Contents/Resources" cp "${BUILT_BINARY}" "${BUNDLE}/Contents/MacOS/${EXECUTABLE_NAME}" cp "${ICON_SOURCE}" "${BUNDLE}/Contents/Resources/menubar-logo.png" +# SwiftPM emits target resources as a sibling bundle; Bundle.module traps at +# runtime if it is missing from Contents/Resources, so ship it when present. +SPM_RESOURCE_BUNDLE="${BIN_PATH}/${EXECUTABLE_NAME}_${EXECUTABLE_NAME}.bundle" +if [[ -d "${SPM_RESOURCE_BUNDLE}" ]]; then + cp -R "${SPM_RESOURCE_BUNDLE}" "${BUNDLE}/Contents/Resources/" +fi + ICONSET="${DIST_DIR}/AppIcon.iconset" rm -rf "${ICONSET}" mkdir -p "${ICONSET}" diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index f8ff5bab..d062ad2f 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -145,12 +145,36 @@ final class AppStore { // first refresh tick. var kimiLoadState: SubscriptionLoadState = KimiSubscriptionService.hasCredential ? .dormant : .notBootstrapped + var geminiUsage: GeminiUsage? + var geminiError: String? + // Same file-based activation as Kimi — reading ~/.gemini/oauth_creds.json + // is prompt-free, so we start dormant and auto-activate on the first + // refresh tick. + var geminiLoadState: SubscriptionLoadState = GeminiSubscriptionService.hasCredential ? .dormant : .notBootstrapped + + var copilotUsage: CopilotUsage? + var copilotError: String? + // Same file-based activation as Kimi/Gemini — reading + // ~/.config/github-copilot is prompt-free, so we start dormant and + // auto-activate on the first refresh tick. + var copilotLoadState: SubscriptionLoadState = CopilotSubscriptionService.hasCredential ? .dormant : .notBootstrapped + + var antigravityUsage: AntigravityUsage? + var antigravityError: String? + // No credential file at all — quota comes from probing the Antigravity + // app's local language server, which is prompt-free, so we start dormant + // and auto-activate (probe) on the first refresh tick. + var antigravityLoadState: SubscriptionLoadState = .dormant + /// 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 geminiRefreshGen: Int = 0 + private var copilotRefreshGen: Int = 0 + private var antigravityRefreshGen: Int = 0 private var cache: [PayloadCacheKey: CachedPayload] = [:] private var cacheDate: String = "" @@ -1252,6 +1276,260 @@ final class AppStore { } } + // MARK: - Gemini + + /// Same prompt-free activation as Kimi: reading the CLI's credential file + /// needs no keychain, so the first refresh tick activates dormant state. + func bootstrapGemini() 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 = geminiRefreshGen + geminiLoadState = .bootstrapping + do { + let usage = try await GeminiSubscriptionService.refresh() + guard gen == geminiRefreshGen else { return } + geminiUsage = usage + geminiError = nil + geminiLoadState = .loaded + } catch let err as GeminiSubscriptionService.FetchError { + guard gen == geminiRefreshGen else { return } + applyGeminiFetchError(err) + } catch { + guard gen == geminiRefreshGen else { return } + geminiError = sanitizeForUI(error.localizedDescription) + geminiLoadState = .failed + } + } + + func refreshGemini() async { + _ = await refreshGeminiReportingSuccess() + } + + @discardableResult + func refreshGeminiReportingSuccess() async -> Bool { + if case .dormant = geminiLoadState { + await bootstrapGemini() + return geminiLoadState == .loaded + } + guard GeminiSubscriptionService.hasCredential else { + if geminiLoadState != .notBootstrapped { geminiLoadState = .notBootstrapped } + return false + } + let gen = geminiRefreshGen + if geminiUsage == nil { geminiLoadState = .loading } + do { + let usage = try await GeminiSubscriptionService.refresh() + guard gen == geminiRefreshGen else { return false } + geminiUsage = usage + geminiError = nil + geminiLoadState = .loaded + return true + } catch let err as GeminiSubscriptionService.FetchError { + guard gen == geminiRefreshGen else { return false } + applyGeminiFetchError(err) + return false + } catch { + guard gen == geminiRefreshGen else { return false } + geminiError = sanitizeForUI(error.localizedDescription) + geminiLoadState = .failed + return false + } + } + + func disconnectGemini() { + GeminiSubscriptionService.disconnect() + geminiRefreshGen &+= 1 + geminiUsage = nil + geminiError = nil + geminiLoadState = .notBootstrapped + NotificationCenter.default.post(name: .codeBurnSubscriptionDisconnected, object: nil) + } + + private func applyGeminiFetchError(_ err: GeminiSubscriptionService.FetchError) { + let sanitized = sanitizeForUI(err.errorDescription) + geminiError = sanitized + if case .noCredentials = err { + geminiLoadState = .noCredentials + } else if err.isTerminal { + geminiLoadState = .terminalFailure(reason: sanitized) + } else if let retryAt = err.rateLimitRetryAt { + geminiLoadState = .transientFailure(retryAt: retryAt) + } else { + // 5xx / network blips back off automatically, mirroring the + // Electron provider's transientFailure mapping. + geminiLoadState = .transientFailure(retryAt: nil) + } + } + + // MARK: - Copilot + + /// Same prompt-free activation as Kimi/Gemini: reading the editor plugins' + /// credential files needs no keychain, so the first refresh tick activates + /// dormant state. + func bootstrapCopilot() 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 = copilotRefreshGen + copilotLoadState = .bootstrapping + do { + let usage = try await CopilotSubscriptionService.refresh() + guard gen == copilotRefreshGen else { return } + copilotUsage = usage + copilotError = nil + copilotLoadState = .loaded + } catch let err as CopilotSubscriptionService.FetchError { + guard gen == copilotRefreshGen else { return } + applyCopilotFetchError(err) + } catch { + guard gen == copilotRefreshGen else { return } + copilotError = sanitizeForUI(error.localizedDescription) + copilotLoadState = .failed + } + } + + func refreshCopilot() async { + _ = await refreshCopilotReportingSuccess() + } + + @discardableResult + func refreshCopilotReportingSuccess() async -> Bool { + if case .dormant = copilotLoadState { + await bootstrapCopilot() + return copilotLoadState == .loaded + } + guard CopilotSubscriptionService.hasCredential else { + if copilotLoadState != .notBootstrapped { copilotLoadState = .notBootstrapped } + return false + } + let gen = copilotRefreshGen + if copilotUsage == nil { copilotLoadState = .loading } + do { + let usage = try await CopilotSubscriptionService.refresh() + guard gen == copilotRefreshGen else { return false } + copilotUsage = usage + copilotError = nil + copilotLoadState = .loaded + return true + } catch let err as CopilotSubscriptionService.FetchError { + guard gen == copilotRefreshGen else { return false } + applyCopilotFetchError(err) + return false + } catch { + guard gen == copilotRefreshGen else { return false } + copilotError = sanitizeForUI(error.localizedDescription) + copilotLoadState = .failed + return false + } + } + + func disconnectCopilot() { + CopilotSubscriptionService.disconnect() + copilotRefreshGen &+= 1 + copilotUsage = nil + copilotError = nil + copilotLoadState = .notBootstrapped + NotificationCenter.default.post(name: .codeBurnSubscriptionDisconnected, object: nil) + } + + private func applyCopilotFetchError(_ err: CopilotSubscriptionService.FetchError) { + let sanitized = sanitizeForUI(err.errorDescription) + copilotError = sanitized + if case .noCredentials = err { + copilotLoadState = .noCredentials + } else if err.isTerminal { + copilotLoadState = .terminalFailure(reason: sanitized) + } else if let retryAt = err.rateLimitRetryAt { + copilotLoadState = .transientFailure(retryAt: retryAt) + } else { + // 5xx / network blips and a rejected-but-unchanged token back off + // automatically, mirroring the Electron provider's + // transientFailure mapping. + copilotLoadState = .transientFailure(retryAt: nil) + } + } + + // MARK: - Antigravity + + /// No credential dance at all: discovery is `ps` + loopback probes of the + /// local language server, so the first refresh tick activates dormant + /// state, like Kimi/Gemini/Copilot. + func bootstrapAntigravity() 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 = antigravityRefreshGen + antigravityLoadState = .bootstrapping + do { + let usage = try await AntigravitySubscriptionService.refresh() + guard gen == antigravityRefreshGen else { return } + antigravityUsage = usage + antigravityError = nil + antigravityLoadState = .loaded + } catch let err as AntigravitySubscriptionService.FetchError { + guard gen == antigravityRefreshGen else { return } + applyAntigravityFetchError(err) + } catch { + guard gen == antigravityRefreshGen else { return } + antigravityError = sanitizeForUI(error.localizedDescription) + antigravityLoadState = .failed + } + } + + func refreshAntigravity() async { + _ = await refreshAntigravityReportingSuccess() + } + + @discardableResult + func refreshAntigravityReportingSuccess() async -> Bool { + if case .dormant = antigravityLoadState { + await bootstrapAntigravity() + return antigravityLoadState == .loaded + } + // Only an explicit Disconnect stops the cadence probe; there is no + // credential file to poll for, the probe IS the availability check. + if case .notBootstrapped = antigravityLoadState { return false } + let gen = antigravityRefreshGen + if antigravityUsage == nil { antigravityLoadState = .loading } + do { + let usage = try await AntigravitySubscriptionService.refresh() + guard gen == antigravityRefreshGen else { return false } + antigravityUsage = usage + antigravityError = nil + antigravityLoadState = .loaded + return true + } catch let err as AntigravitySubscriptionService.FetchError { + guard gen == antigravityRefreshGen else { return false } + applyAntigravityFetchError(err) + return false + } catch { + guard gen == antigravityRefreshGen else { return false } + antigravityError = sanitizeForUI(error.localizedDescription) + antigravityLoadState = .failed + return false + } + } + + func disconnectAntigravity() { + antigravityRefreshGen &+= 1 + antigravityUsage = nil + antigravityError = nil + antigravityLoadState = .notBootstrapped + NotificationCenter.default.post(name: .codeBurnSubscriptionDisconnected, object: nil) + } + + private func applyAntigravityFetchError(_ err: AntigravitySubscriptionService.FetchError) { + let sanitized = sanitizeForUI(err.errorDescription) + antigravityError = sanitized + if case .disconnected = err { + // No local server answered — the routine "app not running" state, + // not an error; the UI shows the Connect affordance. + antigravityLoadState = .noCredentials + } else { + // Unexpected discovery/probe failures back off automatically, + // mirroring the Electron provider's transientFailure mapping. + antigravityLoadState = .transientFailure(retryAt: nil) + } + } + private func applyFetchError(_ err: ClaudeSubscriptionService.FetchError) { let sanitized = sanitizeForUI(err.errorDescription) subscriptionError = sanitized @@ -1324,6 +1602,18 @@ final class AppStore { let worst = max(usage.primary?.usedPercent ?? 0, usage.details.map(\.usedPercent).max() ?? 0) if worst > 0 { providers.append(("Kimi Code", worst)) } } + if let usage = geminiUsage, shouldIncludeCachedQuota(loadState: geminiLoadState) { + let worst = usage.details.map(\.usedPercent).max() ?? 0 + if worst > 0 { providers.append(("Gemini", worst)) } + } + if let usage = copilotUsage, shouldIncludeCachedQuota(loadState: copilotLoadState) { + let worst = usage.details.map(\.usedPercent).max() ?? 0 + if worst > 0 { providers.append(("Copilot", worst)) } + } + if let usage = antigravityUsage, shouldIncludeCachedQuota(loadState: antigravityLoadState) { + let worst = usage.details.map(\.usedPercent).max() ?? 0 + if worst > 0 { providers.append(("Antigravity", worst)) } + } let worst = providers.map(\.percent).max() ?? 0 let severity = QuotaSummary.severity(for: worst / 100) let sorted = providers.sorted { $0.percent > $1.percent } @@ -1345,6 +1635,9 @@ final class AppStore { case .claude: return claudeQuotaSummary(filter: filter) case .codex: return codexQuotaSummary(filter: filter) case .kimiCode: return kimiQuotaSummary(filter: filter) + case .gemini: return geminiQuotaSummary(filter: filter) + case .copilot: return copilotQuotaSummary(filter: filter) + case .antigravity: return antigravityQuotaSummary(filter: filter) default: return nil } } @@ -1506,6 +1799,104 @@ final class AppStore { return QuotaSummary(providerFilter: filter, connection: connection, primary: primary, details: details, planLabel: kimiUsage?.plan ?? "Kimi Code", footerLines: []) } + private func geminiQuotaSummary(filter: ProviderFilter) -> QuotaSummary? { + if case .notBootstrapped = geminiLoadState { return nil } + if case .bootstrapping = geminiLoadState { return nil } + if case .noCredentials = geminiLoadState { return nil } + + let connection: QuotaSummary.Connection = { + switch geminiLoadState { + case .notBootstrapped, .dormant, .bootstrapping, .noCredentials: return .disconnected + case .loading: return geminiUsage == nil ? .loading : .stale + case .loaded: return .connected + case .failed: return geminiUsage == nil ? .loading : .stale + // An expired Gemini login without a refresh path sits terminal + // until the user runs the CLI. Keep the last-known bars (marked + // stale) instead of flapping the chip to a reconnect card. + case let .terminalFailure(reason): return geminiUsage == nil ? .terminalFailure(reason: reason) : .stale + case .transientFailure: return .transientFailure + } + }() + + var primary: QuotaSummary.Window? + var details: [QuotaSummary.Window] = [] + if let usage = geminiUsage { + // details is sorted most-constrained first, so the first row is + // the headline bar. + 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: geminiUsage?.plan ?? "Gemini", footerLines: []) + } + + private func copilotQuotaSummary(filter: ProviderFilter) -> QuotaSummary? { + if case .notBootstrapped = copilotLoadState { return nil } + if case .bootstrapping = copilotLoadState { return nil } + if case .noCredentials = copilotLoadState { return nil } + + let connection: QuotaSummary.Connection = { + switch copilotLoadState { + case .notBootstrapped, .dormant, .bootstrapping, .noCredentials: return .disconnected + case .loading: return copilotUsage == nil ? .loading : .stale + case .loaded: return .connected + case .failed: return copilotUsage == nil ? .loading : .stale + // A revoked Copilot token sits terminal until the user signs in via + // an editor's Copilot plugin again. Keep the last-known bars + // (marked stale) instead of flapping the chip to a reconnect card. + case let .terminalFailure(reason): return copilotUsage == nil ? .terminalFailure(reason: reason) : .stale + case .transientFailure: return .transientFailure + } + }() + + var primary: QuotaSummary.Window? + var details: [QuotaSummary.Window] = [] + if let usage = copilotUsage { + // details leads with the premium-requests window, so the first row + // is the headline bar. + 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: copilotUsage?.plan ?? "Copilot", footerLines: []) + } + + private func antigravityQuotaSummary(filter: ProviderFilter) -> QuotaSummary? { + if case .notBootstrapped = antigravityLoadState { return nil } + if case .bootstrapping = antigravityLoadState { return nil } + if case .noCredentials = antigravityLoadState { return nil } + + let connection: QuotaSummary.Connection = { + switch antigravityLoadState { + case .notBootstrapped, .dormant, .bootstrapping, .noCredentials: return .disconnected + case .loading: return antigravityUsage == nil ? .loading : .stale + case .loaded: return .connected + case .failed: return antigravityUsage == nil ? .loading : .stale + // A vanished local server maps to noCredentials above, so terminal + // is unreachable today; kept for switch exhaustivity. + case let .terminalFailure(reason): return antigravityUsage == nil ? .terminalFailure(reason: reason) : .stale + case .transientFailure: return .transientFailure + } + }() + + var primary: QuotaSummary.Window? + var details: [QuotaSummary.Window] = [] + if let usage = antigravityUsage { + // details is sorted most-constrained first, so the first row is + // the headline bar. + 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: antigravityUsage?.plan ?? "Antigravity", 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, diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index fa42d332..f183ff19 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -45,6 +45,7 @@ struct CodeBurnApp: App { Settings { SettingsView() .environment(delegate.store) + .environment(delegate.updateChecker) } } } @@ -530,6 +531,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM fileprivate var lastSubscriptionRefreshAt: Date? fileprivate var lastCodexRefreshAt: Date? fileprivate var lastKimiRefreshAt: Date? + fileprivate var lastGeminiRefreshAt: Date? + fileprivate var lastCopilotRefreshAt: Date? + fileprivate var lastAntigravityRefreshAt: Date? private var claudeQuotaFailureCount = 0 private var nextClaudeQuotaRefreshAt: Date? @@ -625,23 +629,51 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM if let task = codexQuotaRefreshTask { return await task.value } - // 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. + // Kimi Code, Gemini, Copilot, and Antigravity ride the same tick, + // each with its own cadence anchor: when Codex is not connected its + // refresh returns false + // immediately, so lastCodexRefreshAt never advances — anchoring them + // on it would poll the quota endpoints on every payload tick instead + // of the configured quota cadence. Anchor on attempt (not success) + // so a failing 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 + let geminiDue: Bool = { + let cadence = SubscriptionRefreshCadence.current + guard cadence != .manual else { return false } + return Date().timeIntervalSince(lastGeminiRefreshAt ?? .distantPast) >= TimeInterval(cadence.rawValue) + }() + if geminiDue { lastGeminiRefreshAt = Date() } + let copilotDue: Bool = { + let cadence = SubscriptionRefreshCadence.current + guard cadence != .manual else { return false } + return Date().timeIntervalSince(lastCopilotRefreshAt ?? .distantPast) >= TimeInterval(cadence.rawValue) + }() + if copilotDue { lastCopilotRefreshAt = Date() } + let antigravityDue: Bool = { + let cadence = SubscriptionRefreshCadence.current + guard cadence != .manual else { return false } + return Date().timeIntervalSince(lastAntigravityRefreshAt ?? .distantPast) >= TimeInterval(cadence.rawValue) + }() + if antigravityDue { lastAntigravityRefreshAt = Date() } + let task = Task { [store, kimiDue, geminiDue, copilotDue, antigravityDue] in async let codex = store.refreshCodexReportingSuccess() if kimiDue { _ = await store.refreshKimiReportingSuccess() } + if geminiDue { + _ = await store.refreshGeminiReportingSuccess() + } + if copilotDue { + _ = await store.refreshCopilotReportingSuccess() + } + if antigravityDue { + _ = await store.refreshAntigravityReportingSuccess() + } return await codex } codexQuotaRefreshTask = task @@ -853,14 +885,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM /// Reset the cadence anchor so the next loop tick re-evaluates from "now" /// rather than measuring against a timestamp from the previous connection. - /// Triggered on disconnect of any provider — the cost of clearing both - /// anchors is one extra refresh tick on the unaffected provider, far less + /// Triggered on disconnect of any provider — the cost of clearing all + /// anchors is one extra refresh tick on the unaffected providers, far less /// disruptive than waiting a full cadence after a reconnect. @MainActor func resetSubscriptionCadenceAnchor() { lastSubscriptionRefreshAt = nil lastCodexRefreshAt = nil lastKimiRefreshAt = nil + lastGeminiRefreshAt = nil + lastCopilotRefreshAt = nil + lastAntigravityRefreshAt = nil claudeQuotaFailureCount = 0 nextClaudeQuotaRefreshAt = nil } @@ -892,11 +927,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM // relying on payload/menubarPayload happening to touch the same cache. _ = self.store.isOverDailyBudget // Track the live-quota state too so the flame icon re-tints on - // every subscription / codex usage update, not just every 30s. + // every connected provider's usage update, not just every 30s. _ = self.store.subscription _ = self.store.subscriptionLoadState _ = self.store.codexUsage _ = self.store.codexLoadState + _ = self.store.geminiUsage + _ = self.store.geminiLoadState + _ = self.store.copilotUsage + _ = self.store.copilotLoadState + _ = self.store.antigravityUsage + _ = self.store.antigravityLoadState } onChange: { [weak self] in DispatchQueue.main.async { guard let self else { return } @@ -921,14 +962,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM statusItem = NSStatusBar.system.statusItem(withLength: statusItemWidth) guard let button = statusItem.button else { return } - // Set a simple SF Symbol image immediately to ensure the status item renders. + // Set the bundled flame image immediately to ensure the status item renders. // On macOS Tahoe, status items may fail to appear if only an attributed title // is set during initial setup. - let flameConfig = NSImage.SymbolConfiguration(pointSize: menubarTitleFontSize, weight: .medium) - let flame = NSImage(systemSymbolName: "flame.fill", accessibilityDescription: "CodeBurn")? - .withSymbolConfiguration(flameConfig) - flame?.isTemplate = true - button.image = flame + button.image = Self.menubarFlameImage(tint: nil) button.imagePosition = .imageLeading button.target = self @@ -982,6 +1019,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM } } + /// Loads the bundled binary-flame PNG (Resources/ProviderIcons/flame.png) at the + /// menubar text point size. With no tint it stays a template image so the system + /// auto-adapts to the menu bar; a tint returns a recolored non-template copy for + /// the budget/quota warning states. + private static func menubarFlameImage(tint: NSColor?) -> NSImage? { + let config = NSImage.SymbolConfiguration(pointSize: menubarTitleFontSize, weight: .medium) + guard let symbol = NSImage(systemSymbolName: "flame.fill", accessibilityDescription: "CodeBurn")? + .withSymbolConfiguration(config) else { return nil } + guard let tint else { + symbol.isTemplate = true + return symbol + } + let recolored = NSImage(size: symbol.size, flipped: false) { rect in + symbol.draw(in: rect) + tint.set() + rect.fill(using: .sourceAtop) + return true + } + recolored.isTemplate = false + return recolored + } + private func refreshStatusButton() { guard let button = statusItem.button else { return } // Skip while the popover is anchored to this button. Rewriting the @@ -997,7 +1056,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM button.imagePosition = .noImage 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); // warning/critical/danger override with a fixed palette color so the @@ -1007,15 +1065,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM if tint == nil, store.isOverDailyBudget { tint = NSColor.systemYellow } - let flameConfig: NSImage.SymbolConfiguration - if let tint { - flameConfig = baseConfig.applying(.init(paletteColors: [tint])) - } else { - flameConfig = baseConfig - } - let flame = NSImage(systemSymbolName: "flame.fill", accessibilityDescription: "CodeBurn")? - .withSymbolConfiguration(flameConfig) - flame?.isTemplate = (tint == nil) + let flame = Self.menubarFlameImage(tint: tint) let attachment = NSTextAttachment() attachment.image = flame @@ -1294,7 +1344,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM } let hosting = NSHostingController( - rootView: SettingsView().environment(store) + rootView: SettingsView().environment(store).environment(updateChecker) ) let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 520, height: 380), diff --git a/mac/Sources/CodeBurnMenubar/Data/AntigravityQuotaPresentation.swift b/mac/Sources/CodeBurnMenubar/Data/AntigravityQuotaPresentation.swift new file mode 100644 index 00000000..c7b8063f --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/AntigravityQuotaPresentation.swift @@ -0,0 +1,51 @@ +import Foundation + +/// Pure display-decision helpers for the Antigravity quota surfaces. +/// +/// Antigravity quota comes from probing the local language server — there is +/// no credential file and no refresh path, so a vanished server maps to +/// `.noCredentials` ("start the Antigravity app") rather than a terminal +/// login state. Like the other file-free providers, the always-visible +/// surfaces keep showing the last good snapshot with a quiet caption instead +/// of flapping to a reconnect screen; the reconnect screen is reserved for +/// the no-data case, where there is genuinely nothing to show. +enum AntigravityQuotaPresentation { + /// 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 connection + /// has gone terminal but a snapshot is still on hand — the caller + /// stamps a quiet "start the app" 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