diff --git a/docs/claude-fable-weekly-usage-meter.md b/docs/claude-fable-weekly-usage-meter.md index 4a37fee43e9..ae7bcc0f909 100644 --- a/docs/claude-fable-weekly-usage-meter.md +++ b/docs/claude-fable-weekly-usage-meter.md @@ -2,7 +2,7 @@ ## Problem -Claude Code now exposes weekly subscription usage alongside the 5-hour window, and its live `/usage` panel can also show a Fable-specific weekly bucket. Anthropic documents `rate_limits.five_hour` and `rate_limits.seven_day` in Claude Code statusline JSON, with weekly data present for Claude.ai subscribers after the first API response. The existing Orca Claude meter already has a weekly slot in shared state, but it needs a distinct Fable weekly slot so the status bar can show all three visible meters when Claude reports them. +Claude Code now exposes weekly subscription usage alongside the 5-hour window, and its live `/usage` panel can show an explicit Fable-specific weekly bucket. Anthropic documents `rate_limits.five_hour` and `rate_limits.seven_day` in Claude Code statusline JSON, with weekly data present for Claude.ai subscribers after the first API response. The existing Orca Claude meter already has a weekly slot in shared state, but it needs a distinct Fable weekly slot so the status bar can show all three visible meters when Claude reports them. Relevant code: @@ -17,6 +17,25 @@ Research: - Official Claude Code statusline docs: [`rate_limits.five_hour.used_percentage` and `rate_limits.seven_day.used_percentage`](https://code.claude.com/docs/en/statusline#available-data), plus matching `resets_at`, are the 5-hour and 7-day rate-limit fields. - `harveyxiacn/cc-usage-monitor` uses Claude Code's statusline `rate_limits` data and shows both [`5h` and `7d` windows](https://github.com/harveyxiacn/cc-usage-monitor), matching Orca's existing `session` and `weekly` model. - `leeguooooo/claude-code-usage-bar` independently exposes the same [`5h` and `7d` rate-limit usage](https://github.com/leeguooooo/claude-code-usage-bar) in a Claude Code statusLine integration. +- Fable is not part of the documented statusline schema above. Orca only treats it as Fable weekly usage when the live `/usage` panel renders a standalone `Fable` label or an OAuth response uses an explicit weekly/seven-day Fable field name. + +Redacted live `/usage` shape this parser targets: + +```text +Plan usage limits + +Current session +18% remaining +Resets in 2h 10m + +Current week (all models) +84% left +Resets in 5d 4h + +Fable +42% consumed +Resets in 3d 2h +``` ## Goal @@ -34,7 +53,7 @@ Make Orca's existing Claude status-bar meter show the weekly Claude and Fable us 2. Add `ProviderRateLimits.fableWeekly` as a distinct optional Claude window so the chip and popover can render Session, Weekly, and Fable simultaneously. 3. Accept both OAuth `utilization` windows and Claude Code-style `used_percentage` windows with epoch-second `resets_at` values. 4. Broaden the hidden Claude CLI parser so the generic weekly label accepts both old `Current week` wording and newer usage/statusline wording: `Weekly limits`, `Weekly usage`, `weekly rate limit`, and `7-day`. -5. Parse an explicit `Fable` label into `fableWeekly` instead of collapsing it into generic `weekly`. +5. Parse only a standalone `Fable` label into `fableWeekly` instead of collapsing it into generic `weekly`; ambiguous Fable copy is a section boundary, not a meter. 6. Broaden percent parsing to treat `consumed` like `used`, because Anthropic describes rate-limit percentages as consumed. 7. Add focused tests for the new weekly wording and retain existing old-copy coverage. @@ -43,7 +62,8 @@ Make Orca's existing Claude status-bar meter show the weekly Claude and Fable us - Weekly data may be absent for API-key users or before the first Claude API response; keep `weekly: null`. - The hidden PTY fallback may still only return session data; the status bar should continue showing the 5-hour meter without error. - Reset timestamps/descriptions may be absent from CLI output; keep `resetsAt: null` and parse only visible reset text. -- Fable data may be absent from the documented statusline payload even when the interactive `/usage` panel shows it; keep `fableWeekly: null` unless a distinct field or explicit `Fable` label is present. +- Fable data may be absent from the documented statusline payload even when the interactive `/usage` panel shows it; keep `fableWeekly: null` unless an explicit weekly/seven-day field or standalone `Fable` label is present. +- A bare OAuth `fable` field is ambiguous because it does not encode the window length; ignore it until the upstream contract is clearer. ## Rollout diff --git a/src/main/rate-limits/claude-fetcher.test.ts b/src/main/rate-limits/claude-fetcher.test.ts index ed5a971f97c..0790374ce0b 100644 --- a/src/main/rate-limits/claude-fetcher.test.ts +++ b/src/main/rate-limits/claude-fetcher.test.ts @@ -205,6 +205,42 @@ describe('fetchClaudeRateLimits', () => { }) }) + it('ignores bare Fable OAuth usage because the window length is ambiguous', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + five_hour: { utilization: 11 }, + seven_day: { utilization: 22 }, + fable: { utilization: 33 } + }), + { status: 200 } + ) + ) + + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { usedPercent: 11 }, + weekly: { usedPercent: 22 }, + fableWeekly: null + }) + }) + it('falls back to legacy Keychain credentials for host system default without an explicit config dir', async () => { const configDir = '/Users/test/.claude' const authPreparation: ClaudeRuntimeAuthPreparation = { diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index e9c72fbbe11..c89aef0473c 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -300,7 +300,6 @@ type OAuthUsageWindow = { type OAuthUsageResponse = { five_hour?: OAuthUsageWindow seven_day?: OAuthUsageWindow - fable?: OAuthUsageWindow fable_weekly?: OAuthUsageWindow fable_seven_day?: OAuthUsageWindow seven_day_fable?: OAuthUsageWindow @@ -378,13 +377,12 @@ function mapWindow( } function mapFableWeeklyWindow(data: OAuthUsageResponse): RateLimitWindow | null { - // Why: the Fable weekly bucket is newer than the documented statusline - // contract, so accept the observed likely field names without guessing usage. + // Why: a bare "fable" field does not prove the window length. Only accept + // explicit weekly/seven-day names for the distinct Fable meter. return ( mapWindow(data.fable_weekly, 10080) ?? mapWindow(data.fable_seven_day, 10080) ?? - mapWindow(data.seven_day_fable, 10080) ?? - mapWindow(data.fable, 10080) + mapWindow(data.seven_day_fable, 10080) ) } diff --git a/src/main/rate-limits/claude-pty.test.ts b/src/main/rate-limits/claude-pty.test.ts index 977eec808b5..a613194868e 100644 --- a/src/main/rate-limits/claude-pty.test.ts +++ b/src/main/rate-limits/claude-pty.test.ts @@ -258,6 +258,112 @@ describe('fetchViaPty', () => { }) }) + it('does not let an incomplete Fable section consume later usage sections', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + + const resultPromise = fetchViaPty() + + await vi.advanceTimersByTimeAsync(2_000) + term.emitData(` + Plan usage limits + + Fable + Usage unavailable + + Current session + 12% used + + Current week (all models) + 84% left + `) + await vi.advanceTimersByTimeAsync(2_000) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { + usedPercent: 12 + }, + weekly: { + usedPercent: 16 + }, + fableWeekly: null, + error: null + }) + }) + + it('does not treat inline Fable weekly text as a parsed usage label', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + + const resultPromise = fetchViaPty() + + await vi.advanceTimersByTimeAsync(2_000) + term.emitData(` + Plan usage limits + + Current session + Fable weekly usage + 42% consumed + + Current week (all models) + 84% left + `) + await vi.advanceTimersByTimeAsync(2_000) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: null, + weekly: { + usedPercent: 16 + }, + fableWeekly: null, + error: null + }) + }) + + it('keeps waiting after a bare Fable heading until another usage section renders', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + let settled = false + + const resultPromise = fetchViaPty().finally(() => { + settled = true + }) + + await vi.advanceTimersByTimeAsync(2_000) + term.emitData(` + Plan usage limits + + Fable + `) + await vi.advanceTimersByTimeAsync(2_000) + + expect(settled).toBe(false) + + term.emitData(` + 42% consumed + + Current session + 12% used + `) + await vi.advanceTimersByTimeAsync(2_000) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { + usedPercent: 12 + }, + fableWeekly: { + usedPercent: 42 + }, + error: null + }) + }) + it('parses 7-day weekly labels without the old Current week heading', async () => { const term = makeMockTerm() spawnMock.mockReturnValue(term) diff --git a/src/main/rate-limits/claude-pty.ts b/src/main/rate-limits/claude-pty.ts index b2e2caa3a3b..f0363eba3f2 100644 --- a/src/main/rate-limits/claude-pty.ts +++ b/src/main/rate-limits/claude-pty.ts @@ -20,7 +20,8 @@ const MAX_OUTPUT_LENGTH = 100_000 // 100KB buffer limit // Weekly labels have varied between "Current week" and "Weekly limits". const SESSION_RE = /current\s*session/i const WEEKLY_RE = /(?:current\s*week|weekly\s*(?:limits?|usage|rate\s*limits?)|7\s*[- ]?\s*day)/i -const FABLE_RE = /\bfable\b/i +const FABLE_WORD_RE = /\bfable\b/i +const FABLE_LABEL_RE = /^\s*fable\s*$/i const PERCENT_RE = /(\d{1,3})(?:\.\d+)?\s*%\s*(used|consumed|left|remaining|available)/i const RESET_LINE_RE = /resets?\s+(?:at\s+|in\s+)?(.+)/i const ESC = String.fromCharCode(27) @@ -36,18 +37,29 @@ function stripTerminalControlSequences(output: string): string { * Extract percent-left from lines following a label match. * Scans up to 12 lines after the label to find the associated percent. */ +function matchesWeeklyLabel(line: string): boolean { + return WEEKLY_RE.test(line) && !FABLE_WORD_RE.test(line) +} + +function matchesFableBoundary(line: string): boolean { + return FABLE_LABEL_RE.test(line) || (FABLE_WORD_RE.test(line) && WEEKLY_RE.test(line)) +} + +function isSectionLabel(line: string): boolean { + return SESSION_RE.test(line) || matchesWeeklyLabel(line) || matchesFableBoundary(line) +} + function extractPercentAfterLabel( lines: string[], - labelRe: RegExp, - options: { stopAtFable?: boolean } = {} + matchesLabel: (line: string) => boolean ): number | null { for (let i = 0; i < lines.length; i++) { - if (!labelRe.test(lines[i])) { + if (!matchesLabel(lines[i])) { continue } // Scan next 12 lines for a percent for (let j = i; j < Math.min(i + 12, lines.length); j++) { - if (j > i && options.stopAtFable === true && FABLE_RE.test(lines[j])) { + if (j > i && isSectionLabel(lines[j])) { break } const m = PERCENT_RE.exec(lines[j]) @@ -64,15 +76,14 @@ function extractPercentAfterLabel( function extractResetAfterLabel( lines: string[], - labelRe: RegExp, - options: { stopAtFable?: boolean } = {} + matchesLabel: (line: string) => boolean ): string | null { for (let i = 0; i < lines.length; i++) { - if (!labelRe.test(lines[i])) { + if (!matchesLabel(lines[i])) { continue } for (let j = i; j < Math.min(i + 14, lines.length); j++) { - if (j > i && options.stopAtFable === true && FABLE_RE.test(lines[j])) { + if (j > i && isSectionLabel(lines[j])) { break } const m = RESET_LINE_RE.exec(lines[j]) @@ -91,9 +102,9 @@ function parsePtyUsage(output: string): { } { const lines = output.split(/\r\n|\n|\r/) - const sessionPct = extractPercentAfterLabel(lines, SESSION_RE) - const weeklyPct = extractPercentAfterLabel(lines, WEEKLY_RE, { stopAtFable: true }) - const fableWeeklyPct = extractPercentAfterLabel(lines, FABLE_RE) + const sessionPct = extractPercentAfterLabel(lines, (line) => SESSION_RE.test(line)) + const weeklyPct = extractPercentAfterLabel(lines, matchesWeeklyLabel) + const fableWeeklyPct = extractPercentAfterLabel(lines, (line) => FABLE_LABEL_RE.test(line)) const session: RateLimitWindow | null = sessionPct !== null @@ -101,7 +112,7 @@ function parsePtyUsage(output: string): { usedPercent: Math.min(100, Math.max(0, sessionPct)), windowMinutes: 300, resetsAt: null, - resetDescription: extractResetAfterLabel(lines, SESSION_RE) + resetDescription: extractResetAfterLabel(lines, (line) => SESSION_RE.test(line)) } : null @@ -111,7 +122,7 @@ function parsePtyUsage(output: string): { usedPercent: Math.min(100, Math.max(0, weeklyPct)), windowMinutes: 10080, resetsAt: null, - resetDescription: extractResetAfterLabel(lines, WEEKLY_RE, { stopAtFable: true }) + resetDescription: extractResetAfterLabel(lines, matchesWeeklyLabel) } : null @@ -121,7 +132,7 @@ function parsePtyUsage(output: string): { usedPercent: Math.min(100, Math.max(0, fableWeeklyPct)), windowMinutes: 10080, resetsAt: null, - resetDescription: extractResetAfterLabel(lines, FABLE_RE) + resetDescription: extractResetAfterLabel(lines, (line) => FABLE_LABEL_RE.test(line)) } : null @@ -140,7 +151,6 @@ const STOP_SUBSTRINGS = [ 'Weekly limit', 'Weekly usage', '7-day', - 'Fable', 'Current session', 'Failed to load usage data', 'failed to load usage data'