Tighten Claude Fable usage parsing

This commit is contained in:
Neil
2026-07-02 01:23:05 -07:00
parent f9c0a8c7f5
commit 7f2df9589f
5 changed files with 194 additions and 24 deletions
+23 -3
View File
@@ -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
@@ -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 = {
+3 -5
View File
@@ -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)
)
}
+106
View File
@@ -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)
+26 -16
View File
@@ -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'