diff --git a/src/main/rate-limits/claude-pty.test.ts b/src/main/rate-limits/claude-pty.test.ts index a613194868e..acea13231c7 100644 --- a/src/main/rate-limits/claude-pty.test.ts +++ b/src/main/rate-limits/claude-pty.test.ts @@ -258,6 +258,49 @@ describe('fetchViaPty', () => { }) }) + it('parses Claude current-week Fable usage as a distinct weekly window', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + + const resultPromise = fetchViaPty() + + await vi.advanceTimersByTimeAsync(2_000) + term.emitData(` + Plan usage limits + + Current session + 8% used + Resets 3:39am + + Current week (all models) + 33% used + Resets Jul 3 at 12:59pm + + Current week (Fable) + 62% used + Resets Jul 3 at 12:59pm + `) + await vi.advanceTimersByTimeAsync(2_000) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { + usedPercent: 8, + resetDescription: '3:39am' + }, + weekly: { + usedPercent: 33, + resetDescription: 'Jul 3 at 12:59pm' + }, + fableWeekly: { + usedPercent: 62, + resetDescription: 'Jul 3 at 12:59pm' + }, + error: null + }) + }) + it('does not let an incomplete Fable section consume later usage sections', 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 f0363eba3f2..20919a391ca 100644 --- a/src/main/rate-limits/claude-pty.ts +++ b/src/main/rate-limits/claude-pty.ts @@ -22,6 +22,8 @@ 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_WORD_RE = /\bfable\b/i const FABLE_LABEL_RE = /^\s*fable\s*$/i +const FABLE_WEEKLY_LABEL_RE = + /(?:current\s*week|weekly\s*(?:limits?|usage|rate\s*limits?)|7\s*[- ]?\s*day)\s*(?:\([^)]*\bfable\b[^)]*\)|[-:]?\s*\bfable\b)/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) @@ -45,6 +47,12 @@ function matchesFableBoundary(line: string): boolean { return FABLE_LABEL_RE.test(line) || (FABLE_WORD_RE.test(line) && WEEKLY_RE.test(line)) } +function matchesFableUsageLabel(line: string): boolean { + // Why: broad Fable-weekly copy should stop nearby scans, but only a real + // Fable usage heading should produce the distinct Fable meter. + return FABLE_LABEL_RE.test(line) || FABLE_WEEKLY_LABEL_RE.test(line) +} + function isSectionLabel(line: string): boolean { return SESSION_RE.test(line) || matchesWeeklyLabel(line) || matchesFableBoundary(line) } @@ -104,7 +112,7 @@ function parsePtyUsage(output: string): { 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 fableWeeklyPct = extractPercentAfterLabel(lines, matchesFableUsageLabel) const session: RateLimitWindow | null = sessionPct !== null @@ -132,7 +140,7 @@ function parsePtyUsage(output: string): { usedPercent: Math.min(100, Math.max(0, fableWeeklyPct)), windowMinutes: 10080, resetsAt: null, - resetDescription: extractResetAfterLabel(lines, (line) => FABLE_LABEL_RE.test(line)) + resetDescription: extractResetAfterLabel(lines, matchesFableUsageLabel) } : null