diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 988dec5..f7e0f51 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,5 +29,7 @@ jobs: run: ./script/test_claude_incremental_collector.sh - name: Run settings Codable checks run: ./script/test_settings_codable.sh + - name: Run compact popover checks + run: ./script/test_compact_popover.sh - name: Build TokenStep run: ./script/build_swiftui_and_run.sh --no-launch diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f1f400..3c363c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ TokenStep 各版本的更新内容,新版本在前。每个版本的完整说明在 [docs/RELEASE_NOTES_<版本号>.md](docs/)。 +## 0.2.16 更新:紧凑面板,额度刷新更及时 + +菜单栏面板改为一列 340pt,只放额度、今日 Token、今日排名,13 寸屏幕上不再被截掉;刘海旁的 Token Island 打开同一个面板。额度在面板打开时最多每 5 分钟刷新,刷新按钮会一并刷新额度;只用 Claude Code CLI 时也显示 Claude 图标。详见 [0.2.16 发布说明](docs/RELEASE_NOTES_0.2.16.md)。 + ## 0.2.15 更新:按本地时区统计,采集更稳更省电 每天的用量改为按 Mac 的系统时区切分,更换时区后自动重新统计;修复 Codex 从本地数据库回退统计时可能卡住的问题;Claude Code 长会话只读取新增内容,读取本地数据库不再启动额外进程;设置中填写的密钥不再出现在进程列表里;隐私页列出每一个联网请求。详见 [0.2.15 发布说明](docs/RELEASE_NOTES_0.2.15.md)。 diff --git a/README.md b/README.md index 9fdb25e..b15471f 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ TokenStep 是一个 macOS 菜单栏 App,用来本地统计你在 Codex、Claud 下载最新版 DMG,打开后把 `TokenStep.app` 拖进「应用程序」即可使用: -[下载 TokenStep 最新版](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.15.dmg) +[下载 TokenStep 最新版](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.16.dmg) 也可以从 Release 页面查看所有版本: @@ -32,9 +32,9 @@ TokenStep 已使用 Developer ID 签名并通过 Apple 公证。首次打开时 Windows版本由十七做了移植,欢迎大家前往使用:https://github.com/canyexuanfan/TokenStep-Windows/releases -## 最新版本:0.2.15 +## 最新版本:0.2.16 -按 Mac 的系统时区统计每天的用量,修复 Codex 回退统计时可能卡住的问题,后台采集更省电,隐私页列出每一个联网请求。详见 [0.2.15 发布说明](docs/RELEASE_NOTES_0.2.15.md)。 +菜单栏面板改为一列紧凑面板,13 寸屏幕上不再被截掉,刘海旁的 Token Island 打开同一个面板;额度在面板打开时最多每 5 分钟刷新,刷新按钮会一并刷新额度。详见 [0.2.16 发布说明](docs/RELEASE_NOTES_0.2.16.md)。 历次更新(主题包、引力动效、自动更新等)见 [CHANGELOG.md](CHANGELOG.md)。 @@ -95,7 +95,7 @@ TokenStep 默认只做本地统计。 ## 安装方式 -1. 下载 [TokenStep 最新版 DMG](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.15.dmg)。 +1. 下载 [TokenStep 最新版 DMG](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.16.dmg)。 2. 打开 DMG。 3. 把 `TokenStep.app` 拖到「应用程序」。 4. 启动 TokenStep。 @@ -166,7 +166,7 @@ TokenStepSwift/dist/TokenStep.app 公开发布强制执行 Developer ID 签名、Apple 公证、票据装订、系统分发检查和隔离安装验证。不再生成可被误上传的“仅签名、未公证”发布包: ```bash -TOKENSTEP_VERSION=0.2.15 \ +TOKENSTEP_VERSION=0.2.16 \ CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" \ TOKENSTEP_NOTARY_PROFILE="tokenstep-notary" \ ./script/package_release.sh --notarize diff --git a/TokenStepSwift/Sources/TokenStepSwift/App/TokenStepApp.swift b/TokenStepSwift/Sources/TokenStepSwift/App/TokenStepApp.swift index 35144d1..c378091 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/App/TokenStepApp.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/App/TokenStepApp.swift @@ -65,7 +65,7 @@ struct TokenStepApp: App { .commands { CommandMenu("TokenStep") { Button(L("刷新")) { - appState.refresh() + appState.refreshNow() } .keyboardShortcut("r", modifiers: [.command]) diff --git a/TokenStepSwift/Sources/TokenStepSwift/Services/ClaudeQuotaService.swift b/TokenStepSwift/Sources/TokenStepSwift/Services/ClaudeQuotaService.swift index b633e1a..ae2aaec 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Services/ClaudeQuotaService.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Services/ClaudeQuotaService.swift @@ -2,11 +2,13 @@ import Foundation enum ClaudeQuotaService { private static let keychainServices = ["Claude Code-credentials"] - private static let cacheTTL: TimeInterval = 10 * 60 + // Shorter than EnergyRefreshPolicy.quotaTTL so the file cache only spares + // repeat calls across relaunches and never adds to the app's own interval. + private static let cacheTTL: TimeInterval = 4 * 60 private static let endpoint = URL(string: "https://api.anthropic.com/api/oauth/usage")! - static func read() throws -> CodexQuotaSnapshot { - if let cached = readFreshCache() { + static func read(useCache: Bool = true) throws -> CodexQuotaSnapshot { + if useCache, let cached = readFreshCache() { return cached } diff --git a/TokenStepSwift/Sources/TokenStepSwift/Services/CursorQuotaService.swift b/TokenStepSwift/Sources/TokenStepSwift/Services/CursorQuotaService.swift index 5a49a2a..0433165 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Services/CursorQuotaService.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Services/CursorQuotaService.swift @@ -4,8 +4,8 @@ enum CursorQuotaService { static var databaseURL: URL = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/Application Support/Cursor/User/globalStorage/state.vscdb") - static func read() throws -> ProviderQuota { - if let cached = readFreshCache() { + static func read(useCache: Bool = true) throws -> ProviderQuota { + if useCache, let cached = readFreshCache() { return cached } let token = try readAccessToken() @@ -221,7 +221,8 @@ enum CursorQuotaService { private static func readFreshCache(now: Date = Date()) -> ProviderQuota? { guard let data = try? Data(contentsOf: AppPaths.cursorQuotaCacheJSON), let cache = try? JSONDecoder().decode(ProviderQuotaCache.self, from: data), - now.timeIntervalSince(cache.fetchedAt) <= 10 * 60 + // Shorter than EnergyRefreshPolicy.quotaTTL; see ClaudeQuotaService. + now.timeIntervalSince(cache.fetchedAt) <= 4 * 60 else { return nil } let quota = cache.quota guard quota.isAvailable else { return nil } diff --git a/TokenStepSwift/Sources/TokenStepSwift/Services/QuotaRefreshCoordinator.swift b/TokenStepSwift/Sources/TokenStepSwift/Services/QuotaRefreshCoordinator.swift index 9aabe0b..cda03e5 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Services/QuotaRefreshCoordinator.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Services/QuotaRefreshCoordinator.swift @@ -1,14 +1,15 @@ import Foundation enum QuotaRefreshCoordinator { - static func fetch(providers: Set) -> [QuotaProviderID: ProviderQuota] { + /// `bypassCache` skips provider file caches, for a refresh the user asked for. + static func fetch(providers: Set, bypassCache: Bool = false) -> [QuotaProviderID: ProviderQuota] { var result: [QuotaProviderID: ProviderQuota] = [:] let lock = NSLock() let group = DispatchGroup() for provider in providers { group.enter() DispatchQueue.global(qos: .utility).async { - let quota = read(provider) + let quota = read(provider, bypassCache: bypassCache) lock.lock() result[provider] = quota lock.unlock() @@ -23,15 +24,15 @@ enum QuotaRefreshCoordinator { return result } - static func read(_ provider: QuotaProviderID) -> ProviderQuota { + static func read(_ provider: QuotaProviderID, bypassCache: Bool = false) -> ProviderQuota { do { switch provider { case .codex: return try CodexQuotaService.read().asProviderQuota(.codex) case .claude: - return try ClaudeQuotaService.read().asProviderQuota(.claude) + return try ClaudeQuotaService.read(useCache: !bypassCache).asProviderQuota(.claude) case .cursor: - return try CursorQuotaService.read() + return try CursorQuotaService.read(useCache: !bypassCache) case .glm: return try GLMQuotaService.read() case .kimi: diff --git a/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift b/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift index 958ac26..d16c520 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift @@ -195,6 +195,12 @@ final class AppState: ObservableObject { autostartEnabled = AutostartService.isEnabled } + /// The refresh button and ⌘R: usage plus quotas, skipping quota caches. + func refreshNow() { + refresh() + refreshCodexQuota(force: true, bypassCache: true) + } + func refresh(forceCollection: Bool = true) { guard !isRefreshing else { if forceCollection { @@ -280,7 +286,7 @@ final class AppState: ObservableObject { configureForegroundTimer() } - func refreshCodexQuota(force: Bool = false, now: Date = Date()) { + func refreshCodexQuota(force: Bool = false, bypassCache: Bool = false, now: Date = Date()) { refreshCursorOfficialUsage(force: force, now: now) let providers = settings.enabledQuotaProviders guard !providers.isEmpty else { @@ -301,10 +307,17 @@ final class AppState: ObservableObject { isRefreshingCodexQuota = true Task { let fetched = await Task.detached(priority: .utility) { - QuotaRefreshCoordinator.fetch(providers: providers) + QuotaRefreshCoordinator.fetch(providers: providers, bypassCache: bypassCache) }.value for provider in providers { if let quota = fetched[provider] { + // A transient failure (rate limit, network) keeps the last + // good reading; its "N minutes ago" shows how old it is. + // Login and key problems still replace it. + if !quota.isAvailable, quota.status == .unavailable, + quotas[provider]?.isAvailable == true { + continue + } quotas[provider] = quota } else if quotas[provider]?.isAvailable != true { quotas[provider] = .unavailable(provider) diff --git a/TokenStepSwift/Sources/TokenStepSwift/Support/EnergyRefreshPolicy.swift b/TokenStepSwift/Sources/TokenStepSwift/Support/EnergyRefreshPolicy.swift index 4f973c4..9ab3c3e 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Support/EnergyRefreshPolicy.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Support/EnergyRefreshPolicy.swift @@ -9,7 +9,10 @@ enum TokenStepPowerSource: Equatable { enum EnergyRefreshPolicy { static let acBackgroundFloorSeconds = 15 * 60 static let batteryBackgroundFloorSeconds = 30 * 60 - static let quotaTTL: TimeInterval = 15 * 60 + /// Quotas move fast during heavy use, and the calls are small, so an open + /// popover checks them every 5 minutes. Background refresh still follows + /// the 15/30-minute floors above. + static let quotaTTL: TimeInterval = 5 * 60 static let rankTTL: TimeInterval = 30 * 60 static let minimumAutomaticRetryTTL: TimeInterval = 60 static let maximumForegroundTickSeconds = 60 diff --git a/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift b/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift index 907c1c5..fa83236 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift @@ -490,6 +490,41 @@ enum TokenStepLocalization { "历史范围": "History range", "%d 天": "%d days", "联网项(除检查更新外默认关闭)": "Network items (off by default except update checks)", + "%@(你)": "%@ (you)", + "%d 分钟后": "in %d min", + "%d 小时 %d 分": "%d h %d min", + "/ %d 人": "/ %d people", + "你是今日第一": "You are #1 today", + "再跑": "Run", + "分享": "Share", + "另有 %d 项正常:%@": "%d more, all normal: %@", + "差 %@": "%@ ahead", + "已用": "Used", + "已用 · %@": "Used · %@", + "引力动效": "Gravity motion", + "按最近 1 小时的速度(约 %@/时),约 %@": "At the last hour's pace (about %@/h), about %@", + "收起": "Collapse", + "明天 %@": "Tomorrow %@", + "更多": "More", + "更多:分享、截图、退出": "More: share, screenshot, quit", + "最近 1 小时没有用量": "No usage in the last hour", + "未进入前 %d 名": "Not in the top %d", + "未上榜": "Not ranked", + "今日还没有你的上传": "No uploads from you today yet", + "本地比榜单多 %@,同步后预计升到 #%d": "%@ more locally than on the board; about #%d after sync", + "本地比榜单多 %@,等待同步": "%@ more locally than on the board, waiting for sync", + "榜上 %@": "On board %@", + "榜单 %@": "Board %@", + "照此速度会提前用完": "Runs out early at this pace", + "已用 %@": "%@ used", + "竖线是时间进度:已过 %@,已用 %@": "The tick marks time passed: %@ of the window gone, %@ used", + "。用得比时间快,照此速度会在重置前用完": ". Usage is ahead of time and runs out before the reset at this pace", + "第 %d 名今日 %@": "#%d today: %@", + "触发坠落脉冲": "Trigger plunge pulse", + "超过 #%d %@": "to pass #%d %@", + "进入前 %d 名": "to enter the top %d", + "退出 TokenStep": "Quit TokenStep", + "领先第二名 %@": "%@ ahead of #2", "近似 · %d 会话": "Approx. · %d sessions", "近似值(按会话开始日汇总)": "Approximate (totals by session start day)", "版本": "Ver", @@ -1150,6 +1185,41 @@ enum TokenStepLocalization { "不开代理 · 不按字数估算 token": "不開代理 · 不按字數估算 token", "不上传用量与内容。Token 统计全部来自本机日志。": "不上傳用量與內容。Token 統計全部來自本機日誌。", "联网项(除检查更新外默认关闭)": "連網項(除檢查更新外預設關閉)", + "%@(你)": "%@(你)", + "%d 分钟后": "%d 分鐘後", + "%d 小时 %d 分": "%d 小時 %d 分", + "/ %d 人": "/ %d 人", + "你是今日第一": "你是今日第一", + "再跑": "再跑", + "分享": "分享", + "另有 %d 项正常:%@": "另有 %d 項正常:%@", + "差 %@": "差 %@", + "已用": "已用", + "已用 · %@": "已用 · %@", + "引力动效": "引力動效", + "按最近 1 小时的速度(约 %@/时),约 %@": "按最近 1 小時的速度(約 %@/時),約 %@", + "收起": "收起", + "明天 %@": "明天 %@", + "更多": "更多", + "更多:分享、截图、退出": "更多:分享、截圖、退出", + "最近 1 小时没有用量": "最近 1 小時沒有用量", + "未进入前 %d 名": "未進入前 %d 名", + "未上榜": "未上榜", + "今日还没有你的上传": "今日還沒有你的上傳", + "本地比榜单多 %@,同步后预计升到 #%d": "本機比榜單多 %@,同步後預計升到 #%d", + "本地比榜单多 %@,等待同步": "本機比榜單多 %@,等待同步", + "榜上 %@": "榜上 %@", + "榜单 %@": "榜單 %@", + "照此速度会提前用完": "照此速度會提前用完", + "已用 %@": "已用 %@", + "竖线是时间进度:已过 %@,已用 %@": "豎線是時間進度:已過 %@,已用 %@", + "。用得比时间快,照此速度会在重置前用完": "。用得比時間快,照此速度會在重置前用完", + "第 %d 名今日 %@": "第 %d 名今日 %@", + "触发坠落脉冲": "觸發墜落脈衝", + "超过 #%d %@": "超過 #%d %@", + "进入前 %d 名": "進入前 %d 名", + "退出 TokenStep": "結束 TokenStep", + "领先第二名 %@": "領先第二名 %@", "近似 · %d 会话": "近似 · %d 會話", "近似值(按会话开始日汇总)": "近似值(按會話開始日彙總)", "版本": "版本", diff --git a/TokenStepSwift/Sources/TokenStepSwift/Support/TokenIslandWindowPresenter.swift b/TokenStepSwift/Sources/TokenStepSwift/Support/TokenIslandWindowPresenter.swift index 13e67d4..0368a4c 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Support/TokenIslandWindowPresenter.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Support/TokenIslandWindowPresenter.swift @@ -7,7 +7,6 @@ final class TokenIslandWindowPresenter { static let shared = TokenIslandWindowPresenter() static let collapsedSize = NSSize(width: 88, height: 24) - static let expandedSize = TokenIslandMetrics.expandedWindowSize private weak var appState: AppState? private var ringPanel: TokenIslandPanel? @@ -16,6 +15,11 @@ final class TokenIslandWindowPresenter { private var screenObserver: NSObjectProtocol? private var hidePopoverTask: DispatchWorkItem? private var popoverVisible = false + private var popoverCardHeight = TokenIslandMetrics.initialCardHeight + /// The popover's "..." menu opens outside the card. While any menu is + /// tracking, leaving the card must not collapse it. + private var menuTracking = false + private var menuObservers: [NSObjectProtocol] = [] func bind(appState: AppState) { guard self.appState !== appState else { @@ -43,9 +47,39 @@ final class TokenIslandWindowPresenter { } } + observeMenuTracking() syncVisibility() } + private func observeMenuTracking() { + guard menuObservers.isEmpty else { return } + let center = NotificationCenter.default + menuObservers = [ + center.addObserver(forName: NSMenu.didBeginTrackingNotification, object: nil, queue: .main) { [weak self] _ in + Task { @MainActor in + self?.menuTracking = true + self?.cancelHidePopover() + } + }, + center.addObserver(forName: NSMenu.didEndTrackingNotification, object: nil, queue: .main) { [weak self] _ in + Task { @MainActor in + guard let self else { return } + self.menuTracking = false + if self.popoverVisible, !self.isMouseInsidePopover { + self.scheduleHidePopover() + } + } + } + ] + } + + private var isMouseInsidePopover: Bool { + guard let panel = popoverPanel else { return false } + let card = panel.frame.insetBy(dx: TokenIslandMetrics.expandedShadowMargin, dy: TokenIslandMetrics.expandedShadowMargin) + let mouse = NSEvent.mouseLocation + return card.contains(mouse) || (ringPanel?.frame.contains(mouse) ?? false) + } + func showPopover() { hidePopoverTask?.cancel() hidePopoverTask = nil @@ -78,6 +112,7 @@ final class TokenIslandWindowPresenter { func scheduleHidePopover() { hidePopoverTask?.cancel() + guard !menuTracking else { return } let task = DispatchWorkItem { [weak self] in self?.hidePopover() } @@ -156,19 +191,24 @@ final class TokenIslandWindowPresenter { } private func makePopoverPanel(appState: AppState) -> TokenIslandPanel { - let rootView = TokenIslandPopoverWindowView { [weak self] hovering in - if hovering { - self?.cancelHidePopover() - } else { - self?.scheduleHidePopover() + let rootView = TokenIslandPopoverWindowView( + onHoverChanged: { [weak self] hovering in + if hovering { + self?.cancelHidePopover() + } else { + self?.scheduleHidePopover() + } + }, + onHeightChanged: { [weak self] height in + self?.updatePopoverCardHeight(height) } - } + ) .environmentObject(appState) return makePanel( title: "TokenStep Island Popover", identifier: "token-island-popover", - size: Self.expandedSize, + size: TokenIslandMetrics.expandedWindowSize(cardHeight: popoverCardHeight), rootView: rootView, hasShadow: false ) @@ -183,6 +223,9 @@ final class TokenIslandWindowPresenter { ) -> TokenIslandPanel { let controller = NSHostingController(rootView: rootView) + // The presenter owns every frame; the hosting view must not resize + // the panel on its own. + controller.sizingOptions = [] controller.view.wantsLayer = true controller.view.layer?.backgroundColor = NSColor.clear.cgColor controller.view.layer?.masksToBounds = false @@ -224,10 +267,20 @@ final class TokenIslandWindowPresenter { panel.setFrame(frame, display: true, animate: false) } + private func updatePopoverCardHeight(_ height: CGFloat) { + guard height > 0, abs(height - popoverCardHeight) > 0.5 else { return } + popoverCardHeight = height + guard popoverVisible, + let panel = popoverPanel, + let screen = TokenIslandDisplayDetector.notchedPrimaryScreen + else { return } + positionPopoverPanel(panel, on: screen) + } + private func positionPopoverPanel(_ panel: TokenIslandPanel, on screen: NSScreen) { let margin = TokenIslandMetrics.expandedShadowMargin - let cardSize = TokenIslandMetrics.expandedCardSize - let size = Self.expandedSize + let size = TokenIslandMetrics.expandedWindowSize(cardHeight: popoverCardHeight) + let cardSize = NSSize(width: TokenIslandMetrics.expandedCardWidth, height: popoverCardHeight) let ringFrame = ringPanel?.frame ?? TokenIslandDisplayDetector.collapsedFrame( on: screen, diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/MainWindowView.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/MainWindowView.swift index 9e17d27..17d0f6f 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/MainWindowView.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/MainWindowView.swift @@ -128,7 +128,7 @@ struct MainWindowView: View { .overlay(Capsule().stroke(Color.tokenDivider)) Button { - appState.refresh() + appState.refreshNow() } label: { Image(systemName: "arrow.clockwise") .font(.system(size: 12, weight: .heavy)) diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/CompactPopoverModel.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/CompactPopoverModel.swift new file mode 100644 index 0000000..d66ed8c --- /dev/null +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/CompactPopoverModel.swift @@ -0,0 +1,315 @@ +import Foundation + +/// View-independent content of the compact menu bar popover: which quotas to +/// show and in what order, today's top models, and the rank chase. Kept free of +/// SwiftUI so fixture checks can exercise it without a UI. +enum CompactPopoverModel { + /// Providers shown in full before the calm ones fold into one summary row. + static let expandedProviderLimit = 2 + static let topModelLimit = 3 + + // MARK: - Quotas + + enum QuotaLevel: Equatable { + case normal + case warning + case critical + + init(usedPercent: Double) { + if usedPercent >= 90 { + self = .critical + } else if usedPercent >= 70 { + self = .warning + } else { + self = .normal + } + } + } + + struct QuotaWindowRow: Equatable, Identifiable { + var id: String { kind.rawValue } + var kind: QuotaWindowKind + var title: String + var usedPercent: Double + var level: QuotaLevel + var resetsAt: Date? + /// Share of the window that has passed, 0...1; nil when the window length is unknown. + var elapsedFraction: Double? + /// At the current pace the window runs out before it resets. + var runsOutEarly: Bool + } + + struct QuotaProviderRow: Equatable, Identifiable { + var id: QuotaProviderID { provider } + var provider: QuotaProviderID + var windows: [QuotaWindowRow] + + var worstUsedPercent: Double { + windows.map(\.usedPercent).max() ?? 0 + } + + var level: QuotaLevel { + QuotaLevel(usedPercent: worstUsedPercent) + } + + var runsOutEarly: Bool { + windows.contains(where: \.runsOutEarly) + } + } + + struct QuotaSection: Equatable { + var expanded: [QuotaProviderRow] + /// Calm providers beyond the limit; never includes a warning or critical one. + var folded: [QuotaProviderRow] + + var isEmpty: Bool { + expanded.isEmpty && folded.isEmpty + } + + var worstLevel: QuotaLevel { + (expanded + folded).map(\.level).max { rank($0) < rank($1) } ?? .normal + } + + private func rank(_ level: QuotaLevel) -> Int { + switch level { + case .normal: return 0 + case .warning: return 1 + case .critical: return 2 + } + } + } + + static func quotaSection( + quotas: [ProviderQuota], + now: Date, + calendar: Calendar = TokenStepClock.calendar + ) -> QuotaSection { + let rows = quotas + .filter(\.isAvailable) + .map { quota in + QuotaProviderRow( + provider: quota.provider, + windows: quota.windows.map { windowRow($0, now: now, calendar: calendar) } + ) + } + .sorted { lhs, rhs in + if lhs.worstUsedPercent != rhs.worstUsedPercent { + return lhs.worstUsedPercent > rhs.worstUsedPercent + } + return providerOrder(lhs.provider) < providerOrder(rhs.provider) + } + var expanded: [QuotaProviderRow] = [] + var folded: [QuotaProviderRow] = [] + for row in rows { + if expanded.count < expandedProviderLimit || row.level != .normal || row.runsOutEarly { + expanded.append(row) + } else { + folded.append(row) + } + } + // Folding a single provider saves no space; show it in full instead. + if folded.count == 1 { + expanded.append(contentsOf: folded) + folded = [] + } + return QuotaSection(expanded: expanded, folded: folded) + } + + static func windowRow(_ window: QuotaWindow, now: Date, calendar: Calendar) -> QuotaWindowRow { + let used = min(max(100 - window.remainingPercent, 0), 100) + let elapsed = elapsedFraction(kind: window.kind, resetsAt: window.resetsAt, now: now, calendar: calendar) + let runsOutEarly: Bool + if let elapsed, elapsed >= 0.1, used < 100 { + // Straight-line projection of the current pace to the end of the window. + runsOutEarly = used / 100 > elapsed + } else { + runsOutEarly = false + } + return QuotaWindowRow( + kind: window.kind, + title: window.title, + usedPercent: used, + level: QuotaLevel(usedPercent: used), + resetsAt: window.resetsAt, + elapsedFraction: elapsed, + runsOutEarly: runsOutEarly + ) + } + + /// Only windows with a known fixed length get a pace marker. + static func windowStart(kind: QuotaWindowKind, resetsAt: Date, calendar: Calendar) -> Date? { + switch kind { + case .fiveHour: + return resetsAt.addingTimeInterval(-5 * 60 * 60) + case .sevenDay, .weekly: + return resetsAt.addingTimeInterval(-7 * 24 * 60 * 60) + case .monthlyCredits: + return calendar.date(byAdding: .month, value: -1, to: resetsAt) + case .session, .tokenWindow, .spend, .cursorModels, .otherModels: + return nil + } + } + + static func elapsedFraction( + kind: QuotaWindowKind, + resetsAt: Date?, + now: Date, + calendar: Calendar + ) -> Double? { + guard let resetsAt, + let start = windowStart(kind: kind, resetsAt: resetsAt, calendar: calendar) + else { + return nil + } + let length = resetsAt.timeIntervalSince(start) + guard length > 0 else { return nil } + return min(max(now.timeIntervalSince(start) / length, 0), 1) + } + + private static func providerOrder(_ provider: QuotaProviderID) -> Int { + QuotaProviderID.allCases.firstIndex(of: provider) ?? Int.max + } + + // MARK: - Tokens + + struct ModelRow: Equatable, Identifiable { + var id: String { name } + var name: String + var tokens: Int + /// Bar length relative to the largest model, 0...1. + var share: Double + } + + static func topModels(_ usage: DailyUsage, limit: Int = topModelLimit) -> [ModelRow] { + let sorted = usage.models + .filter { $0.value > 0 } + .sorted { lhs, rhs in + lhs.value != rhs.value ? lhs.value > rhs.value : lhs.key < rhs.key + } + .prefix(limit) + let largest = Double(sorted.first?.value ?? 0) + return sorted.map { name, tokens in + ModelRow(name: name, tokens: tokens, share: largest > 0 ? Double(tokens) / largest : 0) + } + } + + // MARK: - Rank + + /// Token Rank's public board counts days in this zone. + static let rankBoardTimeZoneIdentifier = "Asia/Shanghai" + + struct Podium: Equatable, Identifiable { + var id: Int { rank } + var rank: Int + var name: String + var tokens: Int + var isMe: Bool + /// How far ahead of the viewer this entry is; nil when unknown or not ahead. + var leadOverMe: Int? + } + + enum Chase: Equatable { + /// The next rank up, and how many tokens it takes to pass it. + case next(rank: Int, name: String, gap: Int, progress: Double) + /// Outside the listed ranks: the last listed rank to reach. + case enterBoard(rank: Int, tokens: Int, gap: Int?) + /// First place, and the lead over second. + case leading(lead: Int) + } + + struct RankSection: Equatable { + var myRank: Int? + var rankedUsers: Int + var listedLimit: Int + var myBoardTokens: Int? + var podium: [Podium] + var chase: Chase? + /// Minutes to close the chase gap at the last hour's pace. + var etaMinutes: Int? + var tokensPerHour: Int + /// Local tokens not yet on the board; only when the local day matches the board's. + var unsyncedTokens: Int? + /// Where the local total would place today, when that differs from the board. + var projectedRank: Int? + } + + static func rankSection( + board: TokenRankLeaderboard, + myUserID: Int?, + localTodayTokens: Int, + tokensPerHour: Int, + localTimeZoneIdentifier: String + ) -> RankSection { + let entries = board.entries.sorted { $0.rank < $1.rank } + let mine = myUserID.flatMap { id in entries.first { $0.userID == id } } + let podium = entries.prefix(3).map { entry in + Podium( + rank: entry.rank, + name: entry.name, + tokens: entry.totalTokens, + isMe: entry.userID == mine?.userID, + leadOverMe: mine.flatMap { me in + entry.rank < me.rank ? max(entry.totalTokens - me.totalTokens, 0) : nil + } + ) + } + + let sameDay = localTimeZoneIdentifier == rankBoardTimeZoneIdentifier + var chase: Chase? + var gap: Int? + if let mine { + if let next = entries.last(where: { $0.rank < mine.rank }) { + let needed = max(next.totalTokens - mine.totalTokens, 0) + 1 + gap = needed + let progress = next.totalTokens > 0 ? Double(mine.totalTokens) / Double(next.totalTokens) : 0 + chase = .next(rank: next.rank, name: next.name, gap: needed, progress: min(max(progress, 0), 1)) + } else if let second = entries.first(where: { $0.rank > mine.rank }) { + chase = .leading(lead: max(mine.totalTokens - second.totalTokens, 0)) + } + } else if myUserID != nil, let last = entries.last, entries.count >= board.topLimit { + // Not listed: the board only returns the top `topLimit`. Local tokens are + // comparable only when both count the same day. + let needed = sameDay ? max(last.totalTokens - localTodayTokens, 0) + 1 : nil + gap = needed + chase = .enterBoard(rank: last.rank, tokens: last.totalTokens, gap: needed) + } + + var eta: Int? + if let gap, tokensPerHour > 0 { + eta = Int((Double(gap) / Double(tokensPerHour) * 60).rounded(.up)) + } + + var unsynced: Int? + var projected: Int? + if sameDay, let mine, localTodayTokens > mine.totalTokens { + unsynced = localTodayTokens - mine.totalTokens + let ahead = entries.filter { $0.userID != mine.userID && $0.totalTokens >= localTodayTokens }.count + if ahead + 1 < mine.rank { + projected = ahead + 1 + } + } + + return RankSection( + myRank: mine?.rank, + rankedUsers: board.totalRankedUsers, + listedLimit: board.topLimit, + myBoardTokens: mine?.totalTokens, + podium: podium, + chase: chase, + etaMinutes: eta, + tokensPerHour: tokensPerHour, + unsyncedTokens: unsynced, + projectedRank: projected + ) + } + + /// Tokens used over the last 60 minutes, from today's hourly buckets. The + /// previous hour counts only for the part still inside the window. + static func tokensInLastHour(buckets: [HourlyTokenBucket], now: Date, calendar: Calendar) -> Int { + let hour = calendar.component(.hour, from: now) + let minute = calendar.component(.minute, from: now) + let current = buckets.first { $0.hour == hour }?.tokens ?? 0 + let previous = hour > 0 ? (buckets.first { $0.hour == hour - 1 }?.tokens ?? 0) : 0 + return current + Int(Double(previous) * Double(60 - minute) / 60) + } +} diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/CompactPopoverSections.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/CompactPopoverSections.swift new file mode 100644 index 0000000..847f89c --- /dev/null +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/CompactPopoverSections.swift @@ -0,0 +1,529 @@ +import AppKit +import SwiftUI + +// MARK: - Provider icon + +/// The provider's installed app icon when one is found, then a bundled mark +/// (see `ProviderMark`), otherwise a monogram tile in the provider's color. +struct QuotaProviderIcon: View { + var provider: QuotaProviderID + var size: CGFloat = 20 + + var body: some View { + if let image = Self.appIcon(for: provider) { + Image(nsImage: image) + .resizable() + .interpolation(.high) + .frame(width: size, height: size) + .accessibilityHidden(true) + } else if provider == .claude { + // Cream tile like Claude's app icon, so the mark sits at the same + // visual weight as the other providers' app icons. + RoundedRectangle(cornerRadius: size * 0.25, style: .continuous) + .fill(Color(red: 244 / 255, green: 243 / 255, blue: 238 / 255)) + .frame(width: size, height: size) + .overlay { + SVGPathShape(pathData: ProviderMark.claudePath) + .fill(ProviderMark.claudeColor) + .padding(size * 0.12) + } + .overlay { + RoundedRectangle(cornerRadius: size * 0.25, style: .continuous) + .stroke(Color.black.opacity(0.08), lineWidth: 0.5) + } + .accessibilityHidden(true) + } else { + RoundedRectangle(cornerRadius: size * 0.25, style: .continuous) + .fill(AgentSourceRegistry.color(for: provider.rawValue)) + .frame(width: size, height: size) + .overlay { + Text(Self.monogram(for: provider)) + .font(.system(size: size * 0.42, weight: .bold, design: .rounded)) + .foregroundStyle(Color.white) + } + .accessibilityHidden(true) + } + } + + static func monogram(for provider: QuotaProviderID) -> String { + switch provider { + case .codex: return "Cx" + case .claude: return "Cl" + case .cursor: return "Cu" + case .glm: return "GL" + case .kimi: return "Km" + case .grok: return "Gk" + } + } + + /// Bundle identifiers of each provider's desktop app, most specific first. + private static let bundleIdentifiers: [QuotaProviderID: [String]] = [ + .codex: ["com.openai.codex", "com.openai.chat"], + .claude: ["com.anthropic.claudefordesktop"], + .cursor: ["com.todesktop.230313mzl4w4u92"] + ] + + private static var iconCache: [QuotaProviderID: NSImage?] = [:] + + static func appIcon(for provider: QuotaProviderID) -> NSImage? { + if let cached = iconCache[provider] { + return cached + } + let icon = (bundleIdentifiers[provider] ?? []) + .lazy + .compactMap { NSWorkspace.shared.urlForApplication(withBundleIdentifier: $0) } + .first + .map { NSWorkspace.shared.icon(forFile: $0.path) } + iconCache[provider] = .some(icon) + return icon + } +} + +// MARK: - Shared styling + +enum CompactPopoverStyle { + static let width: CGFloat = 340 + static let horizontalPadding: CGFloat = 16 + + static func color(for level: CompactPopoverModel.QuotaLevel) -> Color { + switch level { + case .normal: return Color.tokenGreen + case .warning: return Color.tokenWarning + case .critical: return Color(red: 201 / 255, green: 60 / 255, blue: 55 / 255) + } + } + + static func textColor(for level: CompactPopoverModel.QuotaLevel) -> Color { + level == .normal ? Color.tokenInk : color(for: level) + } + + static func percent(_ value: Double) -> String { + "\(Int(value.rounded()))%" + } + + static func minutes(_ value: Int) -> String { + if value < 60 { + return LFormat("%d 分钟", value) + } + let hours = value / 60 + let rest = value % 60 + return rest == 0 ? LFormat("%d 小时", hours) : LFormat("%d 小时 %d 分", hours, rest) + } + + static func relative(_ date: Date, now: Date = Date()) -> String { + let seconds = now.timeIntervalSince(date) + if seconds < 60 { + return L("刚刚") + } + let formatter = RelativeDateTimeFormatter() + formatter.locale = TokenStepLocalization.locale + formatter.unitsStyle = .short + return formatter.localizedString(for: date, relativeTo: now) + } + + static func resetText(_ date: Date?, now: Date = Date()) -> String? { + guard let date else { return nil } + let calendar = TokenStepClock.calendar + let minutes = Int(date.timeIntervalSince(now) / 60) + if minutes < 1 { + return L("即将重置") + } + if minutes < 60 { + return LFormat("%d 分钟后", minutes) + } + let time = DateFormatter() + time.locale = TokenStepLocalization.locale + time.timeZone = TokenStepClock.timeZone + time.setLocalizedDateFormatFromTemplate("HHmm") + if calendar.isDate(date, inSameDayAs: now) { + return LFormat("今天 %@", time.string(from: date)) + } + if let tomorrow = calendar.date(byAdding: .day, value: 1, to: now), + calendar.isDate(date, inSameDayAs: tomorrow) { + // "Tomorrow 17:01" does not fit the column in English; a weekday does. + if TokenStepLocalization.language == .en { + time.setLocalizedDateFormatFromTemplate("EEEHHmm") + return time.string(from: date) + } + return LFormat("明天 %@", time.string(from: date)) + } + let day = DateFormatter() + day.locale = TokenStepLocalization.locale + day.timeZone = TokenStepClock.timeZone + day.setLocalizedDateFormatFromTemplate("MMMd") + return day.string(from: date) + } +} + +private struct CompactSectionHeader: View { + var title: String + var trailing: String? + + var body: some View { + HStack(alignment: .firstTextBaseline) { + Text(title) + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(Color.tokenInk.opacity(0.62)) + Spacer(minLength: 8) + if let trailing { + Text(trailing) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } + } +} + +private struct CompactBar: View { + var fraction: Double + var color: Color + var height: CGFloat = 6 + var marker: Double? + + var body: some View { + GeometryReader { proxy in + ZStack(alignment: .leading) { + Capsule().fill(Color.tokenTrack) + Capsule() + .fill(color) + .frame(width: max(proxy.size.width * min(max(fraction, 0), 1), fraction > 0 ? height : 0)) + if let marker { + RoundedRectangle(cornerRadius: 1) + .fill(Color.tokenInk.opacity(0.55)) + .frame(width: 2, height: height + 6) + .offset(x: proxy.size.width * min(max(marker, 0), 1) - 1) + } + } + .frame(height: proxy.size.height) + } + .frame(height: height) + } +} + +// MARK: - Quota section + +struct CompactQuotaSection: View { + var section: CompactPopoverModel.QuotaSection + var updatedAt: Date? + @State private var showsFolded = false + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + CompactSectionHeader( + title: L("额度"), + trailing: updatedAt.map { LFormat("已用 · %@", CompactPopoverStyle.relative($0)) } ?? L("已用") + ) + ForEach(section.expanded) { provider in + providerBlock(provider) + } + if showsFolded { + ForEach(section.folded) { provider in + providerBlock(provider) + } + } + if !section.folded.isEmpty { + Button { + showsFolded.toggle() + } label: { + HStack(spacing: 8) { + Text(showsFolded ? L("收起") : foldedSummary) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 4) + Image(systemName: showsFolded ? "chevron.up" : "chevron.down") + .font(.system(size: 10, weight: .bold)) + } + .font(.system(size: 12)) + .foregroundStyle(Color.tokenInk.opacity(0.74)) + .padding(.horizontal, 10) + .frame(maxWidth: .infinity, minHeight: 32) + .background(Color.tokenTrack.opacity(0.55), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + .buttonStyle(.plain) + .accessibilityLabel(showsFolded ? L("收起") : foldedSummary) + } + } + } + + private var foldedSummary: String { + LFormat( + "另有 %d 项正常:%@", + section.folded.count, + section.folded.map(\.provider.displayName) + .joined(separator: TokenStepLocalization.language == .en ? ", " : "、") + ) + } + + /// Explains the bar's tick: how much of the window's time has passed. + private func paceHelp(_ window: CompactPopoverModel.QuotaWindowRow) -> String { + guard let elapsed = window.elapsedFraction else { + return LFormat("已用 %@", CompactPopoverStyle.percent(window.usedPercent)) + } + let text = LFormat( + "竖线是时间进度:已过 %@,已用 %@", + CompactPopoverStyle.percent(elapsed * 100), + CompactPopoverStyle.percent(window.usedPercent) + ) + return window.runsOutEarly ? text + L("。用得比时间快,照此速度会在重置前用完") : text + } + + private func providerBlock(_ provider: CompactPopoverModel.QuotaProviderRow) -> some View { + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 8) { + QuotaProviderIcon(provider: provider.provider) + Text(provider.provider.displayName) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color.tokenInk) + Spacer(minLength: 4) + if provider.runsOutEarly { + Text(L("照此速度会提前用完")) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(CompactPopoverStyle.color(for: .warning)) + .padding(.horizontal, 7) + .padding(.vertical, 2) + .background(CompactPopoverStyle.color(for: .warning).opacity(0.14), in: Capsule()) + } + } + ForEach(provider.windows) { window in + HStack(spacing: 8) { + Text(window.title) + .font(.system(size: 11, weight: window.runsOutEarly ? .semibold : .regular)) + .foregroundStyle(window.runsOutEarly ? CompactPopoverStyle.color(for: .warning) : Color.secondary) + .frame(width: 58, alignment: .leading) + .lineLimit(1) + CompactBar( + fraction: window.usedPercent / 100, + color: CompactPopoverStyle.color(for: window.level), + marker: window.elapsedFraction + ) + Text(CompactPopoverStyle.percent(window.usedPercent)) + .font(.system(size: 12, weight: .bold)) + .monospacedDigit() + .foregroundStyle(CompactPopoverStyle.textColor(for: window.level)) + .frame(width: 38, alignment: .trailing) + Text(CompactPopoverStyle.resetText(window.resetsAt) ?? "") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(width: 72, alignment: .trailing) + } + .contentShape(Rectangle()) + .help(paceHelp(window)) + .accessibilityElement(children: .combine) + } + } + } +} + +// MARK: - Token section + +struct CompactTokenSection: View { + var usage: DailyUsage + var lap: TokenStepLapProgress + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 12) { + ZStack { + Circle().stroke(Color.tokenTrack, lineWidth: 5) + Circle() + .trim(from: 0, to: lap.currentLapProgress) + .stroke(lap.ringColor, style: StrokeStyle(lineWidth: 5, lineCap: .round)) + .rotationEffect(.degrees(-90)) + } + .frame(width: 42, height: 42) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 1) { + Text(L("今日 Token")) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + Text(TokenStepFormat.tokens(usage.totalTokens, compact: true)) + .font(.system(size: 24, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(Color.tokenInk) + } + Spacer(minLength: 4) + VStack(alignment: .trailing, spacing: 2) { + Text(lap.lapStatusText) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Color.tokenGreenDark) + Text(lap.perLapGoalText) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } + ForEach(CompactPopoverModel.topModels(usage)) { model in + HStack(spacing: 10) { + Text(model.name) + .font(.system(size: 12)) + .foregroundStyle(Color.tokenInk) + .lineLimit(1) + .truncationMode(.middle) + .frame(width: 112, alignment: .leading) + CompactBar(fraction: model.share, color: Color.tokenGreen.opacity(0.85), height: 5) + Text(TokenStepFormat.tokens(model.tokens, compact: true)) + .font(.system(size: 12, weight: .semibold)) + .monospacedDigit() + .foregroundStyle(Color.tokenInk) + .frame(width: 56, alignment: .trailing) + } + .accessibilityElement(children: .combine) + } + } + } +} + +// MARK: - Rank section + +struct CompactRankSection: View { + var section: CompactPopoverModel.RankSection + var fetchedAt: Date + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + CompactSectionHeader(title: L("今日排名"), trailing: LFormat("榜单 %@", CompactPopoverStyle.relative(fetchedAt))) + HStack(alignment: .firstTextBaseline, spacing: 8) { + if let rank = section.myRank { + Text("#\(rank)") + .font(.system(size: 26, weight: .heavy, design: .rounded)) + .monospacedDigit() + .foregroundStyle(Color.tokenInk) + } else { + Text(L("未上榜")) + .font(.system(size: 17, weight: .heavy, design: .rounded)) + .foregroundStyle(Color.tokenInk) + } + Text(rankCaption) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + Spacer(minLength: 4) + if let board = section.myBoardTokens { + Text(LFormat("榜上 %@", TokenStepFormat.tokens(board, compact: true))) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + } + ForEach(section.podium) { entry in + HStack(spacing: 8) { + Text("\(entry.rank)") + .font(.system(size: 11, weight: .heavy, design: .rounded)) + .foregroundStyle(Color.black.opacity(0.78)) + .frame(width: 20, height: 20) + .background(medal(entry.rank), in: Circle()) + Text(entry.isMe ? LFormat("%@(你)", entry.name) : entry.name) + .font(.system(size: 12, weight: entry.isMe ? .bold : .regular)) + .foregroundStyle(Color.tokenInk) + .lineLimit(1) + Spacer(minLength: 4) + Text(TokenStepFormat.tokens(entry.tokens, compact: true)) + .font(.system(size: 12, weight: .semibold)) + .monospacedDigit() + .foregroundStyle(Color.tokenInk) + Text(entry.leadOverMe.map { LFormat("差 %@", TokenStepFormat.tokens($0, compact: true)) } ?? "") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .frame(width: 70, alignment: .trailing) + } + .accessibilityElement(children: .combine) + } + if let chase = section.chase { + chaseCard(chase) + } + } + } + + private var rankCaption: String { + if section.myRank == nil { + // A full board may hide ranks past the limit; a short one lists + // everyone, so being absent means nothing uploaded today. + return section.chase != nil + ? LFormat("未进入前 %d 名", section.listedLimit) + : L("今日还没有你的上传") + } + return LFormat("/ %d 人", section.rankedUsers) + } + + private func medal(_ rank: Int) -> Color { + switch rank { + case 1: return Color(red: 242 / 255, green: 209 / 255, blue: 107 / 255) + case 2: return Color(red: 213 / 255, green: 219 / 255, blue: 225 / 255) + default: return Color(red: 230 / 255, green: 185 / 255, blue: 143 / 255) + } + } + + @ViewBuilder + private func chaseCard(_ chase: CompactPopoverModel.Chase) -> some View { + VStack(alignment: .leading, spacing: 6) { + switch chase { + case let .next(rank, name, gap, progress): + chaseHeadline(gap: gap, target: LFormat("超过 #%d %@", rank, name)) + CompactBar(fraction: progress, color: Color.tokenGreen, height: 5) + paceLine + case let .enterBoard(rank, tokens, gap): + if let gap { + chaseHeadline(gap: gap, target: LFormat("进入前 %d 名", rank)) + paceLine + } else { + Text(LFormat("第 %d 名今日 %@", rank, TokenStepFormat.tokens(tokens, compact: true))) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color.tokenGreenDark) + } + case let .leading(lead): + Text(L("你是今日第一")) + .font(.system(size: 14, weight: .heavy)) + .foregroundStyle(Color.tokenGreenDark) + Text(LFormat("领先第二名 %@", TokenStepFormat.tokens(lead, compact: true))) + .font(.system(size: 11)) + .foregroundStyle(Color.tokenGreenDark.opacity(0.85)) + } + if let unsynced = section.unsyncedTokens { + Text(section.projectedRank.map { + LFormat("本地比榜单多 %@,同步后预计升到 #%d", TokenStepFormat.tokens(unsynced, compact: true), $0) + } ?? LFormat("本地比榜单多 %@,等待同步", TokenStepFormat.tokens(unsynced, compact: true))) + .font(.system(size: 11)) + .foregroundStyle(Color.tokenGreenDark.opacity(0.85)) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.tokenGreen.opacity(0.10), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color.tokenGreen.opacity(0.28)) + } + } + + private func chaseHeadline(gap: Int, target: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(L("再跑")) + .font(.system(size: 12, weight: .medium)) + Text(TokenStepFormat.tokens(gap, compact: true)) + .font(.system(size: 13, weight: .bold, design: .rounded)) + .monospacedDigit() + Text(target) + .font(.system(size: 12, weight: .medium)) + .lineLimit(1) + .truncationMode(.tail) + } + .foregroundStyle(Color.tokenGreenDark) + } + + @ViewBuilder + private var paceLine: some View { + if let eta = section.etaMinutes { + Text(LFormat( + "按最近 1 小时的速度(约 %@/时),约 %@", + TokenStepFormat.tokens(section.tokensPerHour, compact: true), + CompactPopoverStyle.minutes(eta) + )) + .font(.system(size: 11)) + .foregroundStyle(Color.tokenGreenDark.opacity(0.85)) + } else { + Text(L("最近 1 小时没有用量")) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } +} diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverAgentWorkTable.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverAgentWorkTable.swift deleted file mode 100644 index 26b7ec4..0000000 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverAgentWorkTable.swift +++ /dev/null @@ -1,94 +0,0 @@ -import SwiftUI - -struct PopoverAgentWorkTable: View { - @EnvironmentObject private var appState: AppState - - private var work: DailyAgentWork { - appState.todayAgentWork - } - - var body: some View { - let voyage = TokenStepThemeRuntime.isCinematic - return VStack(alignment: .leading, spacing: 0) { - HStack { - Text(L("Agent 用量")) - .font((voyage ? Font.callout : Font.caption).weight(.heavy)) - .foregroundStyle(Color.tokenInk) - Spacer() - Text(LFormat("%d 个来源", rows.count)) - .font((voyage ? Font.caption : Font.caption2).weight(.bold)) - .foregroundStyle(.secondary) - } - .frame(height: 20) - - if rows.isEmpty { - Text(L("今日还没有 Agent 记录")) - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, minHeight: 32, alignment: .leading) - } else { - header - .frame(height: 18) - ForEach(visibleRows) { row in - rowView(row) - } - } - } - .padding(.horizontal, voyage ? 14 : 12) - .padding(.vertical, 8) - .frame(maxWidth: .infinity, alignment: .top) - .contentShape(Rectangle()) - .onTapGesture { - MainWindowPresenter.shared.show(appState: appState, section: .today) - } - } - - private var header: some View { - let voyage = TokenStepThemeRuntime.isCinematic - return HStack(spacing: 10) { - Text(L("来源")).frame(maxWidth: .infinity, alignment: .leading) - Text(L("Token")).frame(width: voyage ? 88 : 72, alignment: .trailing) - } - .font(.system(size: voyage ? 11.5 : 10.5, weight: .semibold)) - .foregroundStyle(.secondary) - } - - private func rowView(_ row: AgentWorkSource) -> some View { - let voyage = TokenStepThemeRuntime.isCinematic - return HStack(alignment: .center, spacing: 10) { - HStack(alignment: .center, spacing: 6) { - Circle() - .fill(tokenToolColor(row.source)) - .frame(width: 6, height: 6) - Text(row.source) - .lineLimit(1) - .truncationMode(.middle) - .multilineTextAlignment(.leading) - .help(row.source) - } - .frame(maxWidth: .infinity, alignment: .leading) - Text(TokenStepFormat.tokens(row.tokens, compact: true)) - .frame(width: voyage ? 88 : 72, alignment: .trailing) - .monospacedDigit() - } - .font(.system(size: voyage ? 11.5 : 10.5, weight: .semibold)) - .foregroundStyle(Color.tokenInk.opacity(0.82)) - .frame(height: 25) - .overlay(alignment: .top) { - Rectangle().fill(Color.tokenDivider).frame(height: 1) - } - } - - private var rows: [AgentWorkSource] { - work.sources - .filter { $0.tokens > 0 } - .sorted { - if $0.tokens != $1.tokens { return $0.tokens > $1.tokens } - return $0.source.localizedStandardCompare($1.source) == .orderedAscending - } - } - - private var visibleRows: [AgentWorkSource] { - Array(rows.prefix(3)) - } -} diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverFooterView.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverFooterView.swift deleted file mode 100644 index 8aca293..0000000 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverFooterView.swift +++ /dev/null @@ -1,185 +0,0 @@ -import AppKit -import SwiftUI - -struct PopoverFooterView: View { - @EnvironmentObject private var appState: AppState - - var body: some View { - Group { - if TokenStepThemeRuntime.isCinematic { - odysseyFooter - } else { - classicFooter - } - } - } - - private var classicFooter: some View { - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 8) { - Label(L("本地统计"), systemImage: "checkmark.shield.fill") - .font(.caption.weight(.bold)) - .foregroundStyle(.secondary) - Spacer() - Text(appState.settings.refreshIntervalSeconds == 0 ? L("手动刷新") : LFormat("刷新 %@", TokenStepFormat.intervalLabel(appState.settings.refreshIntervalSeconds))) - .font(.caption.weight(.bold)) - .foregroundStyle(.secondary) - } - - HStack(spacing: 10) { - Button { - MainWindowPresenter.shared.show(appState: appState) - } label: { - Label(L("打开仪表盘"), systemImage: "arrow.up.right") - .font(.system(size: 14, weight: .heavy, design: .rounded)) - .foregroundStyle(Color.tokenActionText) - .frame(width: 148, height: 40) - .background(Color.tokenGreen, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - } - .buttonStyle(.plain) - .help(L("打开仪表盘")) - - Spacer(minLength: 0) - - PopoverActionButton(title: L("刷新"), symbol: "arrow.clockwise") { - appState.refresh() - } - .disabled(appState.isRefreshing) - - PopoverUpdateActionButton() - - PopoverActionButton(title: L("设置"), symbol: "gearshape") { - SettingsWindowPresenter.shared.show(appState: appState) - } - - PopoverActionButton(title: L("退出"), symbol: "power") { - NSApplication.shared.terminate(nil) - } - } - } - } - - private var odysseyFooter: some View { - HStack(alignment: .bottom, spacing: 18) { - VStack(alignment: .leading, spacing: 10) { - Label(L("本地统计"), systemImage: "checkmark.shield.fill") - .font(.caption.weight(.bold)) - .foregroundStyle(.secondary) - - Button { - MainWindowPresenter.shared.show(appState: appState) - } label: { - Label(L("打开仪表盘"), systemImage: "arrow.up.right") - .font(.system(size: 15, weight: .heavy, design: .rounded)) - .foregroundStyle(Color.tokenActionText) - .frame(width: 190, height: 44) - .background( - LinearGradient( - colors: [Color.tokenGreenDark, Color.tokenGreen], - startPoint: .topLeading, - endPoint: .bottomTrailing - ), - in: RoundedRectangle(cornerRadius: 12, style: .continuous) - ) - .overlay { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(Color.white.opacity(0.18), lineWidth: 0.8) - } - .shadow(color: Color.black.opacity(0.28), radius: 12, y: 6) - } - .buttonStyle(.plain) - .help(L("打开仪表盘")) - } - - Spacer(minLength: 0) - - VStack(alignment: .trailing, spacing: 10) { - Text(appState.settings.refreshIntervalSeconds == 0 ? L("手动刷新") : LFormat("刷新 %@", TokenStepFormat.intervalLabel(appState.settings.refreshIntervalSeconds))) - .font(.caption.weight(.bold)) - .foregroundStyle(.secondary) - - HStack(spacing: 10) { - PopoverActionButton(title: L("刷新"), symbol: "arrow.clockwise") { - appState.refresh() - } - .disabled(appState.isRefreshing) - - PopoverUpdateActionButton() - - PopoverActionButton(title: L("设置"), symbol: "gearshape") { - SettingsWindowPresenter.shared.show(appState: appState) - } - - PopoverActionButton(title: L("退出"), symbol: "power") { - NSApplication.shared.terminate(nil) - } - } - } - } - } -} - -private struct PopoverUpdateActionButton: View { - @EnvironmentObject private var appState: AppState - - var body: some View { - Button { - appState.showUpdateDetails() - } label: { - ZStack { - if state.isChecking { - ProgressView() - .controlSize(.small) - .tint(state.tint) - } else { - Image(systemName: state.symbol) - .font(.system(size: 14, weight: .heavy)) - .foregroundStyle(state.tint) - } - - if state.isAvailable { - Circle() - .fill(Color.tokenGreen) - .frame(width: 5.5, height: 5.5) - .offset(x: 12, y: -12) - } - } - .frame(width: 40, height: 40) - .background(Color.tokenSurface, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(state.isAvailable ? Color.tokenGreen.opacity(0.56) : Color.tokenHairline) - } - .contentShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - } - .buttonStyle(.plain) - .disabled(state.isChecking) - .help(state.help) - .accessibilityLabel(state.accessibilityLabel) - } - - private var state: UpdateActionVisualState { - appState.updateActionVisualState - } -} - -private struct PopoverActionButton: View { - var title: String - var symbol: String - var action: () -> Void - - var body: some View { - Button(action: action) { - Image(systemName: symbol) - .font(.system(size: 14, weight: .heavy)) - .foregroundStyle(Color.tokenInk.opacity(0.78)) - .frame(width: 40, height: 40) - .background(Color.tokenSurface, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous).stroke(Color.tokenHairline)) - .help(title) - } - .buttonStyle(.plain) - .help(title) - .accessibilityLabel(title) - } -} diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverModelUsageSection.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverModelUsageSection.swift deleted file mode 100644 index 0fabac2..0000000 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverModelUsageSection.swift +++ /dev/null @@ -1,230 +0,0 @@ -import SwiftUI - -struct PopoverModelUsageRow: Identifiable, Equatable { - enum Kind: Equatable { - case model(String) - case other - } - - var kind: Kind - var tokens: Int - var percent: Double - var colorSlot: Int? - - var id: String { - switch kind { - case let .model(model): return "model:\(model)" - case .other: return "aggregate:other" - } - } - - var originalModelName: String? { - guard case let .model(model) = kind else { return nil } - return model - } -} -enum PopoverModelUsageState: Equatable { - case hidden - case waiting - case rows([PopoverModelUsageRow], mismatch: Bool) -} - -enum PopoverModelUsageRows { - static let maximumIndividualRows = 3 - static let maximumVisibleRows = 4 - - static func state(from usage: DailyUsage) -> PopoverModelUsageState { - guard usage.totalTokens > 0 else { return .hidden } - let sourceRows = TodayModelUsageRows.allPositiveRows(from: usage) - guard !sourceRows.isEmpty else { return .waiting } - - var rows = sourceRows.prefix(maximumIndividualRows).map { source in - PopoverModelUsageRow( - kind: .model(source.model), - tokens: source.tokens, - percent: source.percent, - colorSlot: TodayModelUsageRows.colorSlot(for: source.model) - ) - } - - let remaining = sourceRows.dropFirst(maximumIndividualRows) - if !remaining.isEmpty { - let otherTokens = saturatedSum(remaining.map(\.tokens)) - rows.append( - PopoverModelUsageRow( - kind: .other, - tokens: otherTokens, - percent: percent(tokens: otherTokens, total: usage.totalTokens), - colorSlot: nil - ) - ) - } - - return .rows( - rows, - mismatch: TodayModelUsageRows.hasTokenTotalMismatch(usage) - ) - } - - private static func percent(tokens: Int, total: Int) -> Double { - guard total > 0 else { return 0 } - return min(100, max(0, Double(tokens) * 100 / Double(total))) - } - - private static func saturatedSum(_ values: [Int]) -> Int { - values.reduce(into: 0) { result, value in - let (sum, overflow) = result.addingReportingOverflow(value) - result = overflow ? Int.max : sum - } - } -} - -struct PopoverModelUsageSection: View { - var usage: DailyUsage - - private var state: PopoverModelUsageState { - PopoverModelUsageRows.state(from: usage) - } - - @ViewBuilder - var body: some View { - switch state { - case .hidden: - EmptyView() - case .waiting: - content(rows: [], mismatch: false, waiting: true) - case let .rows(rows, mismatch): - content(rows: rows, mismatch: mismatch, waiting: false) - } - } - - private func content( - rows: [PopoverModelUsageRow], - mismatch: Bool, - waiting: Bool - ) -> some View { - VStack(alignment: .leading, spacing: 0) { - header(mismatch: mismatch) - .frame(height: 20) - - if waiting { - Text(L("模型明细同步中")) - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, minHeight: 32, alignment: .leading) - .accessibilityLabel(L("模型明细同步中")) - } else { - columnHeader - .frame(height: 18) - ForEach(rows) { row in - modelRow(row) - } - } - } - .padding(.horizontal, TokenStepThemeRuntime.isCinematic ? 14 : 12) - .padding(.vertical, 8) - .frame(maxWidth: .infinity, alignment: .topLeading) - } - - private func header(mismatch: Bool) -> some View { - HStack(spacing: 7) { - Text(L("今日模型")) - .font((TokenStepThemeRuntime.isCinematic ? Font.callout : Font.caption).weight(.heavy)) - .foregroundStyle(Color.tokenInk) - Spacer(minLength: 4) - if mismatch { - Image(systemName: "info.circle") - .font(.system(size: 10.5, weight: .bold)) - .foregroundStyle(.secondary) - .help(L("模型明细合计与今日总量不一致;占比按今日总量计算。")) - .accessibilityLabel(L("模型明细合计与今日总量不一致;占比按今日总量计算。")) - } - } - } - - private var columnHeader: some View { - HStack(spacing: 8) { - Text(L("模型")) - .frame(maxWidth: .infinity, alignment: .leading) - Text("Token") - .frame(width: 68, alignment: .trailing) - Text(L("占比")) - .frame(width: 44, alignment: .trailing) - } - .font(.system(size: 9.5, weight: .semibold)) - .foregroundStyle(.secondary) - } - - private func modelRow(_ row: PopoverModelUsageRow) -> some View { - let color = rowColor(row) - let name = displayName(row) - return VStack(spacing: 3) { - HStack(spacing: 8) { - HStack(spacing: 6) { - Circle() - .fill(color) - .frame(width: 6, height: 6) - Text(name) - .lineLimit(1) - .truncationMode(.middle) - .help(row.originalModelName ?? name) - } - .frame(maxWidth: .infinity, alignment: .leading) - - Text(TokenStepFormat.tokens(row.tokens, compact: true)) - .frame(width: 68, alignment: .trailing) - .monospacedDigit() - Text(TokenStepFormat.percent(row.percent)) - .frame(width: 44, alignment: .trailing) - .monospacedDigit() - } - .font(.system(size: TokenStepThemeRuntime.isCinematic ? 10.5 : 10, weight: .semibold)) - .foregroundStyle(Color.tokenInk.opacity(0.82)) - - GeometryReader { proxy in - ZStack(alignment: .leading) { - Capsule().fill(Color.tokenTrack.opacity(0.78)) - Capsule() - .fill(color) - .frame(width: max(row.tokens > 0 ? 3 : 0, proxy.size.width * row.percent / 100)) - } - } - .frame(height: 3) - .padding(.leading, 12) - } - .frame(height: 25) - .accessibilityElement(children: .ignore) - .accessibilityLabel( - "\(name), \(TokenStepFormat.tokens(row.tokens, compact: true)), \(TokenStepFormat.percent(row.percent))" - ) - } - - private func displayName(_ row: PopoverModelUsageRow) -> String { - switch row.kind { - case .other: - return L("其他") - case let .model(model): - return model.lowercased() == "unknown" ? L("未知模型") : model - } - } - - private func rowColor(_ row: PopoverModelUsageRow) -> Color { - guard let slot = row.colorSlot else { - return Color.secondary.opacity(0.72) - } - if TokenStepThemeRuntime.isCinematic { - switch slot { - case 0: return .tokenGreen - case 1: return TokenStepThemeRuntime.palette.activity3.color - case 2: return TokenStepThemeRuntime.palette.activity2.color - default: return .tokenGreenDark - } - } - switch slot { - case 0: return .tokenGreen - case 1: return Color(red: 0.19, green: 0.52, blue: 0.91) - case 2: return Color.tokenInk.opacity(0.55) - default: return .tokenGreenDark - } - } -} diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverQuotaCard.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverQuotaCard.swift deleted file mode 100644 index 61c0f42..0000000 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverQuotaCard.swift +++ /dev/null @@ -1,124 +0,0 @@ -import SwiftUI - -struct PopoverQuotaCard: View { - @EnvironmentObject private var appState: AppState - - var body: some View { - let voyage = TokenStepThemeRuntime.isCinematic - return VStack(alignment: .leading, spacing: voyage ? 12 : 10) { - HStack(spacing: 8) { - Text(L("订阅额度")) - .font((voyage ? Font.callout : Font.caption).weight(.heavy)) - .foregroundStyle(Color.tokenInk) - Spacer() - if appState.isRefreshingCodexQuota { - ProgressView() - .controlSize(.small) - .scaleEffect(0.72) - } else if let fetchedAt = latestFetchedAt { - Text(quotaFetchedText(fetchedAt)) - .font((voyage ? Font.caption : Font.caption2).weight(.bold)) - .foregroundStyle(.secondary) - } - } - - if visible.isEmpty { - Text(L("暂未读取到额度")) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - } else if usesGrid { - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { - ForEach(visible) { quota in - quotaChip(quota) - } - } - } else { - VStack(spacing: voyage ? 6 : 8) { - ForEach(visible) { quota in - quotaChip(quota) - } - } - } - } - .padding(14) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - } - - private var visible: [ProviderQuota] { - appState.visibleQuotas.filter(\.isAvailable) - } - - private var usesGrid: Bool { - visible.count >= 4 - } - - private var latestFetchedAt: Date? { - visible.compactMap(\.fetchedAt).max() - } - - private func quotaChip(_ quota: ProviderQuota) -> some View { - let voyage = TokenStepThemeRuntime.isCinematic - return VStack(alignment: .leading, spacing: voyage ? 4 : 6) { - Text(quota.provider.displayName) - .font((voyage ? Font.system(size: 11.5) : Font.caption2).weight(.heavy)) - .foregroundStyle(Color.tokenInk.opacity(0.76)) - if quota.isAvailable { - ForEach(quota.windows.prefix(2)) { window in - quotaRow(window) - } - } else { - Text(statusText(quota)) - .font(.caption2.weight(.bold)) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - .padding(voyage ? 6 : 8) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.tokenSurface.opacity(voyage ? 0.84 : 1), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) - .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous).stroke(Color.tokenHairline)) - } - - private func quotaRow(_ window: QuotaWindow) -> some View { - let voyage = TokenStepThemeRuntime.isCinematic - return VStack(alignment: .leading, spacing: voyage ? 3 : 4) { - HStack { - Text(window.kind.shortTitle) - .font(.system(size: voyage ? 10.5 : 10.5, weight: .heavy)) - .foregroundStyle(Color.tokenInk.opacity(0.62)) - Spacer() - Text(LFormat("剩余 %@", TokenStepFormat.percent(window.remainingPercent))) - .font(.system(size: voyage ? 10.5 : 10.5, weight: .heavy)) - .foregroundStyle(window.isLow ? Color.tokenWarning : Color.tokenInk.opacity(0.82)) - } - GeometryReader { proxy in - ZStack(alignment: .leading) { - Capsule().fill(Color.tokenTrack.opacity(0.82)) - Capsule() - .fill(window.isLow ? Color.tokenWarning : Color.tokenGreen) - .frame(width: max(5, proxy.size.width * window.remainingPercent / 100)) - } - } - .frame(height: voyage ? 4 : 5) - } - } - - private func statusText(_ quota: ProviderQuota) -> String { - switch quota.status { - case .notLoggedIn: - return quota.provider == .cursor ? L("未登录 Cursor") : L("未登录") - case .wrongKeyType: - return L("当前 key 非订阅计划") - case .needsLogin: - return L("需要 grok login") - case .unavailable, .available: - return quota.message ?? L("暂不可用") - } - } - - private func quotaFetchedText(_ date: Date) -> String { - let seconds = max(0, Int(Date().timeIntervalSince(date).rounded())) - if seconds < 60 { return L("刚刚") } - return LFormat("%d 分钟前", max(1, seconds / 60)) - } -} diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverTodayRingCard.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverTodayRingCard.swift deleted file mode 100644 index d506433..0000000 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverTodayRingCard.swift +++ /dev/null @@ -1,69 +0,0 @@ -import SwiftUI - -struct PopoverTodayRingCard: View { - @EnvironmentObject private var appState: AppState - - var body: some View { - let lap = appState.todayLap - let voyage = TokenStepThemeRuntime.isCinematic - let ringSize: CGFloat = voyage ? 164 : 118 - let lineWidth: CGFloat = voyage ? 14 : 12 - - return VStack(spacing: voyage ? 13 : 10) { - HStack { - Text(L("今日消耗")) - .font((voyage ? Font.callout : Font.caption).weight(.heavy)) - .foregroundStyle(Color.tokenInk) - Spacer() - Text(appState.today.date.suffix(5)) - .font((voyage ? Font.caption : Font.caption2).weight(.bold)) - .foregroundStyle(.secondary) - } - - ZStack { - if TokenStepThemeRuntime.isVoyage { - Circle() - .fill(Color.tokenCanvas.opacity(0.42)) - .padding(12) - .blur(radius: 0.2) - VoyageBowProgressView(progress: lap.currentLapProgress, lineWidth: lineWidth, color: lap.ringColor) - } else if TokenStepThemeRuntime.isInterstellar { - Circle() - .fill(Color.black.opacity(0.52)) - .padding(12) - ProgressRingView(progress: lap.currentLapProgress, lineWidth: lineWidth, color: lap.ringColor) - .overlay { - InterstellarEventHorizonEmblem() - .padding(30) - .opacity(0.50) - } - } else { - ProgressRingView(progress: lap.currentLapProgress, lineWidth: lineWidth, color: lap.ringColor) - } - VStack(spacing: 2) { - Text(TokenStepFormat.tokens(appState.today.totalTokens, compact: true)) - .font(.system(size: voyage ? 24 : 18, weight: .heavy, design: .rounded)) - .foregroundStyle(Color.tokenInk) - .minimumScaleFactor(0.62) - .lineLimit(1) - Text(LFormat("/ %@", TokenStepFormat.tokens(appState.settings.dailyGoalTokens, compact: true))) - .font((voyage ? Font.caption : Font.caption2).weight(.bold)) - .foregroundStyle(.secondary) - } - .frame(width: voyage ? 118 : 88) - } - .frame(width: ringSize, height: ringSize) - - VStack(spacing: voyage ? 5 : 3) { - Text(lap.lapStatusText) - .font((voyage ? Font.callout : Font.caption).weight(.heavy)) - .foregroundStyle(Color.tokenInk) - Text(TokenStepFormat.money(appState.today.cost)) - .font((voyage ? Font.caption : Font.caption2).weight(.bold)) - .foregroundStyle(.secondary) - } - } - .padding(voyage ? 18 : 14) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - } -} diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverTokenRankCard.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverTokenRankCard.swift deleted file mode 100644 index 3c17181..0000000 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverTokenRankCard.swift +++ /dev/null @@ -1,421 +0,0 @@ -import SwiftUI - -struct PopoverTokenRankCard: View { - enum Layout { - case card - case ribbon - } - - @EnvironmentObject private var appState: AppState - @Environment(\.isScreenshotRendering) private var isScreenshotRendering - @State private var userRankFrame: CGRect = .zero - var layout: Layout = .card - - var body: some View { - Group { - switch layout { - case .card: - cardContent - case .ribbon: - ribbonContent - } - } - .coordinateSpace(name: "tokenRankCard") - .contentShape(RoundedRectangle(cornerRadius: layout == .ribbon ? 16 : 24, style: .continuous)) - .onPreferenceChange(TokenRankUserRowFrameKey.self) { frame in - userRankFrame = frame - } - .gesture( - DragGesture(minimumDistance: 0, coordinateSpace: .named("tokenRankCard")) - .onEnded { value in - let dx = abs(value.location.x - value.startLocation.x) - let dy = abs(value.location.y - value.startLocation.y) - guard dx < 4, dy < 4 else { return } - if appState.agentWorkRankIdentity != nil, userRankFrame.contains(value.location) { - appState.openTokenRankUserPage() - } else { - appState.openTokenRankLeaderboardPage() - } - } - ) - .onAppear { - if !isScreenshotRendering { - appState.refreshTokenRank() - } - } - } - - private var cardContent: some View { - VStack(alignment: .leading, spacing: 10) { - header - userRankContent - metaContent - } - .padding(14) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - } - - private var ribbonContent: some View { - HStack(spacing: 14) { - HStack(spacing: 8) { - Circle() - .fill(Color.tokenGreen) - .frame(width: 8, height: 8) - VStack(alignment: .leading, spacing: 3) { - Text(L("Agent 消耗榜")) - .font(.callout.weight(.heavy)) - .foregroundStyle(Color.tokenInk) - if appState.isRefreshingTokenRank { - Text(L("同步中")) - .font(.caption2.weight(.bold)) - .foregroundStyle(.secondary) - } else if let fetchedAt = appState.tokenRank?.fetchedAt { - Text(headerStatus(fetchedAt)) - .font(.caption2.weight(.bold)) - .foregroundStyle(.secondary) - } - } - } - .frame(width: 150, alignment: .leading) - - ribbonDivider - - HStack(spacing: 10) { - Image(systemName: mainSymbol) - .font(.system(size: 18, weight: .heavy)) - .foregroundStyle(mainTint) - .frame(width: 36, height: 36) - .background(mainTint.opacity(0.16), in: Circle()) - - VStack(alignment: .leading, spacing: 2) { - if let entry = currentUserEntry { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text("#\(entry.rank)") - .font(.system(size: 25, weight: .heavy, design: .rounded)) - .foregroundStyle(Color.tokenInk) - .monospacedDigit() - Text(entry.name) - .font(.caption.weight(.bold)) - .foregroundStyle(.secondary) - .lineLimit(1) - } - if let rankContext { - Text(rankContext) - .font(.caption2.weight(.bold)) - .foregroundStyle(mainTint) - .lineLimit(1) - } - } else { - Text(mainTitle) - .font(.callout.weight(.heavy)) - .foregroundStyle(Color.tokenInk) - Text(mainSubtitle) - .font(.caption2.weight(.bold)) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .background( - GeometryReader { proxy in - Color.clear.preference( - key: TokenRankUserRowFrameKey.self, - value: proxy.frame(in: .named("tokenRankCard")) - ) - } - ) - - ribbonDivider - ribbonMetric( - label: L("全榜今日消耗"), - value: totalRankTokensText, - detail: appState.tokenRank.map { LFormat("%d 人参榜", $0.totalRankedUsers) } - ) - .frame(width: 154, alignment: .leading) - - ribbonDivider - ribbonMetric( - label: L("我的今日"), - value: currentUserEntry.map { TokenStepFormat.tokens($0.totalTokens, compact: true) } ?? L("等待同步"), - detail: currentUserEntry.map { LFormat("主力 %@", primaryClientName($0)) } - ) - .frame(width: 130, alignment: .leading) - - Image(systemName: "arrow.up.right") - .font(.system(size: 13, weight: .heavy)) - .foregroundStyle(Color.tokenInk.opacity(0.48)) - } - .padding(.horizontal, 16) - .padding(.vertical, 10) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .help(appState.agentWorkRankIdentity == nil ? L("打开榜单页") : L("打开个人页")) - } - - private var ribbonDivider: some View { - Rectangle() - .fill(Color.tokenDivider.opacity(0.74)) - .frame(width: 1, height: 38) - } - - private func ribbonMetric(label: String, value: String, detail: String?) -> some View { - VStack(alignment: .leading, spacing: 2) { - Text(label) - .font(.caption2.weight(.bold)) - .foregroundStyle(.secondary) - Text(value) - .font(.system(size: 16, weight: .heavy, design: .rounded)) - .foregroundStyle(Color.tokenInk) - .lineLimit(1) - .minimumScaleFactor(0.70) - .monospacedDigit() - if let detail { - Text(detail) - .font(.system(size: 9.5, weight: .bold)) - .foregroundStyle(Color.tokenInk.opacity(0.54)) - .lineLimit(1) - } - } - } - - private var header: some View { - HStack(spacing: 8) { - Circle() - .fill(Color.tokenGreen) - .frame(width: 8, height: 8) - Text(L("Agent 消耗榜")) - .font(.callout.weight(.heavy)) - .foregroundStyle(Color.tokenInk) - Spacer() - if appState.isRefreshingTokenRank { - ProgressView() - .controlSize(.small) - .scaleEffect(0.72) - } else if let fetchedAt = appState.tokenRank?.fetchedAt { - Text(headerStatus(fetchedAt)) - .font(.caption2.weight(.bold)) - .foregroundStyle(.secondary) - } - } - } - - private var userRankContent: some View { - HStack(alignment: .center, spacing: 12) { - Image(systemName: mainSymbol) - .font(.system(size: 18, weight: .heavy)) - .foregroundStyle(mainTint) - .frame(width: 38, height: 38) - .background(mainTint.opacity(0.16), in: Circle()) - - VStack(alignment: .leading, spacing: 3) { - if let entry = currentUserEntry { - Text(L("今日排名")) - .font(.caption.weight(.bold)) - .foregroundStyle(.secondary) - Text("#\(entry.rank)") - .font(.system(size: 28, weight: .heavy, design: .rounded)) - .foregroundStyle(Color.tokenInk) - .monospacedDigit() - } else { - Text(mainTitle) - .font(.title3.weight(.heavy)) - .foregroundStyle(Color.tokenInk) - .lineLimit(1) - .minimumScaleFactor(0.78) - } - Text(mainSubtitle) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .lineLimit(1) - if let rankContext { - Text(rankContext) - .font(.caption2.weight(.bold)) - .foregroundStyle(mainTint) - .lineLimit(1) - } - } - - Spacer(minLength: 0) - Image(systemName: "arrow.up.right") - .font(.system(size: 13, weight: .heavy)) - .foregroundStyle(Color.tokenInk.opacity(0.48)) - } - .contentShape(Rectangle()) - .background( - GeometryReader { proxy in - Color.clear.preference( - key: TokenRankUserRowFrameKey.self, - value: proxy.frame(in: .named("tokenRankCard")) - ) - } - ) - .help(appState.agentWorkRankIdentity == nil ? L("打开榜单页") : L("打开个人页")) - } - - @ViewBuilder - private var metaContent: some View { - if let error = appState.tokenRankError, appState.tokenRank == nil { - HStack(spacing: 8) { - Image(systemName: "wifi.exclamationmark") - .font(.system(size: 12, weight: .bold)) - .foregroundStyle(.secondary) - Text(error) - .font(.caption.weight(.bold)) - .foregroundStyle(.secondary) - .lineLimit(1) - Spacer(minLength: 0) - } - .padding(.horizontal, 12) - .padding(.vertical, 9) - .background(Color.tokenTrack.opacity(0.30), in: RoundedRectangle(cornerRadius: 13, style: .continuous)) - } else { - VStack(alignment: .leading, spacing: 8) { - VStack(alignment: .leading, spacing: 4) { - Text(L("全榜今日消耗")) - .font(.system(size: 10.5, weight: .heavy)) - .foregroundStyle(TokenStepThemeRuntime.isCinematic ? Color.tokenInk.opacity(0.62) : Color.white.opacity(0.62)) - Text(totalRankTokensText) - .font(.system(size: 22, weight: .heavy, design: .rounded)) - .foregroundStyle(TokenStepThemeRuntime.isCinematic ? Color.tokenInk : Color.white) - .monospacedDigit() - if let count = appState.tokenRank?.totalRankedUsers, count > 0 { - Text(LFormat("%d 人参榜", count)) - .font(.caption2.weight(.bold)) - .foregroundStyle(TokenStepThemeRuntime.isCinematic ? Color.tokenInk.opacity(0.62) : Color.white.opacity(0.62)) - } - } - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - TokenStepThemeRuntime.isCinematic - ? Color.tokenGreen.opacity(0.11) - : Color(red: 0.12, green: 0.18, blue: 0.14), - in: RoundedRectangle(cornerRadius: 12, style: .continuous) - ) - .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous).stroke(Color.tokenHairline)) - - if let entry = currentUserEntry { - TokenRankMetaPill(label: L("我的今日"), value: TokenStepFormat.tokens(entry.totalTokens, compact: true)) - } - - if let error = appState.tokenRankError { - Text(error) - .font(.caption2.weight(.bold)) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - } - } - - private var currentUserEntry: TokenRankEntry? { - guard let identity = appState.agentWorkRankIdentity else { return nil } - return appState.tokenRank?.entry(matching: identity.id) - } - - private var mainTitle: String { - if let entry = currentUserEntry { - return LFormat("今日排名 #%d", entry.rank) - } - if appState.agentWorkRankIdentity == nil { - return L("尚未关联") - } - if appState.tokenRank != nil { - return L("今日未上榜") - } - if appState.tokenRankError != nil { - return L("榜单暂不可用") - } - return L("Agent 消耗榜") - } - - private var mainSubtitle: String { - if let entry = currentUserEntry { - return "\(entry.name) · \(L("主力")) \(primaryClientName(entry))" - } - if let identity = appState.agentWorkRankIdentity { - return identity.name - } - return L("安装 Token Rank 后自动识别") - } - - private var mainSymbol: String { - if currentUserEntry != nil { return "trophy.fill" } - if appState.agentWorkRankIdentity == nil { return "person.crop.circle.badge.questionmark" } - if appState.tokenRank == nil, appState.tokenRankError != nil { return "exclamationmark.triangle.fill" } - return "list.number" - } - - private var mainTint: Color { - appState.tokenRank == nil && appState.tokenRankError != nil ? .secondary : .tokenGreen - } - - private var rankContext: String? { - guard let entry = currentUserEntry, - let leaderboard = appState.tokenRank, - leaderboard.totalRankedUsers > 0 else { return nil } - let percentile = max( - 0, - min(100, Int((Double(leaderboard.totalRankedUsers - entry.rank) / Double(leaderboard.totalRankedUsers) * 100).rounded())) - ) - return LFormat( - "第 %d / %d · 超过 %d%% 参榜用户", - entry.rank, - leaderboard.totalRankedUsers, - percentile - ) - } - - private var totalRankTokensText: String { - guard let leaderboard = appState.tokenRank else { return L("等待同步") } - return TokenStepFormat.tokens(leaderboard.totalTokens, compact: true) - } - - private func primaryClientName(_ entry: TokenRankEntry) -> String { - guard let client = entry.clients.max(by: { $0.value < $1.value })?.key else { - return L("全部工具") - } - return AgentSourceRegistry.displayName(for: client) - } - - private func relativeTime(_ date: Date) -> String { - let seconds = max(0, Int(Date().timeIntervalSince(date).rounded())) - if seconds < 60 { return L("刚刚") } - return LFormat("%d 分钟前", max(1, seconds / 60)) - } - - private func headerStatus(_ date: Date) -> String { - let time = relativeTime(date) - guard let count = appState.tokenRank?.totalRankedUsers, count > 0 else { return time } - return LFormat("%d 人 · %@", count, time) - } -} - -private struct TokenRankMetaPill: View { - var label: String - var value: String - - var body: some View { - HStack(spacing: 6) { - Text(label) - .font(.caption2.weight(.bold)) - .foregroundStyle(.secondary) - Text(value) - .font(.caption.weight(.heavy)) - .foregroundStyle(Color.tokenInk.opacity(0.78)) - .lineLimit(1) - .minimumScaleFactor(0.74) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .background(Color.tokenTrack.opacity(0.30), in: Capsule()) - } -} - -private struct TokenRankUserRowFrameKey: PreferenceKey { - static var defaultValue: CGRect = .zero - - static func reduce(value: inout CGRect, nextValue: () -> CGRect) { - value = nextValue() - } -} diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/ProviderMark.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/ProviderMark.swift new file mode 100644 index 0000000..510bd69 --- /dev/null +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/ProviderMark.swift @@ -0,0 +1,155 @@ +import SwiftUI + +/// Provider marks bundled with the app, drawn from their SVG path data so they +/// need no resource files. Used when the provider's desktop app is not +/// installed and so has no icon to borrow. +enum ProviderMark { + /// The Claude symbol, in a 100×100 view box. + static let claudePath = + "m19.6 66.5 19.7-11 .3-1-.3-.5h-1l-3.3-.2-11.2-.3L14 53l-9.5-.5-2.4-.5L0 49l.2-1.5 2-1.3 2.9.2 6." + + "3.5 9.5.6 6.9.4L38 49.1h1.6l.2-.7-.5-.4-.4-.4L29 41l-10.6-7-5.6-4.1-3-2-1.5-2-.6-4.2 2.7-3 3.7.3" + + ".9.2 3.7 2.9 8 6.1L37 36l1.5 1.2.6-.4.1-.3-.7-1.1L33 25l-6-10.4-2.7-4.3-.7-2.6c-.3-1-.4-2-.4-3l3" + + "-4.2L28 0l4.2.6L33.8 2l2.6 6 4.1 9.3L47 29.9l2 3.8 1 3.4.3 1h.7v-.5l.5-7.2 1-8.7 1-11.2.3-3.2 1." + + "6-3.8 3-2L61 2.6l2 2.9-.3 1.8-1.1 7.7L59 27.1l-1.5 8.2h.9l1-1.1 4.1-5.4 6.9-8.6 3-3.5L77 13l2.3-" + + "1.8h4.3l3.1 4.7-1.4 4.9-4.4 5.6-3.7 4.7-5.3 7.1-3.2 5.7.3.4h.7l12-2.6 6.4-1.1 7.6-1.3 3.5 1.6.4 " + + "1.6-1.4 3.4-8.2 2-9.6 2-14.3 3.3-.2.1.2.3 6.4.6 2.8.2h6.8l12.6 1 3.3 2 1.9 2.7-.3 2-5.1 2.6-6.8-" + + "1.6-16-3.8-5.4-1.3h-.8v.4l4.6 4.5 8.3 7.5L89 80.1l.5 2.4-1.3 2-1.4-.2-9.2-7-3.6-3-8-6.8h-.5v.7l1" + + ".8 2.7 9.8 14.7.5 4.5-.7 1.4-2.6 1-2.7-.6-5.8-8-6-9-4.7-8.2-.5.4-2.9 30.2-1.3 1.5-3 1.2-2.5-2-1." + + "4-3 1.4-6.2 1.6-8 1.3-6.4 1.2-7.9.7-2.6v-.2H49L43 72l-9 12.3-7.2 7.6-1.7.7-3-1.5.3-2.8L24 86l10-" + + "12.8 6-7.9 4-4.6-.1-.5h-.3L17.2 77.4l-4.7.6-2-2 .2-3 1-1 8-5.5Z" + static let claudeColor = Color(red: 217 / 255, green: 119 / 255, blue: 87 / 255) +} + +/// Fills an SVG path string scaled into the view's frame. Supports the +/// commands M, L, H, V, C, S, Z in absolute and relative form, which covers +/// the bundled marks. +struct SVGPathShape: Shape { + var pathData: String + var viewBox: CGSize = CGSize(width: 100, height: 100) + + func path(in rect: CGRect) -> Path { + let scale = min(rect.width / viewBox.width, rect.height / viewBox.height) + let offset = CGPoint( + x: rect.minX + (rect.width - viewBox.width * scale) / 2, + y: rect.minY + (rect.height - viewBox.height * scale) / 2 + ) + return Self.parse(pathData).applying( + CGAffineTransform(translationX: offset.x, y: offset.y).scaledBy(x: scale, y: scale) + ) + } + + static func parse(_ data: String) -> Path { + var path = Path() + var tokens = SVGPathTokens(data) + var current = CGPoint.zero + var start = CGPoint.zero + var lastControl: CGPoint? + var command: Character = "M" + + while let next = tokens.nextCommand(default: command) { + command = next + let relative = command.isLowercase + let base = relative ? current : .zero + func point() -> CGPoint? { + guard let x = tokens.nextNumber(), let y = tokens.nextNumber() else { return nil } + return CGPoint(x: base.x + x, y: base.y + y) + } + + switch command.uppercased() { + case "M": + guard let p = point() else { return path } + path.move(to: p) + current = p + start = p + // Extra pairs after a move are implicit line-tos. + command = relative ? "l" : "L" + lastControl = nil + case "L": + guard let p = point() else { return path } + path.addLine(to: p) + current = p + lastControl = nil + case "H": + guard let x = tokens.nextNumber() else { return path } + current = CGPoint(x: relative ? current.x + x : x, y: current.y) + path.addLine(to: current) + lastControl = nil + case "V": + guard let y = tokens.nextNumber() else { return path } + current = CGPoint(x: current.x, y: relative ? current.y + y : y) + path.addLine(to: current) + lastControl = nil + case "C": + guard let c1 = point(), let c2 = point(), let p = point() else { return path } + path.addCurve(to: p, control1: c1, control2: c2) + current = p + lastControl = c2 + case "S": + guard let c2 = point(), let p = point() else { return path } + let c1 = lastControl.map { CGPoint(x: 2 * current.x - $0.x, y: 2 * current.y - $0.y) } ?? current + path.addCurve(to: p, control1: c1, control2: c2) + current = p + lastControl = c2 + case "Z": + path.closeSubpath() + current = start + lastControl = nil + default: + return path + } + } + return path + } +} + +private struct SVGPathTokens { + private let scalars: [Character] + private var index = 0 + + init(_ data: String) { + scalars = Array(data) + } + + private mutating func skipSeparators() { + while index < scalars.count, scalars[index] == " " || scalars[index] == "," || scalars[index].isNewline { + index += 1 + } + } + + /// The next command letter, or `defaultCommand` repeated when numbers follow + /// without one. Nil at the end of the data. + mutating func nextCommand(default defaultCommand: Character) -> Character? { + skipSeparators() + guard index < scalars.count else { return nil } + let character = scalars[index] + if character.isLetter { + index += 1 + return character + } + return defaultCommand == "Z" || defaultCommand == "z" ? nil : defaultCommand + } + + mutating func nextNumber() -> CGFloat? { + skipSeparators() + let begin = index + if index < scalars.count, scalars[index] == "-" || scalars[index] == "+" { index += 1 } + var seenDot = false + var seenDigit = false + while index < scalars.count { + let character = scalars[index] + if character.isASCII, character.isNumber { + seenDigit = true + } else if character == ".", !seenDot { + seenDot = true + } else { + break + } + index += 1 + } + guard seenDigit, let value = Double(String(scalars[begin..(@ViewBuilder _ content: () -> Content) -> some View { + if TokenStepThemeRuntime.isCinematic { + content() + .padding(12) + .background { + if TokenStepThemeRuntime.isInterstellar { + InterstellarPanelBackground(opacity: 0.72, cornerRadius: 14) + } else { + OdysseyPopoverSectionBackground(opacity: 0.56, cornerRadius: 14) } - ) - } - HStack(spacing: 6) { - Circle() - .fill(appState.isRefreshing ? Color.secondary.opacity(0.68) : Color.tokenSuccess) - .frame(width: 7, height: 7) - Text(appState.isRefreshing ? L("同步中") : L("已同步")) - .font(.caption.weight(.heavy)) - .foregroundStyle(Color.tokenInk.opacity(0.72)) - } - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(Color.tokenSurface, in: Capsule()) - .overlay(Capsule().stroke(Color.tokenDivider)) - - if !isScreenshotRendering { - PopoverCaptureMenuButton( - shareTodayAction: { copyShareCard(.today) }, - shareYesterdayAction: { copyShareCard(.yesterday) }, - shareYesterdayRhythmAction: copyYesterdayRhythmCard, - downloadTodayAction: { downloadShareCard(.today) }, - downloadYesterdayRhythmAction: downloadYesterdayRhythmCard, - copyPopoverAction: copyPopoverScreenshot, - savePopoverAction: savePopoverScreenshot - ) - } - } - .padding(.horizontal, TokenStepThemeRuntime.isCinematic ? 26 : 16) - .frame(height: TokenStepThemeRuntime.isCinematic ? 72 : 48) - .background { - if TokenStepThemeRuntime.isCinematic { - LinearGradient( - colors: [Color.black.opacity(0.30), Color.tokenCanvas.opacity(0.12), Color.black.opacity(0.16)], - startPoint: .leading, - endPoint: .trailing - ) - } - } - .overlay(alignment: .bottom) { - if TokenStepThemeRuntime.isCinematic { - LinearGradient( - colors: [Color.tokenGreen.opacity(0.58), Color.tokenDivider.opacity(0.34), Color.clear], - startPoint: .leading, - endPoint: .trailing - ) - .frame(height: 1) - .padding(.horizontal, 22) - } - } - } - - private var cinematicContent: some View { - VStack(spacing: 10) { - HStack(alignment: .top, spacing: 10) { - PopoverTodayRingCard() - .frame(width: 250, height: odysseyTopCardHeight) - .background(cinematicSectionBackground(opacity: TokenStepThemeRuntime.isInterstellar ? 0.66 : 0.16)) - .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) - - centralUsageCard - .frame(minWidth: 330, maxWidth: .infinity, minHeight: odysseyTopCardHeight, maxHeight: odysseyTopCardHeight) - .background(cinematicSectionBackground(opacity: TokenStepThemeRuntime.isInterstellar ? 0.70 : 0.46)) - .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) - - if appState.showsQuotaColumn { - PopoverQuotaCard() - .frame(width: 260, height: odysseyTopCardHeight) - .background(cinematicSectionBackground(opacity: TokenStepThemeRuntime.isInterstellar ? 0.74 : 0.54)) - .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) } - } - - if appState.shouldShowAgentWorkRank, appState.agentWorkRankIdentity != nil { - PopoverTokenRankCard(layout: .ribbon) - .frame(height: 76) - .background(cinematicSectionBackground(opacity: TokenStepThemeRuntime.isInterstellar ? 0.76 : 0.58, cornerRadius: 16)) - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - } + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .padding(.horizontal, 10) + .padding(.top, 10) + } else { + content() + .padding(.horizontal, CompactPopoverStyle.horizontalPadding) + .padding(.top, 14) + .padding(.bottom, 12) } - .padding(.horizontal, 20) - .padding(.top, 12) } - private var classicColumns: some View { - HStack(alignment: .top, spacing: 0) { - PopoverTodayRingCard() - .frame(width: 188) - columnDivider - centralUsageCard - .frame(minWidth: 240, maxWidth: .infinity) - if appState.showsQuotaColumn { - columnDivider - PopoverQuotaCard() - .frame(width: quotaColumnWidth) - } - if appState.shouldShowAgentWorkRank, appState.agentWorkRankIdentity != nil { - columnDivider - PopoverTokenRankCard() - .frame(width: 196) - } - } - .frame(minHeight: columnsMinHeight) - .overlay(alignment: .top) { + @ViewBuilder + private var sectionDivider: some View { + if !TokenStepThemeRuntime.isCinematic { Rectangle() .fill(Color.tokenDivider) .frame(height: 1) + .padding(.horizontal, CompactPopoverStyle.horizontalPadding) } } - private var quotaColumnWidth: CGFloat { - appState.visibleQuotas.count >= 4 ? 248 : 208 + private var quotaUpdatedAt: Date? { + appState.visibleQuotas.compactMap(\.fetchedAt).max() } - private var odysseyTopCardHeight: CGFloat { 288 } - - @ViewBuilder - private func cinematicSectionBackground(opacity: Double, cornerRadius: CGFloat = 18) -> some View { - if TokenStepThemeRuntime.isInterstellar { - InterstellarPanelBackground(opacity: opacity, cornerRadius: cornerRadius) - } else { - OdysseyPopoverSectionBackground(opacity: opacity, cornerRadius: cornerRadius) + private var rankSection: CompactPopoverModel.RankSection? { + guard appState.shouldShowAgentWorkRank, + let identity = appState.agentWorkRankIdentity, + let board = appState.tokenRank + else { + return nil } + let now = Date() + let buckets = appState.snapshot.rhythm(for: appState.today.date)?.buckets ?? [] + return CompactPopoverModel.rankSection( + board: board, + myUserID: identity.id, + localTodayTokens: appState.today.totalTokens, + tokensPerHour: CompactPopoverModel.tokensInLastHour(buckets: buckets, now: now, calendar: TokenStepClock.calendar), + localTimeZoneIdentifier: TokenStepClock.identifier + ) } - private var showsModelUsageSection: Bool { - switch PopoverModelUsageRows.state(from: appState.today) { - case .hidden: return false - case .waiting, .rows: return true - } - } + private var footer: some View { + HStack(spacing: 4) { + Button { + appState.refreshNow() + } label: { + Group { + if appState.isRefreshing { + ProgressView().controlSize(.small) + } else { + Image(systemName: "arrow.clockwise") + .font(.system(size: 13, weight: .semibold)) + } + } + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(appState.isRefreshing) + .help(appState.isRefreshing ? L("同步中") : L("刷新")) + .accessibilityLabel(L("刷新")) - private var centralUsageCard: some View { - let voyage = TokenStepThemeRuntime.isCinematic - return VStack(spacing: 0) { - PopoverAgentWorkTable() - .frame(height: voyage ? (showsModelUsageSection ? 128 : odysseyTopCardHeight) : nil) + Button { + MainWindowPresenter.shared.show(appState: appState) + } label: { + Label(L("打开仪表盘"), systemImage: "rectangle.on.rectangle") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Color.tokenGreenDark) + .padding(.horizontal, 10) + .frame(height: 32) + .background(Color.tokenGreen.opacity(0.12), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + .buttonStyle(.plain) + + Spacer(minLength: 4) + + Text(appState.settings.refreshIntervalSeconds == 0 + ? L("手动刷新") + : LFormat("刷新 %@", TokenStepFormat.intervalLabel(appState.settings.refreshIntervalSeconds))) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(1) + + if appState.availableUpdate != nil || appState.isCheckingForUpdates { + let update = appState.updateActionVisualState + Button { + appState.showUpdateDetails() + } label: { + Image(systemName: update.symbol) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(update.tint) + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(update.isChecking) + .help(update.help) + .accessibilityLabel(update.accessibilityLabel) + } - if showsModelUsageSection { - Rectangle() - .fill(Color.tokenDivider) - .frame(height: 1) - .padding(.horizontal, voyage ? 14 : 12) + Button { + SettingsWindowPresenter.shared.show(appState: appState) + } label: { + Image(systemName: "gearshape") + .font(.system(size: 13, weight: .semibold)) + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(L("设置")) + .accessibilityLabel(L("设置")) - PopoverModelUsageSection(usage: appState.today) + if !isScreenshotRendering { + moreMenu } } - .frame(maxWidth: .infinity, maxHeight: voyage ? .infinity : nil, alignment: .top) - .contentShape(Rectangle()) - .onTapGesture { - MainWindowPresenter.shared.show(appState: appState, section: .today) + .foregroundStyle(Color.tokenInk.opacity(0.78)) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(TokenStepThemeRuntime.isCinematic ? Color.clear : Color.tokenCanvas.opacity(0.6)) + .overlay(alignment: .top) { + if !TokenStepThemeRuntime.isCinematic { + Rectangle().fill(Color.tokenDivider).frame(height: 1) + } } + .padding(.top, TokenStepThemeRuntime.isCinematic ? 6 : 0) } - private var columnsMinHeight: CGFloat { - appState.visibleQuotas.count >= 5 ? 300 : 248 - } - - private var columnDivider: some View { - Rectangle().fill(Color.tokenDivider) - .frame(width: 1) + private var moreMenu: some View { + Menu { + Section(L("分享")) { + Button(L("分享昨日节奏"), systemImage: "waveform.path.ecg") { copyYesterdayRhythmCard() } + Button(L("分享昨日成绩"), systemImage: "calendar.badge.clock") { copyShareCard(.yesterday) } + Button(L("分享今日卡片"), systemImage: "sun.max.fill") { copyShareCard(.today) } + Button(L("下载昨日节奏"), systemImage: "arrow.down.heart.fill") { downloadYesterdayRhythmCard() } + Button(L("下载今日卡片"), systemImage: "arrow.down.circle.fill") { downloadShareCard(.today) } + } + Section(L("截图")) { + Button(L("复制浮层截图"), systemImage: "doc.on.clipboard") { copyPopoverScreenshot() } + Button(L("保存浮层 PNG"), systemImage: "square.and.arrow.down") { savePopoverScreenshot() } + } + if TokenStepThemeRuntime.isInterstellar, InterstellarMotionLabConfiguration.isEnabled { + Picker(L("引力动效"), selection: $interstellarMotionMode) { + ForEach(InterstellarMotionMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + Button(L("触发坠落脉冲"), systemImage: "waveform") { interstellarManualPulseTrigger &+= 1 } + } + Divider() + Button(L("退出 TokenStep"), systemImage: "power") { NSApplication.shared.terminate(nil) } + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 13, weight: .semibold)) + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + } + .menuStyle(.button) + .menuIndicator(.hidden) + .buttonStyle(.plain) + .fixedSize() + .help(L("更多")) + .accessibilityLabel(L("更多:分享、截图、退出")) } @ViewBuilder @@ -400,75 +412,3 @@ struct PopoverPanelView: View { return (day, rhythm, previousDay(before: day)) } } - -private struct PopoverCaptureMenuButton: View { - var shareTodayAction: () -> Void - var shareYesterdayAction: () -> Void - var shareYesterdayRhythmAction: () -> Void - var downloadTodayAction: () -> Void - var downloadYesterdayRhythmAction: () -> Void - var copyPopoverAction: () -> Void - var savePopoverAction: () -> Void - - var body: some View { - Menu { - Button { - shareYesterdayRhythmAction() - } label: { - Label(L("分享昨日节奏"), systemImage: "waveform.path.ecg") - } - - Button { - shareYesterdayAction() - } label: { - Label(L("分享昨日成绩"), systemImage: "calendar.badge.clock") - } - - Button { - shareTodayAction() - } label: { - Label(L("分享今日卡片"), systemImage: "sun.max.fill") - } - - Divider() - - Button { - downloadYesterdayRhythmAction() - } label: { - Label(L("下载昨日节奏"), systemImage: "arrow.down.heart.fill") - } - - Button { - downloadTodayAction() - } label: { - Label(L("下载今日卡片"), systemImage: "arrow.down.circle.fill") - } - - Divider() - - Button { - copyPopoverAction() - } label: { - Label(L("复制浮层截图"), systemImage: "doc.on.clipboard") - } - - Button { - savePopoverAction() - } label: { - Label(L("保存浮层 PNG"), systemImage: "square.and.arrow.down") - } - } label: { - Image(systemName: "camera.fill") - .font(.system(size: 13, weight: .heavy)) - .foregroundStyle(Color.tokenInk.opacity(0.76)) - .frame(width: 30, height: 30) - .background(Color.tokenSurface, in: Circle()) - .overlay(Circle().stroke(Color.tokenHairline)) - .contentShape(Circle()) - } - .menuStyle(.button) - .buttonStyle(.plain) - .help(L("截图与分享")) - .accessibilityLabel(L("截图与分享")) - } -} diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/TokenIslandView.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/TokenIslandView.swift index 9249cd8..c0243a1 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/TokenIslandView.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/TokenIslandView.swift @@ -2,14 +2,18 @@ import AppKit import SwiftUI enum TokenIslandMetrics { - static let expandedCardSize = NSSize(width: 392, height: 326) - static let expandedCornerRadius: CGFloat = 28 + /// The expanded card shows the same content as the menu bar popover, so it + /// shares its width; the height follows the content. + static let expandedCardWidth = CompactPopoverStyle.width + static let expandedCornerRadius: CGFloat = 16 static let expandedShadowMargin: CGFloat = 26 + /// Used before the card reports its measured height. + static let initialCardHeight: CGFloat = 420 - static var expandedWindowSize: NSSize { + static func expandedWindowSize(cardHeight: CGFloat) -> NSSize { NSSize( - width: expandedCardSize.width + expandedShadowMargin * 2, - height: expandedCardSize.height + expandedShadowMargin * 2 + width: expandedCardWidth + expandedShadowMargin * 2, + height: cardHeight + expandedShadowMargin * 2 ) } } @@ -39,28 +43,42 @@ struct TokenIslandWindowView: View { } } +/// The card that opens from the island. It hosts the menu bar popover so both +/// entry points show the same compact column. struct TokenIslandPopoverWindowView: View { @EnvironmentObject private var appState: AppState var onHoverChanged: (Bool) -> Void + var onHeightChanged: (CGFloat) -> Void = { _ in } + + private var shape: RoundedRectangle { + RoundedRectangle(cornerRadius: TokenIslandMetrics.expandedCornerRadius, style: .continuous) + } var body: some View { - TokenIslandExpandedSurface { - TokenIslandExpandedView() - .environmentObject(appState) - } - .padding(TokenIslandMetrics.expandedShadowMargin) - .frame( - width: TokenIslandMetrics.expandedWindowSize.width, - height: TokenIslandMetrics.expandedWindowSize.height - ) + PopoverPanelView() + .environmentObject(appState) + .fixedSize(horizontal: false, vertical: true) + .clipShape(shape) + .overlay(shape.stroke(Color.tokenHairlineStrong)) + .contentShape(shape) + .shadow(color: Color.tokenShadow, radius: 22, x: 0, y: 12) + .background { + GeometryReader { proxy in + Color.clear + .onAppear { onHeightChanged(proxy.size.height) } + .onChange(of: proxy.size.height) { _, height in + onHeightChanged(height) + } + } + } .onHover { hovering in onHoverChanged(hovering) } - .transition(.opacity.combined(with: .scale(scale: 0.94, anchor: .topLeading))) + .padding(TokenIslandMetrics.expandedShadowMargin) + .frame(maxHeight: .infinity, alignment: .top) .environment(\.colorScheme, appState.settings.theme.colorScheme) .id(appState.appearanceID) - .animation(.spring(response: 0.26, dampingFraction: 0.88), value: appState.appearanceID) } } @@ -121,282 +139,3 @@ struct TokenIslandRingView: View { .id("\(theme.id)-\(language.resolved.id)") } } - -private struct TokenIslandExpandedView: View { - @EnvironmentObject private var appState: AppState - - private var lap: TokenStepLapProgress { appState.todayLap } - - var body: some View { - VStack(alignment: .leading, spacing: 13) { - header - - HStack(alignment: .center, spacing: 16) { - ring - VStack(alignment: .leading, spacing: 7) { - Text(lap.lapStatusText) - .font(.system(size: 20, weight: .heavy, design: .rounded)) - .foregroundStyle(lap.ringColor) - .monospacedDigit() - .lineLimit(1) - Text(TokenStepFormat.tokens(appState.today.totalTokens)) - .font(.system(size: 27, weight: .heavy, design: .rounded)) - .foregroundStyle(Color.tokenInk) - .monospacedDigit() - .lineLimit(1) - Text(lap.perLapGoalText) - .font(.caption.weight(.bold)) - .foregroundStyle(Color.tokenInk.opacity(0.55)) - .lineLimit(1) - } - Spacer(minLength: 0) - } - - TokenIslandToolSplitView(tools: appState.today.tools, total: appState.today.totalTokens) - - if appState.settings.showCodexQuota, appState.hasAnyQuota { - TokenIslandQuotaMiniView(quotas: appState.visibleQuotas.filter(\.isAvailable)) - } - - HStack(spacing: 8) { - TokenIslandActionButton(title: L("打开仪表盘"), symbol: "arrow.up.right") { - MainWindowPresenter.shared.show(appState: appState) - } - TokenIslandActionButton(title: L("刷新"), symbol: "arrow.clockwise") { - appState.refresh() - } - .disabled(appState.isRefreshing) - TokenIslandActionButton(title: L("设置"), symbol: "gearshape") { - SettingsWindowPresenter.shared.show(appState: appState) - } - } - } - .padding(.horizontal, 16) - .padding(.vertical, 14) - } - - private var header: some View { - HStack(spacing: 8) { - TokenStepBrandLockup(markSize: 28, titleSize: 12) - Spacer() - HStack(spacing: 5) { - Circle() - .fill(appState.isRefreshing ? Color.secondary.opacity(0.66) : Color.tokenSuccess) - .frame(width: 6, height: 6) - Text(appState.isRefreshing ? L("同步中") : L("已同步")) - .font(.caption2.weight(.heavy)) - .foregroundStyle(Color.tokenInk.opacity(0.70)) - } - .padding(.horizontal, 9) - .padding(.vertical, 6) - .background(Color.tokenSurface, in: Capsule()) - .overlay(Capsule().stroke(Color.tokenDivider)) - } - } - - private var ring: some View { - ZStack { - if TokenStepThemeRuntime.isVoyage { - VoyageBowProgressView(progress: lap.currentLapProgress, lineWidth: 9, color: lap.ringColor) - } else if TokenStepThemeRuntime.isInterstellar { - ProgressRingView(progress: lap.currentLapProgress, lineWidth: 9, color: lap.ringColor) - .overlay { - InterstellarEventHorizonEmblem() - .padding(18) - .opacity(0.48) - } - } else { - ProgressRingView(progress: lap.currentLapProgress, lineWidth: 9, color: lap.ringColor) - } - VStack(spacing: 2) { - Text(TokenStepFormat.tokens(appState.today.totalTokens, compact: true)) - .font(.system(size: 17, weight: .heavy, design: .rounded)) - .foregroundStyle(Color.tokenInk) - .minimumScaleFactor(0.62) - .lineLimit(1) - Text(lap.lapTitle) - .font(.caption2.weight(.bold)) - .foregroundStyle(Color.tokenInk.opacity(0.48)) - } - .frame(width: 66) - } - .frame(width: 86, height: 86) - } -} - -private struct TokenIslandExpandedSurface: View { - var content: Content - - private var shape: RoundedRectangle { - RoundedRectangle(cornerRadius: TokenIslandMetrics.expandedCornerRadius, style: .continuous) - } - - init(@ViewBuilder content: () -> Content) { - self.content = content() - } - - var body: some View { - content - .frame( - width: TokenIslandMetrics.expandedCardSize.width, - height: TokenIslandMetrics.expandedCardSize.height - ) - .background(TokenIslandExpandedBackground().clipShape(shape)) - .clipShape(shape) - .overlay { - ZStack { - shape.stroke(Color.tokenHairlineStrong) - if TokenStepThemeRuntime.isVoyage { - VoyageCardOrnament(cornerRadius: TokenIslandMetrics.expandedCornerRadius) - } else if TokenStepThemeRuntime.isInterstellar { - InterstellarCardOrnament(cornerRadius: TokenIslandMetrics.expandedCornerRadius) - } - } - } - .contentShape(shape) - .shadow(color: Color.tokenShadow, radius: 22, x: 0, y: 12) - } -} - -private struct TokenIslandExpandedBackground: View { - var body: some View { - ZStack { - Color.tokenSurface - LinearGradient( - colors: [ - Color.tokenGreen.opacity(0.11), - Color.tokenCanvas.opacity(0.35), - Color.tokenGreenDark.opacity(0.045) - ], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - if TokenStepThemeRuntime.isVoyage { - VoyageBackdropAtmosphere(role: .island) - } else if TokenStepThemeRuntime.isInterstellar { - InterstellarBackdrop(role: .island) - } - } - } -} - -private struct TokenIslandToolSplitView: View { - var tools: [String: Int] - var total: Int - - var body: some View { - VStack(spacing: 7) { - TokenIslandSplitRow(name: "Codex", tokens: tools["Codex"] ?? 0, total: total) - TokenIslandSplitRow(name: "Claude Code", tokens: tools["Claude Code"] ?? 0, total: total) - } - .padding(.horizontal, 10) - .padding(.vertical, 9) - .background(Color.tokenTrack.opacity(0.42), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) - } -} - -private struct TokenIslandSplitRow: View { - var name: String - var tokens: Int - var total: Int - - private var percent: Double { - guard total > 0 else { return 0 } - return min(max(Double(tokens) / Double(total), 0), 1) - } - - var body: some View { - HStack(spacing: 8) { - Text(name) - .font(.caption2.weight(.heavy)) - .foregroundStyle(Color.tokenInk.opacity(0.70)) - .frame(width: 70, alignment: .leading) - .lineLimit(1) - - GeometryReader { proxy in - ZStack(alignment: .leading) { - Capsule().fill(Color.tokenTrack.opacity(0.84)) - Capsule() - .fill(Color.tokenGreen) - .frame(width: max(tokens > 0 ? 4 : 0, proxy.size.width * percent)) - } - } - .frame(height: 5) - - Text(TokenStepFormat.tokens(tokens, compact: true)) - .font(.caption2.weight(.heavy)) - .foregroundStyle(Color.tokenInk.opacity(0.78)) - .monospacedDigit() - .frame(width: 48, alignment: .trailing) - } - } -} - -private struct TokenIslandQuotaMiniView: View { - var quotas: [ProviderQuota] - - var body: some View { - HStack(spacing: 10) { - Image(systemName: "hourglass.circle.fill") - .font(.system(size: 13, weight: .bold)) - .foregroundStyle(Color.tokenGreen) - ForEach(Array(quotas.prefix(3).enumerated()), id: \.element.id) { index, quota in - if index > 0 { - Divider().frame(height: 15).overlay(Color.tokenDivider) - } - quotaBlock(quota) - } - Spacer(minLength: 0) - } - .padding(.horizontal, 10) - .padding(.vertical, 8) - .background(Color.tokenTrack.opacity(0.38), in: RoundedRectangle(cornerRadius: 13, style: .continuous)) - } - - private func quotaBlock(_ quota: ProviderQuota) -> some View { - VStack(alignment: .leading, spacing: 3) { - Text(quota.provider.displayName) - .font(.system(size: 9, weight: .heavy, design: .rounded)) - .foregroundStyle(Color.tokenInk.opacity(0.48)) - .lineLimit(1) - HStack(spacing: 6) { - ForEach(quota.windows.prefix(2)) { window in - HStack(spacing: 3) { - Text(window.kind.shortTitle) - .font(.system(size: 9, weight: .heavy, design: .rounded)) - .foregroundStyle(Color.tokenInk.opacity(0.50)) - Text(TokenStepFormat.percent(window.remainingPercent)) - .font(.system(size: 9, weight: .heavy, design: .rounded)) - .foregroundStyle(Color.tokenGreen) - .monospacedDigit() - } - } - } - } - .lineLimit(1) - .minimumScaleFactor(0.78) - } -} - -private struct TokenIslandActionButton: View { - var title: String - var symbol: String - var action: () -> Void - - var body: some View { - Button(action: action) { - Label(title, systemImage: symbol) - .font(.caption2.weight(.heavy)) - .labelStyle(.titleAndIcon) - .lineLimit(1) - .minimumScaleFactor(0.82) - .foregroundStyle(Color.tokenInk.opacity(0.82)) - .frame(maxWidth: .infinity) - .frame(height: 28) - .background(Color.tokenSurface, in: Capsule()) - .overlay(Capsule().stroke(Color.tokenHairline)) - } - .buttonStyle(.plain) - .help(title) - } -} diff --git a/TokenStepSwift/Tests/Fixtures/CompactPopoverFixtureCheck.swift b/TokenStepSwift/Tests/Fixtures/CompactPopoverFixtureCheck.swift new file mode 100644 index 0000000..5acbc1a --- /dev/null +++ b/TokenStepSwift/Tests/Fixtures/CompactPopoverFixtureCheck.swift @@ -0,0 +1,255 @@ +import Darwin +import Foundation + +@main +struct CompactPopoverFixtureCheck { + static var calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "Asia/Shanghai")! + return calendar + }() + static let now = ISO8601DateFormatter().date(from: "2026-09-25T04:57:00Z")! + + static func main() { + do { + try checkOrderingAndFolding() + try checkUrgentProvidersNeverFold() + try checkSingleFoldedProviderStaysExpanded() + try checkPaceProjection() + try checkWindowLengths() + try checkLevels() + try checkTopModels() + try checkChaseNextRank() + try checkLeader() + try checkOutsideBoard() + try checkUnsyncedOnlyOnSameDay() + try checkLastHourRate() + print("Compact popover fixture checks passed") + } catch { + fputs("Compact popover fixture failed: \(error)\n", stderr) + exit(1) + } + } + + // MARK: Quotas + + private static func checkOrderingAndFolding() throws { + let section = CompactPopoverModel.quotaSection( + quotas: [ + quota(.grok, [(.monthlyCredits, 32, nil)]), + quota(.codex, [(.fiveHour, 67, hours(0.2)), (.sevenDay, 69, hours(34.5))]), + quota(.glm, [(.monthlyCredits, 24, nil)]), + quota(.kimi, [(.weekly, 12, nil)]), + quota(.claude, [(.fiveHour, 58, nil)]), + ProviderQuota.unavailable(.cursor) + ], + now: now, + calendar: calendar + ) + try expect(section.expanded.map(\.provider) == [.codex, .claude], "two worst providers expanded: \(section.expanded.map(\.provider))") + try expect(section.folded.map(\.provider) == [.grok, .glm, .kimi], "calm providers fold in order: \(section.folded.map(\.provider))") + } + + private static func checkUrgentProvidersNeverFold() throws { + let section = CompactPopoverModel.quotaSection( + quotas: [ + quota(.codex, [(.fiveHour, 95, nil)]), + quota(.claude, [(.fiveHour, 91, nil)]), + quota(.grok, [(.monthlyCredits, 75, nil)]), + quota(.glm, [(.monthlyCredits, 10, nil)]), + quota(.kimi, [(.weekly, 5, nil)]) + ], + now: now, + calendar: calendar + ) + try expect(section.expanded.map(\.provider) == [.codex, .claude, .grok], "a warning provider stays expanded past the limit") + try expect(section.worstLevel == .critical, "worst level is critical") + } + + private static func checkSingleFoldedProviderStaysExpanded() throws { + let section = CompactPopoverModel.quotaSection( + quotas: [ + quota(.codex, [(.fiveHour, 40, nil)]), + quota(.claude, [(.fiveHour, 30, nil)]), + quota(.grok, [(.monthlyCredits, 20, nil)]) + ], + now: now, + calendar: calendar + ) + try expect(section.expanded.count == 3 && section.folded.isEmpty, "one foldable provider is shown in full") + } + + private static func checkPaceProjection() throws { + // 7 days, resets in 34.5 hours: 79.5% of the window has passed. + let week = row(.sevenDay, used: 89, resetsIn: hours(34.5)) + try expect(abs((week.elapsedFraction ?? 0) - 0.7946) < 0.001, "7-day elapsed \(String(describing: week.elapsedFraction))") + try expect(week.runsOutEarly, "89% used at 79% elapsed runs out early") + let session = row(.fiveHour, used: 67, resetsIn: hours(11.0 / 60)) + try expect(!session.runsOutEarly, "67% used at 96% elapsed is on pace") + let fresh = row(.fiveHour, used: 8, resetsIn: hours(4.8)) + try expect(!fresh.runsOutEarly, "the first 10% of a window is never flagged") + let exhausted = row(.fiveHour, used: 100, resetsIn: hours(1)) + try expect(!exhausted.runsOutEarly, "an exhausted window is not a projection") + } + + private static func checkWindowLengths() throws { + try expect(row(.session, used: 50, resetsIn: hours(1)).elapsedFraction == nil, "unknown session length has no marker") + try expect(row(.cursorModels, used: 50, resetsIn: hours(1)).elapsedFraction == nil, "cursor model window has no marker") + try expect(row(.fiveHour, used: 50, resetsIn: nil).elapsedFraction == nil, "no reset time has no marker") + // Resets 2026-10-01 00:00 Shanghai; the window began 2026-09-01 00:00. + let reset = ISO8601DateFormatter().date(from: "2026-09-30T16:00:00Z")! + let monthly = CompactPopoverModel.windowRow( + QuotaWindow(kind: .monthlyCredits, usedPercent: 50, resetsAt: reset), + now: now, + calendar: calendar + ) + let expected = now.timeIntervalSince(ISO8601DateFormatter().date(from: "2026-08-31T16:00:00Z")!) / (30 * 24 * 3600) + try expect(abs((monthly.elapsedFraction ?? 0) - expected) < 0.0001, "monthly window uses the calendar month") + } + + private static func checkLevels() throws { + try expect(CompactPopoverModel.QuotaLevel(usedPercent: 69.9) == .normal, "69.9 is normal") + try expect(CompactPopoverModel.QuotaLevel(usedPercent: 70) == .warning, "70 is warning") + try expect(CompactPopoverModel.QuotaLevel(usedPercent: 89.9) == .warning, "89.9 is warning") + try expect(CompactPopoverModel.QuotaLevel(usedPercent: 90) == .critical, "90 is critical") + } + + // MARK: Tokens + + private static func checkTopModels() throws { + let usage = DailyUsage( + date: "2026-09-25", + tools: [:], + models: ["GLM-5.3": 24_050_000, "GLM-5.3-Flash": 4_350_000, "gpt-6-luna": 280_000, "b-model": 120_000, "a-model": 120_000, "idle": 0], + totalTokens: 28_920_000, + cost: 0 + ) + let rows = CompactPopoverModel.topModels(usage) + try expect(rows.map(\.name) == ["GLM-5.3", "GLM-5.3-Flash", "gpt-6-luna"], "top three by tokens: \(rows.map(\.name))") + try expect(rows.first?.share == 1 && abs(rows[1].share - 4_350_000.0 / 24_050_000) < 1e-9, "shares are relative to the largest") + let tie = CompactPopoverModel.topModels(usage, limit: 5).suffix(2).map(\.name) + try expect(tie == ["a-model", "b-model"], "ties break by name: \(tie)") + try expect(CompactPopoverModel.topModels(DailyUsage(date: "d", tools: [:], totalTokens: 0, cost: 0)).isEmpty, "no models, no rows") + } + + // MARK: Rank + + private static func checkChaseNextRank() throws { + let board = leaderboard(ranked: 88, limit: 100, [ + (1, 11, "A", 860_000_000), (2, 12, "B", 716_000_000), (3, 13, "C", 666_000_000), + (34, 20, "Next", 32_630_000), (35, 99, "Me", 28_510_000), (36, 21, "Below", 20_000_000) + ]) + let section = CompactPopoverModel.rankSection( + board: board, myUserID: 99, localTodayTokens: 28_800_000, + tokensPerHour: 9_800_000, localTimeZoneIdentifier: "America/Los_Angeles" + ) + try expect(section.myRank == 35 && section.myBoardTokens == 28_510_000, "own entry found") + guard case let .next(rank, name, gap, progress) = section.chase else { + throw FixtureError("expected a next-rank chase, got \(String(describing: section.chase))") + } + try expect(rank == 34 && name == "Next" && gap == 4_120_001, "gap to #34 is \(gap)") + try expect(abs(progress - 28_510_000.0 / 32_630_000) < 1e-9, "progress toward #34") + try expect(section.etaMinutes == 26, "ETA rounds up: \(String(describing: section.etaMinutes))") + try expect(section.podium.map(\.leadOverMe) == [831_490_000, 687_490_000, 637_490_000], "podium leads over me") + try expect(section.unsyncedTokens == nil && section.projectedRank == nil, "different days are not compared") + } + + private static func checkLeader() throws { + let board = leaderboard(ranked: 3, limit: 100, [(1, 99, "Me", 900), (2, 12, "B", 600), (3, 13, "C", 100)]) + let section = CompactPopoverModel.rankSection( + board: board, myUserID: 99, localTodayTokens: 900, tokensPerHour: 100, localTimeZoneIdentifier: "Asia/Shanghai" + ) + try expect(section.chase == .leading(lead: 300), "first place shows its lead") + try expect(section.etaMinutes == nil, "no ETA when leading") + try expect(section.podium.first?.isMe == true && section.podium.first?.leadOverMe == nil, "podium marks me") + } + + private static func checkOutsideBoard() throws { + let rows = (1...100).map { (rank: $0, id: $0, name: "U\($0)", tokens: 1_000_000_000 / $0) } + let board = leaderboard(ranked: 146, limit: 100, rows) + let shanghai = CompactPopoverModel.rankSection( + board: board, myUserID: 999, localTodayTokens: 3_100_000, tokensPerHour: 1_500_000, localTimeZoneIdentifier: "Asia/Shanghai" + ) + try expect(shanghai.myRank == nil, "not listed") + try expect(shanghai.chase == .enterBoard(rank: 100, tokens: 10_000_000, gap: 6_900_001), "gap to enter the board: \(String(describing: shanghai.chase))") + try expect(shanghai.etaMinutes == 277, "ETA to enter the board: \(String(describing: shanghai.etaMinutes))") + let elsewhere = CompactPopoverModel.rankSection( + board: board, myUserID: 999, localTodayTokens: 3_100_000, tokensPerHour: 1_500_000, localTimeZoneIdentifier: "Europe/Berlin" + ) + try expect(elsewhere.chase == .enterBoard(rank: 100, tokens: 10_000_000, gap: nil), "no gap from a different day") + try expect(elsewhere.etaMinutes == nil, "no ETA without a gap") + let small = leaderboard(ranked: 3, limit: 100, [(1, 1, "A", 3), (2, 2, "B", 2), (3, 3, "C", 1)]) + let absent = CompactPopoverModel.rankSection( + board: small, myUserID: 999, localTodayTokens: 0, tokensPerHour: 0, localTimeZoneIdentifier: "Asia/Shanghai" + ) + try expect(absent.chase == nil, "not on a board that lists everyone: no chase") + } + + private static func checkUnsyncedOnlyOnSameDay() throws { + let board = leaderboard(ranked: 5, limit: 100, [ + (1, 1, "A", 500), (2, 2, "B", 400), (3, 3, "C", 300), (4, 99, "Me", 200), (5, 5, "E", 100) + ]) + let section = CompactPopoverModel.rankSection( + board: board, myUserID: 99, localTodayTokens: 350, tokensPerHour: 0, localTimeZoneIdentifier: "Asia/Shanghai" + ) + try expect(section.unsyncedTokens == 150, "unsynced local tokens") + try expect(section.projectedRank == 3, "350 would place third: \(String(describing: section.projectedRank))") + let behind = CompactPopoverModel.rankSection( + board: board, myUserID: 99, localTodayTokens: 180, tokensPerHour: 0, localTimeZoneIdentifier: "Asia/Shanghai" + ) + try expect(behind.unsyncedTokens == nil && behind.projectedRank == nil, "local behind the board shows nothing") + } + + private static func checkLastHourRate() throws { + let buckets = [HourlyTokenBucket(hour: 11, tokens: 6_000_000), HourlyTokenBucket(hour: 12, tokens: 4_000_000)] + // 12:57 Shanghai: all of 12:00-12:57 plus the last 3 minutes of 11:00. + let rate = CompactPopoverModel.tokensInLastHour(buckets: buckets, now: now, calendar: calendar) + try expect(rate == 4_000_000 + 300_000, "last-hour rate \(rate)") + } + + // MARK: Helpers + + private static func hours(_ value: Double) -> TimeInterval { value * 3600 } + + private static func row(_ kind: QuotaWindowKind, used: Double, resetsIn: TimeInterval?) -> CompactPopoverModel.QuotaWindowRow { + CompactPopoverModel.windowRow( + QuotaWindow(kind: kind, usedPercent: used, resetsAt: resetsIn.map { now.addingTimeInterval($0) }), + now: now, + calendar: calendar + ) + } + + private static func quota(_ provider: QuotaProviderID, _ windows: [(QuotaWindowKind, Double, TimeInterval?)]) -> ProviderQuota { + ProviderQuota( + provider: provider, + windows: windows.map { QuotaWindow(kind: $0.0, usedPercent: $0.1, resetsAt: $0.2.map { now.addingTimeInterval($0) }) }, + status: .available, + fetchedAt: now, + message: nil + ) + } + + private static func leaderboard(ranked: Int, limit: Int, _ rows: [(rank: Int, id: Int, name: String, tokens: Int)]) -> TokenRankLeaderboard { + TokenRankLeaderboard( + fetchedAt: now, + range: "today", + client: "all", + usageMode: "all", + totalTokens: rows.map(\.tokens).reduce(0, +), + totalRankedUsers: ranked, + topLimit: limit, + entries: rows.map { + TokenRankEntry(rank: $0.rank, userID: $0.id, name: $0.name, avatarURL: nil, totalTokens: $0.tokens, callCount: 0, sessionCount: 0, clients: [:], models: [:]) + } + ) + } + + static func expect(_ condition: Bool, _ message: String) throws { + if !condition { throw FixtureError(message) } + } +} + +private struct FixtureError: Error, CustomStringConvertible { + var description: String + init(_ description: String) { self.description = description } +} diff --git a/TokenStepSwift/Tests/Fixtures/PopoverPanelRender.swift b/TokenStepSwift/Tests/Fixtures/PopoverPanelRender.swift index 24ed94b..0049f7f 100644 --- a/TokenStepSwift/Tests/Fixtures/PopoverPanelRender.swift +++ b/TokenStepSwift/Tests/Fixtures/PopoverPanelRender.swift @@ -29,7 +29,7 @@ struct PopoverPanelRender { let showsRank = ProcessInfo.processInfo.environment["TOKENSTEP_POPOVER_RANK"] != "hidden" let showsQuotas = ProcessInfo.processInfo.environment["TOKENSTEP_POPOVER_QUOTAS"] != "hidden" let updateState = ProcessInfo.processInfo.environment["TOKENSTEP_POPOVER_UPDATE_STATE"] ?? "idle" - let rank = fixtureRank() + let rank = ProcessInfo.processInfo.environment["TOKENSTEP_POPOVER_RANK"] == "chase" ? chaseRank() : fixtureRank() let update = fixtureUpdate() let appState = AppState() appState.installRenderFixture( @@ -41,7 +41,9 @@ struct PopoverPanelRender { showsQuotas: showsQuotas, language: language ), - quotas: showsQuotas ? fixtureQuotas() : [:], + quotas: showsQuotas + ? (ProcessInfo.processInfo.environment["TOKENSTEP_POPOVER_QUOTAS"] == "many" ? manyQuotas() : fixtureQuotas()) + : [:], tokenRank: showsRank ? rank.leaderboard : nil, agentWorkRankIdentity: showsRank ? rank.identity : nil, updateCheckPhase: fixtureUpdatePhase(updateState, update: update), @@ -73,7 +75,7 @@ struct PopoverPanelRender { throw NSError(domain: "PopoverPanelRender", code: 1) } try png.write(to: output, options: .atomic) - guard bitmap.pixelsWide == 1_800, bitmap.pixelsHigh >= 600 else { + guard bitmap.pixelsWide == Int(CompactPopoverStyle.width * 2), bitmap.pixelsHigh >= 300 else { throw NSError(domain: "PopoverPanelRender", code: 2, userInfo: [ NSLocalizedDescriptionKey: "Unexpected render size: \(bitmap.pixelsWide)x\(bitmap.pixelsHigh)" ]) @@ -230,7 +232,7 @@ struct PopoverPanelRender { requireVerifiedUpdates: true, tokenIslandEnabled: false, tokenIslandPlacement: .menuBar, - enabledQuotaProviders: showsQuotas ? [.codex, .claude, .cursor] : [], + enabledQuotaProviders: showsQuotas ? Set(QuotaProviderID.allCases) : [], cursorQuotaEnabled: showsQuotas, cursorCodeSignalEnabled: false, agentWorkRankVisibility: showsRank ? .visible : .hidden, @@ -275,6 +277,52 @@ struct PopoverPanelRender { ) } + /// Mid-board: the viewer at #35 chasing #34, with the podium ahead. + private static func chaseRank() -> (leaderboard: TokenRankLeaderboard, identity: AgentWorkRankIdentity) { + let identity = AgentWorkRankIdentity(id: 42, name: "Agent 黄叔", avatarURL: nil, lastSyncedAt: Date()) + let rows: [(Int, Int, String, Int)] = [ + (1, 1, "十七°", 860_255_773), (2, 2, "开水庆余年", 716_395_709), (3, 3, "柳间君", 666_412_269), + (34, 34, "ancson", 32_630_000), (35, 42, identity.name, 28_510_000), (36, 36, "Susan", 20_122_373) + ] + return ( + TokenRankLeaderboard( + fetchedAt: Date().addingTimeInterval(-2 * 60), + range: "today", + client: "all", + usageMode: "all", + totalTokens: 8_193_118_395, + totalRankedUsers: 88, + topLimit: 100, + entries: rows.map { + TokenRankEntry(rank: $0.0, userID: $0.1, name: $0.2, avatarURL: nil, totalTokens: $0.3, callCount: 0, sessionCount: 0, clients: [:], models: [:]) + } + ), + identity + ) + } + + /// Six providers: one critical, one running out early, the rest calm. + private static func manyQuotas() -> [QuotaProviderID: ProviderQuota] { + let now = Date() + func quota(_ provider: QuotaProviderID, _ windows: [QuotaWindow]) -> ProviderQuota { + ProviderQuota(provider: provider, windows: windows, status: .available, fetchedAt: now.addingTimeInterval(-7 * 60), message: nil) + } + return [ + .codex: quota(.codex, [ + QuotaWindow(kind: .fiveHour, usedPercent: 96, resetsAt: now.addingTimeInterval(2 * 3600)), + QuotaWindow(kind: .sevenDay, usedPercent: 89, resetsAt: now.addingTimeInterval(34.5 * 3600)) + ]), + .claude: quota(.claude, [ + QuotaWindow(kind: .fiveHour, usedPercent: 58, resetsAt: now.addingTimeInterval(3 * 3600)), + QuotaWindow(kind: .sevenDay, usedPercent: 71, resetsAt: now.addingTimeInterval(96 * 3600)) + ]), + .grok: quota(.grok, [QuotaWindow(kind: .monthlyCredits, usedPercent: 32, resetsAt: now.addingTimeInterval(9 * 86_400))]), + .glm: quota(.glm, [QuotaWindow(kind: .monthlyCredits, usedPercent: 24, resetsAt: now.addingTimeInterval(6 * 86_400))]), + .kimi: quota(.kimi, [QuotaWindow(kind: .weekly, usedPercent: 12, resetsAt: now.addingTimeInterval(5 * 86_400))]), + .cursor: quota(.cursor, [QuotaWindow(kind: .cursorModels, usedPercent: 45, resetsAt: now.addingTimeInterval(6 * 86_400))]) + ] + } + private static func fixtureUpdatePhase( _ state: String, update: AvailableUpdate diff --git a/TokenStepSwift/Tests/TokenStepSwiftTests/PopoverModelUsageRowsTests.swift b/TokenStepSwift/Tests/TokenStepSwiftTests/PopoverModelUsageRowsTests.swift deleted file mode 100644 index 43fbeec..0000000 --- a/TokenStepSwift/Tests/TokenStepSwiftTests/PopoverModelUsageRowsTests.swift +++ /dev/null @@ -1,93 +0,0 @@ -import XCTest -@testable import TokenStepSwift - -final class PopoverModelUsageRowsTests: XCTestCase { - func testOneToThreeModelsRemainIndividualAndSorted() throws { - let usage = makeUsage( - models: ["small": 100, "large": 600, "medium": 300], - totalTokens: 1_200 - ) - - let rows = try rows(from: usage) - - XCTAssertEqual(rows.map(\.kind), [.model("large"), .model("medium"), .model("small")]) - XCTAssertEqual(rows.map(\.tokens), [600, 300, 100]) - XCTAssertEqual(rows.map(\.percent), [50, 25, 100.0 / 12.0]) - } - - func testMoreThanThreeModelsUseTopThreeAndAggregateEveryRemainingToken() throws { - let usage = makeUsage( - models: ["a": 500, "b": 300, "c": 100, "d": 60, "e": 30, "f": 10], - totalTokens: 1_000 - ) - - let rows = try rows(from: usage) - - XCTAssertEqual(rows.map(\.kind), [.model("a"), .model("b"), .model("c"), .other]) - XCTAssertEqual(rows.map(\.tokens), [500, 300, 100, 100]) - XCTAssertEqual(rows.last?.percent, 10) - XCTAssertEqual(rows.count, PopoverModelUsageRows.maximumVisibleRows) - } - - func testOtherIncludesNegligibleRowsHiddenByDashboard() throws { - let usage = makeUsage( - models: ["primary": 9_990, "b": 2, "c": 2, "d": 2, "e": 2, "f": 2], - totalTokens: 10_000 - ) - - XCTAssertLessThan(TodayModelUsageRows.make(from: usage).count, usage.models.count) - let rows = try rows(from: usage) - XCTAssertEqual(rows.map(\.kind), [.model("primary"), .model("b"), .model("c"), .other]) - XCTAssertEqual(rows.last?.tokens, 6) - XCTAssertEqual(rows.reduce(0) { $0 + $1.tokens }, 10_000) - } - - func testZeroTotalHidesSectionAndMissingDetailsWait() { - XCTAssertEqual( - PopoverModelUsageRows.state(from: makeUsage(models: ["gpt": 10], totalTokens: 0)), - .hidden - ) - XCTAssertEqual( - PopoverModelUsageRows.state(from: makeUsage(models: [:], totalTokens: 100)), - .waiting - ) - } - - func testRealOtherModelDoesNotCollideWithAggregateIdentity() throws { - let usage = makeUsage( - models: ["other": 400, "a": 300, "b": 200, "c": 80, "d": 20], - totalTokens: 1_000 - ) - let rows = try rows(from: usage) - XCTAssertEqual(Set(rows.map(\.id)).count, rows.count) - XCTAssertTrue(rows.contains { $0.kind == .model("other") }) - XCTAssertTrue(rows.contains { $0.kind == .other }) - } - - func testMismatchFlagAndPercentClampArePreserved() throws { - let usage = makeUsage(models: ["unexpected": 150], totalTokens: 100) - guard case let .rows(rows, mismatch) = PopoverModelUsageRows.state(from: usage) else { - return XCTFail("Expected rows") - } - XCTAssertTrue(mismatch) - XCTAssertEqual(rows.first?.percent, 100) - } - - private func rows(from usage: DailyUsage) throws -> [PopoverModelUsageRow] { - guard case let .rows(rows, _) = PopoverModelUsageRows.state(from: usage) else { - throw NSError(domain: "PopoverModelUsageRowsTests", code: 1) - } - return rows - } - - private func makeUsage(models: [String: Int], totalTokens: Int) -> DailyUsage { - DailyUsage( - date: "2026-08-25", - tools: totalTokens > 0 ? ["Codex": totalTokens] : [:], - models: models, - modelCosts: [:], - totalTokens: totalTokens, - cost: 0 - ) - } -} diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 9ae9190..b3ea809 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -22,9 +22,9 @@ Token totals are always computed locally. The app makes only the requests listed | Update check | `api.github.com/repos/Backtthefuture/TokenStep/releases/latest`, falling back to `github.com/.../releases/latest` | Yes (Settings → Check for updates automatically) | At launch, periodically, when a window comes to the front, and when you click Check Updates | A `User-Agent` header with the app version. No usage data. | | Update download | `github.com/Backtthefuture/TokenStep/releases/download/...` | Only after you confirm an update | When you choose Install | Nothing beyond a standard download request. | | Token Rank board | `www.zhenganhuo.com/api/token-rank/leaderboard.php` | Automatic: only if a local Token Rank account exists in `~/.token-rank/client-state.json`; can be hidden in Settings | At launch, with background refreshes, and when a window comes to the front; at most every 30 minutes | A read-only request with the board filters (`client`, `range`, `usage_mode`). TokenStep uploads nothing. Uploading to the board is done by the separate Token Rank client, not by TokenStep. | -| Codex quota | Runs the local `codex app-server`, which talks to OpenAI with your Codex login | No | When quota display is on, at most every 15 minutes | Handled by the Codex CLI. TokenStep only reads the rate-limit response. | -| Claude Code quota | `api.anthropic.com/api/oauth/usage` | No | When quota display is on, at most every 15 minutes | Your Claude Code OAuth access token, read from the macOS Keychain item Claude Code created. | -| Cursor quota and usage events | `cursor.com/api/usage-summary`, `cursor.com/api/usage`, `cursor.com/api/dashboard/get-filtered-usage-events` (fallback `api2.cursor.sh`) | No | When Cursor is turned on, at most every 15 minutes | Your Cursor session token, read from Cursor's local `state.vscdb`. It is kept in memory only. | +| Codex quota | Runs the local `codex app-server`, which talks to OpenAI with your Codex login | No | When quota display is on: at most every 5 minutes while a TokenStep window is open, at most every 15 minutes in the background, and when you click Refresh | Handled by the Codex CLI. TokenStep only reads the rate-limit response. | +| Claude Code quota | `api.anthropic.com/api/oauth/usage` | No | When quota display is on: at most every 5 minutes while a TokenStep window is open, at most every 15 minutes in the background, and when you click Refresh | Your Claude Code OAuth access token, read from the macOS Keychain item Claude Code created. | +| Cursor quota and usage events | `cursor.com/api/usage-summary`, `cursor.com/api/usage`, `cursor.com/api/dashboard/get-filtered-usage-events` (fallback `api2.cursor.sh`) | No | When Cursor is turned on: at most every 5 minutes while a TokenStep window is open, at most every 15 minutes in the background, and when you click Refresh | Your Cursor session token, read from Cursor's local `state.vscdb`. It is kept in memory only. | | GLM quota | `open.bigmodel.cn` or `api.z.ai` usage endpoints | No | When GLM is turned on | The API key you entered. It is stored in the macOS Keychain. | | Kimi quota | `api.kimi.com` or `www.kimi.com` usage endpoints | No | When Kimi is turned on | The token from the local Kimi CLI login (`~/.kimi`), or the token you entered, which is stored in the macOS Keychain. | | Grok quota | `cli-chat-proxy.grok.com/v1/billing` | No | When Grok is turned on | The session from the local Grok CLI login (`~/.grok/auth.json`), or the token you entered, which is stored in the macOS Keychain. | diff --git a/docs/RELEASE_NOTES_0.2.16.md b/docs/RELEASE_NOTES_0.2.16.md new file mode 100644 index 0000000..028f0a3 --- /dev/null +++ b/docs/RELEASE_NOTES_0.2.16.md @@ -0,0 +1,12 @@ +# TokenStep 0.2.16 + +菜单栏面板改成一列 340pt 的紧凑面板,在 13 寸屏幕上不再被截掉;额度刷新更及时。 + +- **紧凑面板**:以前的面板宽 900pt,在 13 寸屏幕上左侧会被截掉。现在只放三块最常看的内容:额度、今日 Token、今日排名。分享、截图、退出收进「…」菜单;客户端明细和完整模型列表仍在仪表盘里。 +- **刘海 Mac 同步改版**:点刘海旁的 Token Island 打开的也是同一个新面板,高度跟随内容变化。 +- **额度更好懂**:每行额度的竖线表示这个窗口的时间已经过去多少;用量跑在时间前面时,会提示「照此速度会提前用完」,并标出是哪一行。鼠标停在某一行上可以看到具体数字。 +- **额度刷新更及时**:面板打开时最多每 5 分钟刷新一次额度(以前最长可能 25 分钟才更新),点刷新按钮或按 ⌘R 会立即刷新额度。偶尔查询失败时保留上一次读数,不再整行消失。后台刷新频率不变。 +- **Claude Code 图标**:只用 Claude Code CLI、没有安装 Claude 桌面 App 时,额度区也显示 Claude 的图标。 +- **排名**:今天还没上榜时显示「未上榜」,不再显示一条横线;「再跑多少超过下一名」的字号调小。 + +Token、金额与额度的统计口径不变。 diff --git a/script/build_swiftui_and_run.sh b/script/build_swiftui_and_run.sh index d9ad892..8b25522 100755 --- a/script/build_swiftui_and_run.sh +++ b/script/build_swiftui_and_run.sh @@ -23,7 +23,7 @@ HELPER_EXECUTABLE="$BUILD_DIR/$HELPER_NAME" ICON_FILE="$ROOT_DIR/TokenUsageMenuApp/assets/TokenStepIcon.icns" ODYSSEY_ASSET_DIR="$ROOT_DIR/TokenUsageMenuApp/assets/odyssey" INTERSTELLAR_ASSET_DIR="$ROOT_DIR/TokenUsageMenuApp/assets/interstellar" -VERSION="${TOKENSTEP_VERSION:-0.2.15}" +VERSION="${TOKENSTEP_VERSION:-0.2.16}" LAUNCH=true VERIFY=false diff --git a/script/test_all.sh b/script/test_all.sh index e6c4de1..52d64d8 100755 --- a/script/test_all.sh +++ b/script/test_all.sh @@ -18,6 +18,7 @@ FIXTURE_CHECKS=( test_time_zone_collector test_claude_incremental_collector test_settings_codable + test_compact_popover ) failed=() diff --git a/script/test_compact_popover.sh b/script/test_compact_popover.sh new file mode 100755 index 0000000..608cfc9 --- /dev/null +++ b/script/test_compact_popover.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Checks the compact popover's content rules: quota ordering and folding, pace +# projection, top models, and the rank chase. + +# Fixture expectations are written in this zone. +export TOKENSTEP_TIMEZONE="Asia/Shanghai" + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SWIFT_DIR="$ROOT_DIR/TokenStepSwift" +BUILD_DIR="/tmp/tokenstep-compact-popover-fixture-$UID-$$" +OVERLAY_DIR="$BUILD_DIR/vfs-overlay" +OVERLAY_FILE="$OVERLAY_DIR/overlay.yaml" +EMPTY_MODULEMAP="$OVERLAY_DIR/empty.modulemap" +EXECUTABLE="$BUILD_DIR/compact-popover-fixture-check" + +cleanup() { + rm -rf "$BUILD_DIR" +} +trap cleanup EXIT + +mkdir -p "$BUILD_DIR" "$OVERLAY_DIR" +cat > "$EMPTY_MODULEMAP" <<'EOF' +// Intentionally empty. +EOF +cat > "$OVERLAY_FILE" <