From 64cbfa2b4acbbf4d762bf2bd2a34986300e7f6d9 Mon Sep 17 00:00:00 2001 From: mutoe Date: Fri, 4 Sep 2026 01:05:35 +0800 Subject: [PATCH 1/7] feat(quota): show Claude plan limits (5h / weekly) in the island MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opt-in setting reads the Claude Code OAuth login from Keychain and fetches Anthropic's subscription windows — the numbers /usage shows. Expanded panel: footer line with every window (bar, percent, reset countdown). Collapsed island: ring chip beside the session count for one window; Auto ranks by pace (used share minus elapsed share), or pin a window. Refresh is event-driven off Stop hooks: 15s coalesce, 60s throttle with a trailing fetch, 10-minute idle tick, exponential backoff, and no token refresh — an expired token just asks to run Claude Code once. Keychain reads hop to GCD so the macOS access prompt never blocks the main actor. Tests cover parsing (real response fixture + legacy fallback), the pace selector, countdown formatting, HTTP status mapping, the scheduler's debounce/throttle/backoff rules, and the monitor's fetch behaviour on a private UserDefaults suite. --- CHANGELOG.md | 6 + Sources/CodeIsland/AppState.swift | 12 + Sources/CodeIsland/ClaudeQuotaMonitor.swift | 175 ++++++++++++++ Sources/CodeIsland/DebugHarness.swift | 8 + Sources/CodeIsland/L10n.swift | 91 ++++++++ Sources/CodeIsland/NotchPanelView.swift | 186 ++++++++++++++- Sources/CodeIsland/Settings.swift | 8 + Sources/CodeIsland/SettingsView.swift | 16 ++ Sources/CodeIslandCore/ClaudeQuota.swift | 215 ++++++++++++++++++ .../CodeIslandCore/ClaudeQuotaClient.swift | 129 +++++++++++ .../CodeIslandCore/ClaudeQuotaScheduler.swift | 101 ++++++++ .../ClaudeQuotaSchedulerTests.swift | 81 +++++++ .../ClaudeQuotaTests.swift | 147 ++++++++++++ .../ClaudeQuotaMonitorTests.swift | 109 +++++++++ 14 files changed, 1283 insertions(+), 1 deletion(-) create mode 100644 Sources/CodeIsland/ClaudeQuotaMonitor.swift create mode 100644 Sources/CodeIslandCore/ClaudeQuota.swift create mode 100644 Sources/CodeIslandCore/ClaudeQuotaClient.swift create mode 100644 Sources/CodeIslandCore/ClaudeQuotaScheduler.swift create mode 100644 Tests/CodeIslandCoreTests/ClaudeQuotaSchedulerTests.swift create mode 100644 Tests/CodeIslandCoreTests/ClaudeQuotaTests.swift create mode 100644 Tests/CodeIslandTests/ClaudeQuotaMonitorTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c7c3230..a247b2da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [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 ring chip next to the session count showing one window — "Auto" picks whichever is running ahead of pace (used share minus elapsed share), 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; macOS asks once for Keychain access + +### 中文 +- Claude 套餐额度:新增可选设置「显示 Claude 套餐额度」,用 Keychain 里的 Claude Code 登录向 Anthropic 查询和 `/usage` 一样的 5 小时 / 周窗口。展开面板底部新增一行显示全部窗口(迷你进度条、百分比、重置倒计时);收起药丸在会话数旁加一个环形 chip 只显示一个窗口——「自动」选进度最超前的那个(已用比例减已过时间比例),也可固定为 5 小时 / 周 / 周(当前模型)。刷新由 Stop hook 驱动(15 秒合并、每分钟最多一次、窗口结束后补发一次),空闲时 10 分钟一次,出错指数退避,永不刷新 token——过期只提示「运行一次 Claude Code」。默认关闭;macOS 会请求一次 Keychain 授权 + ## [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..6483b2fb 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; macOS asks once for Keychain access. The token is only read, never refreshed.", + "claude_quota_chip": "Collapsed island shows", + "quota_chip_off": "Nothing", + "quota_chip_auto": "Auto (most pressing window)", + "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; macOS fragt einmal nach Schlüsselbund-Zugriff. Das Token wird nur gelesen, nie erneuert.", + "claude_quota_chip": "Eingeklappte Insel zeigt", + "quota_chip_off": "Nichts", + "quota_chip_auto": "Automatisch (knappstes Fenster)", + "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)。会发起网络请求;macOS 会请求一次 Keychain 授权。只读取 token,不会刷新。", + "claude_quota_chip": "收起时显示", + "quota_chip_off": "不显示", + "quota_chip_auto": "自动(最紧张的窗口)", + "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)。會發起網路請求;macOS 會請求一次 Keychain 授權。僅讀取 token,不會重新整理。", + "claude_quota_chip": "收合時顯示", + "quota_chip_off": "不顯示", + "quota_chip_auto": "自動(最吃緊的視窗)", + "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 と同じ)を取得します。ネットワーク通信を行い、macOS がキーチェーンへのアクセスを一度確認します。トークンは読み取りのみで更新しません。", + "claude_quota_chip": "折りたたみ時に表示", + "quota_chip_off": "表示しない", + "quota_chip_auto": "自動(最も逼迫したウィンドウ)", + "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와 동일)를 가져옵니다. 네트워크 요청을 보내며 macOS가 키체인 접근을 한 번 묻습니다. 토큰은 읽기만 하고 갱신하지 않습니다.", + "claude_quota_chip": "접힌 상태에서 표시", + "quota_chip_off": "표시 안 함", + "quota_chip_auto": "자동(가장 촉박한 창)", + "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; macOS bir kez Anahtar Zinciri erişimi sorar. Jeton yalnızca okunur, yenilenmez.", + "claude_quota_chip": "Daraltılmış ada gösterir", + "quota_chip_off": "Hiçbir şey", + "quota_chip_auto": "Otomatik (en sıkışık pencere)", + "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..85ca251c 100644 --- a/Sources/CodeIsland/NotchPanelView.swift +++ b/Sources/CodeIsland/NotchPanelView.swift @@ -98,6 +98,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 @@ -157,9 +159,11 @@ struct NotchPanelView: View { let extra: CGFloat = appState.status == .idle ? 0 : 20 // Reserve space for tool status — proportional to screen width let toolExtra: CGFloat = displayedToolStatus ? (hasNotch ? screenWidth * 0.03 : screenWidth * 0.04) : 0 + // Plan-limit chip in the right wing (ring + percent). + let quotaExtra: CGFloat = QuotaChip.limit(appState: appState, enabled: showClaudeQuota, modeRaw: claudeQuotaChip) != nil ? QuotaChip.reservedWidth : 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 + return nw + wing * 2 + extra + toolExtra + quotaExtra + prehoverExtra } var body: some View { @@ -549,6 +553,8 @@ private struct CompactRightWing: View { @AppStorage(SettingsKey.quietHoursEnabled) private var quietHoursEnabled = SettingsDefaults.quietHoursEnabled @AppStorage(SettingsKey.quietHoursStart) private var quietHoursStart = SettingsDefaults.quietHoursStart @AppStorage(SettingsKey.quietHoursEnd) private var quietHoursEnd = SettingsDefaults.quietHoursEnd + @AppStorage(SettingsKey.showClaudeQuota) private var showClaudeQuota = SettingsDefaults.showClaudeQuota + @AppStorage(SettingsKey.claudeQuotaChip) private var claudeQuotaChip = SettingsDefaults.claudeQuotaChip /// Re-evaluated on every re-render; the compact bar redraws often enough /// that the moon appears/disappears close to the window edges. @@ -608,6 +614,13 @@ private struct CompactRightWing: View { .symbolEffect(.pulse, options: .repeating) } + // Plan-limit chip: the window most likely to run out (or the + // one the user pinned), ring + percent. Tooltip has all windows. + 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) + } + if showToolStatus { // Detailed mode: session count (project name is shown in center on non-notch) HStack(spacing: 1) { @@ -1765,6 +1778,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 +1934,180 @@ 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: a 9pt ring plus percent for the selected window. +struct QuotaChip: View { + let limit: ClaudeQuotaLimit + let snapshot: ClaudeQuotaSnapshot + let stale: Bool + @ObservedObject private var l10n = L10n.shared + + /// Width reserved in the collapsed bar when the chip is shown. + static let reservedWidth: CGFloat = 40 + + 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) { + 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..2ab631d3 --- /dev/null +++ b/Sources/CodeIslandCore/ClaudeQuota.swift @@ -0,0 +1,215 @@ +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 { + /// Pick the limit for the collapsed chip. `auto` ranks by pace (used + /// fraction minus elapsed fraction) so a 5h window at 40% with four hours + /// left outranks a weekly window at 60% on its last day; ties fall back + /// to raw percent. Fixed modes return that window, or nil if the server + /// didn't report it. + 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: + return snapshot.limits.max { a, b in + let pa = a.paceDelta(now: now), pb = b.paceDelta(now: now) + if abs(pa - pb) > 0.0001 { return pa < pb } + return a.percent < b.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..0504270b --- /dev/null +++ b/Sources/CodeIslandCore/ClaudeQuotaClient.swift @@ -0,0 +1,129 @@ +import Foundation +import Security + +/// 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. The first read from a new binary + /// triggers macOS's "wants to use your confidential information" prompt. + public static func readKeychain(service: String = keychainService) -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess else { return nil } + return result as? Data + } + + /// 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..4c4cfe26 --- /dev/null +++ b/Tests/CodeIslandCoreTests/ClaudeQuotaTests.swift @@ -0,0 +1,147 @@ +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 testAutoPrefersWindowAheadOfPaceOverHigherRawPercent() { + // 5h at 40% with 4h left (pace +0.20) beats weekly at 60% on its last day (pace -0.26). + let snap = 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: snap, mode: .auto, now: now)?.kind, .session) + } + + func testAutoFallsBackToPercentWhenPaceTies() { + let snap = ClaudeQuotaSnapshot(limits: [ + ClaudeQuotaLimit(kind: .weeklyAll, percent: 30), + ClaudeQuotaLimit(kind: .weeklyScoped, percent: 48, scopeLabel: "Fable"), + ], fetchedAt: now) + XCTAssertEqual(ClaudeQuotaSelector.pick(from: snap, mode: .auto, now: now)?.kind, .weeklyScoped) + } + + 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) + } +} From a4fa781aec40a2ac835b5042d53b613e2651e856 Mon Sep 17 00:00:00 2001 From: mutoe Date: Fri, 4 Sep 2026 01:21:10 +0800 Subject: [PATCH 2/7] feat(quota): auto chip shows the weekly budget, labelled by window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto now defaults to the tighter of the two weekly windows and only hands the chip to the 5-hour window while that one is pressing — ahead of pace or past 70%. The chip carries its window label (5h / week / model name) before the ring so the number is never ambiguous; the collapsed bar reserves width for the label. --- CHANGELOG.md | 4 +-- Sources/CodeIsland/L10n.swift | 14 ++++---- Sources/CodeIsland/NotchPanelView.swift | 14 ++++++-- Sources/CodeIslandCore/ClaudeQuota.swift | 31 ++++++++++------ .../ClaudeQuotaTests.swift | 36 +++++++++++++------ 5 files changed, 67 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a247b2da..5f2faa6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,10 @@ ## [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 ring chip next to the session count showing one window — "Auto" picks whichever is running ahead of pace (used share minus elapsed share), 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; macOS asks once for Keychain access +- 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 the tighter weekly budget and switches to the 5-hour window while that one is pressing (ahead of pace, or past 70%), 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; macOS asks once for Keychain access ### 中文 -- Claude 套餐额度:新增可选设置「显示 Claude 套餐额度」,用 Keychain 里的 Claude Code 登录向 Anthropic 查询和 `/usage` 一样的 5 小时 / 周窗口。展开面板底部新增一行显示全部窗口(迷你进度条、百分比、重置倒计时);收起药丸在会话数旁加一个环形 chip 只显示一个窗口——「自动」选进度最超前的那个(已用比例减已过时间比例),也可固定为 5 小时 / 周 / 周(当前模型)。刷新由 Stop hook 驱动(15 秒合并、每分钟最多一次、窗口结束后补发一次),空闲时 10 分钟一次,出错指数退避,永不刷新 token——过期只提示「运行一次 Claude Code」。默认关闭;macOS 会请求一次 Keychain 授权 +- Claude 套餐额度:新增可选设置「显示 Claude 套餐额度」,用 Keychain 里的 Claude Code 登录向 Anthropic 查询和 `/usage` 一样的 5 小时 / 周窗口。展开面板底部新增一行显示全部窗口(迷你进度条、百分比、重置倒计时);收起药丸在会话数旁加一个带窗口标签的环形 chip 只显示一个窗口——「自动」常态显示更紧的那条周额度,5 小时窗口吃紧时(进度超前或超过 70%)切换过去,也可固定为 5 小时 / 周 / 周(当前模型)。刷新由 Stop hook 驱动(15 秒合并、每分钟最多一次、窗口结束后补发一次),空闲时 10 分钟一次,出错指数退避,永不刷新 token——过期只提示「运行一次 Claude Code」。默认关闭;macOS 会请求一次 Keychain 授权 ## [v1.0.33] - 2026-09-01 diff --git a/Sources/CodeIsland/L10n.swift b/Sources/CodeIsland/L10n.swift index 6483b2fb..560908a4 100644 --- a/Sources/CodeIsland/L10n.swift +++ b/Sources/CodeIsland/L10n.swift @@ -201,7 +201,7 @@ final class L10n: ObservableObject { "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; macOS asks once for Keychain access. The token is only read, never refreshed.", "claude_quota_chip": "Collapsed island shows", "quota_chip_off": "Nothing", - "quota_chip_auto": "Auto (most pressing window)", + "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)", @@ -563,7 +563,7 @@ final class L10n: ObservableObject { "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; macOS fragt einmal nach Schlüsselbund-Zugriff. Das Token wird nur gelesen, nie erneuert.", "claude_quota_chip": "Eingeklappte Insel zeigt", "quota_chip_off": "Nichts", - "quota_chip_auto": "Automatisch (knappstes Fenster)", + "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)", @@ -929,7 +929,7 @@ final class L10n: ObservableObject { "show_claude_quota_desc": "使用 Keychain 中保存的 Claude Code 登录信息,向 Anthropic 查询订阅额度(5 小时与周窗口,同 /usage)。会发起网络请求;macOS 会请求一次 Keychain 授权。只读取 token,不会刷新。", "claude_quota_chip": "收起时显示", "quota_chip_off": "不显示", - "quota_chip_auto": "自动(最紧张的窗口)", + "quota_chip_auto": "自动(周额度,5 小时吃紧时切换)", "quota_chip_session": "5 小时", "quota_chip_weekly": "周", "quota_chip_weekly_model": "周(当前模型)", @@ -1295,7 +1295,7 @@ final class L10n: ObservableObject { "show_claude_quota_desc": "使用 Keychain 中儲存的 Claude Code 登入資訊,向 Anthropic 查詢訂閱額度(5 小時與週視窗,同 /usage)。會發起網路請求;macOS 會請求一次 Keychain 授權。僅讀取 token,不會重新整理。", "claude_quota_chip": "收合時顯示", "quota_chip_off": "不顯示", - "quota_chip_auto": "自動(最吃緊的視窗)", + "quota_chip_auto": "自動(週額度,5 小時吃緊時切換)", "quota_chip_session": "5 小時", "quota_chip_weekly": "週", "quota_chip_weekly_model": "週(目前模型)", @@ -1661,7 +1661,7 @@ final class L10n: ObservableObject { "show_claude_quota_desc": "キーチェーンに保存された Claude Code のサインインを使い、Anthropic からサブスクリプション上限(5時間・週ウィンドウ、/usage と同じ)を取得します。ネットワーク通信を行い、macOS がキーチェーンへのアクセスを一度確認します。トークンは読み取りのみで更新しません。", "claude_quota_chip": "折りたたみ時に表示", "quota_chip_off": "表示しない", - "quota_chip_auto": "自動(最も逼迫したウィンドウ)", + "quota_chip_auto": "自動(週。5時間が逼迫時は切替)", "quota_chip_session": "5時間", "quota_chip_weekly": "週", "quota_chip_weekly_model": "週(現在のモデル)", @@ -2027,7 +2027,7 @@ final class L10n: ObservableObject { "show_claude_quota_desc": "키체인에 저장된 Claude Code 로그인으로 Anthropic에서 구독 한도(5시간·주간 창, /usage와 동일)를 가져옵니다. 네트워크 요청을 보내며 macOS가 키체인 접근을 한 번 묻습니다. 토큰은 읽기만 하고 갱신하지 않습니다.", "claude_quota_chip": "접힌 상태에서 표시", "quota_chip_off": "표시 안 함", - "quota_chip_auto": "자동(가장 촉박한 창)", + "quota_chip_auto": "자동(주간, 5시간이 촉박하면 전환)", "quota_chip_session": "5시간", "quota_chip_weekly": "주간", "quota_chip_weekly_model": "주간(현재 모델)", @@ -2393,7 +2393,7 @@ final class L10n: ObservableObject { "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; macOS bir kez Anahtar Zinciri erişimi sorar. Jeton yalnızca okunur, yenilenmez.", "claude_quota_chip": "Daraltılmış ada gösterir", "quota_chip_off": "Hiçbir şey", - "quota_chip_auto": "Otomatik (en sıkışık pencere)", + "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)", diff --git a/Sources/CodeIsland/NotchPanelView.swift b/Sources/CodeIsland/NotchPanelView.swift index 85ca251c..2ba54bc9 100644 --- a/Sources/CodeIsland/NotchPanelView.swift +++ b/Sources/CodeIsland/NotchPanelView.swift @@ -160,7 +160,7 @@ struct NotchPanelView: View { // Reserve space for tool status — proportional to screen width let toolExtra: CGFloat = displayedToolStatus ? (hasNotch ? screenWidth * 0.03 : screenWidth * 0.04) : 0 // Plan-limit chip in the right wing (ring + percent). - let quotaExtra: CGFloat = QuotaChip.limit(appState: appState, enabled: showClaudeQuota, modeRaw: claudeQuotaChip) != nil ? QuotaChip.reservedWidth : 0 + let quotaExtra: CGFloat = QuotaChip.limit(appState: appState, enabled: showClaudeQuota, modeRaw: claudeQuotaChip).map(QuotaChip.reservedWidth(for:)) ?? 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 + quotaExtra + prehoverExtra @@ -1989,8 +1989,11 @@ struct QuotaChip: View { let stale: Bool @ObservedObject private var l10n = L10n.shared - /// Width reserved in the collapsed bar when the chip is shown. - static let reservedWidth: CGFloat = 40 + /// Width reserved in the collapsed bar when the chip is shown: ring + + /// percent plus the window label (10pt monospaced ≈ 6.2pt per glyph). + static func reservedWidth(for limit: ClaudeQuotaLimit) -> CGFloat { + 44 + CGFloat(QuotaStyle.label(limit, l10n: L10n.shared).count) * 6.2 + } init(limit: ClaudeQuotaLimit, snapshot: ClaudeQuotaSnapshot, stale: Bool) { self.limit = limit @@ -2009,6 +2012,11 @@ struct QuotaChip: View { 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() diff --git a/Sources/CodeIslandCore/ClaudeQuota.swift b/Sources/CodeIslandCore/ClaudeQuota.swift index 2ab631d3..c99f0c62 100644 --- a/Sources/CodeIslandCore/ClaudeQuota.swift +++ b/Sources/CodeIslandCore/ClaudeQuota.swift @@ -170,11 +170,17 @@ public enum ClaudeQuotaChipMode: String, CaseIterable, Sendable { } public enum ClaudeQuotaSelector { - /// Pick the limit for the collapsed chip. `auto` ranks by pace (used - /// fraction minus elapsed fraction) so a 5h window at 40% with four hours - /// left outranks a weekly window at 60% on its last day; ties fall back - /// to raw percent. Fixed modes return that window, or nil if the server - /// didn't report it. + /// 5h usage at or above this share always takes the chip in `auto`. + public static let sessionAlertPercent: Double = 70 + + /// Pick the limit for the collapsed chip. Fixed modes return that window, + /// or nil if the server didn't report it. + /// + /// `auto` shows the weekly budget by default — the tighter of the two + /// weekly windows — because that is the one that runs out for days. The + /// 5-hour window takes over only while it is the pressing one: running + /// ahead of pace (used share exceeds elapsed share) or past + /// `sessionAlertPercent`. public static func pick(from snapshot: ClaudeQuotaSnapshot, mode: ClaudeQuotaChipMode, now: Date = Date()) -> ClaudeQuotaLimit? { switch mode { case .off: return nil @@ -182,13 +188,18 @@ public enum ClaudeQuotaSelector { case .weeklyAll: return snapshot.limit(.weeklyAll) case .weeklyScoped: return snapshot.limit(.weeklyScoped) case .auto: - return snapshot.limits.max { a, b in - let pa = a.paceDelta(now: now), pb = b.paceDelta(now: now) - if abs(pa - pb) > 0.0001 { return pa < pb } - return a.percent < b.percent - } + let session = snapshot.limit(.session) + let weekly = [snapshot.limit(.weeklyAll), snapshot.limit(.weeklyScoped)] + .compactMap { $0 } + .max { $0.percent < $1.percent } + if let session, sessionIsPressing(session, now: now) { return session } + return weekly ?? session } } + + public static func sessionIsPressing(_ session: ClaudeQuotaLimit, now: Date = Date()) -> Bool { + session.percent >= sessionAlertPercent || session.paceDelta(now: now) > 0 + } } public enum ClaudeQuotaFormat { diff --git a/Tests/CodeIslandCoreTests/ClaudeQuotaTests.swift b/Tests/CodeIslandCoreTests/ClaudeQuotaTests.swift index 4c4cfe26..e1f43456 100644 --- a/Tests/CodeIslandCoreTests/ClaudeQuotaTests.swift +++ b/Tests/CodeIslandCoreTests/ClaudeQuotaTests.swift @@ -61,21 +61,37 @@ final class ClaudeQuotaTests: XCTestCase { XCTAssertEqual(stale.elapsedFraction(now: now), 1) } - func testAutoPrefersWindowAheadOfPaceOverHigherRawPercent() { - // 5h at 40% with 4h left (pace +0.20) beats weekly at 60% on its last day (pace -0.26). + func testAutoShowsTighterWeeklyWindowByDefault() { + // 5h at 20% with 2.5h left is behind pace → weekly wins; Fable (48%) is the tighter weekly. let snap = ClaudeQuotaSnapshot(limits: [ - ClaudeQuotaLimit(kind: .session, percent: 40, resetsAt: now.addingTimeInterval(4 * 3600)), - ClaudeQuotaLimit(kind: .weeklyAll, percent: 60, resetsAt: now.addingTimeInterval(86_400)), + 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, .session) + 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 testAutoFallsBackToPercentWhenPaceTies() { - let snap = ClaudeQuotaSnapshot(limits: [ - ClaudeQuotaLimit(kind: .weeklyAll, percent: 30), - ClaudeQuotaLimit(kind: .weeklyScoped, percent: 48, scopeLabel: "Fable"), + 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: snap, mode: .auto, now: now)?.kind, .weeklyScoped) + XCTAssertEqual(ClaudeQuotaSelector.pick(from: ahead, mode: .auto, now: now)?.kind, .session) + // 72% used with 10 minutes left is behind pace but past the 70% alert line. + let hot = ClaudeQuotaSnapshot(limits: [ + ClaudeQuotaLimit(kind: .session, percent: 72, 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) + // 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 { From af7b21002c11efc5503c401048eded8fc5a3678b Mon Sep 17 00:00:00 2001 From: mutoe Date: Fri, 4 Sep 2026 01:26:47 +0800 Subject: [PATCH 3/7] feat(quota): chip lives in the left wing, yielding to the running tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The collapsed chip moves next to the mascot and only shows while no tool name is displayed — a running tool is the more urgent signal, and the right wing keeps the session count alone. The chip shares the tool slot's width reserve. --- Sources/CodeIsland/NotchPanelView.swift | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/Sources/CodeIsland/NotchPanelView.swift b/Sources/CodeIsland/NotchPanelView.swift index 2ba54bc9..5127e05a 100644 --- a/Sources/CodeIsland/NotchPanelView.swift +++ b/Sources/CodeIsland/NotchPanelView.swift @@ -159,8 +159,10 @@ struct NotchPanelView: View { let extra: CGFloat = appState.status == .idle ? 0 : 20 // Reserve space for tool status — proportional to screen width let toolExtra: CGFloat = displayedToolStatus ? (hasNotch ? screenWidth * 0.03 : screenWidth * 0.04) : 0 - // Plan-limit chip in the right wing (ring + percent). - let quotaExtra: CGFloat = QuotaChip.limit(appState: appState, enabled: showClaudeQuota, modeRaw: claudeQuotaChip).map(QuotaChip.reservedWidth(for:)) ?? 0 + // Plan-limit chip shares the left-wing tool slot, so only the part its + // width exceeds the tool reserve needs adding. + let quotaExtra: CGFloat = QuotaChip.limit(appState: appState, enabled: showClaudeQuota, modeRaw: claudeQuotaChip) + .map { Swift.max(0, QuotaChip.reservedWidth(for: $0) - toolExtra) } ?? 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 + quotaExtra + prehoverExtra @@ -446,6 +448,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 @@ -516,6 +520,13 @@ private struct CompactLeftWing: View { .frame(maxWidth: ToolNameDisplay.compactMaxWidth, alignment: .leading) .transition(.opacity) .help(tool) + } else if let limit = QuotaChip.limit(appState: appState, enabled: showClaudeQuota, modeRaw: claudeQuotaChip), + let snapshot = appState.claudeQuota.snapshot { + // Plan-limit chip takes the tool slot while no tool is + // running: window label + ring + percent, all windows in + // the tooltip. + QuotaChip(limit: limit, snapshot: snapshot, stale: appState.claudeQuota.lastError != nil) + .transition(.opacity) } } } @@ -553,8 +564,6 @@ private struct CompactRightWing: View { @AppStorage(SettingsKey.quietHoursEnabled) private var quietHoursEnabled = SettingsDefaults.quietHoursEnabled @AppStorage(SettingsKey.quietHoursStart) private var quietHoursStart = SettingsDefaults.quietHoursStart @AppStorage(SettingsKey.quietHoursEnd) private var quietHoursEnd = SettingsDefaults.quietHoursEnd - @AppStorage(SettingsKey.showClaudeQuota) private var showClaudeQuota = SettingsDefaults.showClaudeQuota - @AppStorage(SettingsKey.claudeQuotaChip) private var claudeQuotaChip = SettingsDefaults.claudeQuotaChip /// Re-evaluated on every re-render; the compact bar redraws often enough /// that the moon appears/disappears close to the window edges. @@ -614,13 +623,6 @@ private struct CompactRightWing: View { .symbolEffect(.pulse, options: .repeating) } - // Plan-limit chip: the window most likely to run out (or the - // one the user pinned), ring + percent. Tooltip has all windows. - 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) - } - if showToolStatus { // Detailed mode: session count (project name is shown in center on non-notch) HStack(spacing: 1) { From 739e2ce187ad651c2b2c22727d382c49aae45b92 Mon Sep 17 00:00:00 2001 From: mutoe Date: Fri, 4 Sep 2026 01:35:05 +0800 Subject: [PATCH 4/7] fix(quota): read the login via security(1) and measure the chip width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SecItemCopyMatching from our own process raised the Keychain access prompt on every ad-hoc rebuild. Claude Code stores the item with /usr/bin/security, which is therefore on the item's ACL and reads it silently — the same route the ccusage Raycast extension takes — so the credential is now read through a security(1) subprocess with a timeout. The collapsed chip's reserve was a label-length estimate and clipped "Fable 53%"; the bar now measures the laid-out chip through a PreferenceKey and reserves exactly that. --- CHANGELOG.md | 4 +- Sources/CodeIsland/L10n.swift | 14 +++---- Sources/CodeIsland/NotchPanelView.swift | 26 +++++++++++-- .../CodeIslandCore/ClaudeQuotaClient.swift | 39 ++++++++++++------- 4 files changed, 57 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f2faa6f..ef2bc09a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,10 @@ ## [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 the tighter weekly budget and switches to the 5-hour window while that one is pressing (ahead of pace, or past 70%), 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; macOS asks once for Keychain access +- 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 the tighter weekly budget and switches to the 5-hour window while that one is pressing (ahead of pace, or past 70%), 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 ### 中文 -- Claude 套餐额度:新增可选设置「显示 Claude 套餐额度」,用 Keychain 里的 Claude Code 登录向 Anthropic 查询和 `/usage` 一样的 5 小时 / 周窗口。展开面板底部新增一行显示全部窗口(迷你进度条、百分比、重置倒计时);收起药丸在会话数旁加一个带窗口标签的环形 chip 只显示一个窗口——「自动」常态显示更紧的那条周额度,5 小时窗口吃紧时(进度超前或超过 70%)切换过去,也可固定为 5 小时 / 周 / 周(当前模型)。刷新由 Stop hook 驱动(15 秒合并、每分钟最多一次、窗口结束后补发一次),空闲时 10 分钟一次,出错指数退避,永不刷新 token——过期只提示「运行一次 Claude Code」。默认关闭;macOS 会请求一次 Keychain 授权 +- Claude 套餐额度:新增可选设置「显示 Claude 套餐额度」,用 Keychain 里的 Claude Code 登录向 Anthropic 查询和 `/usage` 一样的 5 小时 / 周窗口。展开面板底部新增一行显示全部窗口(迷你进度条、百分比、重置倒计时);收起药丸在会话数旁加一个带窗口标签的环形 chip 只显示一个窗口——「自动」常态显示更紧的那条周额度,5 小时窗口吃紧时(进度超前或超过 70%)切换过去,也可固定为 5 小时 / 周 / 周(当前模型)。刷新由 Stop hook 驱动(15 秒合并、每分钟最多一次、窗口结束后补发一次),空闲时 10 分钟一次,出错指数退避,永不刷新 token——过期只提示「运行一次 Claude Code」。默认关闭;登录信息通过 `security` 命令读取(Claude Code 自己就是用它写入的),不会弹 Keychain 授权框 ## [v1.0.33] - 2026-09-01 diff --git a/Sources/CodeIsland/L10n.swift b/Sources/CodeIsland/L10n.swift index 560908a4..fba66913 100644 --- a/Sources/CodeIsland/L10n.swift +++ b/Sources/CodeIsland/L10n.swift @@ -198,7 +198,7 @@ final class L10n: ObservableObject { "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; macOS asks once for Keychain access. The token is only read, never refreshed.", + "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)", @@ -560,7 +560,7 @@ final class L10n: ObservableObject { "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; macOS fragt einmal nach Schlüsselbund-Zugriff. Das Token wird nur gelesen, nie erneuert.", + "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)", @@ -926,7 +926,7 @@ final class L10n: ObservableObject { "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)。会发起网络请求;macOS 会请求一次 Keychain 授权。只读取 token,不会刷新。", + "show_claude_quota_desc": "使用 Keychain 中保存的 Claude Code 登录信息,向 Anthropic 查询订阅额度(5 小时与周窗口,同 /usage)。会发起网络请求。只读取 token,不会刷新。", "claude_quota_chip": "收起时显示", "quota_chip_off": "不显示", "quota_chip_auto": "自动(周额度,5 小时吃紧时切换)", @@ -1292,7 +1292,7 @@ final class L10n: ObservableObject { "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)。會發起網路請求;macOS 會請求一次 Keychain 授權。僅讀取 token,不會重新整理。", + "show_claude_quota_desc": "使用 Keychain 中儲存的 Claude Code 登入資訊,向 Anthropic 查詢訂閱額度(5 小時與週視窗,同 /usage)。會發起網路請求。僅讀取 token,不會重新整理。", "claude_quota_chip": "收合時顯示", "quota_chip_off": "不顯示", "quota_chip_auto": "自動(週額度,5 小時吃緊時切換)", @@ -1658,7 +1658,7 @@ final class L10n: ObservableObject { "show_usage_stats": "Claudeトークン使用量を表示", "show_usage_stats_desc": "ローカルの Claude Code トランスクリプトから集計したトークン使用量(5時間ウィンドウと今日)をセッション一覧の下部に表示します。ローカルファイルのみ読み取り、ネットワーク通信は行いません。", "show_claude_quota": "Claude プラン上限を表示", - "show_claude_quota_desc": "キーチェーンに保存された Claude Code のサインインを使い、Anthropic からサブスクリプション上限(5時間・週ウィンドウ、/usage と同じ)を取得します。ネットワーク通信を行い、macOS がキーチェーンへのアクセスを一度確認します。トークンは読み取りのみで更新しません。", + "show_claude_quota_desc": "キーチェーンに保存された Claude Code のサインインを使い、Anthropic からサブスクリプション上限(5時間・週ウィンドウ、/usage と同じ)を取得します。ネットワーク通信を行います。トークンは読み取りのみで更新しません。", "claude_quota_chip": "折りたたみ時に表示", "quota_chip_off": "表示しない", "quota_chip_auto": "自動(週。5時間が逼迫時は切替)", @@ -2024,7 +2024,7 @@ final class L10n: ObservableObject { "show_usage_stats": "Claude 토큰 사용량 표시", "show_usage_stats_desc": "로컬 Claude Code 기록에서 집계한 토큰 사용량(5시간 창과 오늘)을 세션 목록 하단에 표시합니다. 로컬 파일만 읽으며 네트워크 요청은 없습니다.", "show_claude_quota": "Claude 플랜 한도 표시", - "show_claude_quota_desc": "키체인에 저장된 Claude Code 로그인으로 Anthropic에서 구독 한도(5시간·주간 창, /usage와 동일)를 가져옵니다. 네트워크 요청을 보내며 macOS가 키체인 접근을 한 번 묻습니다. 토큰은 읽기만 하고 갱신하지 않습니다.", + "show_claude_quota_desc": "키체인에 저장된 Claude Code 로그인으로 Anthropic에서 구독 한도(5시간·주간 창, /usage와 동일)를 가져옵니다. 네트워크 요청을 보냅니다. 토큰은 읽기만 하고 갱신하지 않습니다.", "claude_quota_chip": "접힌 상태에서 표시", "quota_chip_off": "표시 안 함", "quota_chip_auto": "자동(주간, 5시간이 촉박하면 전환)", @@ -2390,7 +2390,7 @@ final class L10n: ObservableObject { "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; macOS bir kez Anahtar Zinciri erişimi sorar. Jeton yalnızca okunur, yenilenmez.", + "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)", diff --git a/Sources/CodeIsland/NotchPanelView.swift b/Sources/CodeIsland/NotchPanelView.swift index 5127e05a..532c36d9 100644 --- a/Sources/CodeIsland/NotchPanelView.swift +++ b/Sources/CodeIsland/NotchPanelView.swift @@ -113,6 +113,9 @@ struct NotchPanelView: View { @State private var curtainOffset: CGFloat = 0 @State private var curtainOpacity: Double = 1 @State private var displayedToolStatus: Bool = SettingsDefaults.showToolStatus + /// Measured width of the plan-limit chip (0 until first laid out) — the + /// bar reserves exactly this instead of guessing from the label length. + @State private var quotaChipWidth: CGFloat = 0 private var isActive: Bool { !appState.sessions.isEmpty } /// First launch / no-session state should still render a visible marker so the app @@ -160,9 +163,13 @@ struct NotchPanelView: View { // Reserve space for tool status — proportional to screen width let toolExtra: CGFloat = displayedToolStatus ? (hasNotch ? screenWidth * 0.03 : screenWidth * 0.04) : 0 // Plan-limit chip shares the left-wing tool slot, so only the part its - // width exceeds the tool reserve needs adding. + // width exceeds the tool reserve needs adding. Measured when possible; + // the label-length estimate only covers the first frame. let quotaExtra: CGFloat = QuotaChip.limit(appState: appState, enabled: showClaudeQuota, modeRaw: claudeQuotaChip) - .map { Swift.max(0, QuotaChip.reservedWidth(for: $0) - toolExtra) } ?? 0 + .map { limit in + let width = quotaChipWidth > 0 ? quotaChipWidth + 6 : QuotaChip.reservedWidth(for: limit) + return Swift.max(0, width - toolExtra) + } ?? 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 + quotaExtra + prehoverExtra @@ -186,6 +193,7 @@ struct NotchPanelView: View { CompactRightWing(appState: appState, expanded: shouldShowExpanded, hasNotch: hasNotch) } .frame(height: notchHeight) + .onPreferenceChange(QuotaChipWidthKey.self) { quotaChipWidth = $0 } } else if showIdleIndicator { IdleIndicatorBar( mascotSize: mascotSize, @@ -526,6 +534,10 @@ private struct CompactLeftWing: View { // running: window label + ring + percent, all windows in // the tooltip. QuotaChip(limit: limit, snapshot: snapshot, stale: appState.claudeQuota.lastError != nil) + .fixedSize() + .background(GeometryReader { geo in + Color.clear.preference(key: QuotaChipWidthKey.self, value: geo.size.width) + }) .transition(.opacity) } } @@ -1984,7 +1996,15 @@ private enum QuotaStyle { } } -/// Collapsed-island chip: a 9pt ring plus percent for the selected window. +/// Reports the collapsed chip's laid-out width up to the bar for its reserve. +struct QuotaChipWidthKey: PreferenceKey { + static let defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = Swift.max(value, nextValue()) + } +} + +/// Collapsed-island chip: window label, a 9pt ring, and percent. struct QuotaChip: View { let limit: ClaudeQuotaLimit let snapshot: ClaudeQuotaSnapshot diff --git a/Sources/CodeIslandCore/ClaudeQuotaClient.swift b/Sources/CodeIslandCore/ClaudeQuotaClient.swift index 0504270b..ac792721 100644 --- a/Sources/CodeIslandCore/ClaudeQuotaClient.swift +++ b/Sources/CodeIslandCore/ClaudeQuotaClient.swift @@ -1,5 +1,4 @@ import Foundation -import Security /// The Claude Code OAuth login, as Claude Code itself stores it. public struct ClaudeOAuthCredential: Equatable, Sendable { @@ -37,19 +36,31 @@ public enum ClaudeCredentialStore { ) } - /// Raw item data from the login keychain. The first read from a new binary - /// triggers macOS's "wants to use your confidential information" prompt. - public static func readKeychain(service: String = keychainService) -> Data? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - var result: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &result) - guard status == errSecSuccess else { return nil } - return result as? Data + /// 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 From 567da1453425375bfda79d894ffd443e6c663fbd Mon Sep 17 00:00:00 2001 From: mutoe Date: Fri, 4 Sep 2026 01:54:57 +0800 Subject: [PATCH 5/7] fix(notch): size the collapsed wings symmetrically so nothing slides under the notch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The collapsed bar was a flexible row centred on the notch with a spacer of at least the notch width between the wings. That guarantees a gap, not that the gap lines up with the cutout: whichever wing is wider pushes the gap sideways, and its tail ends up under the physical notch. Screenshots show those pixels, the display doesn't — which is how the plan-limit chip read 'Fable ◑5' on screen while every capture looked fine. Widening the total never helps either, since half of any extra goes to the far edge. Each wing now reports its ideal content width (rigid via fixedSize; the tool name still truncates at its own cap) and both wings get the same slot: the reserve, or the wider wing plus a 6pt gap to the notch. The row between them is exactly the notch. Non-notch screens keep the flexible row and just add the measured overflow. The chip's label-length estimate is gone. --- CHANGELOG.md | 2 + Sources/CodeIsland/NotchPanelView.swift | 264 +++++++++++------- .../CodeIslandTests/NotchPanelViewTests.swift | 36 +++ 3 files changed, 198 insertions(+), 104 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef2bc09a..7538e26a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,11 @@ ### 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 the tighter weekly budget and switches to the 5-hour window while that one is pressing (ahead of pace, or past 70%), 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: the two wings are now the same width, sized from their measured content, with the gap between them 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 ### 中文 - Claude 套餐额度:新增可选设置「显示 Claude 套餐额度」,用 Keychain 里的 Claude Code 登录向 Anthropic 查询和 `/usage` 一样的 5 小时 / 周窗口。展开面板底部新增一行显示全部窗口(迷你进度条、百分比、重置倒计时);收起药丸在会话数旁加一个带窗口标签的环形 chip 只显示一个窗口——「自动」常态显示更紧的那条周额度,5 小时窗口吃紧时(进度超前或超过 70%)切换过去,也可固定为 5 小时 / 周 / 周(当前模型)。刷新由 Stop hook 驱动(15 秒合并、每分钟最多一次、窗口结束后补发一次),空闲时 10 分钟一次,出错指数退避,永不刷新 token——过期只提示「运行一次 Claude Code」。默认关闭;登录信息通过 `security` 命令读取(Claude Code 自己就是用它写入的),不会弹 Keychain 授权框 +- 刘海屏收起态:左右翼改为等宽,宽度按各自实测内容取大,中间精确留出刘海宽度。原来的弹性布局只保证中间「有一段」不小于刘海的空隙,并不保证空隙对准刘海,哪边更宽(长工具名、现在的额度 chip)尾巴就钻到刘海底下——屏幕上看不见,截图里却是完整的 ## [v1.0.33] - 2026-09-01 diff --git a/Sources/CodeIsland/NotchPanelView.swift b/Sources/CodeIsland/NotchPanelView.swift index 532c36d9..85b83d84 100644 --- a/Sources/CodeIsland/NotchPanelView.swift +++ b/Sources/CodeIsland/NotchPanelView.swift @@ -18,6 +18,15 @@ enum NotchWidthMetrics { if hasNotch { return Swift.max(notchW, scaled) } return scaled } + + /// Wing width on a notched collapsed bar. The bar is centred on the notch, + /// so both wings must be equal for the gap between them (exactly the + /// notch) to land on the cutout; widening the total by X only moves an + /// edge by X/2. The slot is the reserve unless a wing's measured content + /// (tool name, plan-limit chip, badges) needs more. + static func collapsedWingSlot(reserve: CGFloat, measuredLeft: CGFloat, measuredRight: CGFloat) -> CGFloat { + Swift.max(reserve, measuredLeft, measuredRight) + } } // MARK: - Hover interaction state machine @@ -113,9 +122,10 @@ struct NotchPanelView: View { @State private var curtainOffset: CGFloat = 0 @State private var curtainOpacity: Double = 1 @State private var displayedToolStatus: Bool = SettingsDefaults.showToolStatus - /// Measured width of the plan-limit chip (0 until first laid out) — the - /// bar reserves exactly this instead of guessing from the label length. - @State private var quotaChipWidth: CGFloat = 0 + /// Measured ideal widths of the collapsed wings (0 until first laid out). + /// On notched screens both wings get the same slot so the gap between + /// them sits exactly on the notch; the slot has to fit the wider one. + @State private var wingWidths = CompactWingWidths() private var isActive: Bool { !appState.sessions.isEmpty } /// First launch / no-session state should still render a visible marker so the app @@ -162,17 +172,29 @@ struct NotchPanelView: View { let extra: CGFloat = appState.status == .idle ? 0 : 20 // Reserve space for tool status — proportional to screen width let toolExtra: CGFloat = displayedToolStatus ? (hasNotch ? screenWidth * 0.03 : screenWidth * 0.04) : 0 - // Plan-limit chip shares the left-wing tool slot, so only the part its - // width exceeds the tool reserve needs adding. Measured when possible; - // the label-length estimate only covers the first frame. - let quotaExtra: CGFloat = QuotaChip.limit(appState: appState, enabled: showClaudeQuota, modeRaw: claudeQuotaChip) - .map { limit in - let width = quotaChipWidth > 0 ? quotaChipWidth + 6 : QuotaChip.reservedWidth(for: limit) - return Swift.max(0, width - toolExtra) - } ?? 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 + quotaExtra + prehoverExtra + if hasNotch { + // The bar is centred on the notch, so the wings must be equal for + // the gap between them to line up with it. Widening the total by X + // only moves the left edge by X/2 — the slot itself has to grow. + let slot = NotchWidthMetrics.collapsedWingSlot( + reserve: wing + (extra + toolExtra) / 2, + measuredLeft: wingWidths.left, + measuredRight: wingWidths.right + ) + return nw + slot * 2 + prehoverExtra + } + // 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 width on a notched collapsed bar (nil = flexible layout). + private var collapsedWingSlot: CGFloat? { + guard hasNotch, showBar, !shouldShowExpanded else { return nil } + return (panelWidth - effectiveNotchW) / 2 } var body: some View { @@ -182,8 +204,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: collapsedWingSlot, 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) @@ -191,9 +216,10 @@ struct NotchPanelView: View { Spacer(minLength: 0) } CompactRightWing(appState: appState, expanded: shouldShowExpanded, hasNotch: hasNotch) + .frame(width: collapsedWingSlot, alignment: .trailing) } .frame(height: notchHeight) - .onPreferenceChange(QuotaChipWidthKey.self) { quotaChipWidth = $0 } + .onPreferenceChange(CompactWingWidths.Key.self) { wingWidths = $0 } } else if showIdleIndicator { IdleIndicatorBar( mascotSize: mascotSize, @@ -513,36 +539,44 @@ 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) - .transition(.opacity) - .help(tool) - } else if let limit = QuotaChip.limit(appState: appState, enabled: showClaudeQuota, modeRaw: claudeQuotaChip), - let snapshot = appState.claudeQuota.snapshot { - // Plan-limit chip takes the tool slot while no tool is - // running: window label + ring + percent, all windows in - // the tooltip. - QuotaChip(limit: limit, snapshot: snapshot, stale: appState.claudeQuota.lastError != nil) - .fixedSize() - .background(GeometryReader { geo in - Color.clear.preference(key: QuotaChipWidthKey.self, value: geo.size.width) - }) + // 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) + .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) + .transition(.opacity) + .help(tool) + } else if let limit = QuotaChip.limit(appState: appState, enabled: showClaudeQuota, modeRaw: claudeQuotaChip), + let snapshot = appState.claudeQuota.snapshot { + // Plan-limit chip takes the tool slot while no tool is + // running: window label + ring + percent, all windows in + // the tooltip. + QuotaChip(limit: limit, snapshot: snapshot, stale: appState.claudeQuota.lastError != nil) + .transition(.opacity) + } } + .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() @@ -610,65 +644,101 @@ 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 + /// 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) + } } } @@ -1996,14 +2066,6 @@ private enum QuotaStyle { } } -/// Reports the collapsed chip's laid-out width up to the bar for its reserve. -struct QuotaChipWidthKey: PreferenceKey { - static let defaultValue: CGFloat = 0 - static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { - value = Swift.max(value, nextValue()) - } -} - /// Collapsed-island chip: window label, a 9pt ring, and percent. struct QuotaChip: View { let limit: ClaudeQuotaLimit @@ -2011,12 +2073,6 @@ struct QuotaChip: View { let stale: Bool @ObservedObject private var l10n = L10n.shared - /// Width reserved in the collapsed bar when the chip is shown: ring + - /// percent plus the window label (10pt monospaced ≈ 6.2pt per glyph). - static func reservedWidth(for limit: ClaudeQuotaLimit) -> CGFloat { - 44 + CGFloat(QuotaStyle.label(limit, l10n: L10n.shared).count) * 6.2 - } - init(limit: ClaudeQuotaLimit, snapshot: ClaudeQuotaSnapshot, stale: Bool) { self.limit = limit self.snapshot = snapshot diff --git a/Tests/CodeIslandTests/NotchPanelViewTests.swift b/Tests/CodeIslandTests/NotchPanelViewTests.swift index b20eb764..af9aa3cb 100644 --- a/Tests/CodeIslandTests/NotchPanelViewTests.swift +++ b/Tests/CodeIslandTests/NotchPanelViewTests.swift @@ -263,4 +263,40 @@ final class NotchHoverInteractionTests: XCTestCase { func testSessionJumpValidationUsesThreeIncreasingDelays() { XCTAssertEqual(sessionJumpValidationDelays, [120_000_000, 320_000_000, 640_000_000]) } + + // MARK: - Collapsed wing slot (notched screens) + + func testWingSlotStaysAtReserveWhenContentFits() { + XCTAssertEqual( + NotchWidthMetrics.collapsedWingSlot(reserve: 82, measuredLeft: 71, measuredRight: 40), + 82 + ) + } + + func testWingSlotGrowsToTheWiderWing() { + // Plan-limit chip in the left wing needs more than the reserve. + XCTAssertEqual( + NotchWidthMetrics.collapsedWingSlot(reserve: 82, measuredLeft: 106, measuredRight: 40), + 106 + ) + // Badges on the right can be the wider side too. + XCTAssertEqual( + NotchWidthMetrics.collapsedWingSlot(reserve: 82, measuredLeft: 71, measuredRight: 90), + 90 + ) + } + + func testEqualWingsKeepTheGapOnTheNotch() { + // Bar centred on the notch centre: the left wing must end exactly + // where the cutout starts, however wide the wing content gets. + let notchW: CGFloat = 220 + let notchCenter: CGFloat = 1028 + for left: CGFloat in [40, 71, 106, 160] { + let slot = NotchWidthMetrics.collapsedWingSlot(reserve: 82, measuredLeft: left, measuredRight: 40) + let panelWidth = notchW + slot * 2 + let leftEdge = notchCenter - panelWidth / 2 + XCTAssertEqual(leftEdge + slot, notchCenter - notchW / 2, accuracy: 0.001) + XCTAssertGreaterThanOrEqual(slot, left) + } + } } From 3e3a33e9fe03e3b3405d06b04b7235533cc869ba Mon Sep 17 00:00:00 2001 From: mutoe Date: Fri, 4 Sep 2026 02:05:23 +0800 Subject: [PATCH 6/7] =?UTF-8?q?fix(notch):=20keep=20the=20collapsed=20bar?= =?UTF-8?q?=20still=20=E2=80=94=20placeholder=20chip,=20eased=20width,=20t?= =?UTF-8?q?rimmed=20right=20wing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sizing the wings from their content made the bar jump on every tool change: the tool name and the plan-limit chip take turns in the same slot and are different widths. Now the chip stays laid out as an invisible placeholder while a tool name shows, so that switch never moves anything; only a tool name wider than the chip widens the bar, eased in, and the width shrinks back 5s after the content got narrower. Wings are no longer forced equal: the left keeps the classic reserve or its content, the right is trimmed to its content (count plus badges), and the whole bar shifts by half the difference so the gap between the wings still lands exactly on the notch — a test checks the shift geometry, which caught the sign being inverted on the first try. --- CHANGELOG.md | 4 +- Sources/CodeIsland/NotchPanelView.swift | 128 ++++++++++++------ .../CodeIslandTests/NotchPanelViewTests.swift | 37 ++--- 3 files changed, 102 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7538e26a..5bc9fbca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,11 @@ ### 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 the tighter weekly budget and switches to the 5-hour window while that one is pressing (ahead of pace, or past 70%), 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: the two wings are now the same width, sized from their measured content, with the gap between them 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 +- 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 小时窗口吃紧时(进度超前或超过 70%)切换过去,也可固定为 5 小时 / 周 / 周(当前模型)。刷新由 Stop hook 驱动(15 秒合并、每分钟最多一次、窗口结束后补发一次),空闲时 10 分钟一次,出错指数退避,永不刷新 token——过期只提示「运行一次 Claude Code」。默认关闭;登录信息通过 `security` 命令读取(Claude Code 自己就是用它写入的),不会弹 Keychain 授权框 -- 刘海屏收起态:左右翼改为等宽,宽度按各自实测内容取大,中间精确留出刘海宽度。原来的弹性布局只保证中间「有一段」不小于刘海的空隙,并不保证空隙对准刘海,哪边更宽(长工具名、现在的额度 chip)尾巴就钻到刘海底下——屏幕上看不见,截图里却是完整的 +- 刘海屏收起态:左右翼各按实测内容定宽,整体平移让中间空隙精确对准刘海。原来的弹性布局只保证中间「有一段」不小于刘海的空隙,并不保证空隙对准刘海,哪边更宽(长工具名、现在的额度 chip)尾巴就钻到刘海底下——屏幕上看不见,截图里却是完整的。药丸也不再抖动:显示工具名时额度 chip 以隐形占位保留宽度,变宽走缓动,收窄延迟 5 秒,右翼只保留内容所需宽度,不挤占菜单栏图标 ## [v1.0.33] - 2026-09-01 diff --git a/Sources/CodeIsland/NotchPanelView.swift b/Sources/CodeIsland/NotchPanelView.swift index 85b83d84..5d45468b 100644 --- a/Sources/CodeIsland/NotchPanelView.swift +++ b/Sources/CodeIsland/NotchPanelView.swift @@ -19,13 +19,17 @@ enum NotchWidthMetrics { return scaled } - /// Wing width on a notched collapsed bar. The bar is centred on the notch, - /// so both wings must be equal for the gap between them (exactly the - /// notch) to land on the cutout; widening the total by X only moves an - /// edge by X/2. The slot is the reserve unless a wing's measured content - /// (tool name, plan-limit chip, badges) needs more. - static func collapsedWingSlot(reserve: CGFloat, measuredLeft: CGFloat, measuredRight: CGFloat) -> CGFloat { - Swift.max(reserve, measuredLeft, measuredRight) + /// 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 } } @@ -122,10 +126,12 @@ struct NotchPanelView: View { @State private var curtainOffset: CGFloat = 0 @State private var curtainOpacity: Double = 1 @State private var displayedToolStatus: Bool = SettingsDefaults.showToolStatus - /// Measured ideal widths of the collapsed wings (0 until first laid out). - /// On notched screens both wings get the same slot so the gap between - /// them sits exactly on the notch; the slot has to fit the wider one. + /// 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 @@ -174,16 +180,10 @@ 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 - if hasNotch { - // The bar is centred on the notch, so the wings must be equal for - // the gap between them to line up with it. Widening the total by X - // only moves the left edge by X/2 — the slot itself has to grow. - let slot = NotchWidthMetrics.collapsedWingSlot( - reserve: wing + (extra + toolExtra) / 2, - measuredLeft: wingWidths.left, - measuredRight: wingWidths.right - ) - return nw + slot * 2 + 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. @@ -191,10 +191,46 @@ struct NotchPanelView: View { return nw + wing * 2 + extra + toolExtra + overflow + prehoverExtra } - /// Fixed wing width on a notched collapsed bar (nil = flexible layout). - private var collapsedWingSlot: CGFloat? { + /// 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 } - return (panelWidth - effectiveNotchW) / 2 + 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 { @@ -204,7 +240,7 @@ 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: collapsedWingSlot, alignment: .leading) + .frame(width: collapsedWingSlots?.left, alignment: .leading) if hasNotch && !shouldShowExpanded { // Exactly the notch: the wings are sized so this // gap lands on the physical cutout, not near it. @@ -216,10 +252,10 @@ struct NotchPanelView: View { Spacer(minLength: 0) } CompactRightWing(appState: appState, expanded: shouldShowExpanded, hasNotch: hasNotch) - .frame(width: collapsedWingSlot, alignment: .trailing) + .frame(width: collapsedWingSlots?.right, alignment: .trailing) } .frame(height: notchHeight) - .onPreferenceChange(CompactWingWidths.Key.self) { wingWidths = $0 } + .onPreferenceChange(CompactWingWidths.Key.self) { applyWingWidths($0) } } else if showIdleIndicator { IdleIndicatorBar( mascotSize: mascotSize, @@ -323,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 @@ -549,22 +585,30 @@ private struct CompactLeftWing: View { .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) - .transition(.opacity) - .help(tool) - } else if let limit = QuotaChip.limit(appState: appState, enabled: showClaudeQuota, modeRaw: claudeQuotaChip), - let snapshot = appState.claudeQuota.snapshot { + 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. - QuotaChip(limit: limit, snapshot: snapshot, stale: appState.claudeQuota.lastError != nil) - .transition(.opacity) + // 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) @@ -726,6 +770,8 @@ struct CompactWingWidths: Equatable { /// 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 { diff --git a/Tests/CodeIslandTests/NotchPanelViewTests.swift b/Tests/CodeIslandTests/NotchPanelViewTests.swift index af9aa3cb..0dd06993 100644 --- a/Tests/CodeIslandTests/NotchPanelViewTests.swift +++ b/Tests/CodeIslandTests/NotchPanelViewTests.swift @@ -264,39 +264,28 @@ final class NotchHoverInteractionTests: XCTestCase { XCTAssertEqual(sessionJumpValidationDelays, [120_000_000, 320_000_000, 640_000_000]) } - // MARK: - Collapsed wing slot (notched screens) + // MARK: - Collapsed wing slots (notched screens) func testWingSlotStaysAtReserveWhenContentFits() { - XCTAssertEqual( - NotchWidthMetrics.collapsedWingSlot(reserve: 82, measuredLeft: 71, measuredRight: 40), - 82 - ) + XCTAssertEqual(NotchWidthMetrics.collapsedWingSlot(reserve: 82, measured: 71), 82) } - func testWingSlotGrowsToTheWiderWing() { + func testWingSlotGrowsToItsContent() { // Plan-limit chip in the left wing needs more than the reserve. - XCTAssertEqual( - NotchWidthMetrics.collapsedWingSlot(reserve: 82, measuredLeft: 106, measuredRight: 40), - 106 - ) - // Badges on the right can be the wider side too. - XCTAssertEqual( - NotchWidthMetrics.collapsedWingSlot(reserve: 82, measuredLeft: 71, measuredRight: 90), - 90 - ) + XCTAssertEqual(NotchWidthMetrics.collapsedWingSlot(reserve: 82, measured: 106), 106) } - func testEqualWingsKeepTheGapOnTheNotch() { - // Bar centred on the notch centre: the left wing must end exactly - // where the cutout starts, however wide the wing content gets. + 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: CGFloat in [40, 71, 106, 160] { - let slot = NotchWidthMetrics.collapsedWingSlot(reserve: 82, measuredLeft: left, measuredRight: 40) - let panelWidth = notchW + slot * 2 - let leftEdge = notchCenter - panelWidth / 2 - XCTAssertEqual(leftEdge + slot, notchCenter - notchW / 2, accuracy: 0.001) - XCTAssertGreaterThanOrEqual(slot, left) + 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) } } } From 3589bb5b151888d1f5599dac1b44212f2936cfea Mon Sep 17 00:00:00 2001 From: mutoe Date: Fri, 4 Sep 2026 02:14:32 +0800 Subject: [PATCH 7/7] feat(quota): auto chip alerts at 80% and ranks the weekly windows by pace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 5-hour takeover line moves from 70% to 80%. The two weekly windows (all models, current model) now compete on the same terms as the session: if either is pressing — ahead of pace or past the line — the one further ahead of pace shows; only when neither is pressing does the higher percentage decide. --- CHANGELOG.md | 4 +- Sources/CodeIslandCore/ClaudeQuota.swift | 42 ++++++++++++------- .../ClaudeQuotaTests.swift | 38 +++++++++++++++-- 3 files changed, 65 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bc9fbca..6c7cd6ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,11 +3,11 @@ ## [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 the tighter weekly budget and switches to the 5-hour window while that one is pressing (ahead of pace, or past 70%), 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 +- 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 小时窗口吃紧时(进度超前或超过 70%)切换过去,也可固定为 5 小时 / 周 / 周(当前模型)。刷新由 Stop hook 驱动(15 秒合并、每分钟最多一次、窗口结束后补发一次),空闲时 10 分钟一次,出错指数退避,永不刷新 token——过期只提示「运行一次 Claude Code」。默认关闭;登录信息通过 `security` 命令读取(Claude Code 自己就是用它写入的),不会弹 Keychain 授权框 +- 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 diff --git a/Sources/CodeIslandCore/ClaudeQuota.swift b/Sources/CodeIslandCore/ClaudeQuota.swift index c99f0c62..d0cb35f2 100644 --- a/Sources/CodeIslandCore/ClaudeQuota.swift +++ b/Sources/CodeIslandCore/ClaudeQuota.swift @@ -170,17 +170,23 @@ public enum ClaudeQuotaChipMode: String, CaseIterable, Sendable { } public enum ClaudeQuotaSelector { - /// 5h usage at or above this share always takes the chip in `auto`. - public static let sessionAlertPercent: Double = 70 + /// 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` shows the weekly budget by default — the tighter of the two - /// weekly windows — because that is the one that runs out for days. The - /// 5-hour window takes over only while it is the pressing one: running - /// ahead of pace (used share exceeds elapsed share) or past - /// `sessionAlertPercent`. + /// `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 @@ -189,16 +195,24 @@ public enum ClaudeQuotaSelector { case .weeklyScoped: return snapshot.limit(.weeklyScoped) case .auto: let session = snapshot.limit(.session) - let weekly = [snapshot.limit(.weeklyAll), snapshot.limit(.weeklyScoped)] - .compactMap { $0 } - .max { $0.percent < $1.percent } - if let session, sessionIsPressing(session, now: now) { return session } - return weekly ?? 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 sessionIsPressing(_ session: ClaudeQuotaLimit, now: Date = Date()) -> Bool { - session.percent >= sessionAlertPercent || session.paceDelta(now: now) > 0 + 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 } } } diff --git a/Tests/CodeIslandCoreTests/ClaudeQuotaTests.swift b/Tests/CodeIslandCoreTests/ClaudeQuotaTests.swift index e1f43456..d8abb1e3 100644 --- a/Tests/CodeIslandCoreTests/ClaudeQuotaTests.swift +++ b/Tests/CodeIslandCoreTests/ClaudeQuotaTests.swift @@ -62,7 +62,8 @@ final class ClaudeQuotaTests: XCTestCase { } func testAutoShowsTighterWeeklyWindowByDefault() { - // 5h at 20% with 2.5h left is behind pace → weekly wins; Fable (48%) is the tighter weekly. + // 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)), @@ -76,6 +77,31 @@ final class ClaudeQuotaTests: XCTestCase { 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: [ @@ -83,12 +109,18 @@ final class ClaudeQuotaTests: XCTestCase { ClaudeQuotaLimit(kind: .weeklyAll, percent: 60, resetsAt: now.addingTimeInterval(86_400)), ], fetchedAt: now) XCTAssertEqual(ClaudeQuotaSelector.pick(from: ahead, mode: .auto, now: now)?.kind, .session) - // 72% used with 10 minutes left is behind pace but past the 70% alert line. + // 82% used with 10 minutes left is behind pace but past the 80% alert line. let hot = ClaudeQuotaSnapshot(limits: [ - ClaudeQuotaLimit(kind: .session, percent: 72, resetsAt: now.addingTimeInterval(600)), + 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)