From e96967c22492fe312258bf6ab7f5883250cbc4be Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 9 Jun 2026 14:39:41 -0400 Subject: [PATCH 1/2] feat(bar): per-window subscription quota detail + codex multi-session scan Expose per-window detail (5h / weekly / Opus / Sonnet) on native subscription rows instead of collapsing to a single percentage, so the bar can show the binding window, resets, and a burn-rate pace line. Fix the Codex collector to scan recent session rollouts newest-first for the latest non-null rate_limits (today's exec-mode session is null) and mark the result stale with its source time, so Codex quota appears instead of silently vanishing. --- src/web-server/routes/bar-routes.ts | 55 +++++++- .../usage/codex-local-quota-collector.ts | 126 ++++++++++++++---- .../usage/native-quota-collector.ts | 83 +++++++++++- .../codex-local-quota-collector.test.ts | 121 ++++++++++++++--- .../web-server/native-quota-collector.test.ts | 102 +++++++++++++- 5 files changed, 441 insertions(+), 46 deletions(-) diff --git a/src/web-server/routes/bar-routes.ts b/src/web-server/routes/bar-routes.ts index 948f852a..7c9a1542 100644 --- a/src/web-server/routes/bar-routes.ts +++ b/src/web-server/routes/bar-routes.ts @@ -28,6 +28,32 @@ import type { DailyUsage, HourlyUsage } from '../usage/types'; // Types // ============================================================================ +/** + * Per-window quota detail for native subscription rows (Claude/Codex). + * + * Carries BOTH used and remaining percent so the macOS bar never re-derives + * them. CLIProxy rows omit the parent `quotaWindows` field entirely, keeping + * the payload backward compatible. + * + * JSON shape (decode test pins these exact keys): the inner object keys stay + * camelCase (usedPercent/remainingPercent/resetAt/windowMinutes); only the + * parent field name serializes to snake_case ("quota_windows"). + */ +export interface QuotaWindowDetail { + /** Stable key: "five_hour" | "seven_day" | "seven_day_opus" | "seven_day_sonnet". */ + key: string; + /** Display label: "5h" | "week" | "Opus · week" | "Sonnet · week". */ + label: string; + /** Used percentage (0-100). */ + usedPercent: number; + /** Remaining percentage (0-100). Carried explicitly, never derived in Swift. */ + remainingPercent: number; + /** ISO timestamp when this window resets, null if unknown. */ + resetAt: string | null; + /** Window length in minutes (300 | 10080), null if unknown. */ + windowMinutes: number | null; +} + /** Single account glance row returned by /api/bar/summary */ export interface BarSummaryRow { /** Account identifier (email or custom name) */ @@ -67,6 +93,18 @@ export interface BarSummaryRow { fetchedAt: string; /** True if account token is expired and needs re-authentication */ needsReauth: boolean; + /** + * Native-only per-window quota breakdown (Claude: 5h/week/opus/sonnet, + * Codex: 5h/week). CLIProxy rows OMIT this field so existing decode/encode + * tests and the Swift legacy path stay unaffected. Serialized as + * "quota_windows". + */ + quotaWindows?: QuotaWindowDetail[]; + /** + * Native-only ISO mtime of the source session that supplied a stale Codex + * reading. Present only when stale; serialized as "stale_as_of". + */ + staleAsOf?: string | null; } // ============================================================================ @@ -177,6 +215,21 @@ function withTimeout(p: Promise, ms: number): Promise { }); } +/** + * Map a row to its wire shape. The native-only additions use snake_case parent + * keys ("quota_windows" / "stale_as_of") to match the existing payload's mixed + * casing; inner QuotaWindowDetail keys stay camelCase and pass through as-is. + * Rows without the native fields serialize byte-identically to before, so + * CLIProxy decode/encode tests are unaffected. + */ +function serializeBarRow(row: BarSummaryRow): Record { + const { quotaWindows, staleAsOf, ...rest } = row; + const wire: Record = { ...rest }; + if (quotaWindows !== undefined) wire.quota_windows = quotaWindows; + if (staleAsOf !== undefined && staleAsOf !== null) wire.stale_as_of = staleAsOf; + return wire; +} + // ============================================================================ // Per-account health derivation // ============================================================================ @@ -496,7 +549,7 @@ export function createBarRouter(deps: BarRouterDeps): Router { const getNative = deps.getNativeAccountRows ?? (async () => [] as BarSummaryRow[]); const nativeRows = (await withTimeout(getNative(), NATIVE_SIDELOAD_TIMEOUT_MS)) ?? []; - res.json([...rows, ...nativeRows]); + res.json([...rows, ...nativeRows].map(serializeBarRow)); } catch (err) { console.error('[bar-routes] /summary error:', (err as Error).message); res.status(500).json({ error: 'Internal server error' }); diff --git a/src/web-server/usage/codex-local-quota-collector.ts b/src/web-server/usage/codex-local-quota-collector.ts index 1099c147..ffea21b5 100644 --- a/src/web-server/usage/codex-local-quota-collector.ts +++ b/src/web-server/usage/codex-local-quota-collector.ts @@ -2,19 +2,41 @@ * Codex local quota collector (zero network). * * Codex writes a `rate_limits` object into its rollout session logs - * (~/.codex/sessions////rollout-*.jsonl). We read the most recent - * session's last non-null rate_limits to surface the user's Codex subscription - * quota WITHOUT any network call — pure local file read. + * (~/.codex/sessions////rollout-*.jsonl). We surface the user's Codex + * subscription quota WITHOUT any network call — pure local file read. * - * Exec-mode sessions often never emit rate_limits (the field stays null); in - * that case we return null so the bar simply omits the Codex row rather than - * inventing a fake one. + * Exec-mode sessions often never emit rate_limits (the field stays null). The + * NEWEST session is frequently exec-mode, so reading only it would emit no row + * even though an older interactive session still carries real quota. We instead + * scan recent sessions newest-first and use the first one that yields a usable + * rate_limits, marking the result stale when that source file is old. We only + * return null when no scanned session has any rate_limits — then the bar omits + * the row rather than inventing a fake one. */ import * as fs from 'node:fs'; import * as path from 'node:path'; import { resolveCodexConfigPaths } from '../services/codex-dashboard-service'; +/** + * One Codex quota window normalized for the bar's per-window detail. + * `key`/`label` map primary->five_hour/"5h", secondary->seven_day/"week". + */ +export interface CodexLocalQuotaWindow { + /** Stable window key: "five_hour" | "seven_day". */ + key: string; + /** Short display label: "5h" | "week". */ + label: string; + /** Used percentage (0-100). */ + usedPercent: number; + /** Remaining percentage (0-100) = 100 - usedPercent. */ + remainingPercent: number; + /** ISO timestamp when this window resets, null if unknown. */ + resetAt: string | null; + /** Window length in minutes (300 / 10080), null if absent in the raw log. */ + windowMinutes: number | null; +} + /** Normalized Codex quota snapshot from a local session log. */ export interface CodexLocalQuota { /** Remaining percentage (0-100): min across primary/secondary windows. */ @@ -25,6 +47,13 @@ export interface CodexLocalQuota { tier: string | null; /** True when the source file is older than the freshness window. */ stale: boolean; + /** + * ISO mtime of the session file that SUPPLIED this data, present only when + * stale. Lets the bar render an "as of HH:mm (older session)" footnote. + */ + staleAsOf: string | null; + /** Per-window detail (primary -> five_hour, secondary -> seven_day). */ + windows: CodexLocalQuotaWindow[]; } /** Injectable seams for deterministic tests (no real fs / no tail subprocess). */ @@ -45,9 +74,17 @@ const TAIL_LINES = 200; /** A source older than this is reported stale (but still emitted). */ const STALE_AFTER_MS = 5 * 60 * 1000; +/** + * Cap on how many recent session files we scan newest-first before giving up. + * Bounds the walk when Codex genuinely never reported (avoids touching the whole + * history) while still reaching past several exec-mode sessions to a real one. + */ +const MAX_SESSIONS_SCANNED = 20; + interface CodexRateWindow { usedPercent: number; resetsAtSeconds: number | null; + windowMinutes: number | null; } interface CodexRateLimits { @@ -73,6 +110,7 @@ function parseWindow(value: unknown): CodexRateWindow | null { return { usedPercent, resetsAtSeconds: asFiniteNumber(obj['resets_at']), + windowMinutes: asFiniteNumber(obj['window_minutes']), }; } @@ -162,9 +200,52 @@ function computeNextReset(rate: CodexRateLimits): string | null { return new Date(Math.min(...resets) * 1000).toISOString(); } +function windowDetail(window: CodexRateWindow, key: string, label: string): CodexLocalQuotaWindow { + const usedPercent = Math.max(0, Math.min(100, window.usedPercent)); + return { + key, + label, + usedPercent, + remainingPercent: 100 - usedPercent, + resetAt: + window.resetsAtSeconds !== null + ? new Date(window.resetsAtSeconds * 1000).toISOString() + : null, + windowMinutes: window.windowMinutes, + }; +} + +/** Build per-window detail (primary -> 5h, secondary -> week), omitting absent. */ +function buildWindows(rate: CodexRateLimits): CodexLocalQuotaWindow[] { + const windows: CodexLocalQuotaWindow[] = []; + if (rate.primary) windows.push(windowDetail(rate.primary, 'five_hour', '5h')); + if (rate.secondary) windows.push(windowDetail(rate.secondary, 'seven_day', 'week')); + return windows; +} + +/** Scan one session file's tail backward for the latest non-null rate_limits. */ +async function readRateLimitsFromFile( + file: string, + tailLinesImpl: (file: string, lines: number) => Promise +): Promise { + let lines: string[]; + try { + lines = await tailLinesImpl(file, TAIL_LINES); + } catch { + return null; + } + for (let i = lines.length - 1; i >= 0; i--) { + const found = extractRateLimits(lines[i]); + if (found) return found; + } + return null; +} + /** - * Read the latest Codex session's most recent rate_limits and normalize it. - * Returns null when no session carries a usable rate_limits object. + * Scan recent Codex sessions newest-first and normalize the first usable + * rate_limits. Staleness is computed from the mtime of the file that SUPPLIED + * the data (not the newest file), so a real-but-old session reads correctly as + * stale. Returns null only when none of the scanned sessions carries quota. */ export async function getCodexLocalQuota( deps: CodexLocalQuotaDeps = {} @@ -182,31 +263,28 @@ export async function getCodexLocalQuota( const rolloutFiles = collectRolloutFiles(sessionsDir, existsImpl, readdirImpl); if (rolloutFiles.length === 0) return null; - // Filename carries an ISO timestamp, so the lexicographically-last file is - // the most recent session. - const latest = rolloutFiles[rolloutFiles.length - 1]; + // Filenames carry an ISO timestamp, so the lexicographic order is chronological. + // Walk newest-first, bounded, until a session yields a usable rate_limits. + const newestFirst = rolloutFiles.slice().reverse().slice(0, MAX_SESSIONS_SCANNED); - let lines: string[]; - try { - lines = await tailLinesImpl(latest, TAIL_LINES); - } catch { - return null; - } - - // Iterate backwards for the most recent non-null rate_limits. let rate: CodexRateLimits | null = null; - for (let i = lines.length - 1; i >= 0; i--) { - const found = extractRateLimits(lines[i]); + let sourceFile: string | null = null; + for (const file of newestFirst) { + const found = await readRateLimitsFromFile(file, tailLinesImpl); if (found) { rate = found; + sourceFile = file; break; } } - if (!rate) return null; + if (!rate || !sourceFile) return null; let stale = false; + let staleAsOf: string | null = null; try { - stale = now - statMtimeMsImpl(latest) > STALE_AFTER_MS; + const mtimeMs = statMtimeMsImpl(sourceFile); + stale = now - mtimeMs > STALE_AFTER_MS; + if (stale) staleAsOf = new Date(mtimeMs).toISOString(); } catch { // Unknown mtime -> treat as fresh; the data itself is still valid. stale = false; @@ -217,5 +295,7 @@ export async function getCodexLocalQuota( nextReset: computeNextReset(rate), tier: rate.planType, stale, + staleAsOf, + windows: buildWindows(rate), }; } diff --git a/src/web-server/usage/native-quota-collector.ts b/src/web-server/usage/native-quota-collector.ts index 1fac4721..80ea8eef 100644 --- a/src/web-server/usage/native-quota-collector.ts +++ b/src/web-server/usage/native-quota-collector.ts @@ -27,7 +27,7 @@ import { import { fetchClaudeQuotaWithToken } from '../../cliproxy/quota/quota-fetcher-claude'; import { getCodexLocalQuota, type CodexLocalQuota } from './codex-local-quota-collector'; import type { ClaudeQuotaResult } from '../../cliproxy/quota/quota-types'; -import type { BarSummaryRow } from '../routes/bar-routes'; +import type { BarSummaryRow, QuotaWindowDetail } from '../routes/bar-routes'; // ============================================================================ // Safety constants (concrete, named, module-level) @@ -174,7 +174,69 @@ function deriveClaudeNextReset(quota: ClaudeQuotaResult): string | null { return resets.length > 0 ? resets[0].iso : null; } +/** Window length in minutes by Claude rate-limit family. */ +const FIVE_HOUR_MINUTES = 300; +const SEVEN_DAY_MINUTES = 10080; + +/** + * Build the per-window detail for a Claude subscription row. + * + * 5h + weekly come from coreUsage (the canonical core summary). Opus/Sonnet + * weekly splits come from quota.windows[] and only exist on Max plans, so they + * are omitted entirely when absent. Each window carries BOTH used and remaining + * percent so the bar never re-derives them. + */ +function buildClaudeQuotaWindows(quota: ClaudeQuotaResult): QuotaWindowDetail[] { + const windows: QuotaWindowDetail[] = []; + + const fiveHour = quota.coreUsage?.fiveHour; + if (fiveHour) { + windows.push({ + key: 'five_hour', + label: '5h', + usedPercent: 100 - fiveHour.remainingPercent, + remainingPercent: fiveHour.remainingPercent, + resetAt: fiveHour.resetAt, + windowMinutes: FIVE_HOUR_MINUTES, + }); + } + + const weekly = quota.coreUsage?.weekly; + if (weekly) { + windows.push({ + key: 'seven_day', + label: 'week', + usedPercent: 100 - weekly.remainingPercent, + remainingPercent: weekly.remainingPercent, + resetAt: weekly.resetAt, + windowMinutes: SEVEN_DAY_MINUTES, + }); + } + + // Opus/Sonnet weekly splits are Max-only; surface them when the API carries + // them, otherwise omit so non-Max plans get exactly the two core windows. + const splitLabels: Record = { + seven_day_opus: 'Opus · week', + seven_day_sonnet: 'Sonnet · week', + }; + for (const w of quota.windows) { + const label = splitLabels[w.rateLimitType]; + if (!label) continue; + windows.push({ + key: w.rateLimitType, + label, + usedPercent: w.usedPercent, + remainingPercent: w.remainingPercent, + resetAt: w.resetAt, + windowMinutes: SEVEN_DAY_MINUTES, + }); + } + + return windows; +} + function buildClaudeRow(quota: ClaudeQuotaResult, tier: string | null, now: number): BarSummaryRow { + const quotaWindows = buildClaudeQuotaWindows(quota); return { account_id: CLAUDE_PROVIDER, provider: CLAUDE_PROVIDER, @@ -191,10 +253,26 @@ function buildClaudeRow(quota: ClaudeQuotaResult, tier: string | null, now: numb cached: false, fetchedAt: new Date(now).toISOString(), needsReauth: false, + // Omit the field entirely (rather than an empty array) when no windows + // resolved, so the wire shape stays minimal. + ...(quotaWindows.length > 0 ? { quotaWindows } : {}), }; } +/** Map the Codex local windows into the row's per-window detail shape. */ +function buildCodexQuotaWindows(quota: CodexLocalQuota): QuotaWindowDetail[] { + return quota.windows.map((w) => ({ + key: w.key, + label: w.label, + usedPercent: w.usedPercent, + remainingPercent: w.remainingPercent, + resetAt: w.resetAt, + windowMinutes: w.windowMinutes, + })); +} + function buildCodexRow(quota: CodexLocalQuota, now: number): BarSummaryRow { + const quotaWindows = buildCodexQuotaWindows(quota); return { account_id: CODEX_PROVIDER, provider: CODEX_PROVIDER, @@ -213,6 +291,9 @@ function buildCodexRow(quota: CodexLocalQuota, now: number): BarSummaryRow { cached: false, fetchedAt: new Date(now).toISOString(), needsReauth: false, + ...(quotaWindows.length > 0 ? { quotaWindows } : {}), + // staleAsOf is only present (and serialized) when the source session is old. + ...(quota.staleAsOf ? { staleAsOf: quota.staleAsOf } : {}), }; } diff --git a/tests/unit/web-server/codex-local-quota-collector.test.ts b/tests/unit/web-server/codex-local-quota-collector.test.ts index 682202d8..97a15e55 100644 --- a/tests/unit/web-server/codex-local-quota-collector.test.ts +++ b/tests/unit/web-server/codex-local-quota-collector.test.ts @@ -23,14 +23,25 @@ function tokenCountLine(rateLimits: unknown): string { }); } -function writeRollout(name: string, lines: string[]): string { - const day = path.join(sessionsDir, '2026', '06', '09'); +function writeRollout(sessions: string, name: string, lines: string[]): string { + const day = path.join(sessions, '2026', '06', '09'); fs.mkdirSync(day, { recursive: true }); const file = path.join(day, name); fs.writeFileSync(file, lines.join('\n') + '\n'); return file; } +/** + * Each test gets its OWN codex home so the multi-session scan never bleeds a + * fixture from another test (the scan walks ALL recent sessions, not just one). + */ +function freshHome(slug: string): { env: NodeJS.ProcessEnv; sessions: string } { + const home = path.join(tmpDir, `.codex-${slug}`); + const sessions = path.join(home, 'sessions'); + fs.mkdirSync(sessions, { recursive: true }); + return { env: { CODEX_HOME: home }, sessions }; +} + beforeAll(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-local-quota-')); codexHome = path.join(tmpDir, '.codex'); @@ -44,7 +55,8 @@ afterAll(() => { describe('getCodexLocalQuota', () => { it('parses the last non-null rate_limits into a normalized quota', async () => { - writeRollout('rollout-2026-06-09T10-00-00-aaaa.jsonl', [ + const { env, sessions } = freshHome('parse'); + writeRollout(sessions, 'rollout-2026-06-09T10-00-00-aaaa.jsonl', [ tokenCountLine(null), tokenCountLine({ primary: { used_percent: 0.0, window_minutes: 300, resets_at: 1781033803 }, @@ -53,7 +65,7 @@ describe('getCodexLocalQuota', () => { }), ]); - const quota = await getCodexLocalQuota({ env: { CODEX_HOME: codexHome }, now: Date.now() }); + const quota = await getCodexLocalQuota({ env, now: Date.now() }); expect(quota).not.toBeNull(); // min(100-0, 100-48) = 52 expect(quota?.quotaPercentage).toBe(52); @@ -61,20 +73,94 @@ describe('getCodexLocalQuota', () => { // soonest reset = min(1781033803, 1781192122) -> primary expect(quota?.nextReset).toBe(new Date(1781033803 * 1000).toISOString()); expect(quota?.stale).toBe(false); + expect(quota?.staleAsOf).toBeNull(); }); - it('returns null when every rate_limits is null (exec-mode session)', async () => { - // A newer session (later filename) with only null rate_limits wins the sort. - writeRollout('rollout-2026-06-09T11-00-00-bbbb.jsonl', [ + it('surfaces per-window detail incl. window_minutes (300 / 10080)', async () => { + const { env, sessions } = freshHome('windows'); + writeRollout(sessions, 'rollout-2026-06-09T10-00-00-aaaa.jsonl', [ + tokenCountLine({ + primary: { used_percent: 19.0, window_minutes: 300, resets_at: 1781033803 }, + secondary: { used_percent: 30.0, window_minutes: 10080, resets_at: 1781192122 }, + plan_type: 'pro', + }), + ]); + + const quota = await getCodexLocalQuota({ env, now: Date.now() }); + expect(quota?.windows).toHaveLength(2); + + const five = quota?.windows.find((w) => w.key === 'five_hour'); + expect(five?.label).toBe('5h'); + expect(five?.usedPercent).toBe(19); + expect(five?.remainingPercent).toBe(81); + expect(five?.windowMinutes).toBe(300); + expect(five?.resetAt).toBe(new Date(1781033803 * 1000).toISOString()); + + const week = quota?.windows.find((w) => w.key === 'seven_day'); + expect(week?.label).toBe('week'); + expect(week?.usedPercent).toBe(30); + expect(week?.remainingPercent).toBe(70); + expect(week?.windowMinutes).toBe(10080); + expect(week?.resetAt).toBe(new Date(1781192122 * 1000).toISOString()); + }); + + it('scans an OLDER session when the newest is exec-mode (rate_limits:null)', async () => { + const { env, sessions } = freshHome('fallback'); + // Older interactive session carries real quota. + writeRollout(sessions, 'rollout-2026-06-09T10-00-00-aaaa.jsonl', [ + tokenCountLine({ + primary: { used_percent: 0.0, window_minutes: 300, resets_at: 1781033803 }, + secondary: { used_percent: 48.0, window_minutes: 10080, resets_at: 1781192122 }, + plan_type: 'pro', + }), + ]); + // Newest session is exec-mode: only null rate_limits. + writeRollout(sessions, 'rollout-2026-06-09T11-00-00-bbbb.jsonl', [ tokenCountLine(null), tokenCountLine(null), ]); - const quota = await getCodexLocalQuota({ env: { CODEX_HOME: codexHome }, now: Date.now() }); + + const quota = await getCodexLocalQuota({ env, now: Date.now() }); + // Skips the null-newest file, reads the older file's rate_limits. + expect(quota).not.toBeNull(); + expect(quota?.quotaPercentage).toBe(52); + expect(quota?.tier).toBe('pro'); + }); + + it('returns null when NO scanned session carries rate_limits (no fake row)', async () => { + const { env, sessions } = freshHome('all-null'); + writeRollout(sessions, 'rollout-2026-06-09T10-00-00-aaaa.jsonl', [tokenCountLine(null)]); + writeRollout(sessions, 'rollout-2026-06-09T11-00-00-bbbb.jsonl', [ + tokenCountLine(null), + tokenCountLine(null), + ]); + const quota = await getCodexLocalQuota({ env, now: Date.now() }); expect(quota).toBeNull(); }); - it('flags stale when the source file mtime is older than 5 minutes', async () => { - const file = writeRollout('rollout-2026-06-09T12-00-00-cccc.jsonl', [ + it('flags stale from the SOURCE file mtime and sets staleAsOf', async () => { + const { env, sessions } = freshHome('stale'); + // Newest is exec-mode (null); the data comes from the older file, so stale + // must reflect the OLDER file's mtime, not the newest's. + const sourceFile = writeRollout(sessions, 'rollout-2026-06-09T10-00-00-aaaa.jsonl', [ + tokenCountLine({ + primary: { used_percent: 10, window_minutes: 300, resets_at: 1781033803 }, + secondary: { used_percent: 5, window_minutes: 10080, resets_at: 1781192122 }, + plan_type: 'plus', + }), + ]); + writeRollout(sessions, 'rollout-2026-06-09T11-00-00-bbbb.jsonl', [tokenCountLine(null)]); + + const sourceMtime = fs.statSync(sourceFile).mtimeMs; + const quota = await getCodexLocalQuota({ env, now: sourceMtime + 6 * 60 * 1000 }); + expect(quota?.stale).toBe(true); + expect(quota?.staleAsOf).toBe(new Date(sourceMtime).toISOString()); + expect(quota?.tier).toBe('plus'); + }); + + it('is fresh (no staleAsOf) when the source file is recent', async () => { + const { env, sessions } = freshHome('fresh'); + const file = writeRollout(sessions, 'rollout-2026-06-09T10-00-00-aaaa.jsonl', [ tokenCountLine({ primary: { used_percent: 10, window_minutes: 300, resets_at: 1781033803 }, secondary: { used_percent: 5, window_minutes: 10080, resets_at: 1781192122 }, @@ -82,19 +168,14 @@ describe('getCodexLocalQuota', () => { }), ]); const mtime = fs.statSync(file).mtimeMs; - // now = mtime + 6 minutes -> stale - const quota = await getCodexLocalQuota({ - env: { CODEX_HOME: codexHome }, - now: mtime + 6 * 60 * 1000, - }); - expect(quota?.stale).toBe(true); - expect(quota?.tier).toBe('plus'); + const quota = await getCodexLocalQuota({ env, now: mtime + 60 * 1000 }); + expect(quota?.stale).toBe(false); + expect(quota?.staleAsOf).toBeNull(); }); it('returns null when there are no rollout files at all', async () => { - const emptyHome = path.join(tmpDir, '.codex-empty'); - fs.mkdirSync(path.join(emptyHome, 'sessions'), { recursive: true }); - const quota = await getCodexLocalQuota({ env: { CODEX_HOME: emptyHome }, now: Date.now() }); + const { env } = freshHome('empty'); + const quota = await getCodexLocalQuota({ env, now: Date.now() }); expect(quota).toBeNull(); }); }); diff --git a/tests/unit/web-server/native-quota-collector.test.ts b/tests/unit/web-server/native-quota-collector.test.ts index 13501fa3..cd66991b 100644 --- a/tests/unit/web-server/native-quota-collector.test.ts +++ b/tests/unit/web-server/native-quota-collector.test.ts @@ -47,6 +47,34 @@ function successQuota(): ClaudeQuotaResult { }; } +/** A Max-plan quota carrying the Opus/Sonnet weekly splits in windows[]. */ +function maxQuotaWithSplits(): ClaudeQuotaResult { + const base = successQuota(); + return { + ...base, + windows: [ + { + rateLimitType: 'seven_day_opus', + label: 'Opus weekly', + status: 'allowed', + utilization: 0.25, + usedPercent: 25, + remainingPercent: 75, + resetAt: '2026-06-15T00:00:00.000Z', + }, + { + rateLimitType: 'seven_day_sonnet', + label: 'Sonnet weekly', + status: 'allowed', + utilization: 0.6, + usedPercent: 60, + remainingPercent: 40, + resetAt: '2026-06-15T00:00:00.000Z', + }, + ], + }; +} + function reauthQuota(): ClaudeQuotaResult { return { success: false, @@ -115,6 +143,51 @@ describe('Claude native row mapping', () => { expect(row?.needsReauth).toBe(false); }); + it('emits quotaWindows with five_hour + seven_day from coreUsage (no opus/sonnet on non-Max)', async () => { + const clock = { now: 1_000_000 }; + const deps = makeDeps(async () => successQuota(), clock); + const rows = await getNativeAccountRows(deps); + const row = rows.find((r) => r.provider === 'claude-code'); + + expect(row?.quotaWindows).toHaveLength(2); + const five = row?.quotaWindows?.find((w) => w.key === 'five_hour'); + expect(five?.label).toBe('5h'); + expect(five?.remainingPercent).toBe(42); + expect(five?.usedPercent).toBe(58); // 100 - 42 + expect(five?.windowMinutes).toBe(300); + expect(five?.resetAt).toBe('2026-06-09T20:00:00.000Z'); + + const week = row?.quotaWindows?.find((w) => w.key === 'seven_day'); + expect(week?.label).toBe('week'); + expect(week?.remainingPercent).toBe(70); + expect(week?.usedPercent).toBe(30); + expect(week?.windowMinutes).toBe(10080); + + // Max-only splits absent here. + expect(row?.quotaWindows?.find((w) => w.key === 'seven_day_opus')).toBeUndefined(); + expect(row?.quotaWindows?.find((w) => w.key === 'seven_day_sonnet')).toBeUndefined(); + }); + + it('adds seven_day_opus + seven_day_sonnet windows when present (Max plan)', async () => { + const clock = { now: 1_000_000 }; + const deps = makeDeps(async () => maxQuotaWithSplits(), clock); + const rows = await getNativeAccountRows(deps); + const row = rows.find((r) => r.provider === 'claude-code'); + + // five_hour, seven_day, seven_day_opus, seven_day_sonnet + expect(row?.quotaWindows).toHaveLength(4); + const opus = row?.quotaWindows?.find((w) => w.key === 'seven_day_opus'); + expect(opus?.label).toBe('Opus · week'); + expect(opus?.usedPercent).toBe(25); + expect(opus?.remainingPercent).toBe(75); + expect(opus?.windowMinutes).toBe(10080); + + const sonnet = row?.quotaWindows?.find((w) => w.key === 'seven_day_sonnet'); + expect(sonnet?.label).toBe('Sonnet · week'); + expect(sonnet?.usedPercent).toBe(60); + expect(sonnet?.remainingPercent).toBe(40); + }); + it('emits a reauth error row on 401/needsReauth', async () => { const clock = { now: 1_000_000 }; const deps = makeDeps(async () => reauthQuota(), clock); @@ -288,6 +361,25 @@ describe('Codex path', () => { nextReset: '2026-06-09T19:00:00.000Z', tier: 'pro', stale: false, + staleAsOf: null, + windows: [ + { + key: 'five_hour', + label: '5h', + usedPercent: 19, + remainingPercent: 81, + resetAt: '2026-06-09T19:00:00.000Z', + windowMinutes: 300, + }, + { + key: 'seven_day', + label: 'week', + usedPercent: 48, + remainingPercent: 52, + resetAt: '2026-06-14T00:00:00.000Z', + windowMinutes: 10080, + }, + ], }), now: () => clock.now, }; @@ -297,6 +389,9 @@ describe('Codex path', () => { expect(row?.quota_percentage).toBe(52); expect(row?.tier).toBe('pro'); expect(row?.health).toBe('ok'); + expect(row?.quotaWindows).toHaveLength(2); + expect(row?.quotaWindows?.[0].windowMinutes).toBe(300); + expect(row?.staleAsOf).toBeUndefined(); }); it('flags health warning when the Codex source is stale', async () => { @@ -308,11 +403,16 @@ describe('Codex path', () => { nextReset: null, tier: null, stale: true, + staleAsOf: '2026-06-09T13:30:00.000Z', + windows: [], }), now: () => clock.now, }; const rows = await getNativeAccountRows(deps); - expect(rows.find((r) => r.provider === 'codex')?.health).toBe('warning'); + const row = rows.find((r) => r.provider === 'codex'); + expect(row?.health).toBe('warning'); + // staleAsOf flows through so the bar can render the freshness footnote. + expect(row?.staleAsOf).toBe('2026-06-09T13:30:00.000Z'); }); it('omits the codex row when there is no rate_limits (exec-mode)', async () => { From 0e0b9121878a18d1e77b8418006468991fa3c981 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 9 Jun 2026 14:40:03 -0400 Subject: [PATCH 2/2] feat(bar-app): subscription-first cockpit with detailed quota card Lead the dropdown with subscriptions: a dedicated card shows the binding window as the hero (gauge, remaining %, reset countdown, and a burn-rate 'left at this pace' line), a secondary window, and the Opus/Sonnet split for Max, with a stale marker for older Codex data. Demote spend/usage to a compact strip and surface cross-tool headroom. Add pure burn-rate / time-to-exhaustion and binding-window math in Core. Kill the menu scroll indicator for real (NSScrollView hider) so content stays aligned. Spend-cap alerts default off (pay-per-use, opt-in). --- .../Sources/CCSBarApp/BarAnalyticsView.swift | 147 ++++------ .../Sources/CCSBarApp/BarCardFormatting.swift | 54 ++++ macos-bar/Sources/CCSBarApp/BarMenuView.swift | 122 +++++++-- .../Sources/CCSBarApp/BarPreferences.swift | 11 +- .../CCSBarApp/BarPreferencesView.swift | 11 +- .../CCSBarApp/BarSubscriptionCard.swift | 254 ++++++++++++++++++ .../Sources/CCSBarApp/ScrollerHider.swift | 43 +++ macos-bar/Sources/CCSBarCheck/main.swift | 254 ++++++++++++++++++ .../Sources/CCSBarCore/BarQuotaGauge.swift | 147 ++++++++++ macos-bar/Sources/CCSBarCore/BarSummary.swift | 79 +++++- 10 files changed, 996 insertions(+), 126 deletions(-) create mode 100644 macos-bar/Sources/CCSBarApp/BarCardFormatting.swift create mode 100644 macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift create mode 100644 macos-bar/Sources/CCSBarApp/ScrollerHider.swift diff --git a/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift b/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift index 028f5060..6abc79fc 100644 --- a/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift +++ b/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift @@ -1,14 +1,23 @@ import SwiftUI import CCSBarCore -/// Usage analytics block: spend rollups, a 30-day cost sparkline, surface -/// breakdown, and top models. +/// Demoted spend strip + surface/model breakdown. /// -/// Pivots on `hasRecentData`: when the trailing 30 days carry no spend, the three -/// dead Today/7d/30d cells read as "broken", so they collapse into one honest -/// "No usage in N days" line with all-time + last-active promoted to the hero. +/// Spend is informational pool context, NEVER the headline — so the loud 2×2 +/// StatCell grid + 28pt sparkline that used to dominate the dropdown collapses +/// into one muted caption line ("today $NN · 7d $N.Nk") with an optional thin +/// inline sparkline. The By-surface / Top-models breakdowns stay, tightened and +/// subordinate, below the pool accounts. +/// +/// When the trailing 30 days carry no spend the strip reads the honest idle line +/// ("No usage in N days") instead of three dead "$0.00" cells. struct BarAnalyticsView: View { let analytics: BarAnalytics + /// Which slot of the dropdown this instance renders. `.spend` is the thin strip + /// placed below the subscriptions cockpit; `.breakdown` is the by-surface / + /// top-models detail placed below the pool accounts. + enum Section { case spend, breakdown } + var section: Section = .spend private var lastActive: String? { BarFormatting.lastActiveLabel( @@ -16,20 +25,19 @@ struct BarAnalyticsView: View { } var body: some View { - VStack(alignment: .leading, spacing: 8) { - SectionLabel("Usage") - - if analytics.hasRecentData { - recentGrid - } else { - idleHero - } - - sparklineBlock + switch section { + case .spend: + spendStrip + case .breakdown: + breakdown + } + } + /// By-surface + top-models detail, tightened and subordinate. + @ViewBuilder private var breakdown: some View { + VStack(alignment: .leading, spacing: 6) { // Surface breakdown: "how much Claude Code vs Codex" — only shown when - // the backend supplies at least one surface entry. Top 5 keeps the section - // compact; the full list is available in the dashboard. + // the backend supplies at least one surface entry. Top 5 keeps it compact. if !analytics.bySurface.isEmpty { SectionLabel("By surface") let peakSurface = analytics.bySurface.map(\.cost).max() ?? 1 @@ -50,71 +58,44 @@ struct BarAnalyticsView: View { } } - /// Live spend grid (only when recent windows actually carry data). - private var recentGrid: some View { - VStack(spacing: 5) { - HStack(spacing: 5) { - StatCell(title: "Today", value: BarFormatting.money(analytics.today.cost)) - StatCell(title: "7 days", value: BarFormatting.money(analytics.last7d.cost)) - } - HStack(spacing: 5) { - StatCell(title: "30 days", value: BarFormatting.money(analytics.last30d.cost)) - StatCell(title: "All-time", value: BarFormatting.money(analytics.allTime.cost), accent: true) - } - } + /// True when there's any surface/model detail worth a divider + section. + var hasBreakdown: Bool { + !analytics.bySurface.isEmpty || !analytics.topModels.isEmpty } - /// Honest idle state: a single "No usage in N days" line, with all-time spend - /// promoted to the hero and the last-active caption underneath. - private var idleHero: some View { - VStack(alignment: .leading, spacing: 5) { - HStack(spacing: 6) { - Image(systemName: "moon.zzz") - .font(.caption) + /// The collapsed informational spend strip: a "SPEND" label, one muted caption + /// line, and a thin inline 30-day sparkline when there is real spend. + private var spendStrip: some View { + VStack(alignment: .leading, spacing: 3) { + SectionLabel("Spend") + if analytics.hasRecentData { + Text(spendCaption) + .font(.caption2) .foregroundStyle(.secondary) - Text(idleHeadline) - .font(.caption) - .foregroundStyle(.secondary) - } - StatCell( - title: "All-time spend", - value: BarFormatting.money(analytics.allTime.cost), - accent: true) - if let lastActive { - Text(lastActive) + if !sparklineIsEmpty { + Sparkline(values: analytics.byDay.map(\.cost)).frame(height: 16) + } + } else { + Text(idleCaption) .font(.caption2) .foregroundStyle(.secondary) } } } - private var idleHeadline: String { - if let d = analytics.daysSinceLastActivity { - return "No usage in \(d) days" - } - return "No usage in 30 days" + /// One-line rollup: "today $NN · 7d $N.Nk · 30d $N.Nk". + private var spendCaption: String { + "today \(BarFormatting.money(analytics.today.cost))" + + " · 7d \(BarFormatting.money(analytics.last7d.cost))" + + " · 30d \(BarFormatting.money(analytics.last30d.cost))" } - /// Sparkline over the 30-day series, with an honest placeholder when every day - /// is zero (a flat line with no context reads as broken). - private var sparklineBlock: some View { - VStack(alignment: .leading, spacing: 4) { - if sparklineIsEmpty { - Text(idleHeadline) - .font(.caption2).foregroundStyle(.secondary) - if let lastActive { - Text(lastActive).font(.caption2).foregroundStyle(.tertiary) - } - } else { - HStack { - Text("Last 30 days").font(.caption2).foregroundStyle(.secondary) - Spacer() - Text("\(BarFormatting.count(analytics.last30d.requests)) req") - .font(.caption2).foregroundStyle(.secondary) - } - Sparkline(values: analytics.byDay.map(\.cost)).frame(height: 28) - } - } + /// Honest idle caption when there's no recent spend, folding in last-active. + private var idleCaption: String { + let headline = + analytics.daysSinceLastActivity.map { "No usage in \($0) days" } ?? "No usage in 30 days" + if let lastActive { return "\(headline) · \(lastActive.lowercased())" } + return headline } private var sparklineIsEmpty: Bool { @@ -157,30 +138,6 @@ private struct SurfaceBar: View { } } -/// A small labelled stat tile. -private struct StatCell: View { - let title: String - let value: String - var accent: Bool = false - - var body: some View { - VStack(alignment: .leading, spacing: 2) { - Text(title.uppercased()) - .font(.system(size: 9, weight: .semibold)) - .foregroundStyle(.secondary) - Text(value) - .font(.system(.callout, design: .rounded).weight(.semibold)) - .foregroundStyle(accent ? BarTheme.accent : .primary) - .lineLimit(1) - .minimumScaleFactor(0.7) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, 5) - .padding(.horizontal, 9) - .background(Color.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: 7)) - } -} - /// One top-model row: name + spend with a proportional accent bar behind. private struct ModelBar: View { let model: BarAnalytics.Model diff --git a/macos-bar/Sources/CCSBarApp/BarCardFormatting.swift b/macos-bar/Sources/CCSBarApp/BarCardFormatting.swift new file mode 100644 index 00000000..cf353d3b --- /dev/null +++ b/macos-bar/Sources/CCSBarApp/BarCardFormatting.swift @@ -0,0 +1,54 @@ +import Foundation +import CCSBarCore + +/// App-side date phrasing for the subscription card. Kept out of Core so the +/// shared formatting contract there stays untouched. Parses ISO-8601 the same +/// way Core does (with and without fractional seconds) so timestamp handling +/// matches the rest of the bar. +enum BarCardFormatting { + /// Parse an ISO-8601 timestamp, tolerating an optional fractional-seconds + /// component. Mirrors Core's parser, which is module-internal there. + private static func isoDate(_ iso: String) -> Date? { + let withFraction = ISO8601DateFormatter() + withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let d = withFraction.date(from: iso) { return d } + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + return plain.date(from: iso) + } + + /// Compact reset form for the Opus/Sonnet split + secondary lines: + /// <24h → "2h 18m" + /// <7d → weekday, e.g. "Fri" + /// >=7d → "Jun 14" + /// Returns nil for a missing/unparseable timestamp (caller omits the clause). + static func shortReset(iso: String?, now: Date) -> String? { + guard let iso, let date = isoDate(iso) else { return nil } + let secs = date.timeIntervalSince(now) + if secs <= 0 { return "due" } + if secs < 24 * 3600 { + let total = Int(secs / 60) + let h = total / 60 + let m = total % 60 + return h > 0 ? "\(h)h \(m)m" : "\(m)m" + } + let fmt = DateFormatter() + fmt.locale = Locale(identifier: "en_US_POSIX") + if secs < 7 * 24 * 3600 { + fmt.dateFormat = "EEE" // weekday, e.g. "Fri" + } else { + fmt.dateFormat = "MMM d" // e.g. "Jun 14" + } + return fmt.string(from: date) + } + + /// Local wall-clock "HH:mm" for the Codex stale footnote, e.g. "13:42". + /// Returns nil for a missing/unparseable timestamp. + static func clockTime(iso: String?) -> String? { + guard let iso, let date = isoDate(iso) else { return nil } + let fmt = DateFormatter() + fmt.locale = Locale(identifier: "en_US_POSIX") + fmt.dateFormat = "HH:mm" + return fmt.string(from: date) + } +} diff --git a/macos-bar/Sources/CCSBarApp/BarMenuView.swift b/macos-bar/Sources/CCSBarApp/BarMenuView.swift index 86e1e01a..4078fec0 100644 --- a/macos-bar/Sources/CCSBarApp/BarMenuView.swift +++ b/macos-bar/Sources/CCSBarApp/BarMenuView.swift @@ -21,17 +21,18 @@ struct BarMenuView: View { if viewModel.offline { offlineState.padding(14) } else { - // Scrollbar is hidden: the panel chrome already constrains height and a - // visible scrollbar track adds visual clutter in an always-on-screen widget. - // Overflow is still fully scrollable — the indicator is just not shown. + // The scroll indicator is suppressed (.never, not just .hidden) AND the + // enclosing NSScrollView's scroller is hard-disabled via ScrollerHider: + // inside a MenuBarExtra popover the SwiftUI preference alone is sometimes + // ignored and a scroller track steals width + misaligns content. With the + // reorder + collapsed spend strip the important rows fit without scrolling + // for the common 1-4 subscription setup; the scroll only engages for + // genuine pool/model overflow. ScrollView { - VStack(alignment: .leading, spacing: 10) { - if let analytics = viewModel.analytics { - BarAnalyticsView(analytics: analytics) - } - - // In-dropdown alert list: surfaces the conditions the engine flagged - // this poll so users who deny system notifications still see them. + VStack(alignment: .leading, spacing: 8) { + // (1) ALERTS first — urgent quota crossings surface above everything. + // Spend-cap alerts are opt-in OFF by default, so by default only + // quota/reauth/cooldown conditions appear here. if !viewModel.activeAlerts.isEmpty { VStack(alignment: .leading, spacing: 6) { SectionLabel("Alerts") @@ -41,14 +42,35 @@ struct BarMenuView: View { } } + // (2) SUBSCRIPTIONS — the dominant section, opens here. accountsSection + + // (3) SPEND — demoted to a thin informational strip below the cockpit. + if let analytics = viewModel.analytics { + Divider() + BarAnalyticsView(analytics: analytics, section: .spend) + } + + // (4) POOL ACCOUNTS — compact generic rows, subordinate. + poolSection + + // (5) BY-SURFACE / TOP MODELS — tightened detail, below the pool. + if let analytics = viewModel.analytics, + BarAnalyticsView(analytics: analytics, section: .breakdown).hasBreakdown + { + BarAnalyticsView(analytics: analytics, section: .breakdown) + } + + // Zero-size AppKit bridge that disables the popover's NSScrollView + // scroller at runtime (belt-and-suspenders with .scrollIndicators). + ScrollerHider().frame(width: 0, height: 0) } .padding(12) } - .scrollIndicators(.hidden) - // 580 gives room for the full layout (2×2 grid + sparkline + surface section - // + top models + accounts) without wasted whitespace on a typical 1-4 account - // setup. The scroll still triggers gracefully when content overflows. + .scrollIndicators(.never) + // 580 fits the full reordered layout (subscription cards + spend strip + + // pool rows + surface/models) without wasted whitespace for a typical + // 1-4 account setup. Scroll engages gracefully only on real overflow. .frame(maxHeight: 580) } @@ -62,11 +84,12 @@ struct BarMenuView: View { } } - /// Accounts list. Native first-party subscriptions (Claude Code / Codex) render - /// in a top "Subscriptions" group so the user's own plan quota reads apart from - /// the rotating CLIProxy "Pool accounts". The split is suppressed when there are - /// no subscriptions (or no pool), so the established single "Accounts" header is - /// kept for the common CLIProxy-only setup. + /// The cockpit. Native subscriptions (Claude Code / Codex) render as detailed + /// `BarSubscriptionCard`s at the very top, ordered tightest-binding-first + /// (closest to empty on top) so the window the user is about to run out of + /// leads. CLIProxy pool accounts keep the compact generic `BarRowView` below, + /// subordinate. The two-section split is suppressed when only one kind is + /// present, preserving the single "Accounts" header for a CLIProxy-only setup. @ViewBuilder private var accountsSection: some View { let parts = BarFormatting.partitionSubscriptions(viewModel.rows) VStack(alignment: .leading, spacing: 6) { @@ -78,17 +101,29 @@ struct BarMenuView: View { Text("No accounts configured") .font(.caption) .foregroundStyle(.secondary) - } else if parts.subscriptions.isEmpty || parts.pool.isEmpty { - // Only one kind present: keep the single established header. + } else if parts.subscriptions.isEmpty { + // CLIProxy-only setup: keep the single established header + generic rows. SectionLabel("Accounts") - ForEach(viewModel.rows) { row in + ForEach(parts.pool) { row in BarRowView(row: row, viewModel: viewModel) } } else { - SectionLabel("Subscriptions") - ForEach(parts.subscriptions) { row in - BarRowView(row: row, viewModel: viewModel) + subscriptionsHeader(parts.subscriptions) + ForEach(orderedSubscriptions(parts.subscriptions)) { row in + BarSubscriptionCard(row: row) } + } + } + } + + /// CLIProxy pool accounts as compact generic rows — subordinate, rendered below + /// the spend strip. Suppressed entirely when there are no pool accounts, or + /// when there are no subscriptions (the CLIProxy-only path renders pool rows + /// under the single "Accounts" header in `accountsSection` instead). + @ViewBuilder private var poolSection: some View { + let parts = BarFormatting.partitionSubscriptions(viewModel.rows) + if !parts.subscriptions.isEmpty && !parts.pool.isEmpty { + VStack(alignment: .leading, spacing: 6) { SectionLabel("Pool accounts") ForEach(parts.pool) { row in BarRowView(row: row, viewModel: viewModel) @@ -97,6 +132,43 @@ struct BarMenuView: View { } } + /// "SUBSCRIPTIONS" header, with a right-aligned cross-tool headroom hint + /// ("most room: NN%") when there are >=2 subscriptions with quota data. + /// Falls back to the bare label otherwise. + @ViewBuilder private func subscriptionsHeader(_ subs: [BarSummaryRow]) -> some View { + HStack(alignment: .firstTextBaseline) { + SectionLabel("Subscriptions") + Spacer() + if let leader = BarQuotaGauge.headroomLeader(subs) { + Text("most room: \(leader.label) \(Int(leader.remainingPercent.rounded()))%") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + + /// Order subscription cards by tightest binding window ascending (closest to + /// empty on top). Rows with no binding window (error/reauth) sink to the bottom + /// so the actionable quota always leads. + private func orderedSubscriptions(_ subs: [BarSummaryRow]) -> [BarSummaryRow] { + subs.sorted { a, b in + let ra = BarQuotaGauge.selectBindingWindow(a.quotaWindows ?? [])?.remainingPercent + let rb = BarQuotaGauge.selectBindingWindow(b.quotaWindows ?? [])?.remainingPercent + switch (ra, rb) { + case let (.some(x), .some(y)): + if x != y { return x < y } + return (a.displayName ?? a.provider) < (b.displayName ?? b.provider) + case (.some, .none): + return true // a has quota, b doesn't → a first + case (.none, .some): + return false + case (.none, .none): + return (a.displayName ?? a.provider) < (b.displayName ?? b.provider) + } + } + } + private var header: some View { HStack(spacing: 8) { Image(nsImage: MenuBarIcon.headerImage()) diff --git a/macos-bar/Sources/CCSBarApp/BarPreferences.swift b/macos-bar/Sources/CCSBarApp/BarPreferences.swift index 0e98dab5..7e4e0467 100644 --- a/macos-bar/Sources/CCSBarApp/BarPreferences.swift +++ b/macos-bar/Sources/CCSBarApp/BarPreferences.swift @@ -20,8 +20,17 @@ struct BarPreferences { /// Seed the registration domain so missing keys resolve to their real defaults /// rather than the type-zero value. Idempotent — safe to call on every launch. + /// + /// The two pay-per-use spend-cap alerts default to OFF (opt-in): subscriptions + /// are flat-rate, so a spend alert on them is meaningless, and pool spend is + /// informational context, never a default-on alert. Quota / reauth / cooldown + /// stay default-on (the quota-first alert set). The caps themselves keep Core's + /// sane values so a user who opts in starts with $500 / $10000. func registerDefaults() { - defaults.register(defaults: BarAlertPrefsStore.registrationDefaults) + var d = BarAlertPrefsStore.registrationDefaults + d[BarAlertPrefsStore.Key.dailyEnabled] = false + d[BarAlertPrefsStore.Key.monthEnabled] = false + defaults.register(defaults: d) } /// Read the current preferences. Pulls each key into a plain dictionary and diff --git a/macos-bar/Sources/CCSBarApp/BarPreferencesView.swift b/macos-bar/Sources/CCSBarApp/BarPreferencesView.swift index 2009887e..7ee26bfb 100644 --- a/macos-bar/Sources/CCSBarApp/BarPreferencesView.swift +++ b/macos-bar/Sources/CCSBarApp/BarPreferencesView.swift @@ -77,16 +77,19 @@ struct BarPreferencesView: View { } } + /// Pay-per-use spend caps. Opt-in (OFF by default) and labelled for pool + /// accounts, NOT subscriptions: flat-rate subscription plans have no spend to + /// cap, so these alerts only make sense for metered pool usage. private var spendSection: some View { - Section("Spend") { - Toggle("Alert on daily spend", isOn: $draft.dailySpendEnabled) + Section("Opt-in · pay-per-use spend") { + Toggle("Daily spend cap (pool accounts)", isOn: $draft.dailySpendEnabled) .onChange(of: draft.dailySpendEnabled) { _ in writeThrough() } capRow(label: "Daily cap", value: $draft.dailyCapUSD, enabled: draft.dailySpendEnabled) - Toggle("Alert on monthly spend", isOn: $draft.monthSpendEnabled) + Toggle("Monthly spend cap (pool accounts)", isOn: $draft.monthSpendEnabled) .onChange(of: draft.monthSpendEnabled) { _ in writeThrough() } capRow(label: "Month cap", value: $draft.monthCapUSD, enabled: draft.monthSpendEnabled) - Text("Monthly cap measures calendar month-to-date, so it resets at the start of each billing month.") + Text("Subscriptions are flat-rate and unaffected. These caps only watch metered pay-per-use pool spend, and are off until you enable them.") .font(.caption2) .foregroundStyle(.secondary) } diff --git a/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift b/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift new file mode 100644 index 00000000..aabcf133 --- /dev/null +++ b/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift @@ -0,0 +1,254 @@ +import SwiftUI +import CCSBarCore + +/// Dedicated, detailed card for a native first-party subscription (Claude Code / +/// Codex). Replaces the cramped generic `BarRowView` for native rows so the +/// user's own plan quota reads as the cockpit hero: a single-column vertical +/// stack, one fact per line, no 2-column flex wrap. +/// +/// Why this exists separately from `BarRowView`: the generic row double-labelled +/// the product, showed a confusing "no data" cost cell, and carried pause/solo + +/// tier-lock controls that make no sense for a non-routable flat-rate +/// subscription. Stripping those four elements and stacking the windows +/// vertically is the de-cramping fix. All binding/burn math is pulled fresh at +/// render from `BarQuotaGauge` (pure Core) so the pace clause reflects `now`. +struct BarSubscriptionCard: View { + let row: BarSummaryRow + /// Injected so the pace/countdown math is deterministic; defaults to the live + /// clock in production and is pinned in previews. + var now: Date = Date() + + /// Windows decoded from the row; empty for error/reauth rows (no hero gauge). + private var windows: [QuotaWindowDetail] { row.quotaWindows ?? [] } + + /// The window the subscription runs out of first (lowest remaining). Drives the + /// hero gauge; nil when there is no quota data (error/reauth row). + private var binding: QuotaWindowDetail? { + BarQuotaGauge.selectBindingWindow(windows) + } + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + titleRow + if let binding { + heroBlock(binding) + if let secondary = secondaryWindow(excluding: binding) { + secondaryLine(secondary) + } + opusSonnetLines + staleFootnote + } else { + emptyState + } + } + .padding(.vertical, 9) + .padding(.horizontal, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + BarTheme.subscription.opacity(0.07), + in: RoundedRectangle(cornerRadius: 9)) + } + + // MARK: Title + + /// Product name + tier chip. No provider-chip echo, no "subscription" badge + /// (the section header carries that), no pause toggle, no overflow menu — + /// those four removals are the de-cramping fix. + private var titleRow: some View { + HStack(spacing: 6) { + Circle() + .fill(healthColor) + .frame(width: 8, height: 8) + Text(BarFormatting.providerLabel(row.provider)) + .font(.system(.body, design: .default).weight(.semibold)) + .lineLimit(1) + if row.needsReauth { + Chip("reauth", tint: .red) + } + Spacer(minLength: 4) + if let tier = row.tier { + Chip(tier, tint: BarTheme.subscription) + } + } + } + + // MARK: Hero (binding window) + + private func heroBlock(_ w: QuotaWindowDetail) -> some View { + let band = BarQuotaGauge.band(percentage: w.remainingPercent, status: "ok") + let fill = BarQuotaGauge.fillFraction(percentage: w.remainingPercent, status: "ok") ?? 0 + return VStack(alignment: .leading, spacing: 5) { + HStack(alignment: .center, spacing: 8) { + heroGauge(fill: fill, color: color(for: band)) + Text("\(Int(w.remainingPercent.rounded()))% left") + .font(.system(.callout, design: .rounded).weight(.semibold)) + .foregroundStyle(color(for: band)) + } + Text(heroFacts(w)) + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + /// Full-width hero gauge bar, filled by the remaining fraction and tinted by + /// the severity band (reuses Core band/fill math). + private func heroGauge(fill: Double, color: Color) -> some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(Color.primary.opacity(0.12)) + Capsule() + .fill(color) + .frame(width: max(3, geo.size.width * fill)) + } + } + .frame(height: 7) + .frame(maxWidth: .infinity) + } + + /// One wrapped facts line: "