diff --git a/docs/claude-fable-weekly-usage-meter.md b/docs/claude-fable-weekly-usage-meter.md new file mode 100644 index 00000000000..ae7bcc0f909 --- /dev/null +++ b/docs/claude-fable-weekly-usage-meter.md @@ -0,0 +1,75 @@ +# Claude Fable Weekly Usage Meter + +## Problem + +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: + +- `src/shared/rate-limit-types.ts:46` models provider usage with `session` and `weekly` windows. +- `src/main/rate-limits/claude-fetcher.ts:373` maps OAuth `five_hour` and `seven_day` into Orca's Claude provider state. +- `src/main/rate-limits/claude-pty.ts:18` parses hidden `claude` `/usage` output, but `WEEKLY_RE` only accepts `Current week`. +- `src/renderer/src/components/status-bar/StatusBar.tsx:1112` renders both session and weekly windows when both are present. +- `src/renderer/src/components/status-bar/tooltip.tsx:138` includes weekly usage in the details popover. + +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 + +Make Orca's existing Claude status-bar meter show the weekly Claude and Fable usage windows whenever Claude Code reports them, including newer `/usage` panel wording such as `Weekly limits`, `Fable`, or `7-day`. + +## Non-goals + +- Do not infer subscription quota from token logs. +- Do not spend user Claude quota during automated verification. +- Do not change provider account switching, polling cadence, or OAuth credential handling. + +## Design + +1. Keep `ProviderRateLimits.weekly` as the canonical generic 7-day UI field. OAuth already maps `seven_day` to `weekly`, and the status bar already renders it next to the 5-hour window. +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 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. + +## Edge Cases + +- 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 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 + +1. Update OAuth window mapping for statusline-style percentages, reset timestamps, and distinct Fable weekly fields when present. +2. Update `claude-pty` weekly label, Fable label, and percent parsing. +3. Add focused tests for statusline-style OAuth data, `Weekly limits`, `Fable`, and `7-day` wording. +4. Run focused tests, then typecheck/lint. +5. Validate in Electron by injecting a Claude provider state with 5-hour, generic weekly, and Fable weekly data and capturing status-bar screenshots. +6. Commit, push, open a PR, and attach screenshots in a PR comment. diff --git a/src/main/rate-limits/claude-fetcher.test.ts b/src/main/rate-limits/claude-fetcher.test.ts index 5c984c607e8..0790374ce0b 100644 --- a/src/main/rate-limits/claude-fetcher.test.ts +++ b/src/main/rate-limits/claude-fetcher.test.ts @@ -169,6 +169,78 @@ describe('fetchClaudeRateLimits', () => { ) }) + it('accepts Claude Code statusline-style rate limit window fields', 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: { used_percentage: 23.5, resets_at: 1770000000 }, + seven_day: { used_percentage: 41.2, resets_at: 1770604800 }, + fable_weekly: { used_percentage: 12.3, resets_at: 1770691200 } + }), + { status: 200 } + ) + ) + + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { usedPercent: 23.5, resetsAt: 1770000000000 }, + weekly: { usedPercent: 41.2, resetsAt: 1770604800000 }, + fableWeekly: { usedPercent: 12.3, resetsAt: 1770691200000 } + }) + }) + + 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 94f725c2366..c89aef0473c 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -293,27 +293,50 @@ function warnClaudeUsageFetchFailure( type OAuthUsageWindow = { utilization?: number - resets_at?: string + used_percentage?: number + resets_at?: string | number } type OAuthUsageResponse = { five_hour?: OAuthUsageWindow seven_day?: OAuthUsageWindow + fable_weekly?: OAuthUsageWindow + fable_seven_day?: OAuthUsageWindow + seven_day_fable?: OAuthUsageWindow } type ClaudeUsageAttemptState = { attemptedSources: UsageRateLimitSource[] } -function parseResetDescription(isoString: string | undefined): string | null { - if (!isoString) { +function parseResetTimestamp(value: string | number | undefined): number | null { + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + return null + } + return value > 10_000_000_000 ? value : value * 1000 + } + + if (!value) { + return null + } + + const numericValue = Number(value) + if (Number.isFinite(numericValue) && value.trim() !== '') { + return numericValue > 10_000_000_000 ? numericValue : numericValue * 1000 + } + + const parsed = new Date(value).getTime() + return Number.isNaN(parsed) ? null : parsed +} + +function parseResetDescription(resetValue: string | number | undefined): string | null { + const resetTimestamp = parseResetTimestamp(resetValue) + if (resetTimestamp === null) { return null } try { - const date = new Date(isoString) - if (isNaN(date.getTime())) { - return null - } + const date = new Date(resetTimestamp) const now = new Date() const isToday = date.toDateString() === now.toDateString() if (isToday) { @@ -333,17 +356,36 @@ function mapWindow( raw: OAuthUsageWindow | undefined, windowMinutes: number ): RateLimitWindow | null { - if (!raw || typeof raw.utilization !== 'number') { + if (!raw) { + return null + } + const usedPercent = + typeof raw.utilization === 'number' + ? raw.utilization + : typeof raw.used_percentage === 'number' + ? raw.used_percentage + : null + if (usedPercent === null) { return null } return { - usedPercent: Math.min(100, Math.max(0, raw.utilization)), + usedPercent: Math.min(100, Math.max(0, usedPercent)), windowMinutes, - resetsAt: raw.resets_at ? new Date(raw.resets_at).getTime() || null : null, + resetsAt: parseResetTimestamp(raw.resets_at), resetDescription: parseResetDescription(raw.resets_at) } } +function mapFableWeeklyWindow(data: OAuthUsageResponse): RateLimitWindow | null { + // 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) + ) +} + async function fetchViaOAuth(token: string): Promise { await ensureProxyFromEnv() @@ -374,6 +416,7 @@ async function fetchViaOAuth(token: string): Promise { provider: 'claude', session: mapWindow(data.five_hour, 300), weekly: mapWindow(data.seven_day, 10080), + fableWeekly: mapFableWeeklyWindow(data), updatedAt: Date.now(), error: null, status: 'ok' diff --git a/src/main/rate-limits/claude-pty.test.ts b/src/main/rate-limits/claude-pty.test.ts index 4fbf801975f..a613194868e 100644 --- a/src/main/rate-limits/claude-pty.test.ts +++ b/src/main/rate-limits/claude-pty.test.ts @@ -177,4 +177,223 @@ describe('fetchViaPty', () => { error: null }) }) + + it('parses the newer Claude weekly limits wording for Fable usage', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + + const resultPromise = fetchViaPty() + + await vi.advanceTimersByTimeAsync(2_000) + term.emitData(` + Plan usage limits + + Current session + 18% remaining + Resets in 2h 10m + + Weekly limits + Fable + 42% consumed + Resets in 3d 2h + `) + await vi.advanceTimersByTimeAsync(2_000) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { + usedPercent: 82, + resetDescription: '2h 10m' + }, + weekly: null, + fableWeekly: { + usedPercent: 42, + resetDescription: '3d 2h' + }, + error: null + }) + }) + + it('parses generic weekly and Fable weekly limits as separate windows', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + + const resultPromise = fetchViaPty() + + await vi.advanceTimersByTimeAsync(2_000) + term.emitData(` + 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 + `) + await vi.advanceTimersByTimeAsync(2_000) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { + usedPercent: 82, + resetDescription: '2h 10m' + }, + weekly: { + usedPercent: 16, + resetDescription: '5d 4h' + }, + fableWeekly: { + usedPercent: 42, + resetDescription: '3d 2h' + }, + error: null + }) + }) + + 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) + + const resultPromise = fetchViaPty() + + await vi.advanceTimersByTimeAsync(2_000) + term.emitData(` + Usage + + Current session + 12% used + + 7-day + 84% left + Resets Wed at 9:05 PM + `) + await vi.advanceTimersByTimeAsync(2_000) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { + usedPercent: 12 + }, + weekly: { + usedPercent: 16, + resetDescription: 'Wed at 9:05 PM' + }, + error: null + }) + }) }) diff --git a/src/main/rate-limits/claude-pty.ts b/src/main/rate-limits/claude-pty.ts index a010f145734..f0363eba3f2 100644 --- a/src/main/rate-limits/claude-pty.ts +++ b/src/main/rate-limits/claude-pty.ts @@ -17,10 +17,12 @@ const MAX_OUTPUT_LENGTH = 100_000 // 100KB buffer limit // Why: these patterns match the Claude CLI's /usage TUI panel output. // "Current session" shows a percent like "62% used" or "62% left". -// "Current week" section is similar. +// Weekly labels have varied between "Current week" and "Weekly limits". const SESSION_RE = /current\s*session/i -const WEEKLY_RE = /current\s*week/i -const PERCENT_RE = /(\d{1,3})(?:\.\d+)?\s*%\s*(used|left|remaining|available)/i +const WEEKLY_RE = /(?:current\s*week|weekly\s*(?:limits?|usage|rate\s*limits?)|7\s*[- ]?\s*day)/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) const BEL = String.fromCharCode(7) @@ -35,18 +37,36 @@ 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 extractPercentAfterLabel(lines: string[], labelRe: RegExp): number | null { +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[], + 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 && isSectionLabel(lines[j])) { + break + } const m = PERCENT_RE.exec(lines[j]) if (m) { const pct = parseFloat(m[1]) const word = m[2].toLowerCase() - const isUsed = word === 'used' + const isUsed = word === 'used' || word === 'consumed' return isUsed ? pct : 100 - pct } } @@ -54,12 +74,18 @@ function extractPercentAfterLabel(lines: string[], labelRe: RegExp): number | nu return null } -function extractResetAfterLabel(lines: string[], labelRe: RegExp): string | null { +function extractResetAfterLabel( + lines: string[], + 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 && isSectionLabel(lines[j])) { + break + } const m = RESET_LINE_RE.exec(lines[j]) if (m) { return m[1].trim().replace(/[)]+$/, '') @@ -72,11 +98,13 @@ function extractResetAfterLabel(lines: string[], labelRe: RegExp): string | null function parsePtyUsage(output: string): { session: RateLimitWindow | null weekly: RateLimitWindow | null + fableWeekly: RateLimitWindow | null } { const lines = output.split(/\r\n|\n|\r/) - const sessionPct = extractPercentAfterLabel(lines, SESSION_RE) - const weeklyPct = extractPercentAfterLabel(lines, WEEKLY_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 @@ -84,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 @@ -94,11 +122,21 @@ function parsePtyUsage(output: string): { usedPercent: Math.min(100, Math.max(0, weeklyPct)), windowMinutes: 10080, resetsAt: null, - resetDescription: extractResetAfterLabel(lines, WEEKLY_RE) + resetDescription: extractResetAfterLabel(lines, matchesWeeklyLabel) } : null - return { session, weekly } + const fableWeekly: RateLimitWindow | null = + fableWeeklyPct !== null + ? { + usedPercent: Math.min(100, Math.max(0, fableWeeklyPct)), + windowMinutes: 10080, + resetsAt: null, + resetDescription: extractResetAfterLabel(lines, (line) => FABLE_LABEL_RE.test(line)) + } + : null + + return { session, weekly, fableWeekly } } // Why: these substrings indicate the /usage TUI panel has finished @@ -109,6 +147,10 @@ const STOP_SUBSTRINGS = [ 'Current week (Opus)', 'Current week (Sonnet only)', 'Current week (Sonnet)', + 'Weekly limits', + 'Weekly limit', + 'Weekly usage', + '7-day', 'Current session', 'Failed to load usage data', 'failed to load usage data' @@ -237,12 +279,13 @@ export async function fetchViaPty(options?: { cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) // Even on timeout, try to parse whatever we collected const clean = stripTerminalControlSequences(output) - const { session, weekly } = parsePtyUsage(clean) - if (session || weekly) { + const { session, weekly, fableWeekly } = parsePtyUsage(clean) + if (session || weekly || fableWeekly) { resolve({ provider: 'claude', session, weekly, + fableWeekly, updatedAt: Date.now(), error: null, status: 'ok' @@ -288,9 +331,9 @@ export async function fetchViaPty(options?: { cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) const clean = stripTerminalControlSequences(output) - const { session, weekly } = parsePtyUsage(clean) + const { session, weekly, fableWeekly } = parsePtyUsage(clean) - if (!session && !weekly) { + if (!session && !weekly && !fableWeekly) { resolve({ provider: 'claude', session: null, @@ -304,6 +347,7 @@ export async function fetchViaPty(options?: { provider: 'claude', session, weekly, + fableWeekly, updatedAt: Date.now(), error: null, status: 'ok' @@ -385,17 +429,18 @@ export async function fetchViaPty(options?: { resolved = true clearTimeout(timeout) const clean = stripTerminalControlSequences(output) - const { session, weekly } = parsePtyUsage(clean) + const { session, weekly, fableWeekly } = parsePtyUsage(clean) resolve({ provider: 'claude', session, weekly, + fableWeekly, updatedAt: Date.now(), error: - session || weekly + session || weekly || fableWeekly ? null : withMacTailscaleDnsHint('CLI exited before /usage rendered', clean), - status: session || weekly ? 'ok' : 'error' + status: session || weekly || fableWeekly ? 'ok' : 'error' }) } }) diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 822dfa37101..42d8f8c3580 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -1110,6 +1110,7 @@ export class RateLimitService { const previousHasData = Boolean( previous?.session || previous?.weekly || + previous?.fableWeekly || previous?.monthly || (previous?.buckets && previous.buckets.length > 0) ) diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 4b15511ec7a..eb43c2c5625 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -912,6 +912,9 @@ function InlineUsageBars({ ? Math.max(0, Math.round(100 - limits.session.usedPercent)) : null const weeklyLeft = limits.weekly ? Math.max(0, Math.round(100 - limits.weekly.usedPercent)) : null + const fableLeft = limits.fableWeekly + ? Math.max(0, Math.round(100 - limits.fableWeekly.usedPercent)) + : null return (
@@ -943,7 +946,21 @@ function InlineUsageBars({
)} - {limits.status === 'error' && !limits.session && !limits.weekly && ( + {fableLeft !== null && ( +
+
+
+
+ + {fableLeft} + {translate('auto.components.status.bar.StatusBar.54e8d6bb2d', '% Fable')} + +
+ )} + {limits.status === 'error' && !limits.session && !limits.weekly && !limits.fableWeekly && ( {translate('auto.components.status.bar.StatusBar.f19a63e7cd', 'Sign in to see usage')} @@ -953,7 +970,7 @@ function InlineUsageBars({ } function isUnavailableInactiveUsage(limits: ProviderRateLimits | null | undefined): boolean { - return limits?.status === 'error' && !limits.session && !limits.weekly + return limits?.status === 'error' && !limits.session && !limits.weekly && !limits.fableWeekly } function InlineUsageSignInAction({ @@ -1053,7 +1070,7 @@ function ProviderSegment({ } // Fetching with no prior data - if (p.status === 'fetching' && !p.session && !p.weekly) { + if (p.status === 'fetching' && !p.session && !p.weekly && !p.fableWeekly) { return ( @@ -1072,7 +1089,7 @@ function ProviderSegment({ } // Error with no data - if (p.status === 'error' && !p.session && !p.weekly) { + if (p.status === 'error' && !p.session && !p.weekly && !p.fableWeekly) { return ( @@ -1109,15 +1126,40 @@ function ProviderSegment({ ) } + const visibleWindows = [ + p.session + ? { + key: 'session', + window: p.session, + label: formatWindowLabel(p.session.windowMinutes) + } + : null, + p.weekly + ? { + key: 'weekly', + window: p.weekly, + label: formatWindowLabel(p.weekly.windowMinutes) + } + : null, + p.fableWeekly + ? { + key: 'fableWeekly', + window: p.fableWeekly, + label: translate('auto.components.status.bar.StatusBar.a79c64f87e', 'Fable') + } + : null + ].filter((w): w is { key: string; window: RateLimitWindow; label: string } => w !== null) + return ( {p.session && !compact && } - {p.session && ( - - )} - {p.session && p.weekly && ·} - {p.weekly && } + {visibleWindows.map((window, index) => ( + + {index > 0 && ·} + + + ))} {isStale && } ) @@ -1643,7 +1685,7 @@ export function ProviderDetailsMenu({ {iconOnly ? ( {provider.provider === 'claude' diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts index bd2c5c1dcaa..b6e35c292fb 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts @@ -23,6 +23,7 @@ function hasUsageData(provider: ProviderRateLimits): boolean { return Boolean( provider.session || provider.weekly || + provider.fableWeekly || provider.monthly || (provider.buckets && provider.buckets.length > 0) ) diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index 356ecd7bd49..85d09ebc71b 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -274,6 +274,29 @@ describe('getWindowSections', () => { ]) }) + it('returns a separate Fable section when Claude reports Fable weekly usage', () => { + const p: ProviderRateLimits = { + provider: 'claude', + session: { usedPercent: 40, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: { usedPercent: 20, windowMinutes: 10080, resetsAt: null, resetDescription: null }, + fableWeekly: { + usedPercent: 42, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + }, + updatedAt: Date.now(), + error: null, + status: 'ok' + } + const sections = getWindowSections(p) + expect(sections).toEqual([ + { label: 'Session', window: p.session }, + { label: 'Weekly', window: p.weekly }, + { label: 'Fable', window: p.fableWeekly } + ]) + }) + it('returns session and weekly for empty buckets array', () => { const p: ProviderRateLimits = { provider: 'gemini', diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index 44b3e0d432a..61c56d6e5a7 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -158,6 +158,12 @@ export function getWindowSections( window: p.weekly } ] + if (p.fableWeekly !== undefined && p.fableWeekly !== null) { + sections.push({ + label: translate('auto.components.status.bar.tooltip.a79c64f87e', 'Fable'), + window: p.fableWeekly + }) + } if (p.monthly !== undefined && p.monthly !== null) { sections.push({ label: translate('auto.components.status.bar.tooltip.7f7f208060', 'Monthly'), @@ -229,7 +235,7 @@ export function ProviderPanel({ ) } - if (p.status === 'error' && !p.session && !p.weekly && !p.monthly) { + if (p.status === 'error' && !p.session && !p.weekly && !p.fableWeekly && !p.monthly) { return (
@@ -324,7 +330,7 @@ export function ProviderPanel({ {p.error ? ( ) : null} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 0695879260a..66b74f5c0b8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2903,7 +2903,9 @@ "c35af53b73": "Sign in", "f19a63e7cd": "Sign in to see usage", "5c938d39ac": "% wk", + "54e8d6bb2d": "% Fable", "d79c3362c4": "% 5h", + "a79c64f87e": "Fable", "8295903d17": "Restart live Claude terminals before continuing old conversations after switching.", "c98ea88392": "No other accounts", "9332ba8684": "Switch to", @@ -3067,6 +3069,7 @@ "6d6df77f41": "No data available", "7f7f208060": "Monthly", "252c096536": "Weekly", + "a79c64f87e": "Fable", "94038ad2fa": "Session", "2c35eca8d4": "Unable to fetch usage", "1292d4f2ee": "Unavailable", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 282c041e488..0981dc1e963 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2903,7 +2903,9 @@ "c35af53b73": "Iniciar sesión", "f19a63e7cd": "Inicia sesión para ver el uso", "5c938d39ac": "% semana", + "54e8d6bb2d": "% Fable", "d79c3362c4": "% 5h", + "a79c64f87e": "Fable", "8295903d17": "Reinicie los terminales Claude en vivo antes de continuar conversaciones antiguas después de cambiar.", "c98ea88392": "Ninguna otra cuenta", "9332ba8684": "Cambiar a", @@ -3067,6 +3069,7 @@ "6d6df77f41": "No hay datos disponibles", "7f7f208060": "Mensual", "252c096536": "Semanalmente", + "a79c64f87e": "Fable", "94038ad2fa": "Sesión", "2c35eca8d4": "No se puede recuperar el uso", "1292d4f2ee": "Indisponible", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index db058eddd76..b2e321fc659 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2903,7 +2903,9 @@ "c35af53b73": "サインイン", "f19a63e7cd": "サインインして使用状況を確認する", "5c938d39ac": "%週", + "54e8d6bb2d": "% Fable", "d79c3362c4": "% 5時間", + "a79c64f87e": "Fable", "8295903d17": "切り替え後に古い会話を続ける前に、ライブ Claude terminals を再起動します。", "c98ea88392": "他のアカウントはありません", "9332ba8684": "に切り替えます", @@ -3067,6 +3069,7 @@ "6d6df77f41": "利用可能なデータがありません", "7f7f208060": "毎月", "252c096536": "毎週", + "a79c64f87e": "Fable", "94038ad2fa": "セッション", "2c35eca8d4": "使用状況を取得できません", "1292d4f2ee": "利用不可", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 3c120d72f13..eb7378ea9fa 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2903,7 +2903,9 @@ "c35af53b73": "로그인", "f19a63e7cd": "사용량을 보려면 로그인하세요.", "5c938d39ac": "%주간", + "54e8d6bb2d": "% Fable", "d79c3362c4": "% 5h", + "a79c64f87e": "Fable", "8295903d17": "전환 후 이전 대화를 계속하기 전에 라이브 Claude terminals을 다시 시작하십시오.", "c98ea88392": "다른 계정 없음", "9332ba8684": "다음으로 전환", @@ -3067,6 +3069,7 @@ "6d6df77f41": "데이터가 없습니다", "7f7f208060": "월간", "252c096536": "주간", + "a79c64f87e": "Fable", "94038ad2fa": "세션", "2c35eca8d4": "사용량을 가져올 수 없습니다.", "1292d4f2ee": "사용할 수 없음", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 7510e6718e5..595230d7805 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2903,7 +2903,9 @@ "c35af53b73": "登录", "f19a63e7cd": "登录查看使用情况", "5c938d39ac": "% 周", + "54e8d6bb2d": "% Fable", "d79c3362c4": "% 5小时", + "a79c64f87e": "Fable", "8295903d17": "切换后继续旧对话之前,请重新启动实时 Claude 终端。", "c98ea88392": "没有其他账户", "9332ba8684": "切换到", @@ -3067,6 +3069,7 @@ "6d6df77f41": "无可用数据", "7f7f208060": "每月", "252c096536": "每周", + "a79c64f87e": "Fable", "94038ad2fa": "会话", "2c35eca8d4": "无法获取使用情况", "1292d4f2ee": "不可用", diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 1f22a545302..59a897f51e1 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -49,6 +49,8 @@ export type ProviderRateLimits = { session: RateLimitWindow | null /** 7-day weekly window, null if not available. */ weekly: RateLimitWindow | null + /** Claude Fable 7-day weekly window, null if not available. */ + fableWeekly?: RateLimitWindow | null /** 30-day monthly window (OpenCode Go only), null if not available. */ monthly?: RateLimitWindow | null /** Named per-model buckets (Gemini only). */