diff --git a/.github/workflows/build-windows-store.yml b/.github/workflows/build-windows-store.yml new file mode 100644 index 0000000..a7e6f03 --- /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/CHANGELOG.md b/CHANGELOG.md index 2cfdf1f..9fff006 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # 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) +- **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) +- **`--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) + +### Fixed +- Claude Desktop and Cowork sessions are discovered for Windows Microsoft Store (MSIX) installs. (#611) + ## 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/README.md b/README.md index f6d04b6..d5a318c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

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

diff --git a/app/DISTRIBUTION.md b/app/DISTRIBUTION.md index 7bd4bd0..6916502 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 0000000..f80200b 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 0000000..33d7889 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 0000000..bc440fe 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 0000000..909773a 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 30dfe17..208bd86 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 82a95c0..5eddbdc 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 39426eb..1c4bc3e 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 b8e2de4..4e429d6 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 b339d50..42b32df 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 3301a04..d9e7b42 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 81c7e09..8548917 100644 --- a/app/renderer/App.test.tsx +++ b/app/renderer/App.test.tsx @@ -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(), @@ -273,6 +273,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). diff --git a/app/renderer/App.tsx b/app/renderer/App.tsx index 1540bd3..a3110a2 100644 --- a/app/renderer/App.tsx +++ b/app/renderer/App.tsx @@ -27,7 +27,7 @@ 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. @@ -130,8 +130,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 @@ -172,6 +172,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 @@ -218,20 +227,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 @@ -273,7 +289,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 @@ -285,7 +301,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 @@ -360,7 +376,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 @@ -391,7 +409,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) @@ -451,17 +469,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) @@ -469,6 +492,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' }, @@ -478,7 +514,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 ( @@ -494,12 +534,12 @@ function AppMain() { {section === 'plans' ? ( ) : section === 'settings' ? ( - + ) : ( <>

{section === 'overview' ? ( - + ) : section === 'sessions' ? ( ) : section === 'pullRequests' ? ( diff --git a/app/renderer/lib/types.ts b/app/renderer/lib/types.ts index 14e2d80..faf00a9 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' @@ -637,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 3138792..1093eeb 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 = { diff --git a/app/renderer/sections/Overview.tsx b/app/renderer/sections/Overview.tsx index 06cb9c6..b2a9dd2 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' @@ -650,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', @@ -657,6 +676,7 @@ export function OverviewContent({ overview, onNavigate, ready = true, + scope = 'local', }: { period: Period provider?: string @@ -664,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 @@ -680,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 @@ -715,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
+ )} + + )}
diff --git a/app/renderer/sections/Settings.test.tsx b/app/renderer/sections/Settings.test.tsx index c2d1282..58aa987 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 17666f2..f3af6fe 100644 --- a/app/renderer/sections/Settings.tsx +++ b/app/renderer/sections/Settings.tsx @@ -18,7 +18,7 @@ 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 +97,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 +113,7 @@ export function Settings({ period, refreshToken = 0, onNavigate, initialPane, cl ))}
- {pane === 'general' && } + {pane === 'general' && } {pane === 'providers' && } {pane === 'aliases' && } {pane === 'pricing' && } @@ -128,7 +128,7 @@ export function Settings({ period, refreshToken = 0, onNavigate, initialPane, cl ) } -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,6 +202,7 @@ function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource,
{ setDefaultPeriod(value); writeSetting('codeburn.defaultPeriod', value) }} width={92} />
+
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 64b45c5..f82172b 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; } diff --git a/app/scripts/stage-cli.mjs b/app/scripts/stage-cli.mjs index 827776e..6908567 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 0000000..9c54891 Binary files /dev/null and b/assets/open-source-recipient.png differ diff --git a/docs/providers/claude.md b/docs/providers/claude.md index b5954c1..3412897 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/codex.md b/docs/providers/codex.md index 505b308..b68847d 100644 --- a/docs/providers/codex.md +++ b/docs/providers/codex.md @@ -48,6 +48,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/kimicode.md b/docs/providers/kimicode.md index 8030088..a92356b 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/sync/README.md b/docs/sync/README.md index 5f1343e..64540b8 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 fb3edbb..10f886b 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,6 +1596,7 @@ 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" @@ -1432,6 +1629,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,6 +1651,7 @@ 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 .opencode: "opencode" diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index fefdffa..758dca1 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) } } @@ -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 0eea71f..d25637c 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 3a5d814..2fbbdbf 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 0000000..10aacd9 --- /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