Skip to content
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## [Unreleased]

### English
- Claude plan limits: an opt-in "Show Claude plan limits" setting reads the Claude Code sign-in from Keychain and asks Anthropic for the same 5-hour / weekly windows `/usage` shows. The expanded panel gets a footer line with every window (mini bar, percent, reset countdown); the collapsed island gets a labelled ring chip next to the session count showing one window — "Auto" shows a weekly budget — the one ahead of pace, else the tighter — and switches to the 5-hour window while that one is pressing (ahead of pace, or past 80%), or pin 5h / weekly / weekly (current model). Refreshes are driven by Stop hooks (15s coalesce, at most once a minute, trailing catch-up) with a 10-minute idle tick, exponential backoff on errors, and no token refresh ever — an expired token just says "run Claude Code once". Off by default; the login is read through `security`, the tool Claude Code stores it with, so no Keychain prompt
- Collapsed island on notched screens: each wing is sized from its measured content and the bar is shifted so the gap between them is exactly the notch. The old flexible row only guaranteed *a* gap at least as wide as the notch, so whichever wing was wider (a long tool name, now the plan-limit chip) slid its tail under the cutout — invisible on the display even though screenshots showed it intact. The bar also stops twitching: the chip keeps its slot as an invisible placeholder while a tool name shows, width changes ease in, shrinking waits 5s, and the right wing is trimmed to its content so it stays clear of the menu-bar icons

### 中文
- Claude 套餐额度:新增可选设置「显示 Claude 套餐额度」,用 Keychain 里的 Claude Code 登录向 Anthropic 查询和 `/usage` 一样的 5 小时 / 周窗口。展开面板底部新增一行显示全部窗口(迷你进度条、百分比、重置倒计时);收起药丸在会话数旁加一个带窗口标签的环形 chip 只显示一个窗口——「自动」常态显示周额度(进度超前的那条,否则更紧的那条),5 小时窗口吃紧时(进度超前或超过 80%)切换过去,也可固定为 5 小时 / 周 / 周(当前模型)。刷新由 Stop hook 驱动(15 秒合并、每分钟最多一次、窗口结束后补发一次),空闲时 10 分钟一次,出错指数退避,永不刷新 token——过期只提示「运行一次 Claude Code」。默认关闭;登录信息通过 `security` 命令读取(Claude Code 自己就是用它写入的),不会弹 Keychain 授权框
- 刘海屏收起态:左右翼各按实测内容定宽,整体平移让中间空隙精确对准刘海。原来的弹性布局只保证中间「有一段」不小于刘海的空隙,并不保证空隙对准刘海,哪边更宽(长工具名、现在的额度 chip)尾巴就钻到刘海底下——屏幕上看不见,截图里却是完整的。药丸也不再抖动:显示工具名时额度 chip 以隐形占位保留宽度,变宽走缓动,收窄延迟 5 秒,右翼只保留内容所需宽度,不挤占菜单栏图标

## [v1.0.33] - 2026-09-01

### English
Expand Down
12 changes: 12 additions & 0 deletions Sources/CodeIsland/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -224,13 +224,18 @@ final class AppState {
}
if surface.isExpanded {
refreshClaudeUsageIfStale()
claudeQuota.noteExpanded()
} else {
claudeQuota.noteCollapsed()
}
}
}

/// Local-transcript token usage shown in the session-list footer.
/// Refreshed lazily on panel expansion (no resident timer, no API calls).
var claudeUsage: ClaudeUsageScanner.Snapshot?
/// Subscription rate limits (5h / weekly) from Anthropic — opt-in, network.
let claudeQuota = ClaudeQuotaMonitor()
private var usageScanInFlight = false
/// Incremental parse state — round-trips through each detached scan so
/// growing transcripts are only read past their last consumed offset.
Expand Down Expand Up @@ -1401,6 +1406,13 @@ final class AppState {
// so a remote session can never probe the local filesystem here.
maybeRefreshGitBranch(for: sessionId, cwdBefore: cwdBeforeReduce, normalizedEventName: normalizedEventName)

// A finished local Claude turn is booked against the plan limits now —
// the quota monitor coalesces these into at most one fetch a minute.
if normalizedEventName == "Stop",
let s = sessions[sessionId], s.isClaude, s.isRemote != true {
claudeQuota.noteStop()
}

// Backfill model after metadata extraction. Hooks are inconsistent across providers,
// so retry with a cooldown instead of giving up permanently on the first miss.
if sessions[sessionId]?.isRemote != true {
Expand Down
175 changes: 175 additions & 0 deletions Sources/CodeIsland/ClaudeQuotaMonitor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import AppKit
import CodeIslandCore
import Foundation

/// Owns the plan-limit snapshot and drives fetches through
/// `ClaudeQuotaScheduler`. Event-driven: AppState reports Stop hooks and
/// panel expansion; this class turns them into at most one scheduled fetch.
@MainActor
@Observable
final class ClaudeQuotaMonitor {
private(set) var snapshot: ClaudeQuotaSnapshot?
private(set) var lastError: ClaudeQuotaClientError?
private(set) var scheduler: ClaudeQuotaScheduler
private(set) var isExpanded = false

@ObservationIgnored private var scheduledTask: Task<Void, Never>?
@ObservationIgnored private var inFlight = false
@ObservationIgnored private let fetcher: @Sendable () async throws -> ClaudeQuotaSnapshot
@ObservationIgnored private let now: () -> Date
@ObservationIgnored private let defaults: UserDefaults
@ObservationIgnored private var observers: [NSObjectProtocol] = []
@ObservationIgnored private var workspaceObservers: [NSObjectProtocol] = []

/// `defaults` is injectable so tests never flip the real setting — a
/// stray enabled monitor would hit the keychain and block on its prompt.
init(
scheduler: ClaudeQuotaScheduler = ClaudeQuotaScheduler(),
defaults: UserDefaults = .standard,
now: @escaping () -> Date = Date.init,
fetcher: @escaping @Sendable () async throws -> ClaudeQuotaSnapshot = { try await ClaudeQuotaClient.fetch() }
) {
self.scheduler = scheduler
self.defaults = defaults
self.now = now
self.fetcher = fetcher
self.wasEnabled = defaults.bool(forKey: SettingsKey.showClaudeQuota)
let center = NotificationCenter.default
observers.append(center.addObserver(forName: UserDefaults.didChangeNotification, object: defaults, queue: .main) { [weak self] _ in
Task { @MainActor in self?.settingsChanged() }
})
let ws = NSWorkspace.shared.notificationCenter
for name in [NSWorkspace.didWakeNotification, NSWorkspace.screensDidWakeNotification] {
workspaceObservers.append(ws.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
Task { @MainActor in self?.reschedule() }
})
}
}

deinit {
scheduledTask?.cancel()
for o in observers { NotificationCenter.default.removeObserver(o) }
for o in workspaceObservers { NSWorkspace.shared.notificationCenter.removeObserver(o) }
}

// MARK: Settings

var isEnabled: Bool {
defaults.bool(forKey: SettingsKey.showClaudeQuota)
}

var chipMode: ClaudeQuotaChipMode {
ClaudeQuotaChipMode(rawValue: defaults.string(forKey: SettingsKey.claudeQuotaChip) ?? "") ?? .auto
}

/// The collapsed chip is on screen (setting-wise) — keeps the idle tick alive.
var chipVisible: Bool { isEnabled && chipMode != .off }

/// Something on screen shows the numbers right now.
var wantsLive: Bool { isEnabled && (chipVisible || isExpanded) }

/// Limit for the collapsed chip under the current mode, nil to hide it.
func chipLimit(now: Date = Date()) -> ClaudeQuotaLimit? {
guard chipVisible, let snapshot else { return nil }
return ClaudeQuotaSelector.pick(from: snapshot, mode: chipMode, now: now)
}

// MARK: Events

/// A local Claude Code turn finished — its usage is now booked server-side.
func noteStop() {
guard isEnabled else { return }
scheduler.recordStop(now: now())
reschedule()
}

func noteExpanded() {
isExpanded = true
guard isEnabled else { return }
if scheduler.shouldFetchOnExpand(now: now()) {
fetchNow()
} else {
reschedule()
}
}

func noteCollapsed() {
isExpanded = false
reschedule()
}

@ObservationIgnored private var wasEnabled: Bool
private func settingsChanged() {
let enabled = isEnabled
if enabled != wasEnabled {
wasEnabled = enabled
if !enabled {
snapshot = nil
lastError = nil
scheduler = ClaudeQuotaScheduler(config: scheduler.config)
}
}
reschedule()
}

// MARK: Scheduling

private func reschedule() {
scheduledTask?.cancel()
scheduledTask = nil
guard let fireAt = scheduler.nextFireTime(wantsLive: wantsLive, now: now()) else { return }
let delay = max(0, fireAt.timeIntervalSince(now()))
scheduledTask = Task { [weak self] in
if delay > 0 {
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
}
guard !Task.isCancelled else { return }
self?.fireScheduled()
}
}

private func fireScheduled() {
// Asleep: skip; the wake observer reschedules.
guard MascotAnimationGate.shared.isAwake else { return }
guard wantsLive else { return }
fetchNow()
}

/// Kick off one fetch immediately (respects an in-flight request).
func fetchNow() {
guard isEnabled, !inFlight else { return }
inFlight = true
scheduler.recordFetchStart(now: now())
let fetcher = self.fetcher
// The fetcher only suspends (URLSession); the keychain read, which can
// block on the system access prompt, is hopped off-main inside it.
Task { [weak self] in
let result: Result<ClaudeQuotaSnapshot, Error>
do { result = .success(try await fetcher()) } catch { result = .failure(error) }
self?.apply(result)
}
}

private func apply(_ result: Result<ClaudeQuotaSnapshot, Error>) {
inFlight = false
switch result {
case .success(let snap):
snapshot = snap
lastError = nil
scheduler.recordSuccess()
case .failure(let error):
let typed = (error as? ClaudeQuotaClientError) ?? .transport(error.localizedDescription)
lastError = typed
scheduler.recordFailure(unauthorized: typed == .unauthorized || typed == .noCredential)
}
reschedule()
}
}

extension ClaudeQuotaMonitor {
/// Debug harness / tests: inject a snapshot without any fetch.
func applyPreview(_ snap: ClaudeQuotaSnapshot) {
snapshot = snap
lastError = nil
}
}
8 changes: 8 additions & 0 deletions Sources/CodeIsland/DebugHarness.swift
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,14 @@ enum DebugHarness {
hourlyOutputTokens: [0, 0, 4200, 18_000, 9500, 0, 22_000, 41_000, 12_000, 30_500, 52_000, 17_500],
scannedAt: Date()
)
state.claudeQuota.applyPreview(ClaudeQuotaSnapshot(
limits: [
ClaudeQuotaLimit(kind: .session, percent: 72, severity: "warning", resetsAt: Date().addingTimeInterval(80 * 60)),
ClaudeQuotaLimit(kind: .weeklyAll, percent: 30, resetsAt: Date().addingTimeInterval(2 * 86_400 + 4 * 3600)),
ClaudeQuotaLimit(kind: .weeklyScoped, percent: 48, resetsAt: Date().addingTimeInterval(2 * 86_400 + 4 * 3600), scopeLabel: "Fable", isActive: true),
],
fetchedAt: Date()
))
}

private static func applyBusy(to state: AppState) {
Expand Down
Loading