Closes#278.
Adds Charmbracelet Crush as a lazy-loaded provider:
- src/providers/crush.ts: walks ~/.local/share/crush/projects.json
(XDG_DATA_HOME and CRUSH_GLOBAL_DATA aware), opens each project's
crush.db read-only, queries root sessions where parent_session_id
IS NULL. Emits one ParsedProviderCall per session with real
prompt_tokens, completion_tokens, cost (dollars), and the
dominant model resolved from messages.model.
- src/providers/index.ts: register crush alongside cursor, goose,
opencode, antigravity, cursor-agent in the lazy import path.
- tests/providers/crush.test.ts: 10 fixture-based tests covering
discovery, parsing, missing-registry, malformed JSON, missing db,
child session exclusion, dominant model selection, dedup, and
array-shaped legacy registry.
Schema source: charmbracelet/crush@v0.66.1
internal/db/migrations/20250424200609_initial.sql, verified by
spawning a research agent against upstream. The schema *comments*
in that migration claim millisecond timestamps but every actual
INSERT/UPDATE uses strftime('%s', 'now') which returns Unix
seconds; the parser treats values as seconds. Tokscale's
parser (junhoyeo/tokscale#346) gets this wrong and is off by
1000x, plus its parser misses the prompt_tokens/completion_tokens
columns that exist in Crush's schema. Our integration uses both,
so Crush sessions get real per-model attribution.
Menubar:
- mac/Sources/CodeBurnMenubar/AppStore.swift: add .crush case to
ProviderFilter and its cliArg switch.
- mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift: add
Crush color to the per-tab color extension. The visibleFilters
computed property already filters by detected providers, so the
Crush tab appears automatically when a user has Crush data.
README:
- Replace the provider table with an icon-led layout. Icons live
under assets/providers/<name>.<ext>. 14 icons sourced from
junhoyeo/tokscale (MIT) under nominative fair use, 4 sourced
separately: codex (OpenAI org avatar), cursor-agent (reuses the
Cursor icon), kiro (kiro.dev favicon, ico->png via sips), omp
(can1357/oh-my-pi icon.svg, MIT). Attribution line added.
- Add Crush row.
Docs:
- docs/providers/crush.md: full per-provider doc with verified
schema excerpt, the seconds-vs-milliseconds quirk, and a
"when fixing a bug here" checklist.
- docs/architecture.md: provider count 17 -> 18, test count
41 -> 42, and crush in the lazy list.
- docs/providers/README.md: add Crush row to the lazy index.
- CONTRIBUTING.md: bump test count to 568 (was 558).
All 568 tests pass locally; swift build clean.
Replace blocking availableData drain with non-blocking POSIX read
that respects Task cancellation. Handle EINTR from child SIGCHLD,
close pipe fds after drain to prevent deadlock on oversized output,
and escalate SIGTERM to SIGKILL after 0.5s grace period.
Add 60-second loading watchdog as safety net that auto-clears stuck
state on each refresh loop tick.
Fixes#282
* Quiet routine pricing warnings + menubar recovery from stuck-loading
CLI:
- Default `codeburn` invocation no longer prints "no pricing data for model"
warnings on every run. Greeting a fresh user with three lines of stderr
before the dashboard even draws looked like the tool was broken on first
launch. The warning now requires --verbose, and the suppressed pricing
miss still results in $0 cost (correct for unmapped models).
- Local-model heuristic skips the warning entirely for Ollama tags
(`qwen3.6:35b-a3b-bf16`), GGUF/quantized fingerprints, and similar names
that will never have public pricing. The "update codeburn" hint was
actively misleading there.
- When the warning does fire (with --verbose), it points users at
`codeburn model-alias <model> <known-model>` as the actual escape hatch
alongside the package update suggestion.
Menubar:
- Replace perpetual "Loading…" spinner with a FetchErrorOverlay when the
per-key fetch fails and the cache is empty. User sees the error and a
Retry button instead of an infinite hang.
- Add diagnostic breadcrumbs (NSLog, invisible to normal users — Console.app
/ `log stream --process CodeBurnMenubar` only) for the four states that
produce a stuck loading overlay:
- subprocess timeout after 45s
- fetch result dropped due to Task cancellation (rapid tab switch)
- fetch result dropped due to mid-fetch calendar rollover
- retry attempt where the last successful fetch is >2 min stale
- Track lastSuccessByKey separately from cache freshness so the staleness
diagnostic survives day-rollover cache wipes.
* Stop flashing the compare-view loading screen on background refresh
When the 30s CLI tick updated `projects` while the user was reading the
model comparison results, the projects-watching effect always fired
setLoadTrigger, which flipped phase to 'loading' and re-ran the slow
scanSelfCorrections walk over every provider's session directory. The
user lost their scroll position and saw a loading flash mid-read.
Recompute the comparison rows in place when:
- the user is already on the results phase, AND
- both picked models still exist in the new aggregate.
Skip the corrections rescan on these in-place refreshes — corrections
drift slowly enough that holding the previous value until the user
re-enters compare is acceptable, and the rescan is the slow part of the
load. Initial selection and post-selection load still run the full
pipeline.
Sleep: track forceRefreshTask and cancel it on willSleep alongside
refreshLoopTask. Reset loadingCount on wake so orphaned fetches from
before sleep cannot leave the loading bar stuck.
Tabs: replace Button wrapper with onTapGesture on AgentTab so the
NSPopover hover tooltip does not eat the first click. Add
clickDismissed guard to prevent the popover from re-showing while
the mouse is still over the tab after a tap.
Tab size: only render the quota bar slot when quota data exists
(connected), not for every provider that supports quota. Disconnected
Claude/Codex tabs are now the same height as other tabs.
* Gate Claude OAuth refresh attempts on terminal failures
Anthropic returns invalid_grant (HTTP 400) when the user's refresh token has
been revoked or rotated, typically after they re-ran claude login on another
device. The previous code rethrew the raw error every refresh cycle, leaving
the Plan UI stuck on a Swift error string and pummeling Anthropic's token
endpoint forever.
The new SubscriptionRefreshGate captures a fingerprint of
~/.claude/.credentials.json on terminal failure and stops trying until that
fingerprint changes (the user re-logs-in). Transient 5xx/network failures
get exponential backoff capped at 6 hours.
Two new SubscriptionError cases let the UI distinguish "user must reconnect"
from "Anthropic is flaky right now" and show a clean reconnect CTA instead
of raw HTTP guts.
* Inline live-quota progress bar inside each AgentTab chip
When a provider exposes a live quota source, the AgentTab chip grows by ~3pt
to host a thin weekly-utilization bar directly under the label. Hovering the
chip reveals a popover with all four Anthropic windows (5-hour, weekly, weekly
Opus, weekly Sonnet) plus reset countdowns. Click still switches the tab as
before.
Today only Claude has a quota source (the existing /api/oauth/usage path);
other providers' chips render unchanged. The QuotaSummary abstraction lets
us bolt on Cursor/Copilot/Codex meters in follow-up commits.
Subscription is now refreshed eagerly on the periodic loop so the bar lights
up without forcing the user to open a deep view first. The previous
SubscriptionRefreshGate keeps a dead refresh token from spamming Anthropic.
Adds two new SubscriptionLoadState cases (terminalFailure, transientFailure)
so the deep Plan view shows a "reconnect" message instead of a raw Swift
error string when the user's claude login expired.
* Replace SubscriptionClient with credential-store + service architecture
The previous SubscriptionClient never persisted refreshed access tokens, so
every 30s tick read the expired token from Keychain, refreshed it (1 call),
fetched usage with the new token (2nd call), and threw the new token away —
3 API calls per cycle, which burned through Anthropic's per-account rate
budget and produced the 429s and `invalid_grant` loops users were seeing.
The replacement mirrors CodexBar's proven pattern:
- ClaudeCredentialStore owns the credential lifecycle. Bootstrap is strictly
user-initiated (Connect button in the Plan tab); the menubar does not touch
Claude's keychain at startup. After bootstrap, refreshed tokens — including
rotated refresh tokens — are persisted to a local cache file under
~/Library/Application Support/CodeBurn (mode 0600). Using a file instead of
our own keychain item means rebuild signature changes don't trigger a
startup keychain prompt; the only prompt the user ever sees is the one for
Claude Code-credentials on Connect.
- ClaudeUsageFetcher (folded into the service) is a pure /api/oauth/usage
call with one allowed 401-recovery roundtrip. 429s record an explicit
backoff window honouring Retry-After.
- ClaudeSubscriptionService orchestrates bootstrap / refresh / disconnect,
applies the 429 backoff, and surfaces terminal vs transient failures so
the UI can show the right CTA.
- Reading Claude's keychain now tries the entry keyed by NSUserName() first
and falls back to the unscoped query, so users who re-ran /login and ended
up with two Claude Code-credentials items pick up the fresh one. This was
the actual cause of "I logged in but the menubar still shows stale data".
User-facing additions:
- A proper Settings window (right-click → Settings…) with General / Claude /
About tabs. Provider quota cadence is configurable (Manual / 1m / 2m / 5m /
15m). New providers plug in as additional tabs.
- Plan tab: notBootstrapped → "Connect Claude subscription" CTA;
terminalFailure → "Reconnect Claude" with the correct /login instruction
for Claude Code 2.1; transientFailure preserves the last loaded view with
a retrying badge.
- AgentTab quota bar slot is always reserved so chip height doesn't jitter
when the user connects for the first time. Hover popover has 250ms enter
/ 150ms exit debounce so swiping across chips doesn't pop a popover for
every chip touched.
- Disconnect requires confirmation, clears capacityEstimates and the
subscription snapshot store so a reconnect under a different account
doesn't surface "Based on last cycle" projections from the old account.
Validator findings applied: cadence anchor only updates on successful
refresh (not every attempt), refresh-token rotation persists in memory
before keychain write so a write failure doesn't lock the user out, server
error bodies are sanitized (token redaction + 240-char cap) before they
reach the UI or NSLog, and Refresh Now refreshes both the menubar payload
and quota.
* Add Codex live quota + multi-provider warning, with validator fixes
CodexCredentialStore reads ~/.codex/auth.json (ChatGPT-mode only) on
user-initiated Connect, caches under Application Support like Claude.
CodexSubscriptionService hits chatgpt.com/backend-api/wham/usage with
the bearer token + ChatGPT-Account-Id header, parses primary/secondary
windows, additional per-model rate limits (e.g. GPT-5.3-Codex-Spark),
and credits balance with a Double-or-String fallback.
Plan-tier enum captures the full ChatGPT plan list including prolite,
free_workspace, education, quorum, k12, plus an unknown(String) case
that preserves the raw plan name when OpenAI ships a tier we haven't
mapped yet.
Multi-provider warning system:
- Menubar flame tints from neutral to yellow (70%) → orange (90%) →
red (100%) based on the worst-affected connected provider's worst
window. Uses NSImage.SymbolConfiguration palette colors.
- Popover header gains a warning row when any provider is at 70%+.
"Claude 79% of quota used", "Claude 79% · Codex 92%", or
"Claude over limit (105%)" when severity hits .danger.
- Hover popover gains a plan-name badge in the top-right corner so
users know which subscription is feeding the bar.
- Codex chip surfaces the credits balance and any non-zero per-model
additional rate limits as footer rows.
Validator fixes applied in the same commit:
- Provider-specific reconnect / disconnected copy in QuotaDetailPopover
(was hardcoded to Claude).
- Generation-token guard on refreshSubscriptionReportingSuccess and
refreshCodexReportingSuccess so a Disconnect during an in-flight
fetch can't resume after the await and re-populate the cleared state.
- Codex codexQuotaSummary promotes secondary to primary when only one
window is returned, so free / guest tiers don't render an empty bar.
- Memory-cache TTL is now actually consulted in currentRecord (the
isFresh check was dead code, leaving cached records valid forever).
- sanitizeForUI now redacts OpenAI sk-* keys, JWT tokens, and Bearer
headers in addition to Claude sk-ant-*.
- Removed diagnostic NSLog that wrote raw chatgpt.com response bodies
to the unified log.
- Codex Connect / Reconnect copy in Settings explains the auth.json
prerequisite and the API-key vs ChatGPT-mode distinction.
- Disconnect dialogs now state explicitly that the auth.json /
credentials keychain entry is left untouched.
- Plan badge in the popover gets line-limit + truncation + max-width
so a long unknown plan name can't overflow the row.
- Renamed shadowing `let max` to `let worst` in aggregateQuotaStatus.
* Add Codex Plan tab + size plan badge to content
The Plan tab is now visible when the Codex chip is selected, mirroring
the Claude tab's deep view. CodexPlanInsight renders the user's plan
tier ("Pro Lite", "Plus", etc.), the primary and secondary rate-limit
windows with reset countdowns, and any non-zero per-model additional
limits (e.g. GPT-5.3-Codex-Spark) so power users see them.
The "On pace at reset" projection that Claude's Plan view shows is not
included here — that math feeds from local Claude per-message spend
extrapolated against API quota windows, and our local Codex spend is
not a 1:1 signal for the ChatGPT-subscription rate windows reported by
wham/usage. Wiring a Codex extrapolator is a follow-up.
Drop the maxWidth=90 frame on the plan badge in the hover popover. It
was stretching short labels like "Pro Lite" to fill the full 90pt slot;
fixedSize makes the badge hug the text. Plan names are bounded short
strings, so truncation is a non-issue in practice.
Five interleaving menubar regressions traced back to the cache-wipe and
showLoading additions in 18c3c8f, surfaced by adversarial multi-agent
review against the v0.9.6 baseline.
- forceRefresh no longer calls store.invalidateCache(). Wiping the
whole cache on every wake or manual refresh emptied todayPayload,
flipped showAgentTabs to false, and made cache[key] == nil for all
keys, which forced the full-popover loading overlay over already
rendered data. The day-rollover guard inside refresh() still wipes
the cache when the calendar date changes, so the legitimate part of
18c3c8f is preserved.
- Overlay condition is now !store.hasCachedData. Without this, the
popover briefly rendered $0.00 placeholders before the overlay slid
in on a cold key, and reflashed the overlay on every manual refresh
even when fresh data was on screen.
- refreshStatusButton skips while popover is anchored. Rewriting the
button's attributedTitle changes its intrinsic width, which makes
macOS reflow the status item and detaches the anchored popover to
the screen's top-left default position. popoverDidClose runs the
refresh once so the menubar title catches up immediately on
dismiss.
- showAgentTabs is sticky via hasAnyProvidersInCache. Prevents the
one-frame flicker where the tab strip vanished while the new key's
payload had not yet arrived.
- observeStore tracks store.currency. Without this, switching
currency did not propagate to refreshStatusButton until the next
30s payload tick, leaving the menubar showing the old currency
symbol and rate.
- Day-rollover race in refresh and refreshQuietly: capture cacheDate
at fetch start, drop the write if the calendar date changed during
the await. Prevents an in-flight fetch from yesterday polluting
today's freshly cleared cache.
- Manual refresh button passes showLoading: true again. Safe now that
the overlay is gated on cache state instead of isLoading; the
refresh button icon swaps to the spinner glyph for visible feedback,
while the popover body keeps the existing data and updates when the
fetch lands.
Cache now tracks the calendar date and clears on day rollover so
overnight sleep no longer shows yesterday's numbers. Wake-from-sleep
invalidates the entire cache before fetching. Manual refresh and wake
explicitly request loading feedback so the spinner is visible even
when stale data exists.
The menubar ran --optimize on every 30-second CLI invocation. As
sessions accumulated throughout the day, optimize got heavier until
it exceeded the 45-second timeout. When the fetch failed with no
cached data, the loading overlay had no escape hatch and stayed
forever.
- Never pass includeOptimize from the menubar (background loop,
forceRefresh, tab/period switches, manual refresh button)
- On fetch failure with empty cache, retry without optimize as
fallback so the spinner always clears
- refreshQuietly also skips optimize
PR #221 unified the period logic but missed the TUI hotkey bar,
GNOME indicator popup, and macOS menubar app. All surfaces now
consistently show '6 Months' instead of 'All' or 'all time'.
CLI timeout increased from 20s to 45s to handle cold file-cache latency on
provider-specific queries. Loading overlay now appears when the all-provider
payload confirms a provider has spend but its dedicated data hasn't loaded yet.
Manual refresh (force: true) bypasses the in-flight guard so users can always
re-fetch. Tab strip prefers the provider-specific payload cost when available
so it stays in sync with the hero section.
- Fix refresh loop: proper while loop with 30s sleep and force:true
instead of single-fire Task that never repeated
- Fix loading overlay: counter-based isLoading so concurrent fetches
don't flicker the overlay on/off
- Fix rapid tab switching: cancel previous switchTask, check
Task.isCancelled after CLI returns to discard stale results
- Fix tab strip vs hero desync: fetch provider-specific and all-provider
data in parallel so costs arrive from same data snapshot
- Fix stale menubar icon after wake: forceRefresh now fetches today/all
in parallel alongside the current selection
- Fix accent color: ThemeState is now @Observable so color changes
propagate via observation, removing .id() view hierarchy teardown
- Fix currency flash: defer store.currency and symbol update until a
rate is available so symbol and rate apply atomically
- Fix export: terminationHandler instead of waitUntilExit (no UI freeze),
HHmmss in filename to prevent overwrite on double-export
- Fix CurrencyState: @MainActor isolation with proper Sendable
conformance, nonisolated on pure static functions
- Fix streak count: iterate calendar days instead of sparse history
entries so gaps are counted as streak-breakers
- Fix TrendBar identity: stable date-based id instead of UUID
- Add GPT-5.3 and DeepSeek model display names
Three fixes for issue #184:
1. Menubar Swift code used UTC instead of local timezone in two places:
computeHistoryStats hardcoded TimeZone("UTC") and
effectiveTokensInLast7Days used ISO8601DateFormatter (UTC default).
Both now use .current to match CLI-produced local date keys.
2. Add --timezone flag and CODEBURN_TZ env var to override the system
timezone for all date grouping. Sets process.env.TZ before any Date
operations so all existing local-timezone code works unchanged.
3. Replace MS_PER_DAY arithmetic with Date constructor day-of-month
math for yesterday/backfill computations. Subtracting 86400000ms
from midnight skips a day on DST spring-forward (23-hour day).
Fixes#184
- Extract ensureCacheHydrated() from menubar-json path into daily-cache.ts
- Call it from every command that parses sessions (report, status, today,
month, export, optimize, compare, yield) so CLI-only users also persist
historical data that survives source file deletion
- Replace strict version equality check with fill-defaults migration for
cache versions 2-4, preserving history across schema changes
- Back up old cache to .bak before discarding on unmigrateable versions
- Fix Copilot auto bucket display names in menubar (Copilot (Anthropic),
Copilot (OpenAI))
- Fix Roo Code / KiloCode provider key matching in menubar tab strip
- OpenClaw: JSONL parser with multi-path discovery, tool extraction
(toolCall + tool_use block types), model tracking via model_change
and custom model-snapshot events
- Roo Code + KiloCode: shared Cline-family parser extracts model from
<model> tags in api_conversation_history.json, strips provider
prefixes from model names
- Add cline-auto and openclaw-auto aliases and display names
- Add menubar provider filters and tab colors for all three
- Show cached data instantly instead of blocking on CLI refresh
Parse ~/.gemini/tmp/<project>/chats/session-*.json files from Gemini
CLI 0.38+. Uses real token counts (input, output, cached, thoughts)
embedded in each message instead of character estimation. Correctly
separates cached tokens from fresh input to avoid double-charging.
- Pricing for gemini-3.1-pro-preview, gemini-3-flash-preview,
gemini-2.5-pro, gemini-2.5-flash from official Google API rates
- Tool name normalization (ReadFile->Read, SearchText->Grep, etc.)
- Menubar tab with Google Blue color (#4485F4)
Closes#166
* Add Kiro provider and transparent auto-model naming
- Add Kiro IDE provider: parses .chat JSON files, estimates tokens,
normalizes dot-versioned model IDs for cost lookup
- Show "Cursor (auto)", "Copilot (auto)", "Kiro (auto)" in CLI
dashboard instead of pretending to know which model was used
- Route auto model names through BUILTIN_ALIASES for cost estimation
* Fix menubar tabs: add missing providers, show period-scoped costs
- Add Kiro, OMP to ProviderFilter enum so installed providers appear as tabs
- Merge Cursor + Cursor Agent into single Cursor tab
- Tab costs now reflect the selected period (7d/30d/month/all) instead
of always showing today
- Tab visibility still uses today's provider list so tabs don't
disappear when switching to periods with no data
* Add accent color picker to menubar with Apple system presets
- 9 presets using Apple's exact macOS dark-mode accent colors
(Ember, Blue, Purple, Pink, Red, Orange, Yellow, Green, Graphite)
- Color picker in header, persisted via UserDefaults
- "Burn" text stays fixed ember regardless of accent
- ThemeState is MainActor-isolated for thread safety
- Picker state lifted to AppStore so it survives .id() tree rebuild
- Accessibility labels on all color swatches
- Renamed brandAccentDark/brandEmberDeep/brandEmberGlow to match
their actual light/deep/glow semantics
* Fix review findings: case-sensitive cost lookup, Kiro timestamp guard, cache versioning
- Normalize provider dictionary keys to lowercase in tab cost lookup
so "Cursor Agent" (title-case from CLI) matches providerKeys
- Guard against missing/invalid/epoch startTime in Kiro parser to
prevent RangeError crash or 1970-01-01 ghost entries
- Bump DAILY_CACHE_VERSION to 4 so upgraded users get a clean
recompute with the new auto-model naming (cursor-auto vs default)
- Add version field to cursor-results.json cache to invalidate stale
entries that still use the old 'default' model name
UserDefaults key CodeBurnMenubarCompact toggles a tighter menubar
display: no decimals, no leading space, variable width that hugs
the rendered text instead of the fixed 130pt slot.
Closes#129
The detached prefetch spawned four codeburn subprocesses (week, 30days,
month, all) that competed with the main refresh loop's Today fetch for
disk and parser time. On large session corpora that starved the Today
refresh for minutes at a time, and the status label drifted stale until
the prefetch queue finally drained. Users perceived this as the menubar
"stopping" until they clicked a provider tab, which coincided with the
queue clearing.
Drop prefetchAll. Period tabs in the popover now fetch lazily on first
click (3-10 seconds once per app lifetime per period), same as they did
in 0.8.1. Today's label stays fresh because nothing else is hammering
the parser in the background.
Loading overlay no longer flashes on every 15s poll. isLoading now only
toggles when the cache is cold, and all periods prefetch once on launch
so tab switching is instant.
Heatmap tooltip, trend bars, forecast, and all-time stats were computing
on UTC dates while the CLI reports on local dates, so the two disagreed
at day boundaries. Switched every date formatter and calendar in these
paths to .current so the menubar matches codeburn today output.
Menubar: reduce cache TTL from 300s to 30s, background refresh from
60s to 15s, always fetch fresh data on tab switch instead of serving
stale cache. TUI: default auto-refresh to 30s (--refresh 0 to disable).
Closes#107
The tab strip was visible for everyone regardless of which tools they
actually run, which produced a row of All + one provider for Claude-only
users and a row of All + zeros for users on exotic stacks. Hide the
whole row until a second provider has real spend, matching the behavior
the GNOME extension ships with.
Also expand ProviderFilter to include every provider the CLI supports
(OpenCode and Pi were missing) so their tabs appear when those tools
produce sessions. The CLI already emits pi and opencode in the payload's
providers map; the Mac app just wasn't offering a tab for them.
visibleFilters now filters on value > 0 instead of key presence, because
the CLI includes zero-cost entries for discovered-but-unused providers
and we don't want those rendering as blank tabs.