diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c7c3230..6c7cd6ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/CodeIsland/AppState.swift b/Sources/CodeIsland/AppState.swift index 82e24ec3..8095c142 100644 --- a/Sources/CodeIsland/AppState.swift +++ b/Sources/CodeIsland/AppState.swift @@ -224,6 +224,9 @@ final class AppState { } if surface.isExpanded { refreshClaudeUsageIfStale() + claudeQuota.noteExpanded() + } else { + claudeQuota.noteCollapsed() } } } @@ -231,6 +234,8 @@ final class AppState { /// 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. @@ -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 { diff --git a/Sources/CodeIsland/ClaudeQuotaMonitor.swift b/Sources/CodeIsland/ClaudeQuotaMonitor.swift new file mode 100644 index 00000000..f9b8b3eb --- /dev/null +++ b/Sources/CodeIsland/ClaudeQuotaMonitor.swift @@ -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? + @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 + do { result = .success(try await fetcher()) } catch { result = .failure(error) } + self?.apply(result) + } + } + + private func apply(_ result: Result) { + 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 + } +} diff --git a/Sources/CodeIsland/DebugHarness.swift b/Sources/CodeIsland/DebugHarness.swift index 0425cb29..ec668718 100644 --- a/Sources/CodeIsland/DebugHarness.swift +++ b/Sources/CodeIsland/DebugHarness.swift @@ -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) { diff --git a/Sources/CodeIsland/L10n.swift b/Sources/CodeIsland/L10n.swift index 749b3809..fba66913 100644 --- a/Sources/CodeIsland/L10n.swift +++ b/Sources/CodeIsland/L10n.swift @@ -197,6 +197,19 @@ final class L10n: ObservableObject { "usage_today": "Today", "show_usage_stats": "Show Claude token usage", "show_usage_stats_desc": "Footer line aggregating token usage from the local Claude Code transcripts (5-hour window and today). Local files only — no network calls.", + "show_claude_quota": "Show Claude plan limits", + "show_claude_quota_desc": "Fetches your subscription limits (5-hour and weekly windows, as in /usage) from Anthropic using the Claude Code sign-in stored in Keychain. Makes network requests. The token is only read, never refreshed.", + "claude_quota_chip": "Collapsed island shows", + "quota_chip_off": "Nothing", + "quota_chip_auto": "Auto (weekly; 5-hour when it's pressing)", + "quota_chip_session": "5-hour window", + "quota_chip_weekly": "Weekly", + "quota_chip_weekly_model": "Weekly (current model)", + "quota_label": "Limits", + "quota_week": "Week", + "quota_login_needed": "Plan limits: sign in / run Claude Code once to refresh", + "quota_unreachable": "Plan limits: could not reach Anthropic", + "quota_stale": "Last refresh failed — showing cached values", // Mascots "preview_status": "Preview Status", @@ -546,6 +559,19 @@ final class L10n: ObservableObject { "usage_today": "Heute", "show_usage_stats": "Claude-Token-Nutzung anzeigen", "show_usage_stats_desc": "Fußzeile mit Token-Nutzung aus den lokalen Claude-Code-Transkripten (5-Stunden-Fenster und heute). Nur lokale Dateien — keine Netzwerkzugriffe.", + "show_claude_quota": "Claude-Planlimits anzeigen", + "show_claude_quota_desc": "Ruft die Limits deines Abos (5-Stunden- und Wochenfenster, wie in /usage) über die im Schlüsselbund gespeicherte Claude-Code-Anmeldung von Anthropic ab. Nutzt das Netzwerk. Das Token wird nur gelesen, nie erneuert.", + "claude_quota_chip": "Eingeklappte Insel zeigt", + "quota_chip_off": "Nichts", + "quota_chip_auto": "Automatisch (Woche; 5-Stunden wenn knapp)", + "quota_chip_session": "5-Stunden-Fenster", + "quota_chip_weekly": "Woche", + "quota_chip_weekly_model": "Woche (aktuelles Modell)", + "quota_label": "Limits", + "quota_week": "Woche", + "quota_login_needed": "Planlimits: Claude Code einmal ausführen / anmelden", + "quota_unreachable": "Planlimits: Anthropic nicht erreichbar", + "quota_stale": "Letzte Aktualisierung fehlgeschlagen — zeige zwischengespeicherte Werte", // Mascots "preview_status": "Statusvorschau", @@ -899,6 +925,19 @@ final class L10n: ObservableObject { "usage_today": "今日", "show_usage_stats": "显示 Claude 用量统计", "show_usage_stats_desc": "在会话列表底部显示从本地 Claude Code 记录聚合的 token 用量(5 小时窗口与今日)。只读本地文件,不发起任何网络请求。", + "show_claude_quota": "显示 Claude 套餐额度", + "show_claude_quota_desc": "使用 Keychain 中保存的 Claude Code 登录信息,向 Anthropic 查询订阅额度(5 小时与周窗口,同 /usage)。会发起网络请求。只读取 token,不会刷新。", + "claude_quota_chip": "收起时显示", + "quota_chip_off": "不显示", + "quota_chip_auto": "自动(周额度,5 小时吃紧时切换)", + "quota_chip_session": "5 小时", + "quota_chip_weekly": "周", + "quota_chip_weekly_model": "周(当前模型)", + "quota_label": "额度", + "quota_week": "周", + "quota_login_needed": "套餐额度:请在 Claude Code 里登录或运行一次后刷新", + "quota_unreachable": "套餐额度:无法连接 Anthropic", + "quota_stale": "上次刷新失败,显示的是缓存值", // Mascots "preview_status": "预览状态", @@ -1252,6 +1291,19 @@ final class L10n: ObservableObject { "usage_today": "今日", "show_usage_stats": "顯示 Claude 用量統計", "show_usage_stats_desc": "在會話列表底部顯示從本地 Claude Code 記錄彙總的 token 用量(5 小時視窗與今日)。僅讀取本地檔案,不發起任何網路請求。", + "show_claude_quota": "顯示 Claude 方案額度", + "show_claude_quota_desc": "使用 Keychain 中儲存的 Claude Code 登入資訊,向 Anthropic 查詢訂閱額度(5 小時與週視窗,同 /usage)。會發起網路請求。僅讀取 token,不會重新整理。", + "claude_quota_chip": "收合時顯示", + "quota_chip_off": "不顯示", + "quota_chip_auto": "自動(週額度,5 小時吃緊時切換)", + "quota_chip_session": "5 小時", + "quota_chip_weekly": "週", + "quota_chip_weekly_model": "週(目前模型)", + "quota_label": "額度", + "quota_week": "週", + "quota_login_needed": "方案額度:請在 Claude Code 裡登入或執行一次後重新整理", + "quota_unreachable": "方案額度:無法連線 Anthropic", + "quota_stale": "上次重新整理失敗,顯示的是快取值", // Mascots "preview_status": "預覽狀態", @@ -1605,6 +1657,19 @@ final class L10n: ObservableObject { "usage_today": "今日", "show_usage_stats": "Claudeトークン使用量を表示", "show_usage_stats_desc": "ローカルの Claude Code トランスクリプトから集計したトークン使用量(5時間ウィンドウと今日)をセッション一覧の下部に表示します。ローカルファイルのみ読み取り、ネットワーク通信は行いません。", + "show_claude_quota": "Claude プラン上限を表示", + "show_claude_quota_desc": "キーチェーンに保存された Claude Code のサインインを使い、Anthropic からサブスクリプション上限(5時間・週ウィンドウ、/usage と同じ)を取得します。ネットワーク通信を行います。トークンは読み取りのみで更新しません。", + "claude_quota_chip": "折りたたみ時に表示", + "quota_chip_off": "表示しない", + "quota_chip_auto": "自動(週。5時間が逼迫時は切替)", + "quota_chip_session": "5時間", + "quota_chip_weekly": "週", + "quota_chip_weekly_model": "週(現在のモデル)", + "quota_label": "上限", + "quota_week": "週", + "quota_login_needed": "プラン上限: Claude Code でサインインまたは一度実行してください", + "quota_unreachable": "プラン上限: Anthropic に接続できません", + "quota_stale": "前回の更新に失敗 — キャッシュ値を表示中", // Mascots "preview_status": "プレビュー状態", @@ -1958,6 +2023,19 @@ final class L10n: ObservableObject { "usage_today": "오늘", "show_usage_stats": "Claude 토큰 사용량 표시", "show_usage_stats_desc": "로컬 Claude Code 기록에서 집계한 토큰 사용량(5시간 창과 오늘)을 세션 목록 하단에 표시합니다. 로컬 파일만 읽으며 네트워크 요청은 없습니다.", + "show_claude_quota": "Claude 플랜 한도 표시", + "show_claude_quota_desc": "키체인에 저장된 Claude Code 로그인으로 Anthropic에서 구독 한도(5시간·주간 창, /usage와 동일)를 가져옵니다. 네트워크 요청을 보냅니다. 토큰은 읽기만 하고 갱신하지 않습니다.", + "claude_quota_chip": "접힌 상태에서 표시", + "quota_chip_off": "표시 안 함", + "quota_chip_auto": "자동(주간, 5시간이 촉박하면 전환)", + "quota_chip_session": "5시간", + "quota_chip_weekly": "주간", + "quota_chip_weekly_model": "주간(현재 모델)", + "quota_label": "한도", + "quota_week": "주", + "quota_login_needed": "플랜 한도: Claude Code에서 로그인하거나 한 번 실행하세요", + "quota_unreachable": "플랜 한도: Anthropic에 연결할 수 없음", + "quota_stale": "마지막 갱신 실패 — 캐시된 값 표시 중", // Mascots "preview_status": "미리보기 상태", @@ -2311,6 +2389,19 @@ final class L10n: ObservableObject { "usage_today": "Bugün", "show_usage_stats": "Claude jeton kullanımını göster", "show_usage_stats_desc": "Yerel Claude Code dökümlerinden toplanan jeton kullanımını (5 saatlik pencere ve bugün) oturum listesinin altında gösterir. Yalnızca yerel dosyalar okunur — ağ isteği yapılmaz.", + "show_claude_quota": "Claude plan limitlerini göster", + "show_claude_quota_desc": "Anahtar Zinciri’ndeki Claude Code oturumunu kullanarak abonelik limitlerini (5 saatlik ve haftalık pencereler, /usage ile aynı) Anthropic’ten alır. Ağ isteği yapar. Jeton yalnızca okunur, yenilenmez.", + "claude_quota_chip": "Daraltılmış ada gösterir", + "quota_chip_off": "Hiçbir şey", + "quota_chip_auto": "Otomatik (haftalık; 5 saatlik sıkışınca)", + "quota_chip_session": "5 saatlik pencere", + "quota_chip_weekly": "Haftalık", + "quota_chip_weekly_model": "Haftalık (geçerli model)", + "quota_label": "Limitler", + "quota_week": "Hafta", + "quota_login_needed": "Plan limitleri: Claude Code’da oturum açın / bir kez çalıştırın", + "quota_unreachable": "Plan limitleri: Anthropic’e ulaşılamadı", + "quota_stale": "Son yenileme başarısız — önbellekteki değerler gösteriliyor", // Mascots "preview_status": "Durumu Önizleme", diff --git a/Sources/CodeIsland/NotchPanelView.swift b/Sources/CodeIsland/NotchPanelView.swift index 9519ad57..5d45468b 100644 --- a/Sources/CodeIsland/NotchPanelView.swift +++ b/Sources/CodeIsland/NotchPanelView.swift @@ -18,6 +18,19 @@ enum NotchWidthMetrics { if hasNotch { return Swift.max(notchW, scaled) } return scaled } + + /// Width of one wing on a notched collapsed bar: the reserve unless the + /// wing's measured content (tool name, plan-limit chip, badges) needs more. + static func collapsedWingSlot(reserve: CGFloat, measured: CGFloat) -> CGFloat { + Swift.max(reserve, measured) + } + + /// The bar is centred on the notch, so a wider left wing pushes the gap + /// between the wings to the right of the cutout (and vice versa); moving + /// the whole bar back by half the difference puts the gap on the notch. + static func collapsedShift(leftSlot: CGFloat, rightSlot: CGFloat) -> CGFloat { + (rightSlot - leftSlot) / 2 + } } // MARK: - Hover interaction state machine @@ -98,6 +111,8 @@ struct NotchPanelView: View { @AppStorage(SettingsKey.hideWhenNoSession) private var hideWhenNoSession = SettingsDefaults.hideWhenNoSession @AppStorage(SettingsKey.showToolStatus) private var showToolStatus = SettingsDefaults.showToolStatus @AppStorage(SettingsKey.collapsedWidthScale) private var collapsedWidthScale = SettingsDefaults.collapsedWidthScale + @AppStorage(SettingsKey.showClaudeQuota) private var showClaudeQuota = SettingsDefaults.showClaudeQuota + @AppStorage(SettingsKey.claudeQuotaChip) private var claudeQuotaChip = SettingsDefaults.claudeQuotaChip @AppStorage(SettingsKey.hapticOnHover) private var hapticOnHover = SettingsDefaults.hapticOnHover @AppStorage(SettingsKey.hapticIntensity) private var hapticIntensity = SettingsDefaults.hapticIntensity @@ -111,6 +126,12 @@ struct NotchPanelView: View { @State private var curtainOffset: CGFloat = 0 @State private var curtainOpacity: Double = 1 @State private var displayedToolStatus: Bool = SettingsDefaults.showToolStatus + /// Widths the collapsed wings are laid out for (0 until first measured). + /// Grows as soon as a wing's content needs more; shrinks only after the + /// content has been narrower for a while, so the bar doesn't twitch on + /// every tool change. + @State private var wingWidths = CompactWingWidths() + @State private var wingShrinkTimer: Timer? private var isActive: Bool { !appState.sessions.isEmpty } /// First launch / no-session state should still render a visible marker so the app @@ -159,7 +180,57 @@ struct NotchPanelView: View { let toolExtra: CGFloat = displayedToolStatus ? (hasNotch ? screenWidth * 0.03 : screenWidth * 0.04) : 0 // Immediate hover acknowledgement: a slight widen while the expand delay runs let prehoverExtra: CGFloat = shouldShowPrehover ? NotchHoverInteraction.prehoverWidthDelta : 0 - return nw + wing * 2 + extra + toolExtra + prehoverExtra + if let slots = collapsedWingSlots { + // Each wing is exactly as wide as it needs, the notch sits between + // them, and the bar is shifted so that gap lands on the cutout. + return nw + slots.left + slots.right + } + // Without a notch the wings sit at the edges of a flexible row; only + // the part the measured content exceeds the reserve needs adding. + let overflow = Swift.max(0, wingWidths.left - wing) + Swift.max(0, wingWidths.right - wing) + return nw + wing * 2 + extra + toolExtra + overflow + prehoverExtra + } + + /// Fixed wing widths on a notched collapsed bar (nil = flexible layout). + /// Left: the classic reserve (mascot plus room for a short tool name) or + /// the measured content if wider. Right: just its content — the count and + /// a badge or two — so the bar stays clear of the menu-bar icons. + private var collapsedWingSlots: (left: CGFloat, right: CGFloat)? { + guard hasNotch, showBar, !shouldShowExpanded else { return nil } + let extra: CGFloat = appState.status == .idle ? 0 : 20 + let toolExtra: CGFloat = displayedToolStatus ? screenWidth * 0.03 : 0 + let leftReserve = compactWingWidth + (extra + toolExtra) / 2 + let prehover: CGFloat = shouldShowPrehover ? NotchHoverInteraction.prehoverWidthDelta / 2 : 0 + return ( + NotchWidthMetrics.collapsedWingSlot(reserve: leftReserve, measured: wingWidths.left) + prehover, + NotchWidthMetrics.collapsedWingSlot(reserve: compactWingWidth, measured: wingWidths.right) + prehover + ) + } + + /// Horizontal shift that keeps the gap between unequal wings on the notch. + private var collapsedShift: CGFloat { + guard let slots = collapsedWingSlots else { return 0 } + return NotchWidthMetrics.collapsedShift(leftSlot: slots.left, rightSlot: slots.right) + } + + /// Widen at once (content would clip otherwise), narrow only after the + /// content has stayed narrower for a while — a tool name that comes and + /// goes shouldn't make the bar breathe. + private func applyWingWidths(_ measured: CompactWingWidths) { + let grown = CompactWingWidths( + left: Swift.max(wingWidths.left, measured.left), + right: Swift.max(wingWidths.right, measured.right) + ) + if grown != wingWidths { + withAnimation(.easeOut(duration: 0.25)) { wingWidths = grown } + } + wingShrinkTimer?.invalidate() + guard measured != grown else { return } + wingShrinkTimer = Timer.scheduledTimer(withTimeInterval: CompactWingWidths.shrinkDelay, repeats: false) { _ in + DispatchQueue.main.async { + withAnimation(.easeInOut(duration: 0.3)) { wingWidths = measured } + } + } } var body: some View { @@ -169,8 +240,11 @@ struct NotchPanelView: View { // Active: compact bar — wider version when expanded HStack(spacing: 0) { CompactLeftWing(appState: appState, expanded: shouldShowExpanded, mascotSize: mascotSize, hasNotch: hasNotch, showToolStatus: showToolStatus) + .frame(width: collapsedWingSlots?.left, alignment: .leading) if hasNotch && !shouldShowExpanded { - Spacer(minLength: effectiveNotchW) + // Exactly the notch: the wings are sized so this + // gap lands on the physical cutout, not near it. + Color.clear.frame(width: effectiveNotchW) } else if !shouldShowExpanded && showToolStatus { CompactToolStatus(appState: appState) Spacer(minLength: 0) @@ -178,8 +252,10 @@ struct NotchPanelView: View { Spacer(minLength: 0) } CompactRightWing(appState: appState, expanded: shouldShowExpanded, hasNotch: hasNotch) + .frame(width: collapsedWingSlots?.right, alignment: .trailing) } .frame(height: notchHeight) + .onPreferenceChange(CompactWingWidths.Key.self) { applyWingWidths($0) } } else if showIdleIndicator { IdleIndicatorBar( mascotSize: mascotSize, @@ -283,7 +359,7 @@ struct NotchPanelView: View { ) .fill(.black) ) - .offset(y: curtainOffset) + .offset(x: collapsedShift, y: curtainOffset) .opacity(curtainOpacity) .onChange(of: showToolStatus) { _, newValue in // Phase 1: entire bar slides up and fades out @@ -442,6 +518,8 @@ private struct CompactLeftWing: View { // Bound via @AppStorage so flipping the default mascot in Settings rerenders this view // even when AppState.primarySource wasn't recomputed (no session mutations in flight). @AppStorage(SettingsKey.defaultSource) private var settingsDefaultSource = SettingsDefaults.defaultSource + @AppStorage(SettingsKey.showClaudeQuota) private var showClaudeQuota = SettingsDefaults.showClaudeQuota + @AppStorage(SettingsKey.claudeQuotaChip) private var claudeQuotaChip = SettingsDefaults.claudeQuotaChip private var displaySession: SessionSnapshot? { let sid = appState.rotatingSessionId ?? appState.activeSessionId ?? appState.sessions.keys.sorted().first @@ -497,25 +575,52 @@ private struct CompactLeftWing: View { .overlay(Rectangle().stroke(.white.opacity(0.1), lineWidth: 1)) } } else { - MascotView(source: displaySource, status: displayStatus, size: mascotSize) - .id(displaySource) - .transition(.opacity) - .animation(.easeInOut(duration: 0.3), value: displaySource) - - // On notch screens, show tool name only (no description, space is tight) - if hasNotch, showToolStatus, let tool = shownTool { - Text(ToolNameDisplay.compact(tool)) - .font(.system(size: 10, weight: .medium, design: .monospaced)) - .foregroundStyle(toolStatusColor(tool)) - .lineLimit(1) - .truncationMode(.middle) - .frame(maxWidth: ToolNameDisplay.compactMaxWidth, alignment: .leading) + // Laid out at its ideal width and reported up: the bar sizes + // the wing slot from this, so nothing here gets squeezed into + // the notch. The tool name still truncates at its own cap. + HStack(spacing: 6) { + MascotView(source: displaySource, status: displayStatus, size: mascotSize) + .id(displaySource) .transition(.opacity) - .help(tool) + .animation(.easeInOut(duration: 0.3), value: displaySource) + + // On notch screens, show tool name only (no description, space is tight) + let toolShown = hasNotch && showToolStatus && shownTool != nil + ZStack(alignment: .leading) { + // Plan-limit chip takes the tool slot while no tool is + // running: window label + ring + percent, all windows in + // the tooltip. While a tool name shows it stays as an + // invisible placeholder so the slot — and the bar — + // keep their width across the switch. + if let limit = QuotaChip.limit(appState: appState, enabled: showClaudeQuota, modeRaw: claudeQuotaChip), + let snapshot = appState.claudeQuota.snapshot { + QuotaChip(limit: limit, snapshot: snapshot, stale: appState.claudeQuota.lastError != nil) + .opacity(toolShown ? 0 : 1) + .allowsHitTesting(!toolShown) + .transition(.opacity) + } + if toolShown, let tool = shownTool { + Text(ToolNameDisplay.compact(tool)) + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundStyle(toolStatusColor(tool)) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: ToolNameDisplay.compactMaxWidth, alignment: .leading) + .transition(.opacity) + .help(tool) + } + } } + .fixedSize(horizontal: true, vertical: false) + .background(GeometryReader { geo in + Color.clear.preference( + key: CompactWingWidths.Key.self, + value: CompactWingWidths(left: CompactWingWidths.reported(geo.size.width)) + ) + }) } } - .padding(.leading, 6) + .padding(.leading, CompactWingWidths.edgePadding) .clipped() .onChange(of: liveTool) { _, newTool in lingerTimer?.invalidate() @@ -583,65 +688,103 @@ private struct CompactRightWing: View { NSApplication.shared.terminate(nil) } } else { - // Quiet hours active — explains why event sounds are silent. - if inQuietHours { - Image(systemName: "moon.fill") - .font(.system(size: 8, weight: .bold)) - .foregroundStyle(.white.opacity(0.35)) - .help(l10n["quiet_hours"]) - } + HStack(spacing: 6) { + // Quiet hours active — explains why event sounds are silent. + if inQuietHours { + Image(systemName: "moon.fill") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(.white.opacity(0.35)) + .help(l10n["quiet_hours"]) + } - // Glance completion dot — an agent finished while collapsed; - // cleared as soon as the panel expands. - if appState.glanceCompletionActive { - Circle() - .fill(Color(red: 0.4, green: 1.0, blue: 0.5)) - .frame(width: 7, height: 7) - .shadow(color: Color(red: 0.4, green: 1.0, blue: 0.5).opacity(0.7), radius: 3) - } + // Glance completion dot — an agent finished while collapsed; + // cleared as soon as the panel expands. + if appState.glanceCompletionActive { + Circle() + .fill(Color(red: 0.4, green: 1.0, blue: 0.5)) + .frame(width: 7, height: 7) + .shadow(color: Color(red: 0.4, green: 1.0, blue: 0.5).opacity(0.7), radius: 3) + } - // Pending approval/question badge - if appState.status == .waitingApproval || appState.status == .waitingQuestion { - Image(systemName: "bell.fill") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(Color(red: 1.0, green: 0.7, blue: 0.28)) - .symbolEffect(.pulse, options: .repeating) - } + // Pending approval/question badge + if appState.status == .waitingApproval || appState.status == .waitingQuestion { + Image(systemName: "bell.fill") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(Color(red: 1.0, green: 0.7, blue: 0.28)) + .symbolEffect(.pulse, options: .repeating) + } - if showToolStatus { - // Detailed mode: session count (project name is shown in center on non-notch) - HStack(spacing: 1) { - let active = appState.activeSessionCount - let total = appState.totalSessionCount - if active > 0 { - Text("\(active)") - .foregroundStyle(Color(red: 0.4, green: 1.0, blue: 0.5)) - Text("/") - .foregroundStyle(.white.opacity(0.4)) + if showToolStatus { + // Detailed mode: session count (project name is shown in center on non-notch) + HStack(spacing: 1) { + let active = appState.activeSessionCount + let total = appState.totalSessionCount + if active > 0 { + Text("\(active)") + .foregroundStyle(Color(red: 0.4, green: 1.0, blue: 0.5)) + Text("/") + .foregroundStyle(.white.opacity(0.4)) + } + Text("\(total)") + .foregroundStyle(.white.opacity(0.9)) } - Text("\(total)") - .foregroundStyle(.white.opacity(0.9)) - } - .font(.system(size: 12, weight: .semibold, design: .monospaced)) - } else { - // Simple mode: original session count only - HStack(spacing: 1) { - let active = appState.activeSessionCount - let total = appState.totalSessionCount - if active > 0 { - Text("\(active)") - .foregroundStyle(Color(red: 0.4, green: 1.0, blue: 0.5)) - Text("/") - .foregroundStyle(.white.opacity(0.4)) + .font(.system(size: 12, weight: .semibold, design: .monospaced)) + } else { + // Simple mode: original session count only + HStack(spacing: 1) { + let active = appState.activeSessionCount + let total = appState.totalSessionCount + if active > 0 { + Text("\(active)") + .foregroundStyle(Color(red: 0.4, green: 1.0, blue: 0.5)) + Text("/") + .foregroundStyle(.white.opacity(0.4)) + } + Text("\(total)") + .foregroundStyle(.white.opacity(0.9)) } - Text("\(total)") - .foregroundStyle(.white.opacity(0.9)) + .font(.system(size: 13, weight: .bold, design: .monospaced)) } - .font(.system(size: 13, weight: .bold, design: .monospaced)) } + .fixedSize(horizontal: true, vertical: false) + .background(GeometryReader { geo in + Color.clear.preference( + key: CompactWingWidths.Key.self, + value: CompactWingWidths(right: CompactWingWidths.reported(geo.size.width)) + ) + }) } } - .padding(.trailing, 6) + .padding(.trailing, CompactWingWidths.edgePadding) + } +} + +/// Ideal widths of the collapsed wings, reported up to the bar so it can +/// size the wing slots (notched screens) or its total width (others). +struct CompactWingWidths: Equatable { + var left: CGFloat = 0 + var right: CGFloat = 0 + + /// Padding between the bar edge and the wing content. + static let edgePadding: CGFloat = 6 + /// Breathing room between the wing content and the notch; without it the + /// last glyph sits flush against the cutout. + static let notchGap: CGFloat = 6 + /// How long a wing stays wide after its content got narrower. + static let shrinkDelay: TimeInterval = 5 + /// What a wing reports for its content: the content plus both margins, + /// so the slot covers them. + static func reported(_ contentWidth: CGFloat) -> CGFloat { + contentWidth + edgePadding + notchGap + } + + struct Key: PreferenceKey { + static let defaultValue = CompactWingWidths() + static func reduce(value: inout CompactWingWidths, nextValue: () -> CompactWingWidths) { + let next = nextValue() + value.left = Swift.max(value.left, next.left) + value.right = Swift.max(value.right, next.right) + } } } @@ -1765,6 +1908,7 @@ private struct SessionListView: View { @AppStorage(SettingsKey.sessionGroupingMode) private var groupingMode = SettingsDefaults.sessionGroupingMode @AppStorage(SettingsKey.maxVisibleSessions) private var maxVisibleSessions = SettingsDefaults.maxVisibleSessions @AppStorage(SettingsKey.showUsageStats) private var showUsageStats = SettingsDefaults.showUsageStats + @AppStorage(SettingsKey.showClaudeQuota) private var showClaudeQuota = SettingsDefaults.showClaudeQuota private var groupedSessions: [(header: String, source: String?, ids: [String])] { if let only = onlySessionId, appState.sessions[only] != nil { @@ -1920,10 +2064,182 @@ private struct SessionListView: View { !(usage.last5h.isEmpty && usage.today.isEmpty) { UsageFooterLine(usage: usage) } + if showClaudeQuota, onlySessionId == nil { + if let snapshot = appState.claudeQuota.snapshot { + QuotaFooterLine(snapshot: snapshot, error: appState.claudeQuota.lastError) + } else if let error = appState.claudeQuota.lastError { + QuotaFooterMessage(error: error) + } + } } } } +// MARK: - Plan limits (Anthropic subscription windows) + +private enum QuotaStyle { + static let normal = Color.white.opacity(0.85) + static let warning = Color(red: 1.0, green: 0.7, blue: 0.28) + static let critical = Color(red: 1.0, green: 0.4, blue: 0.4) + + static func color(_ level: ClaudeQuotaLimit.Level) -> Color { + switch level { + case .normal: return normal + case .warning: return warning + case .critical: return critical + } + } + + static func label(_ limit: ClaudeQuotaLimit, l10n: L10n) -> String { + switch limit.kind { + case .session: return "5h" + case .weeklyAll: return l10n["quota_week"] + case .weeklyScoped: return limit.scopeLabel ?? l10n["quota_week"] + } + } + + /// One line per window for tooltips: "5h 3% · resets in 1h20m". + static func tooltip(_ snapshot: ClaudeQuotaSnapshot, stale: Bool, l10n: L10n, now: Date = Date()) -> String { + var lines = snapshot.ordered.map { limit -> String in + var line = "\(label(limit, l10n: l10n)) \(ClaudeQuotaFormat.percent(limit.percent))" + if let resetsAt = limit.resetsAt, let cd = ClaudeQuotaFormat.countdown(until: resetsAt, now: now) { + line += " · ↻ \(cd)" + } + return line + } + if stale { lines.append(l10n["quota_stale"]) } + return lines.joined(separator: "\n") + } +} + +/// Collapsed-island chip: window label, a 9pt ring, and percent. +struct QuotaChip: View { + let limit: ClaudeQuotaLimit + let snapshot: ClaudeQuotaSnapshot + let stale: Bool + @ObservedObject private var l10n = L10n.shared + + init(limit: ClaudeQuotaLimit, snapshot: ClaudeQuotaSnapshot, stale: Bool) { + self.limit = limit + self.snapshot = snapshot + self.stale = stale + } + + /// Shared resolution for the chip's limit so the bar width and the wing + /// agree on whether it is shown. + static func limit(appState: AppState, enabled: Bool, modeRaw: String) -> ClaudeQuotaLimit? { + guard enabled, let mode = ClaudeQuotaChipMode(rawValue: modeRaw), mode != .off, + let snapshot = appState.claudeQuota.snapshot else { return nil } + return ClaudeQuotaSelector.pick(from: snapshot, mode: mode) + } + + var body: some View { + let color = QuotaStyle.color(limit.level) + HStack(spacing: 3) { + // Which window this is: 5h / week / model name. + Text(QuotaStyle.label(limit, l10n: l10n)) + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundStyle(.white.opacity(0.5)) + .lineLimit(1) + ZStack { + Circle().stroke(.white.opacity(0.18), lineWidth: 2) + Circle() + .trim(from: 0, to: min(limit.percent / 100, 1)) + .stroke(color, style: StrokeStyle(lineWidth: 2, lineCap: .round)) + .rotationEffect(.degrees(-90)) + } + .frame(width: 9, height: 9) + Text(ClaudeQuotaFormat.percent(limit.percent)) + .font(.system(size: 10, weight: .semibold, design: .monospaced)) + .foregroundStyle(color) + } + .opacity(stale ? 0.55 : 1) + .help(QuotaStyle.tooltip(snapshot, stale: stale, l10n: l10n)) + } +} + +/// Expanded footer: every window with a mini bar, percent, and reset countdown. +private struct QuotaFooterLine: View { + let snapshot: ClaudeQuotaSnapshot + let error: ClaudeQuotaClientError? + @ObservedObject private var l10n = L10n.shared + + var body: some View { + // Countdowns tick once a minute; the panel is only open briefly. + TimelineView(.periodic(from: .now, by: 60)) { context in + HStack(spacing: 6) { + Image(systemName: "clock.arrow.circlepath") + .font(.system(size: 9, weight: .semibold)) + Text(l10n["quota_label"]) + .fontWeight(.semibold) + ForEach(Array(snapshot.ordered.enumerated()), id: \.offset) { index, limit in + if index > 0 { + Text("·").foregroundStyle(.white.opacity(0.25)) + } + segment(limit, now: context.date) + } + Spacer() + if error != nil { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 9)) + .foregroundStyle(QuotaStyle.warning) + .help(l10n["quota_stale"]) + } + } + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundStyle(.white.opacity(0.45)) + .padding(.horizontal, 14) + .padding(.vertical, 5) + .help(QuotaStyle.tooltip(snapshot, stale: error != nil, l10n: l10n, now: context.date)) + } + } + + private func segment(_ limit: ClaudeQuotaLimit, now: Date) -> some View { + let color = QuotaStyle.color(limit.level) + return HStack(spacing: 4) { + Text(QuotaStyle.label(limit, l10n: l10n)) + ZStack(alignment: .leading) { + Capsule().fill(.white.opacity(0.12)) + Capsule().fill(color) + .frame(width: 30 * min(limit.percent / 100, 1)) + } + .frame(width: 30, height: 4) + Text(ClaudeQuotaFormat.percent(limit.percent)) + .foregroundStyle(color) + if let resetsAt = limit.resetsAt, let cd = ClaudeQuotaFormat.countdown(until: resetsAt, now: now) { + Text("↻\(cd)") + .foregroundStyle(.white.opacity(0.3)) + } + } + } +} + +/// Footer fallback when there is no snapshot yet but the fetch failed. +private struct QuotaFooterMessage: View { + let error: ClaudeQuotaClientError + @ObservedObject private var l10n = L10n.shared + + private var text: String { + switch error { + case .unauthorized, .noCredential: return l10n["quota_login_needed"] + default: return l10n["quota_unreachable"] + } + } + + var body: some View { + HStack(spacing: 5) { + Image(systemName: "clock.arrow.circlepath") + .font(.system(size: 9, weight: .semibold)) + Text(text) + Spacer() + } + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundStyle(.white.opacity(0.35)) + .padding(.horizontal, 14) + .padding(.vertical, 5) + } +} + /// Token totals from the local Claude transcripts — "in" is billed input /// (input + cache writes); cache reads live in the tooltip. private struct UsageFooterLine: View { diff --git a/Sources/CodeIsland/Settings.swift b/Sources/CodeIsland/Settings.swift index 4b3e2007..fe3b64ca 100644 --- a/Sources/CodeIsland/Settings.swift +++ b/Sources/CodeIsland/Settings.swift @@ -70,6 +70,10 @@ enum SettingsKey { // Token-usage footer (local Claude transcript aggregation) static let showUsageStats = "showUsageStats" + // Claude plan limits (Anthropic usage endpoint via the Claude Code login) + static let showClaudeQuota = "showClaudeQuota" + static let claudeQuotaChip = "claudeQuotaChip" // ClaudeQuotaChipMode raw value + // Completion notification: "expand" | "glance" | "off". Successor of the // boolean autoExpandOnCompletion — see AppState.completionStyle migration. static let completionNotificationStyle = "completionNotificationStyle" @@ -174,6 +178,8 @@ struct SettingsDefaults { static let quietHoursEnd = 8 * 60 static let showGitBranch = true static let showUsageStats = true + static let showClaudeQuota = false + static let claudeQuotaChip = "auto" static let rotationInterval = 5 @@ -259,6 +265,8 @@ class SettingsManager { SettingsKey.quietHoursEnd: SettingsDefaults.quietHoursEnd, SettingsKey.showGitBranch: SettingsDefaults.showGitBranch, SettingsKey.showUsageStats: SettingsDefaults.showUsageStats, + SettingsKey.showClaudeQuota: SettingsDefaults.showClaudeQuota, + SettingsKey.claudeQuotaChip: SettingsDefaults.claudeQuotaChip, SettingsKey.rotationInterval: SettingsDefaults.rotationInterval, SettingsKey.maxToolHistory: SettingsDefaults.maxToolHistory, SettingsKey.mascotSpeed: SettingsDefaults.mascotSpeed, diff --git a/Sources/CodeIsland/SettingsView.swift b/Sources/CodeIsland/SettingsView.swift index 11c14459..7e174bab 100644 --- a/Sources/CodeIsland/SettingsView.swift +++ b/Sources/CodeIsland/SettingsView.swift @@ -866,6 +866,8 @@ private struct AppearancePage: View { @AppStorage(SettingsKey.showToolStatus) private var showToolStatus = SettingsDefaults.showToolStatus @AppStorage(SettingsKey.showGitBranch) private var showGitBranch = SettingsDefaults.showGitBranch @AppStorage(SettingsKey.showUsageStats) private var showUsageStats = SettingsDefaults.showUsageStats + @AppStorage(SettingsKey.showClaudeQuota) private var showClaudeQuota = SettingsDefaults.showClaudeQuota + @AppStorage(SettingsKey.claudeQuotaChip) private var claudeQuotaChip = SettingsDefaults.claudeQuotaChip @AppStorage(SettingsKey.collapsedWidthScale) private var collapsedWidthScale = SettingsDefaults.collapsedWidthScale @AppStorage(SettingsKey.notchHeightMode) private var notchHeightModeRaw = SettingsDefaults.notchHeightMode @AppStorage(SettingsKey.customNotchHeight) private var customNotchHeight = SettingsDefaults.customNotchHeight @@ -965,6 +967,20 @@ private struct AppearancePage: View { .font(.system(size: 11)) .foregroundStyle(.tertiary) } + VStack(alignment: .leading, spacing: 2) { + Toggle(l10n["show_claude_quota"], isOn: $showClaudeQuota) + Text(l10n["show_claude_quota_desc"]) + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + } + Picker(l10n["claude_quota_chip"], selection: $claudeQuotaChip) { + Text(l10n["quota_chip_off"]).tag(ClaudeQuotaChipMode.off.rawValue) + Text(l10n["quota_chip_auto"]).tag(ClaudeQuotaChipMode.auto.rawValue) + Text(l10n["quota_chip_session"]).tag(ClaudeQuotaChipMode.session.rawValue) + Text(l10n["quota_chip_weekly"]).tag(ClaudeQuotaChipMode.weeklyAll.rawValue) + Text(l10n["quota_chip_weekly_model"]).tag(ClaudeQuotaChipMode.weeklyScoped.rawValue) + } + .disabled(!showClaudeQuota) } } .formStyle(.grouped) diff --git a/Sources/CodeIslandCore/ClaudeQuota.swift b/Sources/CodeIslandCore/ClaudeQuota.swift new file mode 100644 index 00000000..d0cb35f2 --- /dev/null +++ b/Sources/CodeIslandCore/ClaudeQuota.swift @@ -0,0 +1,240 @@ +import Foundation + +/// One rate-limit window from Anthropic's subscription usage endpoint — +/// the same numbers Claude Code's `/usage` shows. +public struct ClaudeQuotaLimit: Equatable, Sendable { + public enum Kind: String, Sendable, CaseIterable { + /// Rolling 5-hour window. + case session + /// 7-day window across all models. + case weeklyAll = "weekly_all" + /// 7-day window scoped to one model (e.g. the plan's flagship model). + case weeklyScoped = "weekly_scoped" + + public var windowSeconds: TimeInterval { + switch self { + case .session: return 5 * 3600 + case .weeklyAll, .weeklyScoped: return 7 * 86_400 + } + } + } + + public let kind: Kind + /// 0…100 (may exceed 100 when the account is over its limit). + public let percent: Double + /// Server-side severity, e.g. "normal" / "warning" / "critical". Free-form. + public let severity: String + public let resetsAt: Date? + /// Model display name for `.weeklyScoped` ("Fable", "Opus", …), nil otherwise. + public let scopeLabel: String? + /// Server hint that this is the currently governing limit. + public let isActive: Bool + + public init(kind: Kind, percent: Double, severity: String = "normal", resetsAt: Date? = nil, scopeLabel: String? = nil, isActive: Bool = false) { + self.kind = kind + self.percent = percent + self.severity = severity + self.resetsAt = resetsAt + self.scopeLabel = scopeLabel + self.isActive = isActive + } + + /// Fraction of the window already elapsed (0…1), derived from `resetsAt`. + /// nil when the server gave no reset time. + public func elapsedFraction(now: Date = Date()) -> Double? { + guard let resetsAt else { return nil } + let remaining = resetsAt.timeIntervalSince(now) + let elapsed = 1 - remaining / kind.windowSeconds + return min(max(elapsed, 0), 1) + } + + /// How far ahead of pace this window is: used fraction minus elapsed + /// fraction. Positive means the limit will be hit before it resets if + /// usage continues at the same rate. Falls back to the used fraction when + /// there is no reset time, so windows stay comparable. + public func paceDelta(now: Date = Date()) -> Double { + let used = percent / 100 + guard let elapsed = elapsedFraction(now: now) else { return used } + return used - elapsed + } + + public var isOverLimit: Bool { percent >= 100 } + + /// Severity bucket for colouring; tolerant of unknown server strings. + public enum Level: Sendable { case normal, warning, critical } + public var level: Level { + if isOverLimit { return .critical } + switch severity.lowercased() { + case "normal", "": return .normal + case "warning", "warn", "elevated": return .warning + default: return .critical + } + } +} + +public struct ClaudeQuotaSnapshot: Equatable, Sendable { + public let limits: [ClaudeQuotaLimit] + public let fetchedAt: Date + + public init(limits: [ClaudeQuotaLimit], fetchedAt: Date) { + self.limits = limits + self.fetchedAt = fetchedAt + } + + public var isEmpty: Bool { limits.isEmpty } + public func limit(_ kind: ClaudeQuotaLimit.Kind) -> ClaudeQuotaLimit? { + limits.first { $0.kind == kind } + } + + /// Display order: 5h, weekly, weekly (model). + public var ordered: [ClaudeQuotaLimit] { + ClaudeQuotaLimit.Kind.allCases.compactMap { limit($0) } + } + + public enum ParseError: Error, Equatable { case notJSON, noLimits } + + /// Parse the `/api/oauth/usage` response. Prefers the normalised `limits[]` + /// array; falls back to the legacy top-level `five_hour` / `seven_day*` + /// objects so accounts that only get those still render. + public static func parse(_ data: Data, fetchedAt: Date = Date()) throws -> ClaudeQuotaSnapshot { + guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw ParseError.notJSON + } + var limits: [ClaudeQuotaLimit] = [] + if let raw = obj["limits"] as? [[String: Any]] { + for item in raw { + guard let kindRaw = item["kind"] as? String, + let kind = ClaudeQuotaLimit.Kind(rawValue: kindRaw), + let percent = number(item["percent"]) else { continue } + var scopeLabel: String? + if let scope = item["scope"] as? [String: Any], + let model = scope["model"] as? [String: Any] { + scopeLabel = model["display_name"] as? String ?? model["id"] as? String + } + limits.append(ClaudeQuotaLimit( + kind: kind, + percent: percent, + severity: item["severity"] as? String ?? "normal", + resetsAt: date(item["resets_at"]), + scopeLabel: scopeLabel, + isActive: item["is_active"] as? Bool ?? false + )) + } + } + if limits.isEmpty { + func legacy(_ key: String, _ kind: ClaudeQuotaLimit.Kind, label: String? = nil) { + guard let item = obj[key] as? [String: Any], let util = number(item["utilization"]) else { return } + limits.append(ClaudeQuotaLimit(kind: kind, percent: util, resetsAt: date(item["resets_at"]), scopeLabel: label)) + } + legacy("five_hour", .session) + legacy("seven_day", .weeklyAll) + legacy("seven_day_opus", .weeklyScoped, label: "Opus") + if limits.first(where: { $0.kind == .weeklyScoped }) == nil { + legacy("seven_day_sonnet", .weeklyScoped, label: "Sonnet") + } + } + // One entry per kind, first wins. + var seen = Set() + limits = limits.filter { seen.insert($0.kind).inserted } + guard !limits.isEmpty else { throw ParseError.noLimits } + return ClaudeQuotaSnapshot(limits: limits, fetchedAt: fetchedAt) + } + + private static func number(_ any: Any?) -> Double? { + if let n = any as? NSNumber { return n.doubleValue } + if let s = any as? String { return Double(s) } + return nil + } + + private static let fractionalFormatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + private static let plainFormatter = ISO8601DateFormatter() + + static func date(_ any: Any?) -> Date? { + guard let s = any as? String else { return nil } + return fractionalFormatter.date(from: s) ?? plainFormatter.date(from: s) + } +} + +/// What the collapsed island shows when plan limits are enabled. +public enum ClaudeQuotaChipMode: String, CaseIterable, Sendable { + case off + /// The window most likely to run out first (see `ClaudeQuotaSelector`). + case auto + case session + case weeklyAll + case weeklyScoped +} + +public enum ClaudeQuotaSelector { + /// Usage at or above this share counts as pressing regardless of pace. + public static let alertPercent: Double = 80 + + /// Pick the limit for the collapsed chip. Fixed modes return that window, + /// or nil if the server didn't report it. + /// + /// `auto`: + /// 1. The 5-hour window takes the chip while it is pressing — it is the + /// one that can block you within the hour. + /// 2. Otherwise a weekly window: if either is pressing, the one further + /// ahead of pace; if neither, the one with more used (the tighter + /// budget). Weekly (all models) and weekly (current model) compete on + /// the same terms. + /// 3. With no weekly reported, the 5-hour window is all there is. + /// + /// "Pressing" = used share exceeds the elapsed share of the window + /// (ahead of pace), or usage is at or past `alertPercent`. + public static func pick(from snapshot: ClaudeQuotaSnapshot, mode: ClaudeQuotaChipMode, now: Date = Date()) -> ClaudeQuotaLimit? { + switch mode { + case .off: return nil + case .session: return snapshot.limit(.session) + case .weeklyAll: return snapshot.limit(.weeklyAll) + case .weeklyScoped: return snapshot.limit(.weeklyScoped) + case .auto: + let session = snapshot.limit(.session) + if let session, isPressing(session, now: now) { return session } + let weeklies = [snapshot.limit(.weeklyAll), snapshot.limit(.weeklyScoped)].compactMap { $0 } + return pickWeekly(weeklies, now: now) ?? session + } + } + + public static func isPressing(_ limit: ClaudeQuotaLimit, now: Date = Date()) -> Bool { + limit.percent >= alertPercent || limit.paceDelta(now: now) > 0 + } + + /// Among the weekly windows: the one furthest ahead of pace if any is + /// pressing, else the one with the most used. + static func pickWeekly(_ weeklies: [ClaudeQuotaLimit], now: Date) -> ClaudeQuotaLimit? { + let pressing = weeklies.filter { isPressing($0, now: now) } + if !pressing.isEmpty { + return pressing.max { $0.paceDelta(now: now) < $1.paceDelta(now: now) } + } + return weeklies.max { $0.percent < $1.percent } + } +} + +public enum ClaudeQuotaFormat { + /// "2d 4h" / "1h20m" / "45m" (minutes round up, so 30s shows "1m"); + /// nil once the reset time has passed. + public static func countdown(until resetsAt: Date, now: Date = Date()) -> String? { + let remaining = Int(resetsAt.timeIntervalSince(now).rounded(.down)) + guard remaining > 0 else { return nil } + let minutes = (remaining + 59) / 60 + if minutes < 60 { return "\(minutes)m" } + let hours = minutes / 60 + if hours < 24 { + let m = minutes % 60 + return m == 0 ? "\(hours)h" : "\(hours)h\(String(format: "%02d", m))m" + } + let days = hours / 24 + let h = hours % 24 + return h == 0 ? "\(days)d" : "\(days)d \(h)h" + } + + public static func percent(_ value: Double) -> String { + "\(Int(value.rounded()))%" + } +} diff --git a/Sources/CodeIslandCore/ClaudeQuotaClient.swift b/Sources/CodeIslandCore/ClaudeQuotaClient.swift new file mode 100644 index 00000000..ac792721 --- /dev/null +++ b/Sources/CodeIslandCore/ClaudeQuotaClient.swift @@ -0,0 +1,140 @@ +import Foundation + +/// The Claude Code OAuth login, as Claude Code itself stores it. +public struct ClaudeOAuthCredential: Equatable, Sendable { + public let accessToken: String + public let subscriptionType: String? + public let expiresAt: Date? + + public init(accessToken: String, subscriptionType: String? = nil, expiresAt: Date? = nil) { + self.accessToken = accessToken + self.subscriptionType = subscriptionType + self.expiresAt = expiresAt + } +} + +/// Read-only access to the Claude Code login. Never refreshes the token — +/// rotating it from here would invalidate the copy Claude Code holds, so an +/// expired token simply means "run Claude Code once". +public enum ClaudeCredentialStore { + /// Keychain generic-password service Claude Code writes on macOS. + public static let keychainService = "Claude Code-credentials" + + /// `{"claudeAiOauth":{"accessToken":…,"subscriptionType":…,"expiresAt":ms}}` + public static func parse(_ data: Data) -> ClaudeOAuthCredential? { + guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let oauth = obj["claudeAiOauth"] as? [String: Any], + let token = oauth["accessToken"] as? String, !token.isEmpty else { return nil } + var expires: Date? + if let ms = oauth["expiresAt"] as? NSNumber { + expires = Date(timeIntervalSince1970: ms.doubleValue / 1000) + } + return ClaudeOAuthCredential( + accessToken: token, + subscriptionType: oauth["subscriptionType"] as? String, + expiresAt: expires + ) + } + + /// Raw item data from the login keychain, read through `/usr/bin/security`. + /// + /// Claude Code stores the item with that same tool, so `security` is on + /// the item's access list and reads it silently. `SecItemCopyMatching` + /// from our own process would instead raise the "wants to use your + /// confidential information" prompt — and, for an ad-hoc signed build, + /// raise it again after every rebuild. + public static func readKeychain(service: String = keychainService, timeout: TimeInterval = 5) -> Data? { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/usr/bin/security") + proc.arguments = ["find-generic-password", "-s", service, "-w"] + let out = Pipe() + proc.standardOutput = out + proc.standardError = FileHandle.nullDevice + proc.standardInput = FileHandle.nullDevice + do { try proc.run() } catch { return nil } + let data = out.fileHandleForReading.readDataToEndOfFile() + let deadline = Date().addingTimeInterval(timeout) + while proc.isRunning && Date() < deadline { Thread.sleep(forTimeInterval: 0.02) } + if proc.isRunning { proc.terminate(); return nil } + guard proc.terminationStatus == 0 else { return nil } + // `-w` prints the secret followed by a newline. + var bytes = data + while let last = bytes.last, last == UInt8(ascii: "\n") || last == UInt8(ascii: "\r") { bytes.removeLast() } + return bytes.isEmpty ? nil : bytes + } + + /// File fallback (`~/.claude/.credentials.json`) used by Claude Code where + /// no keychain is available. + public static func readFile(claudeHome: String = ClaudeConfigPaths.configDir()) -> Data? { + FileManager.default.contents(atPath: claudeHome + "/.credentials.json") + } + + public static func load() -> ClaudeOAuthCredential? { + if let data = readKeychain(), let cred = parse(data) { return cred } + if let data = readFile(), let cred = parse(data) { return cred } + return nil + } +} + +public enum ClaudeQuotaClientError: Error, Equatable { + /// No Claude Code login found (not signed in, or keychain access denied). + case noCredential + /// Token rejected — expired or revoked; Claude Code refreshes it on next run. + case unauthorized + case rateLimited + case http(Int) + case transport(String) + case parse +} + +public enum ClaudeQuotaClient { + public static let endpoint = URL(string: "https://api.anthropic.com/api/oauth/usage")! + + public static func request(token: String) -> URLRequest { + var req = URLRequest(url: endpoint) + req.httpMethod = "GET" + req.timeoutInterval = 15 + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + req.setValue("oauth-2025-04-20", forHTTPHeaderField: "anthropic-beta") + req.setValue("application/json", forHTTPHeaderField: "Accept") + req.setValue("CodeIsland", forHTTPHeaderField: "User-Agent") + return req + } + + /// Map an HTTP response to a snapshot or a typed error. + public static func interpret(data: Data, response: URLResponse, now: Date = Date()) throws -> ClaudeQuotaSnapshot { + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + switch status { + case 200..<300: + do { return try ClaudeQuotaSnapshot.parse(data, fetchedAt: now) } + catch { throw ClaudeQuotaClientError.parse } + case 401, 403: throw ClaudeQuotaClientError.unauthorized + case 429: throw ClaudeQuotaClientError.rateLimited + default: throw ClaudeQuotaClientError.http(status) + } + } + + /// One fetch. The credential is re-read every call so a token Claude Code + /// rotated in the meantime is picked up without any state here. + public static func fetch( + credential: @escaping @Sendable () -> ClaudeOAuthCredential? = ClaudeCredentialStore.load, + session: URLSession = .shared, + now: Date = Date() + ) async throws -> ClaudeQuotaSnapshot { + // Off the caller's actor: SecItemCopyMatching blocks while macOS shows + // its keychain access prompt, and that must never stall the UI. A GCD + // queue rather than a detached Task — the blocking call must not sit + // on a cooperative-pool thread either. + let loaded: ClaudeOAuthCredential? = await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .utility).async { continuation.resume(returning: credential()) } + } + guard let cred = loaded else { throw ClaudeQuotaClientError.noCredential } + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: request(token: cred.accessToken)) + } catch { + throw ClaudeQuotaClientError.transport(error.localizedDescription) + } + return try interpret(data: data, response: response, now: now) + } +} diff --git a/Sources/CodeIslandCore/ClaudeQuotaScheduler.swift b/Sources/CodeIslandCore/ClaudeQuotaScheduler.swift new file mode 100644 index 00000000..60ef8bcb --- /dev/null +++ b/Sources/CodeIslandCore/ClaudeQuotaScheduler.swift @@ -0,0 +1,101 @@ +import Foundation + +/// Pure refresh policy for plan-limit fetches. Limits only move when a turn +/// finishes, so the driver is the Stop hook rather than a clock: +/// +/// - Stop events are coalesced: wait `debounce` after the *last* Stop, so a +/// burst of sessions finishing together costs one request. +/// - Never more often than `throttle`; a Stop inside the window is remembered +/// and served once the window ends (trailing fetch). +/// - With the collapsed chip visible and no Stops, a slow `idleFloor` tick +/// catches window resets. +/// - Failures back off exponentially; a rejected token stops background +/// fetches entirely until the user expands the panel again. +/// +/// The owner turns `nextFireTime` into one scheduled task; nothing here touches +/// timers, so it is fully testable. +public struct ClaudeQuotaScheduler: Equatable, Sendable { + public struct Config: Equatable, Sendable { + public var debounce: TimeInterval = 15 + public var throttle: TimeInterval = 60 + public var idleFloor: TimeInterval = 600 + public var expandStale: TimeInterval = 60 + public var backoffBase: TimeInterval = 60 + public var backoffMax: TimeInterval = 900 + public init() {} + } + + public var config = Config() + public private(set) var lastFetchAt: Date? + public private(set) var lastStopAt: Date? + /// A Stop arrived after the last fetch started and hasn't been served. + public private(set) var pending = false + public private(set) var failures = 0 + public private(set) var needsLogin = false + + public init(config: Config = Config()) { self.config = config } + + public mutating func recordStop(now: Date) { + lastStopAt = now + pending = true + } + + public mutating func recordFetchStart(now: Date) { + lastFetchAt = now + pending = false + } + + public mutating func recordSuccess() { + failures = 0 + needsLogin = false + } + + /// `unauthorized` covers both a rejected token and a missing credential. + public mutating func recordFailure(unauthorized: Bool) { + if unauthorized { + needsLogin = true + failures = 0 + } else { + failures += 1 + } + } + + /// Current backoff window, nil when the last fetch succeeded. + public var backoff: TimeInterval? { + guard failures > 0 else { return nil } + let exp = min(Double(failures - 1), 10) + return min(config.backoffBase * pow(2, exp), config.backoffMax) + } + + /// Earliest time a background fetch may run. `wantsLive` is true while + /// anything on screen shows the numbers (collapsed chip, or expanded + /// footer). nil means nothing to schedule. + public func nextFireTime(wantsLive: Bool, now: Date) -> Date? { + guard wantsLive, !needsLogin else { return nil } + var candidate: Date + if pending, let lastStopAt { + candidate = lastStopAt.addingTimeInterval(config.debounce) + if let lastFetchAt { + candidate = max(candidate, lastFetchAt.addingTimeInterval(config.throttle)) + } + } else if let lastFetchAt { + candidate = lastFetchAt.addingTimeInterval(config.idleFloor) + } else { + candidate = now + } + if let lastFetchAt, let backoff { + candidate = max(candidate, lastFetchAt.addingTimeInterval(backoff)) + } + return candidate + } + + /// Panel expansion is user-initiated: refresh if stale, and let it retry a + /// rejected token (the user may have signed in again) once the stale + /// window has passed. Still honours error backoff. + public func shouldFetchOnExpand(now: Date) -> Bool { + guard let lastFetchAt else { return true } + let age = now.timeIntervalSince(lastFetchAt) + if let backoff { return age >= backoff } + return age >= config.expandStale + } +} diff --git a/Tests/CodeIslandCoreTests/ClaudeQuotaSchedulerTests.swift b/Tests/CodeIslandCoreTests/ClaudeQuotaSchedulerTests.swift new file mode 100644 index 00000000..7f8b585a --- /dev/null +++ b/Tests/CodeIslandCoreTests/ClaudeQuotaSchedulerTests.swift @@ -0,0 +1,81 @@ +import XCTest +@testable import CodeIslandCore + +final class ClaudeQuotaSchedulerTests: XCTestCase { + private let t0 = Date(timeIntervalSince1970: 1_800_000_000) + private func at(_ s: TimeInterval) -> Date { t0.addingTimeInterval(s) } + + func testFirstLiveTickFiresImmediately() { + let s = ClaudeQuotaScheduler() + XCTAssertEqual(s.nextFireTime(wantsLive: true, now: t0), t0) + XCTAssertNil(s.nextFireTime(wantsLive: false, now: t0)) + } + + /// Two sessions finishing 12s apart cost one request, 15s after the last Stop. + func testStopsWithinDebounceCoalesceIntoOneFetch() { + var s = ClaudeQuotaScheduler() + s.recordFetchStart(now: at(-3600)) // some old fetch, throttle long expired + s.recordStop(now: at(0)) + XCTAssertEqual(s.nextFireTime(wantsLive: true, now: at(0)), at(15)) + s.recordStop(now: at(12)) + XCTAssertEqual(s.nextFireTime(wantsLive: true, now: at(12)), at(27)) + s.recordFetchStart(now: at(27)) + XCTAssertFalse(s.pending) + // No more stops: only the idle floor remains. + XCTAssertEqual(s.nextFireTime(wantsLive: true, now: at(27)), at(27 + 600)) + } + + /// A Stop inside the 60s throttle is served by one trailing fetch at the window edge. + func testStopInsideThrottleGetsTrailingFetch() { + var s = ClaudeQuotaScheduler() + s.recordFetchStart(now: at(0)) + s.recordStop(now: at(10)) + XCTAssertEqual(s.nextFireTime(wantsLive: true, now: at(10)), at(60)) + s.recordStop(now: at(50)) // debounce would say 65 → later than throttle edge + XCTAssertEqual(s.nextFireTime(wantsLive: true, now: at(50)), at(65)) + } + + func testStopsAreRememberedWhileNothingIsOnScreen() { + var s = ClaudeQuotaScheduler() + s.recordStop(now: at(0)) + XCTAssertNil(s.nextFireTime(wantsLive: false, now: at(0))) + XCTAssertEqual(s.nextFireTime(wantsLive: true, now: at(100)), at(15)) + } + + func testFailuresBackOffExponentiallyAndCap() { + var s = ClaudeQuotaScheduler() + s.recordFetchStart(now: at(0)) + s.recordFailure(unauthorized: false) + XCTAssertEqual(s.backoff, 60) + s.recordStop(now: at(1)) + XCTAssertEqual(s.nextFireTime(wantsLive: true, now: at(1)), at(60)) + for _ in 0..<10 { s.recordFailure(unauthorized: false) } + XCTAssertEqual(s.backoff, 900) + XCTAssertEqual(s.nextFireTime(wantsLive: true, now: at(1)), at(900)) + XCTAssertFalse(s.shouldFetchOnExpand(now: at(899))) + XCTAssertTrue(s.shouldFetchOnExpand(now: at(900))) + s.recordSuccess() + XCTAssertNil(s.backoff) + } + + func testRejectedTokenStopsBackgroundFetchesButExpandRetries() { + var s = ClaudeQuotaScheduler() + s.recordFetchStart(now: at(0)) + s.recordFailure(unauthorized: true) + XCTAssertTrue(s.needsLogin) + s.recordStop(now: at(5)) + XCTAssertNil(s.nextFireTime(wantsLive: true, now: at(5))) + XCTAssertFalse(s.shouldFetchOnExpand(now: at(30))) + XCTAssertTrue(s.shouldFetchOnExpand(now: at(60))) + s.recordSuccess() + XCTAssertFalse(s.needsLogin) + } + + func testExpandRefreshesOnlyWhenStale() { + var s = ClaudeQuotaScheduler() + XCTAssertTrue(s.shouldFetchOnExpand(now: t0)) + s.recordFetchStart(now: t0) + XCTAssertFalse(s.shouldFetchOnExpand(now: at(59))) + XCTAssertTrue(s.shouldFetchOnExpand(now: at(60))) + } +} diff --git a/Tests/CodeIslandCoreTests/ClaudeQuotaTests.swift b/Tests/CodeIslandCoreTests/ClaudeQuotaTests.swift new file mode 100644 index 00000000..d8abb1e3 --- /dev/null +++ b/Tests/CodeIslandCoreTests/ClaudeQuotaTests.swift @@ -0,0 +1,195 @@ +import XCTest +@testable import CodeIslandCore + +final class ClaudeQuotaTests: XCTestCase { + /// Trimmed copy of a real `/api/oauth/usage` response (Max plan, 2026-09-03). + static let fixture = """ + {"five_hour":{"utilization":3.0,"resets_at":"2026-09-03T19:50:00.017533+00:00"}, + "seven_day":{"utilization":30.0,"resets_at":"2026-09-04T00:00:00.017553+00:00"}, + "seven_day_opus":null, + "limits":[ + {"kind":"session","group":"session","percent":3,"severity":"normal","resets_at":"2026-09-03T19:50:00.017533+00:00","scope":null,"is_active":false}, + {"kind":"weekly_all","group":"weekly","percent":30,"severity":"normal","resets_at":"2026-09-04T00:00:00.017553+00:00","scope":null,"is_active":false}, + {"kind":"weekly_scoped","group":"weekly","percent":48,"severity":"normal","resets_at":"2026-09-04T00:00:00.017765+00:00","scope":{"model":{"id":null,"display_name":"Fable"},"surface":null},"is_active":true}, + {"kind":"something_new","percent":1} + ]} + """.data(using: .utf8)! + + private let now = ISO8601DateFormatter().date(from: "2026-09-03T15:00:00Z")! + + func testParsesNormalisedLimitsAndIgnoresUnknownKinds() throws { + let snap = try ClaudeQuotaSnapshot.parse(Self.fixture, fetchedAt: now) + XCTAssertEqual(snap.limits.count, 3) + XCTAssertEqual(snap.ordered.map(\.kind), [.session, .weeklyAll, .weeklyScoped]) + let scoped = try XCTUnwrap(snap.limit(.weeklyScoped)) + XCTAssertEqual(scoped.percent, 48) + XCTAssertEqual(scoped.scopeLabel, "Fable") + XCTAssertTrue(scoped.isActive) + let resets = try XCTUnwrap(snap.limit(.session)?.resetsAt) + XCTAssertEqual(resets.timeIntervalSince1970, ISO8601DateFormatter().date(from: "2026-09-03T19:50:00Z")!.timeIntervalSince1970, accuracy: 0.1) + } + + func testFallsBackToLegacyFieldsWhenLimitsMissing() throws { + let json = """ + {"five_hour":{"utilization":12.5,"resets_at":"2026-09-03T19:50:00Z"}, + "seven_day":{"utilization":70,"resets_at":null}, + "seven_day_sonnet":{"utilization":5,"resets_at":null}} + """.data(using: .utf8)! + let snap = try ClaudeQuotaSnapshot.parse(json, fetchedAt: now) + XCTAssertEqual(snap.limit(.session)?.percent, 12.5) + XCTAssertEqual(snap.limit(.weeklyAll)?.percent, 70) + XCTAssertNil(snap.limit(.weeklyAll)?.resetsAt) + XCTAssertEqual(snap.limit(.weeklyScoped)?.scopeLabel, "Sonnet") + } + + func testParseRejectsGarbage() { + XCTAssertThrowsError(try ClaudeQuotaSnapshot.parse(Data("nope".utf8))) + XCTAssertThrowsError(try ClaudeQuotaSnapshot.parse(Data("{}".utf8))) { error in + XCTAssertEqual(error as? ClaudeQuotaSnapshot.ParseError, .noLimits) + } + } + + // MARK: pace + selector + + func testElapsedFractionDerivesFromResetTime() { + // 5h window, resets in 4h → 20% elapsed. + let limit = ClaudeQuotaLimit(kind: .session, percent: 40, resetsAt: now.addingTimeInterval(4 * 3600)) + XCTAssertEqual(limit.elapsedFraction(now: now)!, 0.2, accuracy: 0.001) + XCTAssertEqual(limit.paceDelta(now: now), 0.2, accuracy: 0.001) + // Past reset clamps to 1. + let stale = ClaudeQuotaLimit(kind: .session, percent: 40, resetsAt: now.addingTimeInterval(-60)) + XCTAssertEqual(stale.elapsedFraction(now: now), 1) + } + + func testAutoShowsTighterWeeklyWindowByDefault() { + // 5h at 20% with 2.5h left is behind pace → weekly wins. Both weeklies + // behind pace (1 day of 7 left, so 86% elapsed) → the one with more used. + let snap = ClaudeQuotaSnapshot(limits: [ + ClaudeQuotaLimit(kind: .session, percent: 20, resetsAt: now.addingTimeInterval(2.5 * 3600)), + ClaudeQuotaLimit(kind: .weeklyAll, percent: 30, resetsAt: now.addingTimeInterval(86_400)), + ClaudeQuotaLimit(kind: .weeklyScoped, percent: 48, resetsAt: now.addingTimeInterval(86_400), scopeLabel: "Fable"), + ], fetchedAt: now) + XCTAssertEqual(ClaudeQuotaSelector.pick(from: snap, mode: .auto, now: now)?.kind, .weeklyScoped) + let allTighter = ClaudeQuotaSnapshot(limits: [ + ClaudeQuotaLimit(kind: .weeklyAll, percent: 60), + ClaudeQuotaLimit(kind: .weeklyScoped, percent: 10, scopeLabel: "Fable"), + ], fetchedAt: now) + XCTAssertEqual(ClaudeQuotaSelector.pick(from: allTighter, mode: .auto, now: now)?.kind, .weeklyAll) + } + + func testAutoPrefersTheWeeklyWindowAheadOfPace() { + // Weekly-all has more used but is behind pace (1 day left → 86% + // elapsed, 40% used); Fable is ahead of pace (6 days left → 14% + // elapsed, 25% used) → Fable. + let fableAhead = ClaudeQuotaSnapshot(limits: [ + ClaudeQuotaLimit(kind: .session, percent: 5, resetsAt: now.addingTimeInterval(4 * 3600)), + ClaudeQuotaLimit(kind: .weeklyAll, percent: 40, resetsAt: now.addingTimeInterval(86_400)), + ClaudeQuotaLimit(kind: .weeklyScoped, percent: 25, resetsAt: now.addingTimeInterval(6 * 86_400), scopeLabel: "Fable"), + ], fetchedAt: now) + XCTAssertEqual(ClaudeQuotaSelector.pick(from: fableAhead, mode: .auto, now: now)?.kind, .weeklyScoped) + // Both ahead of pace (6 days left, 14% elapsed): the one further ahead wins, not the higher percent. + let bothAhead = ClaudeQuotaSnapshot(limits: [ + ClaudeQuotaLimit(kind: .weeklyAll, percent: 30, resetsAt: now.addingTimeInterval(6 * 86_400)), + ClaudeQuotaLimit(kind: .weeklyScoped, percent: 28, resetsAt: now.addingTimeInterval(6.5 * 86_400), scopeLabel: "Fable"), + ], fetchedAt: now) + // weekly-all: 0.30 - 0.143 = 0.157; Fable: 0.28 - 0.071 = 0.209 → Fable. + XCTAssertEqual(ClaudeQuotaSelector.pick(from: bothAhead, mode: .auto, now: now)?.kind, .weeklyScoped) + // A weekly past the alert line is pressing even when behind pace. + let hotWeekly = ClaudeQuotaSnapshot(limits: [ + ClaudeQuotaLimit(kind: .weeklyAll, percent: 85, resetsAt: now.addingTimeInterval(3600)), + ClaudeQuotaLimit(kind: .weeklyScoped, percent: 20, resetsAt: now.addingTimeInterval(3600), scopeLabel: "Fable"), + ], fetchedAt: now) + XCTAssertEqual(ClaudeQuotaSelector.pick(from: hotWeekly, mode: .auto, now: now)?.kind, .weeklyAll) + } + + func testAutoSwitchesToSessionWhenAheadOfPaceOrPastThreshold() { + // 40% used with 4h of 5h left → 20% elapsed → ahead of pace. + let ahead = ClaudeQuotaSnapshot(limits: [ + ClaudeQuotaLimit(kind: .session, percent: 40, resetsAt: now.addingTimeInterval(4 * 3600)), + ClaudeQuotaLimit(kind: .weeklyAll, percent: 60, resetsAt: now.addingTimeInterval(86_400)), + ], fetchedAt: now) + XCTAssertEqual(ClaudeQuotaSelector.pick(from: ahead, mode: .auto, now: now)?.kind, .session) + // 82% used with 10 minutes left is behind pace but past the 80% alert line. + let hot = ClaudeQuotaSnapshot(limits: [ + ClaudeQuotaLimit(kind: .session, percent: 82, resetsAt: now.addingTimeInterval(600)), + ClaudeQuotaLimit(kind: .weeklyAll, percent: 10, resetsAt: now.addingTimeInterval(86_400)), + ], fetchedAt: now) + XCTAssertEqual(ClaudeQuotaSelector.pick(from: hot, mode: .auto, now: now)?.kind, .session) + // 75% with 10 minutes left: behind pace and under the line → weekly stays. + let warm = ClaudeQuotaSnapshot(limits: [ + ClaudeQuotaLimit(kind: .session, percent: 75, resetsAt: now.addingTimeInterval(600)), + ClaudeQuotaLimit(kind: .weeklyAll, percent: 10, resetsAt: now.addingTimeInterval(86_400)), + ], fetchedAt: now) + XCTAssertEqual(ClaudeQuotaSelector.pick(from: warm, mode: .auto, now: now)?.kind, .weeklyAll) + // No weekly reported at all → session is all there is. + let only = ClaudeQuotaSnapshot(limits: [ClaudeQuotaLimit(kind: .session, percent: 5)], fetchedAt: now) + XCTAssertEqual(ClaudeQuotaSelector.pick(from: only, mode: .auto, now: now)?.kind, .session) + } + + func testFixedModesReturnThatWindowOrNil() throws { + let snap = try ClaudeQuotaSnapshot.parse(Self.fixture, fetchedAt: now) + XCTAssertEqual(ClaudeQuotaSelector.pick(from: snap, mode: .session)?.kind, .session) + XCTAssertEqual(ClaudeQuotaSelector.pick(from: snap, mode: .weeklyAll)?.kind, .weeklyAll) + XCTAssertEqual(ClaudeQuotaSelector.pick(from: snap, mode: .weeklyScoped)?.scopeLabel, "Fable") + XCTAssertNil(ClaudeQuotaSelector.pick(from: snap, mode: .off)) + let noScoped = ClaudeQuotaSnapshot(limits: [ClaudeQuotaLimit(kind: .session, percent: 1)], fetchedAt: now) + XCTAssertNil(ClaudeQuotaSelector.pick(from: noScoped, mode: .weeklyScoped)) + } + + // MARK: formatting + levels + + func testCountdownFormats() { + XCTAssertEqual(ClaudeQuotaFormat.countdown(until: now.addingTimeInterval(30), now: now), "1m") + XCTAssertEqual(ClaudeQuotaFormat.countdown(until: now.addingTimeInterval(45 * 60), now: now), "45m") + XCTAssertEqual(ClaudeQuotaFormat.countdown(until: now.addingTimeInterval(80 * 60), now: now), "1h20m") + XCTAssertEqual(ClaudeQuotaFormat.countdown(until: now.addingTimeInterval(3 * 3600), now: now), "3h") + XCTAssertEqual(ClaudeQuotaFormat.countdown(until: now.addingTimeInterval(2 * 86_400 + 4 * 3600), now: now), "2d 4h") + XCTAssertEqual(ClaudeQuotaFormat.countdown(until: now.addingTimeInterval(7 * 86_400), now: now), "7d") + XCTAssertNil(ClaudeQuotaFormat.countdown(until: now.addingTimeInterval(-1), now: now)) + XCTAssertEqual(ClaudeQuotaFormat.percent(47.6), "48%") + } + + func testSeverityLevels() { + XCTAssertEqual(ClaudeQuotaLimit(kind: .session, percent: 10, severity: "normal").level, .normal) + XCTAssertEqual(ClaudeQuotaLimit(kind: .session, percent: 10, severity: "warning").level, .warning) + XCTAssertEqual(ClaudeQuotaLimit(kind: .session, percent: 10, severity: "exceeded").level, .critical) + XCTAssertEqual(ClaudeQuotaLimit(kind: .session, percent: 100, severity: "normal").level, .critical) + } + + // MARK: credential + client + + func testCredentialParse() throws { + let json = """ + {"claudeAiOauth":{"accessToken":"sk-ant-oat01-abc","refreshToken":"r","expiresAt":1788472382000,"subscriptionType":"max"}} + """.data(using: .utf8)! + let cred = try XCTUnwrap(ClaudeCredentialStore.parse(json)) + XCTAssertEqual(cred.accessToken, "sk-ant-oat01-abc") + XCTAssertEqual(cred.subscriptionType, "max") + XCTAssertEqual(cred.expiresAt?.timeIntervalSince1970, 1_788_472_382) + XCTAssertNil(ClaudeCredentialStore.parse(Data("{\"claudeAiOauth\":{\"accessToken\":\"\"}}".utf8))) + } + + func testRequestCarriesBearerAndBetaHeader() { + let req = ClaudeQuotaClient.request(token: "tok") + XCTAssertEqual(req.url, ClaudeQuotaClient.endpoint) + XCTAssertEqual(req.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + XCTAssertEqual(req.value(forHTTPHeaderField: "anthropic-beta"), "oauth-2025-04-20") + } + + private func response(_ status: Int) -> HTTPURLResponse { + HTTPURLResponse(url: ClaudeQuotaClient.endpoint, statusCode: status, httpVersion: nil, headerFields: nil)! + } + + func testInterpretMapsStatusCodes() { + XCTAssertNoThrow(try ClaudeQuotaClient.interpret(data: Self.fixture, response: response(200))) + func err(_ status: Int, _ data: Data = Data()) -> ClaudeQuotaClientError? { + do { _ = try ClaudeQuotaClient.interpret(data: data, response: response(status)); return nil } + catch { return error as? ClaudeQuotaClientError } + } + XCTAssertEqual(err(401), .unauthorized) + XCTAssertEqual(err(403), .unauthorized) + XCTAssertEqual(err(429), .rateLimited) + XCTAssertEqual(err(503), .http(503)) + XCTAssertEqual(err(200, Data("{}".utf8)), .parse) + } +} diff --git a/Tests/CodeIslandTests/ClaudeQuotaMonitorTests.swift b/Tests/CodeIslandTests/ClaudeQuotaMonitorTests.swift new file mode 100644 index 00000000..b79c7fa3 --- /dev/null +++ b/Tests/CodeIslandTests/ClaudeQuotaMonitorTests.swift @@ -0,0 +1,109 @@ +import XCTest +import CodeIslandCore +@testable import CodeIsland + +/// Uses a private defaults suite: flipping the real `showClaudeQuota` would +/// wake every other test's AppState-owned monitor into a real keychain read, +/// which blocks on the macOS access prompt and hangs the run. +@MainActor +final class ClaudeQuotaMonitorTests: XCTestCase { + private let suiteName = "ClaudeQuotaMonitorTests" + private var defaults: UserDefaults! + + override func setUp() { + super.setUp() + defaults = UserDefaults(suiteName: suiteName) + defaults.removePersistentDomain(forName: suiteName) + defaults.set(true, forKey: SettingsKey.showClaudeQuota) + defaults.set(ClaudeQuotaChipMode.auto.rawValue, forKey: SettingsKey.claudeQuotaChip) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + super.tearDown() + } + + private static let snapshot = ClaudeQuotaSnapshot(limits: [ + ClaudeQuotaLimit(kind: .session, percent: 3, resetsAt: Date().addingTimeInterval(3600)), + ClaudeQuotaLimit(kind: .weeklyScoped, percent: 48, scopeLabel: "Fable"), + ], fetchedAt: Date()) + + /// Counts fetches; safe to touch from the detached fetch task. + private final class Counter: @unchecked Sendable { + private let lock = NSLock() + private var n = 0 + func bump() -> Int { lock.lock(); defer { lock.unlock() }; n += 1; return n } + var value: Int { lock.lock(); defer { lock.unlock() }; return n } + } + + private func fastConfig() -> ClaudeQuotaScheduler.Config { + var c = ClaudeQuotaScheduler.Config() + c.debounce = 0.05 + c.throttle = 0.2 + c.idleFloor = 100 + return c + } + + private func waitUntil(_ cond: @escaping @MainActor () -> Bool, timeout: TimeInterval = 2) async { + let deadline = Date().addingTimeInterval(timeout) + while !cond() && Date() < deadline { + try? await Task.sleep(nanoseconds: 20_000_000) + } + } + + func testExpandFetchesAndPublishesSnapshot() async { + let counter = Counter() + let m = ClaudeQuotaMonitor(scheduler: .init(config: fastConfig()), defaults: defaults, fetcher: { + _ = counter.bump(); return Self.snapshot + }) + m.noteExpanded() + await waitUntil { m.snapshot != nil } + XCTAssertEqual(m.snapshot, Self.snapshot) + XCTAssertNil(m.lastError) + XCTAssertEqual(m.chipLimit()?.kind, .weeklyScoped) + // Second expand inside the stale window does not refetch. + m.noteCollapsed(); m.noteExpanded() + try? await Task.sleep(nanoseconds: 100_000_000) + XCTAssertEqual(counter.value, 1) + } + + func testDisabledSettingNeverFetches() async { + defaults.set(false, forKey: SettingsKey.showClaudeQuota) + let counter = Counter() + let m = ClaudeQuotaMonitor(scheduler: .init(config: fastConfig()), defaults: defaults, fetcher: { + _ = counter.bump(); return Self.snapshot + }) + m.noteExpanded(); m.noteStop() + try? await Task.sleep(nanoseconds: 150_000_000) + XCTAssertEqual(counter.value, 0) + XCTAssertNil(m.chipLimit()) + } + + func testBurstOfStopsCoalescesIntoOneFetch() async { + defaults.set(ClaudeQuotaChipMode.off.rawValue, forKey: SettingsKey.claudeQuotaChip) + let counter = Counter() + let m = ClaudeQuotaMonitor(scheduler: .init(config: fastConfig()), defaults: defaults, fetcher: { + _ = counter.bump(); return Self.snapshot + }) + m.noteExpanded() // first fetch (stale) → 1 + await waitUntil { counter.value == 1 } + m.noteStop(); m.noteStop(); m.noteStop() + await waitUntil { counter.value == 2 } + try? await Task.sleep(nanoseconds: 300_000_000) + XCTAssertEqual(counter.value, 2, "three Stops inside the debounce window must produce exactly one trailing fetch") + } + + func testUnauthorizedSurfacesLoginErrorAndStopsPolling() async { + let counter = Counter() + let m = ClaudeQuotaMonitor(scheduler: .init(config: fastConfig()), defaults: defaults, fetcher: { + _ = counter.bump(); throw ClaudeQuotaClientError.unauthorized + }) + m.noteExpanded() + await waitUntil { m.lastError != nil } + XCTAssertEqual(m.lastError, .unauthorized) + XCTAssertTrue(m.scheduler.needsLogin) + m.noteStop() + try? await Task.sleep(nanoseconds: 300_000_000) + XCTAssertEqual(counter.value, 1) + } +} diff --git a/Tests/CodeIslandTests/NotchPanelViewTests.swift b/Tests/CodeIslandTests/NotchPanelViewTests.swift index b20eb764..0dd06993 100644 --- a/Tests/CodeIslandTests/NotchPanelViewTests.swift +++ b/Tests/CodeIslandTests/NotchPanelViewTests.swift @@ -263,4 +263,29 @@ final class NotchHoverInteractionTests: XCTestCase { func testSessionJumpValidationUsesThreeIncreasingDelays() { XCTAssertEqual(sessionJumpValidationDelays, [120_000_000, 320_000_000, 640_000_000]) } + + // MARK: - Collapsed wing slots (notched screens) + + func testWingSlotStaysAtReserveWhenContentFits() { + XCTAssertEqual(NotchWidthMetrics.collapsedWingSlot(reserve: 82, measured: 71), 82) + } + + func testWingSlotGrowsToItsContent() { + // Plan-limit chip in the left wing needs more than the reserve. + XCTAssertEqual(NotchWidthMetrics.collapsedWingSlot(reserve: 82, measured: 106), 106) + } + + func testShiftKeepsTheGapOnTheNotch() { + // Bar centred on the notch centre, wings of different widths: after + // the shift the left wing must end exactly where the cutout starts. + let notchW: CGFloat = 220 + let notchCenter: CGFloat = 1028 + for (left, right): (CGFloat, CGFloat) in [(82, 82), (106, 82), (82, 120), (160, 40)] { + let panelWidth = notchW + left + right + let shift = NotchWidthMetrics.collapsedShift(leftSlot: left, rightSlot: right) + let leftEdge = notchCenter - panelWidth / 2 + shift + XCTAssertEqual(leftEdge + left, notchCenter - notchW / 2, accuracy: 0.001) + XCTAssertEqual(leftEdge + left + notchW, notchCenter + notchW / 2, accuracy: 0.001) + } + } }