Support Claude weekly Fable usage meter (#7079)

* Support Claude weekly Fable usage meter

* Reference Claude weekly usage research

* Add distinct Claude Fable weekly meter

* Tighten Claude Fable usage parsing
This commit is contained in:
Neil
2026-07-02 01:34:44 -07:00
committed by GitHub
parent 9fc61aa7ca
commit 366746ad2b
16 changed files with 586 additions and 42 deletions
+75
View File
@@ -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.
@@ -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 = {
+53 -10
View File
@@ -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<ProviderRateLimits> {
await ensureProxyFromEnv()
@@ -374,6 +416,7 @@ async function fetchViaOAuth(token: string): Promise<ProviderRateLimits> {
provider: 'claude',
session: mapWindow(data.five_hour, 300),
weekly: mapWindow(data.seven_day, 10080),
fableWeekly: mapFableWeeklyWindow(data),
updatedAt: Date.now(),
error: null,
status: 'ok'
+219
View File
@@ -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
})
})
})
+65 -20
View File
@@ -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'
})
}
})
+1
View File
@@ -1110,6 +1110,7 @@ export class RateLimitService {
const previousHasData = Boolean(
previous?.session ||
previous?.weekly ||
previous?.fableWeekly ||
previous?.monthly ||
(previous?.buckets && previous.buckets.length > 0)
)
@@ -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 (
<div className={`flex w-full items-center gap-2 ${isFetching ? 'animate-pulse' : ''}`}>
@@ -943,7 +946,21 @@ function InlineUsageBars({
</span>
</div>
)}
{limits.status === 'error' && !limits.session && !limits.weekly && (
{fableLeft !== null && (
<div className="flex flex-1 items-center gap-1">
<div className="h-[4px] flex-1 overflow-hidden rounded-full bg-muted">
<div
className={`h-full rounded-full ${barColor(fableLeft)}`}
style={{ width: `${fableLeft}%` }}
/>
</div>
<span className="text-[10px] tabular-nums text-muted-foreground shrink-0">
{fableLeft}
{translate('auto.components.status.bar.StatusBar.54e8d6bb2d', '% Fable')}
</span>
</div>
)}
{limits.status === 'error' && !limits.session && !limits.weekly && !limits.fableWeekly && (
<span className="text-[10px] text-muted-foreground">
{translate('auto.components.status.bar.StatusBar.f19a63e7cd', 'Sign in to see usage')}
</span>
@@ -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 (
<span className="inline-flex items-center gap-1 text-muted-foreground">
<ProviderIcon provider={provider} />
@@ -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 (
<span className="inline-flex items-center gap-1 text-muted-foreground">
<ProviderIcon provider={provider} />
@@ -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 (
<span className="inline-flex items-center gap-1.5">
<ProviderIcon provider={provider} />
{p.session && !compact && <MiniBar leftPct={Math.max(0, 100 - p.session.usedPercent)} />}
{p.session && (
<WindowLabel w={p.session} label={formatWindowLabel(p.session.windowMinutes)} />
)}
{p.session && p.weekly && <span className="text-muted-foreground">·</span>}
{p.weekly && <WindowLabel w={p.weekly} label={formatWindowLabel(p.weekly.windowMinutes)} />}
{visibleWindows.map((window, index) => (
<React.Fragment key={window.key}>
{index > 0 && <span className="text-muted-foreground">·</span>}
<WindowLabel w={window.window} label={window.label} />
</React.Fragment>
))}
{isStale && <AlertTriangle size={11} className="text-muted-foreground/80" />}
</span>
)
@@ -1643,7 +1685,7 @@ export function ProviderDetailsMenu({
{iconOnly ? (
<span className="inline-flex items-center gap-1">
<span
className={`inline-block h-2 w-2 rounded-full ${provider.session || provider.weekly ? 'bg-muted-foreground/60' : 'bg-muted-foreground/30'}`}
className={`inline-block h-2 w-2 rounded-full ${provider.session || provider.weekly || provider.fableWeekly ? 'bg-muted-foreground/60' : 'bg-muted-foreground/30'}`}
/>
<span className="text-muted-foreground">
{provider.provider === 'claude'
@@ -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)
)
@@ -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',
@@ -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 (
<div className={`text-xs ${className ?? 'w-full'}`}>
<div className={`flex items-center gap-1.5 font-medium ${textClass}`}>
@@ -324,7 +330,7 @@ export function ProviderPanel({
{p.error ? (
<ErrorMessage
message={p.error}
stale={!!(p.session || p.weekly || p.monthly)}
stale={!!(p.session || p.weekly || p.fableWeekly || p.monthly)}
inverted={inverted}
/>
) : null}
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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": "利用不可",
+3
View File
@@ -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": "사용할 수 없음",
+3
View File
@@ -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": "不可用",
+2
View File
@@ -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). */