mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
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:
@@ -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 = {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "利用不可",
|
||||
|
||||
@@ -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": "사용할 수 없음",
|
||||
|
||||
@@ -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": "不可用",
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user