Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- Claude Fable 5 weekly limit is now parsed from both the CLI `/usage` output
("Current week (Fable)") and the OAuth usage API's new `limits` array, shown as a
quota card in the window and selectable as a menu-bar metric. The API-side parsing
is generic over model-scoped limits, so future scoped models appear automatically.

### Fixed
- Claude CLI probe no longer fails on every tick with recent Claude CLI versions.
The `/usage` screen grew taller than the probe's 50-row terminal (usage-contribution
report), scrolling the quota sections off the visible screen; the terminal renderer
now includes scrollback, so all sections are parsed again.

---

## [0.4.69] - 2026-06-25
Expand Down
63 changes: 63 additions & 0 deletions Sources/Infrastructure/Claude/ClaudeAPIUsageProbe.swift
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,38 @@ public struct ClaudeAPIUsageProbe: UsageProbe, @unchecked Sendable {
))
}

// Parse model-scoped limits from the generic `limits` array (e.g. Fable).
// Session/weekly entries there mirror `five_hour`/`seven_day` and are
// skipped; a model already covered by a legacy field is not duplicated.
// If the legacy `five_hour`/`seven_day` fields ever go null (as
// `seven_day_opus`/`seven_day_sonnet` did), extend this loop to the
// `session`/`weekly_all` kinds.
for entry in response.limits ?? [] {
guard entry.kind == "weekly_scoped",
// Key on the first word of the display name ("Fable 5" -> "fable")
// — must stay in sync with the key the CLI probe hardcodes so a
// persisted "model:<name>" menu-bar selection survives switching
// probe modes.
let modelName = entry.scope?.model?.displayName?
.split(separator: " ").first.map({ $0.lowercased() }),
!modelName.isEmpty,
let percent = entry.percent else {
continue
}
let quotaType = QuotaType.modelSpecific(modelName)
guard !quotas.contains(where: { $0.quotaType == quotaType }) else {
continue
}
let resetsAt = parseISODate(entry.resetsAt)
quotas.append(UsageQuota(
percentRemaining: 100.0 - percent,
quotaType: quotaType,
providerId: "claude",
resetsAt: resetsAt,
resetText: formatResetText(resetsAt)
))
}

// Parse extra usage
// API returns used_credits and monthly_limit in cents, convert to dollars
var costUsage: CostUsage?
Expand Down Expand Up @@ -586,13 +618,44 @@ private struct UsageResponse: Decodable {
let sevenDaySonnet: UsageQuotaData?
let sevenDayOpus: UsageQuotaData?
let extraUsage: ExtraUsageData?
let limits: [LimitEntry]?

enum CodingKeys: String, CodingKey {
case fiveHour = "five_hour"
case sevenDay = "seven_day"
case sevenDaySonnet = "seven_day_sonnet"
case sevenDayOpus = "seven_day_opus"
case extraUsage = "extra_usage"
case limits
}
}

/// Entry in the newer generic `limits` array. Model-scoped limits (e.g. Fable)
/// are reported here as `kind: "weekly_scoped"` with the model in `scope`,
/// instead of dedicated `seven_day_<model>` fields.
private struct LimitEntry: Decodable {
let kind: String?
let percent: Double?
let resetsAt: String?
let scope: LimitScope?

enum CodingKeys: String, CodingKey {
case kind
case percent
case resetsAt = "resets_at"
case scope
}
}

private struct LimitScope: Decodable {
let model: LimitScopeModel?
}

private struct LimitScopeModel: Decodable {
let displayName: String?

enum CodingKeys: String, CodingKey {
case displayName = "display_name"
}
}

Expand Down
19 changes: 18 additions & 1 deletion Sources/Infrastructure/Claude/ClaudeUsageProbe.swift
Original file line number Diff line number Diff line change
Expand Up @@ -290,12 +290,14 @@ public final class ClaudeUsageProbe: UsageProbe, @unchecked Sendable {
// Extract percentages
let sessionPct = extractPercent(labelSubstring: "Current session", text: clean)
let weeklyPct = extractPercent(labelSubstring: "Current week (all models)", text: clean)
// Check for model-specific quota (Opus or Sonnet)
// Check for model-specific quota (Opus, Sonnet, or Fable)
let opusPct = extractPercent(labelSubstring: "Current week (Opus)", text: clean)
let sonnetPct = extractPercent(labelSubstrings: [
"Current week (Sonnet only)",
"Current week (Sonnet)",
], text: clean)
// Paren-open anchor also matches a future "Current week (Fable 5)" label
let fablePct = extractPercent(labelSubstring: "Current week (Fable", text: clean)

guard let sessionPct else {
AppLog.probes.error("Claude parse failed: could not find 'Current session' percentage in output")
Expand Down Expand Up @@ -349,6 +351,21 @@ public final class ClaudeUsageProbe: UsageProbe, @unchecked Sendable {
))
}

if let fablePct {
// Promotional Fable window can reset at a different time than the
// all-models weekly, so anchor on its own section before falling back.
// The model key must match what the API probe derives from the scoped
// limit's display name ("fable").
let fableReset = extractReset(labelSubstring: "Current week (Fable", text: clean) ?? weeklyReset
quotas.append(UsageQuota(
percentRemaining: Double(fablePct),
quotaType: .modelSpecific("fable"),
providerId: "claude",
resetsAt: parseResetDate(fableReset),
resetText: cleanResetText(fableReset)
))
}

// Extract Extra usage for Pro accounts (if enabled)
let extraUsage = extractExtraUsage(clean)

Expand Down
22 changes: 16 additions & 6 deletions Sources/Infrastructure/Shared/TerminalRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ public final class TerminalRenderer {
private let cols: Int
private let rows: Int

/// Scrollback capacity in lines. Output longer than `rows + scrollback`
/// loses its OLDEST lines — for `claude /usage` that's the quota sections
/// at the top, so keep this comfortably above the tallest known screen.
private let scrollback = 2000

/// Creates a terminal renderer with the specified dimensions.
/// - Parameters:
/// - cols: Number of columns (default: 160)
Expand All @@ -41,7 +46,7 @@ public final class TerminalRenderer {
public func render(_ raw: String) -> String {
let delegate = RenderDelegate()
// Enable convertEol to handle \n as \r\n (newline + carriage return)
let options = TerminalOptions(cols: cols, rows: rows, convertEol: true)
let options = TerminalOptions(cols: cols, rows: rows, convertEol: true, scrollback: scrollback)
let terminal = Terminal(delegate: delegate, options: options)

// Feed the raw output to the terminal emulator
Expand All @@ -51,14 +56,19 @@ public final class TerminalRenderer {
return extractScreenText(from: terminal)
}

/// Extracts text content from the terminal buffer.
/// Extracts text content from the terminal buffer, including scrollback.
///
/// Output taller than the terminal (e.g. `claude /usage`'s usage-contribution
/// report) scrolls its earlier lines off the visible screen into scrollback;
/// reading only the visible rows would silently drop them.
private func extractScreenText(from terminal: Terminal) -> String {
var lines: [String] = []

// Iterate through all lines in the terminal buffer
for row in 0..<rows {
guard let line = terminal.getLine(row: row) else {
lines.append("")
// Iterate the whole buffer (scrollback + visible screen).
// getScrollInvariantLine returns nil past the end of the buffer, and
// for rows trimmed off the front when scrollback overflows.
for row in 0..<(rows + scrollback) {
guard let line = terminal.getScrollInvariantLine(row: row) else {
continue
}

Expand Down
9 changes: 9 additions & 0 deletions Tests/DomainTests/Provider/QuotaTypeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ struct QuotaTypeTests {
#expect(QuotaType.modelSpecific("haiku").displayName == "Haiku")
}

@Test
func `fable quota key round trips through persistence`() {
let fable = QuotaType.modelSpecific("fable")
#expect(fable.displayName == "Fable")
#expect(fable.shortLabel == "Fable")
#expect(fable.quotaKey == "model:fable")
#expect(QuotaType(quotaKey: "model:fable") == fable)
}

@Test
func `model specific quota handles multi-word names`() {
// .capitalized capitalizes each word
Expand Down
142 changes: 142 additions & 0 deletions Tests/InfrastructureTests/Claude/ClaudeAPIUsageProbeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,148 @@ struct ClaudeAPIUsageProbeTests {
#expect(opusQuota?.percentRemaining == 40.0) // 100 - 60
}

@Test
func `probe parses fable quota from scoped limits array`() async throws {
let tempDir = try makeTemporaryDirectory()
defer { try? FileManager.default.removeItem(at: tempDir) }

let futureExpiry = Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000
try createCredentialsFile(at: tempDir, expiresAt: futureExpiry)

let mockNetwork = MockNetworkClient()
// Newer API responses report model limits via a generic "limits" array
// (kind "weekly_scoped" + scope.model.display_name) instead of
// dedicated seven_day_<model> fields.
let responseJSON = """
{
"five_hour": { "utilization": 23.0, "resets_at": "2026-07-02T07:09:59Z" },
"seven_day": { "utilization": 10.0, "resets_at": "2026-07-02T10:59:59Z" },
"seven_day_opus": null,
"seven_day_sonnet": null,
"limits": [
{ "kind": "session", "group": "session", "percent": 23, "resets_at": "2026-07-02T07:09:59Z", "scope": null },
{ "kind": "weekly_all", "group": "weekly", "percent": 10, "resets_at": "2026-07-02T10:59:59Z", "scope": null },
{ "kind": "weekly_scoped", "group": "weekly", "percent": 17, "resets_at": "2026-07-02T11:00:00Z",
"scope": { "model": { "id": null, "display_name": "Fable" }, "surface": null } }
]
}
""".data(using: .utf8)!

let response = HTTPURLResponse(
url: URL(string: "https://api.anthropic.com")!,
statusCode: 200,
httpVersion: nil,
headerFields: nil
)!

given(mockNetwork).request(.any).willReturn((responseJSON, response))

let loader = ClaudeCredentialLoader(homeDirectory: tempDir.path, useKeychain: false)
let probe = ClaudeAPIUsageProbe(credentialLoader: loader, networkClient: mockNetwork)

let snapshot = try await probe.probe()

let fableQuota = snapshot.quotas.first { $0.quotaType == .modelSpecific("fable") }
#expect(fableQuota != nil)
#expect(fableQuota?.percentRemaining == 83.0) // 100 - 17
#expect(fableQuota?.resetsAt != nil)

// Unscoped session/weekly entries in the limits array must not create duplicates
#expect(snapshot.quotas.filter { $0.quotaType == .session }.count == 1)
#expect(snapshot.quotas.filter { $0.quotaType == .weekly }.count == 1)
}

@Test
func `probe skips malformed limits entries and keeps over-quota negative remaining`() async throws {
let tempDir = try makeTemporaryDirectory()
defer { try? FileManager.default.removeItem(at: tempDir) }

let futureExpiry = Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000
try createCredentialsFile(at: tempDir, expiresAt: futureExpiry)

let mockNetwork = MockNetworkClient()
// Malformed scoped entries (no scope, no model, empty name, no percent) are
// skipped; duplicate scoped entries yield one quota; a multi-word display
// name keys on its first word; 105% used stays negative (over-quota signal).
let responseJSON = """
{
"five_hour": { "utilization": 10.0, "resets_at": "2025-01-15T10:00:00Z" },
"limits": [
{ "kind": "weekly_scoped", "group": "weekly", "percent": 50, "resets_at": "2025-01-20T00:00:00Z", "scope": null },
{ "kind": "weekly_scoped", "group": "weekly", "percent": 50, "resets_at": "2025-01-20T00:00:00Z", "scope": { "model": null } },
{ "kind": "weekly_scoped", "group": "weekly", "percent": 50, "resets_at": "2025-01-20T00:00:00Z",
"scope": { "model": { "id": null, "display_name": "" } } },
{ "kind": "weekly_scoped", "group": "weekly", "resets_at": "2025-01-20T00:00:00Z",
"scope": { "model": { "id": null, "display_name": "Opus" } } },
{ "kind": "weekly_scoped", "group": "weekly", "percent": 105, "resets_at": "2025-01-20T00:00:00Z",
"scope": { "model": { "id": null, "display_name": "Fable 5" } } },
{ "kind": "weekly_scoped", "group": "weekly", "percent": 40, "resets_at": "2025-01-20T00:00:00Z",
"scope": { "model": { "id": null, "display_name": "Fable" } } }
]
}
""".data(using: .utf8)!

let response = HTTPURLResponse(
url: URL(string: "https://api.anthropic.com")!,
statusCode: 200,
httpVersion: nil,
headerFields: nil
)!

given(mockNetwork).request(.any).willReturn((responseJSON, response))

let loader = ClaudeCredentialLoader(homeDirectory: tempDir.path, useKeychain: false)
let probe = ClaudeAPIUsageProbe(credentialLoader: loader, networkClient: mockNetwork)

let snapshot = try await probe.probe()

let fableQuotas = snapshot.quotas.filter { $0.quotaType == .modelSpecific("fable") }
#expect(fableQuotas.count == 1)
#expect(fableQuotas.first?.percentRemaining == -5.0) // 100 - 105, first entry wins

// Malformed entries produce no quotas: session (legacy) + fable only
#expect(snapshot.quotas.count == 2)
}

@Test
func `probe does not duplicate model quota reported in both legacy field and limits array`() async throws {
let tempDir = try makeTemporaryDirectory()
defer { try? FileManager.default.removeItem(at: tempDir) }

let futureExpiry = Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000
try createCredentialsFile(at: tempDir, expiresAt: futureExpiry)

let mockNetwork = MockNetworkClient()
let responseJSON = """
{
"five_hour": { "utilization": 10.0, "resets_at": "2025-01-15T10:00:00Z" },
"seven_day_opus": { "utilization": 60.0, "resets_at": "2025-01-20T00:00:00Z" },
"limits": [
{ "kind": "weekly_scoped", "group": "weekly", "percent": 60, "resets_at": "2025-01-20T00:00:00Z",
"scope": { "model": { "id": null, "display_name": "Opus" }, "surface": null } }
]
}
""".data(using: .utf8)!

let response = HTTPURLResponse(
url: URL(string: "https://api.anthropic.com")!,
statusCode: 200,
httpVersion: nil,
headerFields: nil
)!

given(mockNetwork).request(.any).willReturn((responseJSON, response))

let loader = ClaudeCredentialLoader(homeDirectory: tempDir.path, useKeychain: false)
let probe = ClaudeAPIUsageProbe(credentialLoader: loader, networkClient: mockNetwork)

let snapshot = try await probe.probe()

let opusQuotas = snapshot.quotas.filter { $0.quotaType == .modelSpecific("opus") }
#expect(opusQuotas.count == 1)
#expect(opusQuotas.first?.percentRemaining == 40.0) // 100 - 60
}

@Test
func `probe parses extra usage correctly converting cents to dollars`() async throws {
let tempDir = try makeTemporaryDirectory()
Expand Down
Loading
Loading