diff --git a/.github/workflows/offline-check.yml b/.github/workflows/offline-check.yml new file mode 100644 index 0000000..1dc0637 --- /dev/null +++ b/.github/workflows/offline-check.yml @@ -0,0 +1,16 @@ +name: offline-check + +on: + push: + pull_request: + workflow_dispatch: + +# Enforces the offline-by-default guarantee: no networking outside +# Sources/AgentMeter/Network/ (and none at all in AgentMeterCore). +jobs: + offline: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check no networking outside Network/ + run: ./Scripts/check-offline.sh diff --git a/README.md b/README.md index d4ada3d..dcc49fd 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,13 @@ brew install --cask TeaLance/tap/agentmeter ## 隱私 -只**讀取**本機 `~/.claude` 與 `~/.codex` 的檔案來統計用量,**不連網、不讀 Keychain、不傳送任何資料**。 +用量統計只讀取本機 `~/.claude` 與 `~/.codex` 的紀錄,**不讀 Keychain、不傳送任何資料給第三方**。 + +為了顯示登入帳號與真實額度,預設會做兩件事(都可在**設定 → 進階**關閉): +- **顯示登入帳號**:讀取本機登入憑證「檔案」(**不讀 Keychain**)解出 email/方案;不連網。 +- **Codex 即時額度**:用本機憑證的權杖連線 OpenAI 取得真實 5h/每週額度。 + +關閉以上兩項後即**完全離線**。網路碼僅限於 `Sources/AgentMeter/Network/`(有 CI `Scripts/check-offline.sh` 把關)。 ## 授權 diff --git a/Scripts/check-offline.sh b/Scripts/check-offline.sh new file mode 100755 index 0000000..4927966 --- /dev/null +++ b/Scripts/check-offline.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Verifiable "offline by default" guarantee: no networking outside Sources/AgentMeter/Network/. +# AgentMeterCore must never touch the network at all. +set -euo pipefail +cd "$(dirname "$0")/.." + +pattern='URLSession|NWConnection|NWBrowser|import Network|CFSocketRef|getaddrinfo' + +# Anything under Network/ is allowed; everything else (incl. all of AgentMeterCore) is not. +hits=$(grep -rnE "$pattern" Sources \ + | grep -v '/Network/' || true) + +if [[ -n "$hits" ]]; then + echo "❌ offline check FAILED — networking found outside Sources/AgentMeter/Network/:" + echo "$hits" + exit 1 +fi + +echo "✅ offline check passed — no networking outside Sources/AgentMeter/Network/" diff --git a/Sources/AgentMeter/AgentMeterApp.swift b/Sources/AgentMeter/AgentMeterApp.swift index d6ded1e..9b198f7 100644 --- a/Sources/AgentMeter/AgentMeterApp.swift +++ b/Sources/AgentMeter/AgentMeterApp.swift @@ -5,21 +5,20 @@ import AgentMeterCore @main struct AgentMeterApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate - @StateObject private var store = UsageStore() + @StateObject private var store = UsageStore.shared + @StateObject private var lang = LanguageStore.shared + @StateObject private var colors = ServiceColorStore.shared var body: some Scene { MenuBarExtra { MenuContentView() .environmentObject(store) + .environmentObject(lang) + .environmentObject(colors) } label: { MenuBarLabel(store: store) } .menuBarExtraStyle(.window) - - Settings { - SettingsView() - .environmentObject(store) - } } } @@ -28,14 +27,97 @@ struct AgentMeterApp: App { struct MenuBarLabel: View { @ObservedObject var store: UsageStore @AppStorage(SettingsKeys.menuBarMetrics) private var metricsCSV = defaultMenuBarMetricsCSV + // Re-render when these settings change (render reads them). + @AppStorage(SettingsKeys.meterShowsRemaining) private var showRemaining = false + @AppStorage(SettingsKeys.menuBarOrientation) private var orientation = "vertical" + @AppStorage(SettingsKeys.menuBarShowIcon) private var showIcon = true var body: some View { - let text = MenuBarMetric.barString(MenuBarMetric.list(fromCSV: metricsCSV), store: store) - if text.isEmpty { + let cells = MenuBarMetric.cells(MenuBarMetric.list(fromCSV: metricsCSV), store: store) + if cells.isEmpty { Image(systemName: "gauge.with.dots.needle.33percent") } else { - Text(text) + // SwiftUI multi-line labels get clipped to the menu-bar height, so draw + // the label ourselves into a template image the system scales to fit. + Image(nsImage: MenuBarLabel.render(cells, horizontal: orientation == "horizontal", showIcon: showIcon)) + } + } + + private enum Item { case icon(AgentTool); case cell(top: NSAttributedString, bot: NSAttributedString) } + + /// Render the metric cells as a template image. Each service group is preceded + /// by its logo; cells are stacked (vertical) or inline (horizontal). + static func render(_ cells: [(tool: AgentTool?, top: String, bottom: String)], horizontal: Bool, showIcon: Bool = true) -> NSImage { + // Horizontal: label and value share one size so the row is a single height. + // Vertical: a smaller label sits above the bold value. + let topFont: NSFont, botFont: NSFont + if horizontal { + topFont = NSFont.systemFont(ofSize: 13, weight: .regular) + botFont = NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .semibold) + } else { + topFont = NSFont.systemFont(ofSize: 8, weight: .regular) + botFont = NSFont.monospacedDigitSystemFont(ofSize: 10, weight: .semibold) + } + let cellGap: CGFloat = 8, iconGap: CGFloat = 4, inlineGap: CGFloat = 3 + let attrs: (NSFont) -> [NSAttributedString.Key: Any] = { [.font: $0, .foregroundColor: NSColor.black] } + + // Baseline-relative line metrics (descender is negative → flip to a magnitude). + let topAsc = topFont.ascender, topDesc = -topFont.descender + let botAsc = botFont.ascender, botDesc = -botFont.descender + let botLineH = botAsc + botDesc + let height = ceil(horizontal ? max(topAsc, botAsc) + max(topDesc, botDesc) + : botLineH + topAsc + topDesc) + // Logo matches the text height (horizontal) or spans both lines a bit smaller + // (vertical). Centered vertically in the cell. + let iconSide = round(height * (horizontal ? 0.92 : 0.62)) + let iconY = round((height - iconSide) / 2) + + // Lay out icons + cells with per-item leading gaps. + var items: [(item: Item, width: CGFloat, gap: CGFloat)] = [] + var lastTool: AgentTool? + for c in cells { + let topA = NSAttributedString(string: c.top, attributes: attrs(topFont)) + let botA = NSAttributedString(string: c.bottom, attributes: attrs(botFont)) + if showIcon, let t = c.tool, t != lastTool { + items.append((.icon(t), iconSide, items.isEmpty ? 0 : cellGap)) + lastTool = t + } + let precededByIcon = items.last.map { if case .icon = $0.item { return true } else { return false } } ?? false + let topW = topA.size().width + let lead = topW > 0 ? topW + inlineGap : 0 // drop the gap when there's no label + let w = horizontal ? ceil(lead + botA.size().width) + : ceil(max(topW, botA.size().width)) + items.append((.cell(top: topA, bot: botA), w, items.isEmpty ? 0 : (precededByIcon ? iconGap : cellGap))) + if c.tool == nil { lastTool = nil } + } + + let totalW = items.reduce(0) { $0 + $1.gap + $1.width } + let image = NSImage(size: NSSize(width: max(1, ceil(totalW)), height: max(1, height))) + image.lockFocus() + // Shared baseline (distance from image bottom) so label and value sit on one line. + let baseline = max(topDesc, botDesc) + var x: CGFloat = 0 + for entry in items { + x += entry.gap + switch entry.item { + case .icon(let tool): + MenuBarIcon.draw(tool, side: iconSide, origin: NSPoint(x: x, y: iconY)) + case .cell(let topA, let botA): + let topW = topA.size().width + if horizontal { + if topW > 0 { topA.draw(at: NSPoint(x: x, y: baseline - topDesc)) } + let valX = x + (topW > 0 ? topW + inlineGap : 0) + botA.draw(at: NSPoint(x: valX, y: baseline - botDesc)) + } else { + botA.draw(at: NSPoint(x: x + (entry.width - botA.size().width) / 2, y: 0)) + if topW > 0 { topA.draw(at: NSPoint(x: x + (entry.width - topW) / 2, y: botLineH)) } + } + } + x += entry.width } + image.unlockFocus() + image.isTemplate = true // adapt to light/dark menu bar automatically + return image } } @@ -44,12 +126,13 @@ struct MenuBarLabel: View { final class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) - // After the Settings window is dismissed (app loses focus), drop the Dock - // icon again so we stay a menu-bar-only app. + FloatingPanelController.shared.refresh() // restore the HUD if enabled + // When the app loses focus, drop back to a menu-bar-only app. Resetting the + // coordinator also clears any window entry whose close we couldn't hook. NotificationCenter.default.addObserver( forName: NSApplication.willResignActiveNotification, object: nil, queue: .main ) { _ in - NSApp.setActivationPolicy(.accessory) + MainActor.assumeIsolated { ActivationPolicyCoordinator.shared.reset() } } } } diff --git a/Sources/AgentMeter/AppSettings.swift b/Sources/AgentMeter/AppSettings.swift index c812d2d..279ff9d 100644 --- a/Sources/AgentMeter/AppSettings.swift +++ b/Sources/AgentMeter/AppSettings.swift @@ -6,15 +6,40 @@ enum SettingsKeys { static let menuBarMetrics = "menuBarMetrics" static let showClaude = "showClaude" static let showCodex = "showCodex" + /// Which quota the Claude panel shows as its hero number (`ClaudeHero`). + static let heroMetricClaude = "heroMetricClaude" + /// Meters show remaining (true) vs used (false) percentage. + static let meterShowsRemaining = "meterShowsRemaining" + /// Menu-bar cell layout: "vertical" (stacked) or "horizontal" (inline). + static let menuBarOrientation = "menuBarOrientation" + /// Whether each service group shows its agent logo in the menu bar. + static let menuBarShowIcon = "menuBarShowIcon" + // Floating desktop HUD. + static let floatingEnabled = "floatingEnabled" + static let floatingShowClaude = "floatingShowClaude" + static let floatingShowCodex = "floatingShowCodex" + static let floatingIdleOpacity = "floatingIdleOpacity" + // Opt-in (default OFF; each gated behind a confirmation). + static let netCodexQuota = "netCodexQuota" // networked Codex quota + static let showAccounts = "showAccounts" // read credential files to show account } +/// The Claude panel's primary "hero" metric. Default 5-hour; the panel's +/// `[5h|週]` toggle and Settings can switch it to weekly. +enum ClaudeHero: String { case fiveHour, weekly } + /// Default menu-bar selection: a single combined token figure (v1/v2 behaviour). let defaultMenuBarMetricsCSV = MenuBarMetric.combinedTokens.rawValue /// Refresh interval choices, in seconds. -let refreshIntervalOptions: [(label: String, seconds: Double)] = [ - ("15 秒", 15), - ("30 秒", 30), - ("1 分鐘", 60), - ("5 分鐘", 300), -] +let refreshIntervalSecondsOptions: [Double] = [15, 30, 60, 300] + +/// Localized label for a refresh interval (re-evaluated on language change). +func refreshIntervalLabel(_ seconds: Double) -> String { + switch seconds { + case 15: return tr("15 sec", "15 秒") + case 30: return tr("30 sec", "30 秒") + case 60: return tr("1 min", "1 分鐘") + default: return tr("5 min", "5 分鐘") + } +} diff --git a/Sources/AgentMeter/Appearance/ServiceColors.swift b/Sources/AgentMeter/Appearance/ServiceColors.swift new file mode 100644 index 0000000..c5b2239 --- /dev/null +++ b/Sources/AgentMeter/Appearance/ServiceColors.swift @@ -0,0 +1,57 @@ +import SwiftUI +import AppKit +import AgentMeterCore + +/// Per-service identity colour (used for the menu-bar icon, the swatch, the +/// floating ring frame, and the stats chart series). NEVER used for status — +/// status stays on the 4-state ramp in `DesignSystem`. +@MainActor +final class ServiceColorStore: ObservableObject { + static let shared = ServiceColorStore() + + enum Key { + static let claude = "serviceColorClaude" + static let codex = "serviceColorCodex" + } + /// Brand defaults (also offered as presets alongside a neutral mono option). + static let claudeBrand = "#D97757" + static let codexBrand = "#6C6C70" + static let mono = "#8A857A" + + @Published var claudeHex: String { didSet { persist(claudeHex, Key.claude) } } + @Published var codexHex: String { didSet { persist(codexHex, Key.codex) } } + + init() { + let d = UserDefaults.standard + claudeHex = d.string(forKey: Key.claude) ?? Self.claudeBrand + codexHex = d.string(forKey: Key.codex) ?? Self.codexBrand + } + + func color(for tool: AgentTool) -> Color { + switch tool { + case .claudeCode: return Color(amHex: claudeHex, fallback: Color(amHex: Self.claudeBrand)) + case .codex: return Color(amHex: codexHex, fallback: Color(amHex: Self.codexBrand)) + } + } + + func hex(for tool: AgentTool) -> String { + tool == .claudeCode ? claudeHex : codexHex + } + + func setHex(_ hex: String, for tool: AgentTool) { + if tool == .claudeCode { claudeHex = hex } else { codexHex = hex } + } + + private func persist(_ value: String, _ key: String) { + UserDefaults.standard.set(value, forKey: key) + } +} + +/// Convert a SwiftUI `Color` (often display-P3 from `ColorPicker`) to a stable +/// `#RRGGBB` string by pinning to sRGB first — otherwise the round-trip drifts. +func hexString(from color: Color) -> String { + let ns = NSColor(color).usingColorSpace(.sRGB) ?? .gray + return HexColor.string(r: Int((ns.redComponent * 255).rounded()), + g: Int((ns.greenComponent * 255).rounded()), + b: Int((ns.blueComponent * 255).rounded())) +} diff --git a/Sources/AgentMeter/Design/Components.swift b/Sources/AgentMeter/Design/Components.swift new file mode 100644 index 0000000..209f73e --- /dev/null +++ b/Sources/AgentMeter/Design/Components.swift @@ -0,0 +1,122 @@ +import SwiftUI +import AgentMeterCore + +/// 1px hairline separator (replaces card shadows for grouping). +struct Hairline: View { + var inset: CGFloat = 0 + var body: some View { + Rectangle().fill(AM.hairline).frame(height: 1).padding(.horizontal, inset) + } +} + +/// Small rounded-square swatch in a service's identity colour. +struct ServiceSwatch: View { + let color: Color + var size: CGFloat = 7 + var body: some View { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(color).frame(width: size, height: size) + } +} + +/// Thin status-coloured progress bar (no chunky filled track). +struct ThinBar: View { + let fraction: Double + let level: StatusLevel + var body: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(AM.track) + Capsule().fill(statusColor(level)) + .frame(width: max(0, min(1, fraction)) * geo.size.width) + } + } + .frame(height: 3) + } +} + +/// A single aligned metric line: label | thin bar | value (right, status-coloured). +struct MetricRow: View { + let label: String + let fraction: Double + let value: String + let level: StatusLevel + var body: some View { + HStack(spacing: AM.Space.m) { + Text(label) + .font(.system(size: 11)).foregroundStyle(AM.ink2) + .frame(width: 60, alignment: .leading) + ThinBar(fraction: fraction, level: level) + Text(value) + .font(.system(size: 12.5)).monospacedDigit() + .foregroundStyle(statusColor(level)) + .frame(minWidth: 84, alignment: .trailing) + } + } +} + +/// The big editorial hero number for a service's primary metric. +struct HeroNumber: View { + let percent: Double + let label: String + let level: StatusLevel + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text("\(Int(percent.rounded()))%") + .font(.system(size: 48, weight: .light)).monospacedDigit() + .tracking(-1) + .foregroundStyle(statusColor(level)) + Text(label).font(.system(size: 12)).foregroundStyle(AM.ink2) + } + } +} + +/// Thin-line progress ring: faint full track + a status-colored arc whose length +/// encodes the metric, with the percentage in the centre. Used by the floating HUD. +struct RingMeter: View { + let fraction: Double + let level: StatusLevel + let percentText: String + var size: CGFloat = 48 + var lineWidth: CGFloat = 3 + /// Track (unfilled) colour — the floating HUD tints it with the identity colour. + var trackColor: Color = AM.track + var body: some View { + ZStack { + Circle().stroke(trackColor, lineWidth: lineWidth) + Circle().trim(from: 0, to: max(0, min(1, fraction))) + .stroke(statusColor(level), style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .rotationEffect(.degrees(-90)) + Text(percentText) + .font(.system(size: size * 0.3, weight: .semibold)).monospacedDigit() + .foregroundStyle(statusColor(level)) + } + .frame(width: size, height: size) + } +} + +/// Tiny segmented toggle, e.g. [5h | 週], for choosing the hero metric. +struct SegmentedPair: View { + @Binding var rightSelected: Bool + let leftLabel: String + let rightLabel: String + var body: some View { + HStack(spacing: 0) { + seg(leftLabel, active: !rightSelected) { rightSelected = false } + seg(rightLabel, active: rightSelected) { rightSelected = true } + } + .clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 7, style: .continuous).stroke(AM.hairline, lineWidth: 1)) + } + + private func seg(_ text: String, active: Bool, _ action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(text) + .font(.system(size: 10.5)) + .padding(.horizontal, 8).padding(.vertical, 3) + .foregroundStyle(active ? AM.paper : AM.ink2) + .background(active ? AM.ink : Color.clear) + } + .buttonStyle(.plain) + } +} diff --git a/Sources/AgentMeter/Design/DesignSystem.swift b/Sources/AgentMeter/Design/DesignSystem.swift new file mode 100644 index 0000000..7cc41bd --- /dev/null +++ b/Sources/AgentMeter/Design/DesignSystem.swift @@ -0,0 +1,68 @@ +import SwiftUI +import AppKit +import AgentMeterCore + +/// Editorial-minimal design tokens. Deliberately NOT cc-bar's soft-card/gradient +/// look: paper background, ink text, hairline separators (no card shadows), +/// tabular numbers, thin bars. Identity colour (per service) and status colour +/// (by remaining quota) are kept strictly separate. +enum AM { + static let paper = Color(amLight: "#FAF9F6", dark: "#1B1A17") + static let ink = Color(amLight: "#1A1813", dark: "#ECEAE3") + static let ink2 = Color(amLight: "#76726A", dark: "#9A958A") + static let ink3 = Color(amLight: "#A8A395", dark: "#6F6B61") + static let hairline = Color(amLight: "#EBE7DE", dark: "#33302A") + static let track = Color(amLight: "#E7E3D9", dark: "#33302A") + + enum Space { + static let xs: CGFloat = 4 + static let s: CGFloat = 8 + static let m: CGFloat = 12 + static let l: CGFloat = 16 + static let xl: CGFloat = 20 + } +} + +/// 4-state status colour, legible on both paper (darker) and dark (brighter). +/// Single source of truth used by every bar / hero number. +func statusColor(_ level: StatusLevel) -> Color { + switch level { + case .normal: return Color(amLight: "#1F8A4C", dark: "#4EC56F") + case .warning: return Color(amLight: "#A9790F", dark: "#E6C34A") + case .low: return Color(amLight: "#C95E16", dark: "#FF8A3D") + case .empty: return Color(amLight: "#C9304A", dark: "#FF6B82") + } +} + +func statusColor(forUsed percent: Double) -> Color { + statusColor(.forUsed(percent: percent)) +} + +/// Plain money string — no `≈`, no `+`. `$X.XX` when priced; `—` when the cost is +/// entirely unknown (unpriced models), so we never show a misleading `$0.00`. +func moneyString(_ est: CostEstimate) -> String { + (!est.isComplete && est.amountUSD == 0) ? "—" : "$" + String(format: "%.2f", est.amountUSD) +} + +extension Color { + /// Adaptive sRGB colour from two `#RRGGBB` strings (light / dark appearance). + init(amLight light: String, dark: String) { + self.init(nsColor: NSColor(name: nil) { appearance in + let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + let c = HexColor.rgb(isDark ? dark : light) ?? (r: 0, g: 0, b: 0) + return NSColor(srgbRed: CGFloat(c.r) / 255, + green: CGFloat(c.g) / 255, + blue: CGFloat(c.b) / 255, + alpha: 1) + }) + } + + /// Solid sRGB colour from a single `#RRGGBB` string (used for identity colours). + init(amHex hex: String, fallback: Color = .secondary) { + guard let c = HexColor.rgb(hex) else { self = fallback; return } + self.init(.sRGB, + red: Double(c.r) / 255, + green: Double(c.g) / 255, + blue: Double(c.b) / 255) + } +} diff --git a/Sources/AgentMeter/Floating/FloatingHUDView.swift b/Sources/AgentMeter/Floating/FloatingHUDView.swift new file mode 100644 index 0000000..54358c7 --- /dev/null +++ b/Sources/AgentMeter/Floating/FloatingHUDView.swift @@ -0,0 +1,68 @@ +import SwiftUI +import AgentMeterCore + +/// The dual-ring desktop HUD content. Ring frame = identity colour, arc = status +/// colour, centre = the metric. Dims to the configured idle opacity unless hovered. +struct FloatingHUDView: View { + @EnvironmentObject private var store: UsageStore + @EnvironmentObject private var colors: ServiceColorStore + @AppStorage(SettingsKeys.floatingShowClaude) private var showClaude = true + @AppStorage(SettingsKeys.floatingShowCodex) private var showCodex = true + @AppStorage(SettingsKeys.floatingIdleOpacity) private var idleOpacity = 0.7 + @AppStorage(SettingsKeys.meterShowsRemaining) private var showRemaining = false + @State private var hovering = false + + struct Metric { let used: Double; let label: String + var fraction: Double { used / 100 } + var pct: String { "\(Int(used.rounded()))%" } + var level: StatusLevel { .forUsed(percent: used) } + } + + var body: some View { + HStack(spacing: 20) { + if showClaude, let m = claudeMetric { cell(.claudeCode, "Claude", m) } + if showCodex, let m = codexMetric { cell(.codex, "Codex", m) } + if visibleCount == 0 { + Text("AgentMeter").font(.system(size: 11)).foregroundStyle(AM.ink3) + } + } + .padding(.horizontal, 16).padding(.vertical, 12) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 13, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 13, style: .continuous).stroke(AM.hairline, lineWidth: 0.5)) + .foregroundStyle(AM.ink) + .opacity(hovering ? 1 : idleOpacity) + .animation(.easeOut(duration: 0.18), value: hovering) + .onHover { hovering = $0 } + .fixedSize() + } + + private func cell(_ tool: AgentTool, _ name: String, _ m: Metric) -> some View { + let shown = showRemaining ? max(0, 100 - m.used) : m.used + return VStack(spacing: 5) { + RingMeter(fraction: shown / 100, level: m.level, percentText: "\(Int(shown.rounded()))%", + trackColor: colors.color(for: tool).opacity(0.3)) + HStack(spacing: 4) { + ServiceSwatch(color: colors.color(for: tool), size: 6) + Text("\(name) · \(m.label)").font(.system(size: 9.5)).foregroundStyle(AM.ink2) + } + } + } + + private var visibleCount: Int { + (showClaude && claudeMetric != nil ? 1 : 0) + (showCodex && codexMetric != nil ? 1 : 0) + } + + private var claudeMetric: Metric? { + let q = store.claudeQuota + if let fh = q.fiveHour { return Metric(used: fh.usedPercent, label: "5h") } + if let cw = q.contextWindow ?? store.claude.contextWindow { + return Metric(used: q.contextPercent ?? cw.fraction * 100, label: "ctx") + } + return nil + } + + private var codexMetric: Metric? { + guard let cw = store.codex.contextWindow else { return nil } + return Metric(used: cw.fraction * 100, label: "ctx") + } +} diff --git a/Sources/AgentMeter/Floating/FloatingPanel.swift b/Sources/AgentMeter/Floating/FloatingPanel.swift new file mode 100644 index 0000000..a2202eb --- /dev/null +++ b/Sources/AgentMeter/Floating/FloatingPanel.swift @@ -0,0 +1,23 @@ +import AppKit + +/// Always-on-top, non-activating HUD panel. Draggable by its background; snaps to +/// the nearest screen edge on mouse-up when close enough. +final class FloatingPanel: NSPanel { + override var canBecomeKey: Bool { false } + override var canBecomeMain: Bool { false } + + override func mouseUp(with event: NSEvent) { + super.mouseUp(with: event) + snapToEdge() + } + + private func snapToEdge(threshold: CGFloat = 26, margin: CGFloat = 10) { + guard let area = (screen ?? NSScreen.main)?.visibleFrame else { return } + var f = frame + if f.minX - area.minX < threshold { f.origin.x = area.minX + margin } + else if area.maxX - f.maxX < threshold { f.origin.x = area.maxX - f.width - margin } + if area.maxY - f.maxY < threshold { f.origin.y = area.maxY - f.height - margin } + else if f.minY - area.minY < threshold { f.origin.y = area.minY + margin } + setFrame(f, display: true, animate: true) + } +} diff --git a/Sources/AgentMeter/Floating/FloatingPanelController.swift b/Sources/AgentMeter/Floating/FloatingPanelController.swift new file mode 100644 index 0000000..f775daa --- /dev/null +++ b/Sources/AgentMeter/Floating/FloatingPanelController.swift @@ -0,0 +1,54 @@ +import AppKit +import SwiftUI + +/// Shows/hides the floating HUD panel. Reads `floatingEnabled` from defaults so +/// both launch and the Settings toggle can call `refresh()`. +@MainActor +final class FloatingPanelController { + static let shared = FloatingPanelController() + private var panel: FloatingPanel? + + /// Apply the persisted `floatingEnabled` setting. + func refresh() { + UserDefaults.standard.bool(forKey: SettingsKeys.floatingEnabled) ? show() : hide() + } + + func setEnabled(_ enabled: Bool) { + UserDefaults.standard.set(enabled, forKey: SettingsKeys.floatingEnabled) + refresh() + } + + private func show() { + if panel == nil { panel = makePanel() } + panel?.orderFrontRegardless() // appears without activating the app + } + + private func hide() { panel?.orderOut(nil) } + + private func makePanel() -> FloatingPanel { + let root = FloatingHUDView() + .environmentObject(UsageStore.shared) + .environmentObject(ServiceColorStore.shared) + let hosting = NSHostingView(rootView: root) + + let panel = FloatingPanel( + contentRect: NSRect(x: 0, y: 0, width: 180, height: 80), + styleMask: [.nonactivatingPanel, .borderless], + backing: .buffered, defer: false) + panel.isFloatingPanel = true + panel.level = .floating + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + panel.hidesOnDeactivate = false + panel.isMovableByWindowBackground = true + panel.backgroundColor = .clear + panel.isOpaque = false + panel.hasShadow = true + panel.contentView = hosting + panel.setContentSize(hosting.fittingSize) + if let area = NSScreen.main?.visibleFrame { + panel.setFrameOrigin(NSPoint(x: area.maxX - panel.frame.width - 24, + y: area.maxY - panel.frame.height - 24)) + } + return panel + } +} diff --git a/Sources/AgentMeter/Localization/Localization.swift b/Sources/AgentMeter/Localization/Localization.swift new file mode 100644 index 0000000..c5894e6 --- /dev/null +++ b/Sources/AgentMeter/Localization/Localization.swift @@ -0,0 +1,45 @@ +import Foundation +import Combine + +/// UI language. Manual choice only (no follow-system), per product decision. +enum AppLanguage: String, CaseIterable, Identifiable { + case zh // 繁體中文 + case en // English + var id: String { rawValue } + var displayName: String { self == .zh ? "繁體中文" : "English" } +} + +/// Drives live language switching. Views observe this; toggling re-renders the +/// whole tree without a restart. Non-View code reads the static snapshot. +@MainActor +final class LanguageStore: ObservableObject { + static let shared = LanguageStore() + static let key = "appLanguage" + + @Published var language: AppLanguage { + didSet { + UserDefaults.standard.set(language.rawValue, forKey: Self.key) + _currentLanguage = language + } + } + + init() { + let stored = UserDefaults.standard.string(forKey: Self.key).flatMap(AppLanguage.init) + let lang = stored ?? .zh + self.language = lang + _currentLanguage = lang + } + + /// View-facing translate that participates in SwiftUI dependency tracking. + func tr(_ en: String, _ zh: String) -> String { language == .zh ? zh : en } +} + +/// Snapshot for non-View callers (formatters, `MenuBarMetric`). Written on the +/// main actor by `LanguageStore`; read anywhere. +var _currentLanguage: AppLanguage = .zh + +/// Global translate for non-View code. Views should prefer `languageStore.tr(...)` +/// so changes re-render immediately. +func tr(_ en: String, _ zh: String) -> String { + _currentLanguage == .zh ? zh : en +} diff --git a/Sources/AgentMeter/MenuBarIconPath.swift b/Sources/AgentMeter/MenuBarIconPath.swift new file mode 100644 index 0000000..281942a --- /dev/null +++ b/Sources/AgentMeter/MenuBarIconPath.swift @@ -0,0 +1,92 @@ +import AppKit +import AgentMeterCore + +/// Per-service logo for the menu bar, drawn as a monochrome template glyph. +/// Path data is from cc-bar (MIT) `Resources/Logos/{claude,codex}.svg` — each a +/// single `` with absolute commands (M/L/H/V/C/Z) in a 100×100 viewBox. +enum MenuBarIcon { + static func path(for tool: AgentTool) -> NSBezierPath { + parse(tool == .claudeCode ? claude : codex) + } + + /// Fill `tool`'s logo (template black) into a square of side `side` at `origin` + /// in the current (y-up) graphics context, flipping from the SVG's y-down space. + static func draw(_ tool: AgentTool, side: CGFloat, origin: NSPoint) { + let p = path(for: tool) + let s = side / 100 + // Map SVG (0..100, y-down) → (origin + scaled, y-up): y' = origin.y + (100 - y)*s. + let t = AffineTransform(m11: s, m12: 0, m21: 0, m22: -s, + tX: origin.x, tY: origin.y + 100 * s) + p.transform(using: t) + NSColor.black.setFill() // template image ignores colour; uses alpha as mask + p.fill() + } + + // MARK: - Minimal SVG path parser (absolute M/L/H/V/C/Z only) + + private static func parse(_ d: String) -> NSBezierPath { + let path = NSBezierPath() + let tokens = tokenize(d) + var i = 0 + func num() -> CGFloat { + while i < tokens.count, tokens[i].count == 1, tokens[i].first!.isLetter { return 0 } + guard i < tokens.count, let v = Double(tokens[i]) else { return 0 } + i += 1 + return CGFloat(v) + } + func point() -> NSPoint { NSPoint(x: num(), y: num()) } + + var cmd: Character = "M" + var cur = NSPoint.zero + var startPt = NSPoint.zero + while i < tokens.count { + if let c = tokens[i].first, tokens[i].count == 1, c.isLetter { + cmd = c + i += 1 + if cmd == "Z" || cmd == "z" { path.close(); cur = startPt; continue } + } + switch cmd { + case "M": + let p = point(); path.move(to: p); cur = p; startPt = p; cmd = "L" // extra pairs → lineto + case "L": + let p = point(); path.line(to: p); cur = p + case "H": + cur = NSPoint(x: num(), y: cur.y); path.line(to: cur) + case "V": + cur = NSPoint(x: cur.x, y: num()); path.line(to: cur) + case "C": + let c1 = point(), c2 = point(), e = point() + path.curve(to: e, controlPoint1: c1, controlPoint2: c2); cur = e + default: + i += 1 // unknown command — skip defensively + } + } + return path + } + + private static func tokenize(_ d: String) -> [String] { + var tokens: [String] = [] + var num = "" + func flush() { if !num.isEmpty { tokens.append(num); num = "" } } + for ch in d { + if ch.isLetter { + flush(); tokens.append(String(ch)) + } else if ch == "-" { + if !num.isEmpty, !(num.hasSuffix("e") || num.hasSuffix("E")) { flush() } + num.append(ch) + } else if ch == " " || ch == "," || ch == "\n" || ch == "\t" { + flush() + } else { + num.append(ch) // digit, '.', 'e', 'E', '+' + } + } + flush() + return tokens + } + + // MARK: - Logo path data (cc-bar, MIT) + + private static let claude = "M25.7146 63.2153L41.4393 54.3917L41.7025 53.6226L41.4393 53.1976H40.6705L38.0394 53.0359L29.054 52.7929L21.2624 52.4691L13.7134 52.0644L11.8111 51.6594L10.0303 49.3118L10.2123 48.138L11.8111 47.0657L14.0981 47.2681L19.1574 47.6119L26.7467 48.138L32.2516 48.4618L40.4073 49.3118H41.7025L41.8846 48.7857L41.4393 48.4618L41.0955 48.138L33.243 42.8155L24.7432 37.1894L20.2909 33.9513L17.8824 32.3119L16.6684 30.774L16.1422 27.4147L18.328 25.0062L21.2624 25.2088L22.0112 25.4112L24.9861 27.6979L31.3407 32.616L39.6381 38.7273L40.8525 39.7391L41.3381 39.395L41.399 39.1523L40.8525 38.2415L36.3394 30.0858L31.5227 21.7883L29.3775 18.3478L28.811 16.2837C28.6087 15.4334 28.4669 14.7252 28.4669 13.8549L30.9563 10.4753L32.3321 10.0303L35.6515 10.4756L37.0479 11.6897L39.112 16.4052L42.4513 23.8327L47.6321 33.9313L49.15 36.9265L49.9594 39.6991L50.2632 40.5491H50.7894V40.0632L51.2141 34.3766L52.0035 27.3944L52.7726 18.4087L53.0358 15.8793L54.2905 12.8435L56.7795 11.2041L58.7224 12.135L60.3212 14.422L60.0986 15.899L59.1474 22.0718L57.2857 31.7458L56.0713 38.2218H56.7795L57.5892 37.4121L60.8677 33.061L66.3723 26.18L68.801 23.448L71.6342 20.4325L73.4556 18.9957H76.8962L79.4255 22.7601L78.2926 26.6456L74.7509 31.1384L71.8163 34.943L67.607 40.6097L64.9758 45.1431L65.2188 45.5072L65.8464 45.4466L75.358 43.4228L80.4984 42.4917L86.6304 41.4393L89.4033 42.7346L89.7065 44.0502L88.6135 46.7419L82.0566 48.3607L74.3662 49.8989L62.9118 52.6109L62.77 52.7121L62.9321 52.9144L68.0925 53.4L70.2987 53.5214H75.7021L85.7601 54.2702L88.3912 56.0108L89.9697 58.1358L89.7065 59.7545L85.6589 61.8189L80.1949 60.5236L67.4452 57.4881L63.0735 56.3952H62.4665V56.7596L66.1093 60.3213L72.7877 66.3523L81.1461 74.1236L81.5707 76.0462L80.4984 77.5638L79.3649 77.4021L72.0186 71.8772L69.1854 69.3879L62.77 63.9844H62.3453V64.5509L63.8223 66.7164L71.6342 78.4544L72.0389 82.0567L71.4725 83.2308L69.4487 83.939L67.2222 83.534L62.6485 77.1189L57.9333 69.8937L54.1284 63.4177L53.6631 63.6809L51.4167 87.8651L50.3644 89.0995L47.9356 90.0303L45.9121 88.4924L44.8392 86.0031L45.9118 81.0852L47.2071 74.6701L48.2594 69.5699L49.2106 63.2356L49.7773 61.131L49.7367 60.9892L49.2715 61.0498L44.4954 67.607L37.23 77.4224L31.4825 83.5746L30.1063 84.1211L27.7181 82.8864L27.9408 80.6805L29.2763 78.7177L37.2297 68.5988L42.026 62.3248L45.1227 58.7025L45.1024 58.176H44.9204L23.7917 71.8975L20.0274 72.3831L18.4083 70.8655L18.6106 68.3761L19.3798 67.5664L25.7343 63.195L25.7146 63.2153Z" + + private static let codex = "M83.7733 42.8087C84.6678 40.1149 84.9771 37.2613 84.6807 34.4385C84.3843 31.6156 83.489 28.8885 82.0544 26.4394C77.6908 18.8436 68.9203 14.9365 60.3548 16.7725C57.9831 14.1344 54.9591 12.1668 51.5864 11.0673C48.2137 9.96772 44.611 9.77498 41.1402 10.5084C37.6694 11.2418 34.4527 12.8755 31.8132 15.2455C29.1736 17.6155 27.204 20.6383 26.1024 24.0103C23.3212 24.5806 20.6938 25.738 18.3958 27.405C16.0977 29.0721 14.1819 31.2104 12.7765 33.6772C8.36538 41.2609 9.3669 50.8267 15.2527 57.3327C14.3549 60.0251 14.0424 62.8782 14.3361 65.7012C14.6298 68.5241 15.523 71.2518 16.9558 73.7017C21.325 81.3002 30.1011 85.207 38.6712 83.3686C40.5554 85.4904 42.8707 87.1858 45.4623 88.3416C48.0539 89.4975 50.8622 90.0871 53.6999 90.0713C62.4793 90.079 70.2575 84.4114 72.9393 76.0515C75.7201 75.4802 78.347 74.3225 80.6449 72.6555C82.9427 70.9886 84.8587 68.8507 86.2649 66.3846C90.6227 58.8145 89.6172 49.3005 83.7733 42.8087ZM53.6999 84.8356C50.1955 84.8411 46.801 83.6129 44.1116 81.3661L44.5848 81.098L60.5123 71.9043C60.9087 71.6718 61.2379 71.3402 61.4674 70.942C61.6969 70.5439 61.8189 70.0929 61.8215 69.6333V47.1769L68.5553 51.072C68.6225 51.1063 68.6694 51.1707 68.6814 51.2456V69.854C68.6641 78.1208 61.9667 84.8183 53.6999 84.8356ZM21.4977 71.0843C19.7402 68.0497 19.1092 64.4925 19.7156 61.0386L20.1885 61.3225L36.1321 70.5165C36.5266 70.748 36.9757 70.87 37.4331 70.87C37.8905 70.87 38.3396 70.748 38.7341 70.5165L58.21 59.2883V67.0628C58.2081 67.1031 58.1973 67.1424 58.1782 67.1779C58.1591 67.2134 58.1322 67.2441 58.0996 67.2678L41.9671 76.5722C34.798 80.7022 25.6388 78.2463 21.4977 71.0843ZM17.3026 36.3898C19.0723 33.3357 21.8655 31.0062 25.1878 29.8138V48.7376C25.1818 49.1949 25.2986 49.6453 25.5261 50.042C25.7535 50.4387 26.0833 50.7671 26.4809 50.9928L45.8622 62.1739L39.1283 66.069C39.0919 66.0883 39.0513 66.0984 39.0101 66.0984C38.9689 66.0984 38.9283 66.0883 38.8919 66.069L22.7908 56.7809C15.6359 52.6337 13.1822 43.4816 17.3026 36.3112V36.3898ZM72.624 49.2426L53.1792 37.9512L59.8976 34.0718C59.9341 34.0524 59.9747 34.0423 60.016 34.0423C60.0573 34.0423 60.0979 34.0524 60.1344 34.0718L76.2355 43.3761C78.6973 44.7966 80.7043 46.8882 82.0221 49.4065C83.3398 51.9249 83.914 54.7661 83.6775 57.5985C83.4411 60.431 82.4038 63.1377 80.6867 65.4027C78.9696 67.6677 76.6436 69.3975 73.9803 70.3901V51.466C73.9663 51.0096 73.834 50.5647 73.5962 50.1749C73.3584 49.7851 73.0234 49.4638 72.624 49.2426ZM79.3261 39.1657L78.8529 38.8815L62.9411 29.6089C62.5442 29.376 62.0924 29.2532 61.6322 29.2532C61.172 29.2532 60.7202 29.376 60.3233 29.6089L40.8629 40.8374V33.0628C40.8587 33.0233 40.8654 32.9834 40.882 32.9473C40.8987 32.9113 40.9248 32.8803 40.9575 32.8579L57.0586 23.5692C59.5263 22.1476 62.3478 21.458 65.193 21.5811C68.0382 21.7042 70.7896 22.6348 73.1253 24.2642C75.461 25.8936 77.2845 28.1543 78.3825 30.782C79.4806 33.4097 79.8077 36.2957 79.3257 39.1025V39.1657H79.3261ZM37.1888 52.9484L30.455 49.069C30.4213 49.0487 30.3925 49.0212 30.3707 48.9884C30.3488 48.9557 30.3345 48.9186 30.3286 48.8797V30.3188C30.3323 27.4714 31.1466 24.6839 32.6761 22.2822C34.2057 19.8805 36.3874 17.9639 38.9661 16.7564C41.5448 15.549 44.4139 15.1005 47.2381 15.4636C50.0622 15.8267 52.7247 16.9862 54.9141 18.8067L54.4409 19.0748L38.5134 28.2686C38.117 28.5011 37.7879 28.8327 37.5584 29.2308C37.329 29.629 37.207 30.0799 37.2045 30.5395L37.1888 52.9487V52.9484ZM40.8472 45.0632L49.5209 40.0643L58.21 45.0635V55.0615L49.5523 60.0608L40.8632 55.0615L40.8472 45.0632Z" +} diff --git a/Sources/AgentMeter/MenuBarMetric.swift b/Sources/AgentMeter/MenuBarMetric.swift index 0edd9a2..0dac08c 100644 --- a/Sources/AgentMeter/MenuBarMetric.swift +++ b/Sources/AgentMeter/MenuBarMetric.swift @@ -10,6 +10,8 @@ enum MenuBarMetric: String, CaseIterable, Identifiable { case claudeContext case claudeMessages case codexTokens + case codexFiveHour + case codexWeekly case codexContext case codexMessages case combinedTokens @@ -26,8 +28,8 @@ enum MenuBarMetric: String, CaseIterable, Identifiable { case .claudeTokens, .codexTokens, .combinedTokens: return .token case .claudeContext, .codexContext: return .context case .claudeMessages, .codexMessages: return .messages - case .claudeFiveHour: return .fiveHour - case .claudeWeekly: return .weekly + case .claudeFiveHour, .codexFiveHour: return .fiveHour + case .claudeWeekly, .codexWeekly: return .weekly } } @@ -35,66 +37,70 @@ enum MenuBarMetric: String, CaseIterable, Identifiable { switch self { case .claudeTokens, .claudeFiveHour, .claudeWeekly, .claudeContext, .claudeMessages: return .claude - case .codexTokens, .codexContext, .codexMessages: + case .codexTokens, .codexFiveHour, .codexWeekly, .codexContext, .codexMessages: return .codex case .combinedTokens: return .combined } } - /// Label shown in the Settings multi-select list. - var settingsTitle: String { - switch self { - case .claudeTokens: return "Claude · 今日 tokens" - case .claudeFiveHour: return "Claude · 5h 額度 %" - case .claudeWeekly: return "Claude · 週額度 %" - case .claudeContext: return "Claude · Context %" - case .claudeMessages: return "Claude · 訊息數" - case .codexTokens: return "Codex · 今日 tokens" - case .codexContext: return "Codex · Context %" - case .codexMessages: return "Codex · 訊息數" - case .combinedTokens: return "合計 · 今日 tokens" + /// The service whose logo precedes this cell in the menu bar (nil for combined). + var agentTool: AgentTool? { + switch tool { + case .claude: return .claudeCode + case .codex: return .codex + case .combined: return nil } } - /// Short tool prefix used only when the same kind spans both tools. - private var toolPrefix: String { - switch tool { - case .claude: return "CC" - case .codex: return "CX" - case .combined: return "Σ" + /// Label shown in the Settings multi-select list. + var settingsTitle: String { + switch self { + case .claudeTokens: return tr("Claude · today tokens", "Claude · 今日 tokens") + case .claudeFiveHour: return tr("Claude · 5h limit %", "Claude · 5h 額度 %") + case .claudeWeekly: return tr("Claude · weekly %", "Claude · 週額度 %") + case .claudeContext: return tr("Claude · context %", "Claude · Context %") + case .claudeMessages: return tr("Claude · messages", "Claude · 訊息數") + case .codexTokens: return tr("Codex · today tokens", "Codex · 今日 tokens") + case .codexFiveHour: return tr("Codex · 5h limit %", "Codex · 5h 額度 %") + case .codexWeekly: return tr("Codex · weekly %", "Codex · 週額度 %") + case .codexContext: return tr("Codex · context %", "Codex · Context %") + case .codexMessages: return tr("Codex · messages", "Codex · 訊息數") + case .combinedTokens: return tr("Combined · today tokens", "合計 · 今日 tokens") } } - /// The metric's display value, or nil when there's no data to show. + /// A menu-bar cell rendered as two stacked lines: a short label on top and the + /// value below (Stats-app style). `label` is the kind tag ("5h"/"ctx"/…) or + /// empty for token counts (where the tool tag is the top line). @MainActor - func value(_ store: UsageStore) -> String? { + func parts(_ store: UsageStore) -> (label: String, value: String)? { + // Honor the used/remaining display setting for percentage metrics. + let remaining = UserDefaults.standard.bool(forKey: SettingsKeys.meterShowsRemaining) + func pct(_ used: Double) -> String { "\(Int((remaining ? max(0, 100 - used) : used).rounded()))%" } + switch self { - case .claudeTokens: - return tokenString(store.claude) - case .codexTokens: - return tokenString(store.codex) + case .claudeTokens: return tokenString(store.claude).map { ("", $0) } + case .codexTokens: return tokenString(store.codex).map { ("", $0) } case .combinedTokens: - let total = store.combinedTodayBillable - return total > 0 ? total.compactTokenString : nil - case .claudeFiveHour: - return store.claudeQuota.fiveHour.map { "5h \(percent($0.usedPercent))%" } - case .claudeWeekly: - return store.claudeQuota.weekly.map { "7d \(percent($0.usedPercent))%" } + let total = store.combinedTodayTotal + return total > 0 ? ("", total.compactTokenString) : nil + case .claudeFiveHour: return store.claudeQuota.fiveHour.map { ("5h", pct($0.usedPercent)) } + case .claudeWeekly: return store.claudeQuota.weekly.map { ("7d", pct($0.usedPercent)) } + case .codexFiveHour: return store.codexFiveHour.map { ("5h", pct($0.usedPercent)) } + case .codexWeekly: return store.codexWeekly.map { ("7d", pct($0.usedPercent)) } case .claudeContext: let cw = store.claudeQuota.contextWindow ?? store.claude.contextWindow - return cw.map { "ctx \(Int(($0.fraction * 100).rounded()))%" } + return cw.map { ("ctx", pct($0.fraction * 100)) } case .codexContext: - return store.codex.contextWindow.map { "ctx \(Int(($0.fraction * 100).rounded()))%" } - case .claudeMessages: - return messageString(store.claude) - case .codexMessages: - return messageString(store.codex) + return store.codex.contextWindow.map { ("ctx", pct($0.fraction * 100)) } + case .claudeMessages: return messageString(store.claude).map { ("msg", $0) } + case .codexMessages: return messageString(store.codex).map { ("msg", $0) } } } private func tokenString(_ usage: ToolUsage) -> String? { - let total = usage.today.billableTotal + let total = usage.today.total return (usage.available && total > 0) ? total.compactTokenString : nil } @@ -102,25 +108,21 @@ enum MenuBarMetric: String, CaseIterable, Identifiable { (usage.available && usage.messageCount > 0) ? "\(usage.messageCount)" : nil } - private func percent(_ p: Double) -> Int { Int(p.rounded()) } - - // MARK: - Bar rendering + // MARK: - Cell rendering - /// Build the inline menu-bar string for the selected metrics (data-less ones hidden). + /// Build (tool, top, bottom) cells for the selected metrics; data-less ones are + /// hidden. The per-service logo identifies Claude vs Codex, so no text prefix is + /// added; token cells (which have no kind label) carry "Σ" only for the combined + /// total, which has no logo of its own. @MainActor - static func barString(_ selected: [MenuBarMetric], store: UsageStore) -> String { - // A kind needs tool prefixes only when more than one tool's metric of that - // kind is selected (e.g. Claude tokens AND Codex tokens). - var toolsByKind: [Kind: Set] = [:] - for m in selected { toolsByKind[m.kind, default: []].insert(m.tool) } - - var parts: [String] = [] - for metric in selected { - guard let value = metric.value(store) else { continue } - let needsPrefix = (toolsByKind[metric.kind]?.count ?? 0) > 1 - parts.append(needsPrefix ? "\(metric.toolPrefix) \(value)" : value) + static func cells(_ selected: [MenuBarMetric], store: UsageStore) -> [(tool: AgentTool?, top: String, bottom: String)] { + var out: [(AgentTool?, String, String)] = [] + for m in selected { + guard let pv = m.parts(store) else { continue } + let top = pv.label.isEmpty ? (m.tool == .combined ? "Σ" : "") : pv.label + out.append((m.agentTool, top, pv.value)) } - return parts.joined(separator: " ") + return out } // MARK: - Persistence diff --git a/Sources/AgentMeter/MenuContentView.swift b/Sources/AgentMeter/MenuContentView.swift index fdd99fd..2e547b0 100644 --- a/Sources/AgentMeter/MenuContentView.swift +++ b/Sources/AgentMeter/MenuContentView.swift @@ -2,159 +2,249 @@ import SwiftUI import AppKit import AgentMeterCore +/// The dropdown panel: editorial-minimal. Per service, a big "hero" number +/// (Claude defaults to 5-hour, switchable to weekly; Codex shows context% since +/// quota needs the network), aligned mini-rows for the other metrics, and a +/// footer of today's tokens + messages. Hairlines, no cards, tabular numbers. struct MenuContentView: View { @EnvironmentObject private var store: UsageStore + @EnvironmentObject private var lang: LanguageStore + @EnvironmentObject private var colors: ServiceColorStore @AppStorage(SettingsKeys.showClaude) private var showClaude = true @AppStorage(SettingsKeys.showCodex) private var showCodex = true - @Environment(\.openSettings) private var openSettings + @AppStorage(SettingsKeys.heroMetricClaude) private var claudeHeroRaw = ClaudeHero.fiveHour.rawValue + @AppStorage(SettingsKeys.meterShowsRemaining) private var showRemaining = false + + private var claudeHero: ClaudeHero { ClaudeHero(rawValue: claudeHeroRaw) ?? .fiveHour } + + /// Displayed percentage given the used %, honoring the remaining-vs-used setting. + private func disp(_ used: Double) -> Double { showRemaining ? max(0, 100 - used) : used } + private func dispFrac(_ used: Double) -> Double { disp(used) / 100 } var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Text("AgentMeter").font(.headline) - Spacer() - if store.isRefreshing { ProgressView().controlSize(.small) } + VStack(alignment: .leading, spacing: AM.Space.m) { + header + Hairline() + if showClaude { claudeBlock } + if showCodex { + if showClaude { Hairline() } + codexBlock } + } + .padding(AM.Space.l) + .frame(width: 312) + .background(AM.paper) + .foregroundStyle(AM.ink) + } - if showClaude { claudeSection } - if showCodex { - if showClaude { Divider() } - codexSection + // MARK: Header + + private var header: some View { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 1) { + Text(lang.tr("USAGE", "用量")) + .font(.system(size: 11, weight: .semibold)).tracking(0.5) + .foregroundStyle(AM.ink2) + Text(updatedText).font(.system(size: 11)).foregroundStyle(AM.ink3) + } + Spacer() + HStack(spacing: 2) { + if store.isRefreshing { ProgressView().controlSize(.small).scaleEffect(0.7) } + iconButton("arrow.clockwise") { store.refreshNow() } + iconButton("chart.bar") { StatsWindowController.shared.show(tab: .stats) } + iconButton("gearshape") { StatsWindowController.shared.show(tab: .general) } + iconButton("power") { NSApplication.shared.terminate(nil) } } + } + } - Divider() - footer + private func iconButton(_ name: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: name).font(.system(size: 12)) + .foregroundStyle(AM.ink2) + .frame(width: 24, height: 20) } - .padding(14) - .frame(width: 330) + .buttonStyle(.plain) } // MARK: Claude - private var claudeSection: some View { - VStack(alignment: .leading, spacing: 8) { - sectionHeader("Claude Code", available: store.claude.available) - + private var claudeBlock: some View { + VStack(alignment: .leading, spacing: AM.Space.s) { + serviceHeader(name: "Claude Code", tool: .claudeCode, available: store.claude.available, + plan: store.claudeAccount?.plan) if store.claude.available { - // Prefer the authoritative context window from the bridge; fall back - // to the transcript-derived one. - if let cw = store.claudeQuota.contextWindow ?? store.claude.contextWindow { - MeterBar(title: "Context window", - valueText: contextValue(cw, percent: store.claudeQuota.contextPercent), - fraction: cw.fraction) - } + claudeBody + footer(store.claude) + } else { + noData + } + } + } - if store.claudeQuota.available, store.claudeQuota.fiveHour != nil || store.claudeQuota.weekly != nil { - if let fh = store.claudeQuota.fiveHour { - MeterBar(title: "5-hour limit", valueText: quotaValue(fh), fraction: fh.usedPercent / 100) + @ViewBuilder private var claudeBody: some View { + let q = store.claudeQuota + let cw = q.contextWindow ?? store.claude.contextWindow + let ctxPct = q.contextPercent ?? cw.map { $0.fraction * 100 } + let hasQuota = q.available && (q.fiveHour != nil || q.weekly != nil) + + VStack(alignment: .leading, spacing: AM.Space.m) { + if hasQuota { + let useWeekly = (claudeHero == .weekly && q.weekly != nil) || q.fiveHour == nil + if let hero = useWeekly ? q.weekly : q.fiveHour { + HStack(alignment: .top) { + heroFromQuota(hero, label: useWeekly ? lang.tr("weekly", "每週額度") + : lang.tr("5-hour", "5 小時額度")) + Spacer() + SegmentedPair( + rightSelected: Binding( + get: { claudeHero == .weekly }, + set: { claudeHeroRaw = ($0 ? ClaudeHero.weekly : .fiveHour).rawValue }), + leftLabel: "5h", rightLabel: lang.tr("Wk", "週")) } - if let wk = store.claudeQuota.weekly { - MeterBar(title: "Weekly · all models", valueText: quotaValue(wk), fraction: wk.usedPercent / 100) - } - } else { - Button { - openSettingsWindow() - } label: { - Label("啟用即時額度顯示 5h/每週 %", systemImage: "bolt.horizontal.circle") - .font(.caption) - } - .buttonStyle(.link) + ThinBar(fraction: dispFrac(hero.usedPercent), level: .forUsed(percent: hero.usedPercent)) } - - secondaryMetrics(store.claude) + if let cw, let ctxPct { + MetricRow(label: lang.tr("Context", "Context"), fraction: dispFrac(ctxPct), + value: contextMini(cw, ctxPct), level: .forUsed(percent: ctxPct)) + } + if let other = useWeekly ? q.fiveHour : q.weekly { + MetricRow(label: useWeekly ? lang.tr("5-hour", "5 小時") : lang.tr("weekly", "每週"), + fraction: dispFrac(other.usedPercent), value: quotaMini(other), + level: .forUsed(percent: other.usedPercent)) + } + } else if let cw, let ctxPct { + heroFromPercent(ctxPct, label: lang.tr("context", "Context")) + ThinBar(fraction: dispFrac(ctxPct), level: .forUsed(percent: ctxPct)) + enableQuotaLink } else { - Text("未偵測到使用資料").font(.caption).foregroundStyle(.secondary) + enableQuotaLink } } } + private var enableQuotaLink: some View { + Button { StatsWindowController.shared.show(tab: .general) } label: { + Text(lang.tr("Enable live 5h / weekly quota", "啟用即時 5h/每週額度")) + .font(.system(size: 11)) + } + .buttonStyle(.link) + } + // MARK: Codex - private var codexSection: some View { - VStack(alignment: .leading, spacing: 8) { - sectionHeader("Codex", available: store.codex.available) + private var codexBlock: some View { + VStack(alignment: .leading, spacing: AM.Space.s) { + serviceHeader(name: "Codex", tool: .codex, available: store.codex.available, + plan: store.codexAccount?.plan) if store.codex.available { - if let cw = store.codex.contextWindow { - MeterBar(title: "Context window", valueText: contextValue(cw), fraction: cw.fraction) - } - secondaryMetrics(store.codex) + codexBody + footer(store.codex) } else { - Text("未偵測到使用資料").font(.caption).foregroundStyle(.secondary) + noData + } + } + } + + @ViewBuilder private var codexBody: some View { + let cw = store.codex.contextWindow + let ctxPct = cw.map { $0.fraction * 100 } + VStack(alignment: .leading, spacing: AM.Space.m) { + if let fh = store.codexFiveHour { + // Real networked 5-hour quota. + heroFromQuota(fh, label: lang.tr("5-hour", "5 小時額度")) + ThinBar(fraction: dispFrac(fh.usedPercent), level: .forUsed(percent: fh.usedPercent)) + if let cw, let ctxPct { + MetricRow(label: lang.tr("Context", "Context"), fraction: dispFrac(ctxPct), + value: contextMini(cw, ctxPct), level: .forUsed(percent: ctxPct)) + } + if let wk = store.codexWeekly { + MetricRow(label: lang.tr("weekly", "每週"), fraction: dispFrac(wk.usedPercent), + value: quotaMini(wk), level: .forUsed(percent: wk.usedPercent)) + } + } else if let cw, let ctxPct { + heroFromPercent(ctxPct, label: lang.tr("context", "Context")) + ThinBar(fraction: dispFrac(ctxPct), level: .forUsed(percent: ctxPct)) + if !NetworkFeature.codexQuota.isEnabled { + Button { StatsWindowController.shared.show(tab: .general) } label: { + Text(lang.tr("Enable live quota (needs internet)", "啟用即時額度(需連網)")) + .font(.system(size: 11)) + } + .buttonStyle(.link) + } } } } - // MARK: Pieces + // MARK: Shared pieces - private func sectionHeader(_ name: String, available: Bool) -> some View { - HStack { - Text(name).font(.subheadline).bold() + private func serviceHeader(name: String, tool: AgentTool, available: Bool, plan: String?) -> some View { + HStack(spacing: AM.Space.s) { + ServiceSwatch(color: colors.color(for: tool)) + Text(name).font(.system(size: 13.5, weight: .semibold)) + if let plan, !plan.isEmpty { + Text(plan.capitalized).font(.system(size: 10.5)).foregroundStyle(AM.ink3) + } Spacer() Circle() - .fill(available ? Color.green : Color.secondary.opacity(0.4)) + .fill(available ? Color(amLight: "#27A35A", dark: "#34C759") : AM.ink3.opacity(0.6)) .frame(width: 7, height: 7) } } - private func secondaryMetrics(_ usage: ToolUsage) -> some View { - HStack(spacing: 18) { - metric("今日 tokens", usage.today.billableTotal.compactTokenString) - metric("訊息", "\(usage.messageCount)") - metric("近 5h(估計)", usage.rolling5h.billableTotal.compactTokenString) - } + private func heroFromQuota(_ w: QuotaWindow, label: String) -> some View { + var full = label + if let r = shortReset(until: w.resetsAt) { full += " · " + lang.tr("resets \(r)", "\(r) 重置") } + return HeroNumber(percent: disp(w.usedPercent), label: full, level: .forUsed(percent: w.usedPercent)) } - private func metric(_ label: String, _ value: String) -> some View { - VStack(alignment: .leading, spacing: 1) { - Text(label).font(.caption2).foregroundStyle(.secondary) - Text(value).font(.callout).bold() - } + /// `usedPct` is the used percentage; display honors the remaining/used setting. + private func heroFromPercent(_ usedPct: Double, label: String) -> some View { + HeroNumber(percent: disp(usedPct), label: label, level: .forUsed(percent: usedPct)) } - private var footer: some View { - VStack(alignment: .leading, spacing: 8) { - Text(lastUpdatedText).font(.caption).foregroundStyle(.secondary) - HStack { - Button("立即更新") { store.refreshNow() } + private func footer(_ u: ToolUsage) -> some View { + VStack(alignment: .leading, spacing: AM.Space.s) { + Hairline() + HStack(spacing: AM.Space.l) { + footerItem(lang.tr("Today", "今日"), "\(u.today.total.compactTokenString) tokens") + footerItem(lang.tr("Messages", "訊息"), "\(u.messageCount)") + if !u.todayByModel.isEmpty { + footerItem("", moneyString(costEstimate(byModel: u.todayByModel))) + } Spacer() - Button("設定…") { openSettingsWindow() } - Button("結束") { NSApplication.shared.terminate(nil) } } } } + private func footerItem(_ label: String, _ value: String) -> Text { + let prefix = label.isEmpty ? Text("") : Text(label + " ").foregroundColor(AM.ink2) + return (prefix + Text(value).foregroundColor(AM.ink).bold()) + .font(.system(size: 11.5).monospacedDigit()) + } + + private var noData: some View { + Text(lang.tr("No usage detected", "未偵測到使用資料")) + .font(.system(size: 11)).foregroundStyle(AM.ink2) + } + // MARK: Formatting - private func contextValue(_ cw: ContextWindow, percent: Double? = nil) -> String { - let pct = Int((percent ?? cw.fraction * 100).rounded()) - return "\(cw.used.compactTokenString) / \(cw.total.compactTokenString) (\(pct)%)" + private func contextMini(_ cw: ContextWindow, _ usedPct: Double) -> String { + "\(Int(disp(usedPct).rounded()))% · \(cw.used.compactTokenString)" } - private func quotaValue(_ w: QuotaWindow) -> String { - let pct = "\(Int(w.usedPercent.rounded()))%" - if let reset = shortReset(until: w.resetsAt) { - return "\(pct) · resets \(reset)" - } + private func quotaMini(_ w: QuotaWindow) -> String { + let pct = "\(Int(disp(w.usedPercent).rounded()))%" + if let r = shortReset(until: w.resetsAt) { return "\(pct) · \(r)" } return pct } - private var lastUpdatedText: String { - guard let date = store.lastRefresh else { return "尚未更新" } - let f = DateFormatter() - f.dateFormat = "HH:mm:ss" - var text = "最後更新:\(f.string(from: date))" - if let asOf = store.claudeQuota.asOf { - text += " · 額度 as of \(f.string(from: asOf))" - } - return text + private var updatedText: String { + guard let d = store.lastRefresh else { return lang.tr("not updated yet", "尚未更新") } + let s = max(0, Int(Date().timeIntervalSince(d))) + let ago = s < 60 ? "\(s)s" : "\(s / 60)m" + return lang.tr("updated \(ago) ago", "\(ago)前已更新") } - private func openSettingsWindow() { - // Becoming a regular app makes the Settings window reliably show and focus - // from a menu-bar-only (.accessory) app. AppDelegate reverts to .accessory - // once the window is dismissed. - NSApp.setActivationPolicy(.regular) - NSApp.activate(ignoringOtherApps: true) - openSettings() - } } diff --git a/Sources/AgentMeter/Network/CodexQuotaClient.swift b/Sources/AgentMeter/Network/CodexQuotaClient.swift new file mode 100644 index 0000000..7c66a20 --- /dev/null +++ b/Sources/AgentMeter/Network/CodexQuotaClient.swift @@ -0,0 +1,62 @@ +import Foundation +import AgentMeterCore + +/// Fetches Codex's real 5-hour / weekly quota. Gated behind the `codexQuota` +/// opt-in — refuses to run unless the user enabled it. +/// +/// Endpoint + auth are from the cc-bar reference (MIT): `GET wham/usage` with a +/// Bearer access token (refreshed on 401). The response field names aren't +/// publicly documented, so parsing is defensive and falls back to `.unavailable` +/// rather than guessing. +struct CodexQuotaClient { + enum Result: Equatable { + case disabled // not opted in + case unavailable // no credentials / network or parse failure + case ok(fiveHour: QuotaWindow, weekly: QuotaWindow?) + } + + private let usageURL = URL(string: "https://chatgpt.com/backend-api/wham/usage")! + + func fetch(reader: CredentialReader = CredentialReader()) async -> Result { + guard NetworkFeature.codexQuota.isEnabled else { return .disabled } + guard let creds = reader.codex(), let token = creds.accessToken else { return .unavailable } + + if let result = await request(token: token, accountId: creds.accountId) { return result } + // 401 / failure → try one refresh, then retry once. + if let refresh = creds.refreshToken, + let fresh = await CodexTokenRefresher().refresh(refreshToken: refresh), + let retried = await request(token: fresh, accountId: creds.accountId) { + return retried + } + return .unavailable + } + + private func request(token: String, accountId: String?) async -> Result? { + var req = URLRequest(url: usageURL) + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + req.setValue("application/json", forHTTPHeaderField: "Accept") + if let accountId { req.setValue(accountId, forHTTPHeaderField: "ChatGPT-Account-Id") } + + guard let (data, resp) = try? await URLSession.shared.data(for: req), + let http = resp as? HTTPURLResponse else { return nil } + guard http.statusCode == 200 else { return nil } // 401 → caller refreshes + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let rate = json["rate_limit"] as? [String: Any], + let primary = window(rate["primary_window"]) else { return .unavailable } + return .ok(fiveHour: primary, weekly: window(rate["secondary_window"])) + } + + /// Map a window object to QuotaWindow, tolerating a few field-name variants. + private func window(_ any: Any?) -> QuotaWindow? { + guard let w = any as? [String: Any] else { return nil } + let used = (w["used_percent"] ?? w["usage_percent"] ?? w["used"]) as? NSNumber + guard let usedPercent = used?.doubleValue else { return nil } + var resetsAt: Date? + if let secs = (w["resets_in_seconds"] ?? w["reset_after_seconds"]) as? NSNumber { + resetsAt = Date().addingTimeInterval(secs.doubleValue) + } else if let epoch = (w["resets_at"] ?? w["reset_at"]) as? NSNumber { + resetsAt = Date(timeIntervalSince1970: epoch.doubleValue) + } + return QuotaWindow(usedPercent: usedPercent, resetsAt: resetsAt) + } +} diff --git a/Sources/AgentMeter/Network/CodexTokenRefresher.swift b/Sources/AgentMeter/Network/CodexTokenRefresher.swift new file mode 100644 index 0000000..f6ac974 --- /dev/null +++ b/Sources/AgentMeter/Network/CodexTokenRefresher.swift @@ -0,0 +1,46 @@ +import Foundation + +/// Refreshes the Codex OAuth access token and writes the new tokens back to +/// ~/.codex/auth.json. Endpoint + client_id are from the cc-bar reference (MIT). +/// Networking lives only here and in CodexQuotaClient (see Scripts/check-offline.sh). +struct CodexTokenRefresher { + private let tokenURL = URL(string: "https://auth.openai.com/oauth/token")! + private let clientID = "app_EMoamEEZ73f0CkXaXp7hrann" + private let authFile = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".codex/auth.json") + + /// Exchange the refresh token for a fresh access (and id) token; persist + return it. + func refresh(refreshToken: String) async -> String? { + var req = URLRequest(url: tokenURL) + req.httpMethod = "POST" + req.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + let body = ["grant_type": "refresh_token", + "refresh_token": refreshToken, + "client_id": clientID, + "scope": "openid profile email"] + req.httpBody = body.map { "\($0.key)=\($0.value)" }.joined(separator: "&").data(using: .utf8) + + guard let (data, resp) = try? await URLSession.shared.data(for: req), + (resp as? HTTPURLResponse)?.statusCode == 200, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let access = json["access_token"] as? String else { return nil } + persist(access: access, + refresh: json["refresh_token"] as? String ?? refreshToken, + idToken: json["id_token"] as? String) + return access + } + + /// Merge new tokens into the existing auth.json (preserving other fields). + private func persist(access: String, refresh: String, idToken: String?) { + guard let data = try? Data(contentsOf: authFile), + var root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return } + var tokens = root["tokens"] as? [String: Any] ?? [:] + tokens["access_token"] = access + tokens["refresh_token"] = refresh + if let idToken { tokens["id_token"] = idToken } + root["tokens"] = tokens + if let out = try? JSONSerialization.data(withJSONObject: root, options: .prettyPrinted) { + try? out.write(to: authFile) + } + } +} diff --git a/Sources/AgentMeter/Network/NetworkOptIn.swift b/Sources/AgentMeter/Network/NetworkOptIn.swift new file mode 100644 index 0000000..673faa6 --- /dev/null +++ b/Sources/AgentMeter/Network/NetworkOptIn.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Opt-in features that read sensitive local credentials and/or touch the network. +/// AgentMeter is fully offline by default; each feature here is OFF until the user +/// confirms a dialog. ALL code that touches the network lives under +/// `Sources/AgentMeter/Network/` — enforced by `Scripts/check-offline.sh` (CI) so +/// the offline-by-default guarantee stays verifiable. +enum NetworkFeature: String, CaseIterable, Identifiable { + /// Real Codex 5h / weekly quota — reads ~/.codex credentials and contacts OpenAI. + case codexQuota + /// Show the logged-in account (email / plan). Reads local credential FILES only + /// (never the Keychain); NOT a network call, but gated behind the same + /// confirmation because it reads sensitive files. + case showAccounts + var id: String { rawValue } + + var defaultsKey: String { + switch self { + case .codexQuota: return SettingsKeys.netCodexQuota + case .showAccounts: return SettingsKeys.showAccounts + } + } + + /// Whether the feature actually touches the network (vs. only reading files). + var usesNetwork: Bool { self == .codexQuota } + + /// Uses the global `tr` (nonisolated); views observing `LanguageStore` re-render. + var title: String { + switch self { + case .codexQuota: return tr("Codex live quota", "Codex 即時額度") + case .showAccounts: return tr("Show logged-in accounts", "顯示登入帳號") + } + } + + /// Shown inside the confirmation dialog before the feature is enabled. + var explanation: String { + switch self { + case .codexQuota: + return tr("Reads local ~/.codex credentials and contacts OpenAI to fetch your 5-hour / weekly quota. AgentMeter is otherwise fully offline and only connects while this is on.", + "會讀取本機 ~/.codex 憑證並連線 OpenAI 取得 5 小時/每週額度。AgentMeter 平常完全離線,僅在此功能開啟時連線。") + case .showAccounts: + return tr("Reads your local Claude/Codex login credential files (not the Keychain) to show which account is signed in and its plan. Stays fully offline — no network.", + "會讀取本機 Claude/Codex 登入憑證檔(不讀 Keychain)以顯示登入的帳號與方案。完全離線、不連網。") + } + } + + /// Default ON (for accurate quota / account display): absent key reads as true. + var isEnabled: Bool { (UserDefaults.standard.object(forKey: defaultsKey) as? Bool) ?? true } +} diff --git a/Sources/AgentMeter/SettingsView.swift b/Sources/AgentMeter/SettingsView.swift index 5062dc5..1268b94 100644 --- a/Sources/AgentMeter/SettingsView.swift +++ b/Sources/AgentMeter/SettingsView.swift @@ -1,89 +1,152 @@ import SwiftUI import ServiceManagement +import AgentMeterCore -struct SettingsView: View { - @EnvironmentObject private var store: UsageStore - @AppStorage(SettingsKeys.interval) private var interval: Double = 30 - @AppStorage(SettingsKeys.menuBarMetrics) private var metricsCSV = defaultMenuBarMetricsCSV - @AppStorage(SettingsKeys.showClaude) private var showClaude = true - @AppStorage(SettingsKeys.showCodex) private var showCodex = true +/// The single window's content: usage stats + settings panes in one tab bar. +struct RootTabView: View { + @EnvironmentObject private var lang: LanguageStore + @EnvironmentObject private var nav: MainWindowModel - @State private var launchAtLogin = SMAppService.mainApp.status == .enabled - @State private var loginError: String? + var body: some View { + TabView(selection: $nav.selection) { + StatsRootView() + .tabItem { Label(lang.tr("Usage", "用量統計"), systemImage: "chart.bar") } + .tag(AppTab.stats) + GeneralSettings() + .tabItem { Label(lang.tr("General", "一般"), systemImage: "gearshape") } + .tag(AppTab.general) + AppearanceSettings() + .tabItem { Label(lang.tr("Appearance", "外觀"), systemImage: "paintpalette") } + .tag(AppTab.appearance) + MenuBarSettings() + .tabItem { Label(lang.tr("Menu Bar", "選單列"), systemImage: "menubar.rectangle") } + .tag(AppTab.menubar) + FloatingSettings() + .tabItem { Label(lang.tr("Floating", "浮動"), systemImage: "macwindow.on.rectangle") } + .tag(AppTab.floating) + } + .background(AM.paper) + } +} +// MARK: - General + +private struct GeneralSettings: View { + @EnvironmentObject private var store: UsageStore + @EnvironmentObject private var lang: LanguageStore + @EnvironmentObject private var colors: ServiceColorStore + @AppStorage(SettingsKeys.interval) private var interval: Double = 30 + @AppStorage(SettingsKeys.meterShowsRemaining) private var meterShowsRemaining = false + // Live quota / connectivity (default on). + @AppStorage(SettingsKeys.netCodexQuota) private var codexQuota = true + @AppStorage(SettingsKeys.showAccounts) private var showAccounts = true + @State private var pending: NetworkFeature? + // Claude live quota via the statusLine bridge (local, no network). @State private var bridgeState = StatusLineBridge.shared.state() @State private var bridgeError: String? + // Launch at login. + @State private var launchAtLogin = SMAppService.mainApp.status == .enabled + @State private var loginError: String? var body: some View { Form { - Picker("更新頻率", selection: $interval) { - ForEach(refreshIntervalOptions, id: \.seconds) { option in - Text(option.label).tag(option.seconds) - } + Section(lang.tr("Accounts", "帳號")) { + accountRow("Claude Code", provider: "Anthropic", tool: .claudeCode, account: store.claudeAccount) + accountRow("Codex", provider: "OpenAI", tool: .codex, account: store.codexAccount) } - .onChange(of: interval) { _, newValue in store.setInterval(newValue) } - Section("選單列顯示內容(可多選)") { - ForEach(MenuBarMetric.allCases) { metric in - Toggle(metric.settingsTitle, isOn: metricBinding(metric)) + Picker(lang.tr("Language", "語言"), selection: $lang.language) { + Text("繁體中文").tag(AppLanguage.zh) + Text("English").tag(AppLanguage.en) + } + + Picker(lang.tr("Refresh interval", "更新頻率"), selection: $interval) { + ForEach(refreshIntervalSecondsOptions, id: \.self) { s in + Text(refreshIntervalLabel(s)).tag(s) } - Text("沒資料的項目會自動隱藏;全部隱藏時顯示一個小圖示。") - .font(.caption).foregroundStyle(.secondary) + } + .onChange(of: interval) { _, v in store.setInterval(v) } + + Picker(lang.tr("Meters show", "量表顯示"), selection: $meterShowsRemaining) { + Text(lang.tr("Used", "已使用")).tag(false) + Text(lang.tr("Remaining", "剩餘")).tag(true) } - Section("下拉面板") { - Toggle("顯示 Claude Code", isOn: $showClaude) - Toggle("顯示 Codex", isOn: $showCodex) + Section(lang.tr("Live quota & connectivity", "即時額度與連線")) { + featureToggle(.showAccounts) + claudeLiveQuotaRow + featureToggle(.codexQuota) } Section { - Toggle("開機時自動啟動", isOn: $launchAtLogin) + Toggle(lang.tr("Launch at login", "開機時自動啟動"), isOn: $launchAtLogin) .onChange(of: launchAtLogin) { _, on in setLaunchAtLogin(on) } - if let loginError { + .disabled(!isInstalledApp) + if isInstalledApp, let loginError { Text(loginError).font(.caption).foregroundStyle(.red) } } - - Section("即時額度(Claude,實驗性)") { - Toggle("啟用即時額度(statusLine 橋接)", isOn: bridgeBinding) - Text(bridgeHelpText) - .font(.caption) - .foregroundStyle(.secondary) - if let bridgeError { - Text(bridgeError).font(.caption).foregroundStyle(.red) - } - } } .formStyle(.grouped) - .frame(width: 380) + .alert(pending?.title ?? "", + isPresented: Binding(get: { pending != nil }, set: { if !$0 { pending = nil } }), + presenting: pending) { feature in + Button(lang.tr("Cancel", "取消"), role: .cancel) {} + Button(lang.tr("Enable", "啟用")) { + binding(for: feature).wrappedValue = true + UsageStore.shared.refreshNow() // pick up the new data immediately + } + } message: { feature in + Text(feature.explanation) + } } - private func metricBinding(_ metric: MenuBarMetric) -> Binding { - Binding( - get: { MenuBarMetric.list(fromCSV: metricsCSV).contains(metric) }, - set: { isOn in - var set = Set(MenuBarMetric.list(fromCSV: metricsCSV)) - if isOn { set.insert(metric) } else { set.remove(metric) } - metricsCSV = MenuBarMetric.csv(from: set) + // Claude live quota: same row shape as the network toggles, but it reads the + // local statusLine data Claude Code writes (no network), so its badge says so. + @ViewBuilder private var claudeLiveQuotaRow: some View { + Toggle(isOn: Binding(get: { bridgeState == .enabled }, set: { setBridge($0) })) { + HStack(spacing: 6) { + Text(lang.tr("Claude live quota", "Claude 即時額度")) + badge(lang.tr("reads local data", "讀本機資料"), network: false) } - ) + } + if bridgeState == .conflict { + Text(lang.tr("Detected an existing custom statusLine, so it wasn't enabled (to avoid overwriting). Remove the existing one first.", + "偵測到你已有自訂 statusLine,為避免覆蓋而未啟用。請先移除既有設定。")) + .font(.caption).foregroundStyle(.secondary) + } else if let bridgeError { + Text(bridgeError).font(.caption).foregroundStyle(.red) + } } - private var bridgeBinding: Binding { - Binding( - get: { bridgeState == .enabled }, - set: { setBridge($0) } - ) + private func badge(_ text: String, network: Bool) -> some View { + Text(text) + .font(.system(size: 9.5, weight: .semibold)) + .padding(.horizontal, 5).padding(.vertical, 1) + .background((network ? Color.orange : Color.secondary).opacity(0.18), in: Capsule()) + .foregroundStyle(network ? .orange : .secondary) + } + + private func featureToggle(_ feature: NetworkFeature) -> some View { + Toggle(isOn: Binding( + get: { binding(for: feature).wrappedValue }, + set: { newValue in + if newValue { pending = feature } // confirm before enabling + else { binding(for: feature).wrappedValue = false; UsageStore.shared.refreshNow() } + })) { + HStack(spacing: 6) { + Text(feature.title) + badge(feature.usesNetwork ? lang.tr("needs internet", "需連網") + : lang.tr("reads credentials", "讀本機憑證"), + network: feature.usesNetwork) + } + } } - private var bridgeHelpText: String { - switch bridgeState { - case .enabled: - return "已啟用。請在 Claude Code 送出一次訊息,5h/每週額度條才會出現。會在 ~/.claude/settings.json 設定 statusLine(已備份原檔)。" - case .conflict: - return "偵測到你已有自訂 statusLine,為避免覆蓋而未啟用。可手動整合或先移除既有設定。" - case .disabled: - return "啟用後會讀取 Claude Code 傳給 statusLine 的官方資料來顯示真實 5h/每週 %。不連網、不讀 Keychain。" + private func binding(for feature: NetworkFeature) -> Binding { + switch feature { + case .codexQuota: return $codexQuota + case .showAccounts: return $showAccounts } } @@ -98,17 +161,168 @@ struct SettingsView: View { bridgeState = StatusLineBridge.shared.state() } + private func accountRow(_ name: String, provider: String, tool: AgentTool, + account: ServiceAccount?) -> some View { + HStack(spacing: 10) { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(colors.color(for: tool)).frame(width: 22, height: 22) + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 5) { + Text(name).font(.system(size: 12.5, weight: .semibold)) + Text(provider).font(.system(size: 10)).foregroundStyle(.secondary) + } + if let a = account, !a.isEmpty { + Text([a.email, a.plan?.capitalized].compactMap { $0 }.joined(separator: " · ")) + .font(.system(size: 10.5)).foregroundStyle(.secondary) + } else { + Text(lang.tr("Not detected", "未偵測到")) + .font(.system(size: 10.5)).foregroundStyle(.secondary) + } + } + Spacer() + HStack(spacing: 4) { + Circle().fill(account != nil ? Color.green : Color.secondary.opacity(0.4)) + .frame(width: 7, height: 7) + Text(account != nil ? lang.tr("connected", "已連線") : lang.tr("offline", "未連線")) + .font(.system(size: 10.5)).foregroundStyle(.secondary) + } + } + } + + /// SMAppService.mainApp only works for a bundled, installed .app — not a bare + /// `swift run` binary (which fails with "Invalid argument"). + private var isInstalledApp: Bool { + Bundle.main.bundleURL.pathExtension == "app" && Bundle.main.bundleIdentifier != nil + } + private func setLaunchAtLogin(_ enabled: Bool) { do { - if enabled { - try SMAppService.mainApp.register() - } else { - try SMAppService.mainApp.unregister() - } + if enabled { try SMAppService.mainApp.register() } + else { try SMAppService.mainApp.unregister() } loginError = nil } catch { - loginError = "設定開機啟動失敗:\(error.localizedDescription)" + loginError = lang.tr("Couldn't set launch at login: ", "設定開機啟動失敗:") + error.localizedDescription launchAtLogin = SMAppService.mainApp.status == .enabled } } } + +// MARK: - Appearance + +private struct AppearanceSettings: View { + @EnvironmentObject private var lang: LanguageStore + @EnvironmentObject private var colors: ServiceColorStore + + var body: some View { + Form { + Section(lang.tr("Identity colors", "識別色")) { + colorRow("Claude Code", tool: .claudeCode, + presets: [ServiceColorStore.claudeBrand, ServiceColorStore.mono]) + colorRow("Codex", tool: .codex, + presets: [ServiceColorStore.codexBrand, ServiceColorStore.mono]) + } + } + .formStyle(.grouped) + } + + private func colorRow(_ name: String, tool: AgentTool, presets: [String]) -> some View { + HStack { + ColorPicker(name, selection: Binding( + get: { colors.color(for: tool) }, + set: { colors.setHex(hexString(from: $0), for: tool) })) + Spacer() + ForEach(presets, id: \.self) { hex in + Button { colors.setHex(hex, for: tool) } label: { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(Color(amHex: hex)).frame(width: 18, height: 18) + .overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.secondary.opacity(0.3))) + } + .buttonStyle(.plain) + } + } + } +} + +// MARK: - Menu Bar + +private struct MenuBarSettings: View { + @EnvironmentObject private var lang: LanguageStore + @AppStorage(SettingsKeys.menuBarMetrics) private var metricsCSV = defaultMenuBarMetricsCSV + @AppStorage(SettingsKeys.showClaude) private var showClaude = true + @AppStorage(SettingsKeys.showCodex) private var showCodex = true + @AppStorage(SettingsKeys.menuBarOrientation) private var orientation = "vertical" + @AppStorage(SettingsKeys.menuBarShowIcon) private var showIcon = true + + private let claudeMetrics: [MenuBarMetric] = + [.claudeTokens, .claudeFiveHour, .claudeWeekly, .claudeContext, .claudeMessages] + private let codexMetrics: [MenuBarMetric] = + [.codexTokens, .codexFiveHour, .codexWeekly, .codexContext, .codexMessages] + + var body: some View { + Form { + Section(lang.tr("Claude metrics", "Claude 指標")) { + ForEach(claudeMetrics) { Toggle($0.settingsTitle, isOn: metricBinding($0)) } + } + Section(lang.tr("Codex metrics", "Codex 指標")) { + ForEach(codexMetrics) { Toggle($0.settingsTitle, isOn: metricBinding($0)) } + } + Section(lang.tr("Combined", "合計")) { + Toggle(MenuBarMetric.combinedTokens.settingsTitle, + isOn: metricBinding(.combinedTokens)) + } + Section(lang.tr("Layout", "排列方式")) { + Picker(lang.tr("Label & value", "標籤與數值"), selection: $orientation) { + Text(lang.tr("Stacked (label above)", "直向(上下)")).tag("vertical") + Text(lang.tr("Inline (side by side)", "橫向(並排)")).tag("horizontal") + } + Toggle(lang.tr("Show agent icon", "顯示 agent 圖示"), isOn: $showIcon) + } + Section(lang.tr("Dropdown panel", "下拉面板")) { + Toggle(lang.tr("Show Claude Code", "顯示 Claude Code"), isOn: $showClaude) + Toggle(lang.tr("Show Codex", "顯示 Codex"), isOn: $showCodex) + } + } + .formStyle(.grouped) + } + + private func metricBinding(_ metric: MenuBarMetric) -> Binding { + Binding( + get: { MenuBarMetric.list(fromCSV: metricsCSV).contains(metric) }, + set: { isOn in + var set = Set(MenuBarMetric.list(fromCSV: metricsCSV)) + if isOn { set.insert(metric) } else { set.remove(metric) } + metricsCSV = MenuBarMetric.csv(from: set) + }) + } +} + +// MARK: - Floating HUD + +private struct FloatingSettings: View { + @EnvironmentObject private var lang: LanguageStore + @AppStorage(SettingsKeys.floatingEnabled) private var enabled = false + @AppStorage(SettingsKeys.floatingShowClaude) private var showClaude = true + @AppStorage(SettingsKeys.floatingShowCodex) private var showCodex = true + @AppStorage(SettingsKeys.floatingIdleOpacity) private var idleOpacity = 0.7 + + var body: some View { + Form { + Section { + Toggle(lang.tr("Show floating desktop HUD", "顯示桌面浮動面板"), isOn: $enabled) + .onChange(of: enabled) { _, on in FloatingPanelController.shared.setEnabled(on) } + } + Section(lang.tr("HUD content", "面板內容")) { + Toggle("Claude", isOn: $showClaude) + Toggle("Codex", isOn: $showCodex) + HStack { + Text(lang.tr("Idle opacity", "閒置透明度")) + Slider(value: $idleOpacity, in: 0.3...1.0) + Text("\(Int(idleOpacity * 100))%").font(.caption).monospacedDigit() + .foregroundStyle(.secondary).frame(width: 36, alignment: .trailing) + } + } + .disabled(!enabled) + } + .formStyle(.grouped) + } +} diff --git a/Sources/AgentMeter/Stats/ActivationPolicyCoordinator.swift b/Sources/AgentMeter/Stats/ActivationPolicyCoordinator.swift new file mode 100644 index 0000000..faaaa14 --- /dev/null +++ b/Sources/AgentMeter/Stats/ActivationPolicyCoordinator.swift @@ -0,0 +1,21 @@ +import AppKit + +/// Single owner of the app's activation policy. A menu-bar-only (.accessory) app +/// must briefly become .regular to show a real focusable window (Stats / Settings). +/// Each window `enter()`s on show; windows we own `leave()` on close. The whole +/// count resets to .accessory when the app deactivates, so a never-decremented +/// entry (the SwiftUI Settings scene, whose close we can't hook) self-heals. +@MainActor +final class ActivationPolicyCoordinator { + static let shared = ActivationPolicyCoordinator() + private var count = 0 + + func enter() { count += 1; apply() } + func leave() { count = max(0, count - 1); apply() } + func reset() { count = 0; apply() } + + private func apply() { + NSApp.setActivationPolicy(count > 0 ? .regular : .accessory) + if count > 0 { NSApp.activate(ignoringOtherApps: true) } + } +} diff --git a/Sources/AgentMeter/Stats/MainWindowRootView.swift b/Sources/AgentMeter/Stats/MainWindowRootView.swift new file mode 100644 index 0000000..a00bc38 --- /dev/null +++ b/Sources/AgentMeter/Stats/MainWindowRootView.swift @@ -0,0 +1,12 @@ +import Foundation + +/// Tabs of the single main window: usage stats sits alongside the settings panes +/// (one tab bar — no separate stats window / settings window). +enum AppTab: Hashable { case stats, general, appearance, menubar, floating } + +/// Which tab the window shows. Owned by StatsWindowController so the menu-bar +/// buttons can switch tabs on an already-open window. +@MainActor +final class MainWindowModel: ObservableObject { + @Published var selection: AppTab = .stats +} diff --git a/Sources/AgentMeter/Stats/StatsRootView.swift b/Sources/AgentMeter/Stats/StatsRootView.swift new file mode 100644 index 0000000..7b17282 --- /dev/null +++ b/Sources/AgentMeter/Stats/StatsRootView.swift @@ -0,0 +1,282 @@ +import SwiftUI +import Charts +import AgentMeterCore + +// MARK: - Range + +enum StatsRange: String, CaseIterable, Identifiable { + case today, week, month, all + var id: String { rawValue } + + func interval(now: Date = Date()) -> DateInterval { + let end = now.addingTimeInterval(60) + switch self { + case .today: return DateInterval(start: Calendar.current.startOfDay(for: now), end: end) + case .week: return DateInterval(start: now.addingTimeInterval(-7 * 86400), end: end) + case .month: return DateInterval(start: now.addingTimeInterval(-30 * 86400), end: end) + case .all: return DateInterval(start: Date(timeIntervalSince1970: 0), end: end) + } + } +} + +// MARK: - View model + +@MainActor +final class StatsViewModel: ObservableObject { + @Published var range: StatsRange = .month { didSet { reload() } } + @Published private(set) var claude: UsageHistory? + @Published private(set) var codex: UsageHistory? + @Published private(set) var isLoading = false + + func reload() { + isLoading = true + let interval = range.interval() + Task.detached(priority: .utility) { + let c = try? ClaudeCodeReader().history(range: interval) + let x = try? CodexReader().history(range: interval) + await MainActor.run { + self.claude = c + self.codex = x + self.isLoading = false + } + } + } +} + +// MARK: - Root + +enum ServiceFilter: String, CaseIterable, Identifiable { case all, claude, codex; var id: String { rawValue } } +enum StatsPane: String, CaseIterable, Identifiable { case overview, byModel; var id: String { rawValue } } + +struct StatsRootView: View { + @EnvironmentObject private var lang: LanguageStore + @EnvironmentObject private var colors: ServiceColorStore + @StateObject private var vm = StatsViewModel() + @State private var service: ServiceFilter = .all + @State private var pane: StatsPane = .overview + + var body: some View { + HStack(spacing: 0) { + sidebar.frame(width: 156) + Rectangle().fill(AM.hairline).frame(width: 1) + detail.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + .background(AM.paper) + .foregroundStyle(AM.ink) + .onAppear { if vm.claude == nil { vm.reload() } } + } + + // MARK: Sidebar + + private var sidebar: some View { + VStack(alignment: .leading, spacing: AM.Space.l) { + sidebarGroup(lang.tr("Service", "服務")) { + sidebarRow(lang.tr("All", "全部"), selected: service == .all) { service = .all } + sidebarRow("Claude", selected: service == .claude) { service = .claude } + sidebarRow("Codex", selected: service == .codex) { service = .codex } + } + sidebarGroup(lang.tr("View", "視圖")) { + sidebarRow(lang.tr("Overview", "總覽"), selected: pane == .overview) { pane = .overview } + sidebarRow(lang.tr("By model", "按模型"), selected: pane == .byModel) { pane = .byModel } + } + Spacer() + } + .padding(AM.Space.m) + } + + private func sidebarGroup(_ title: String, @ViewBuilder _ content: () -> C) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(title.uppercased()).font(.system(size: 9.5)).tracking(0.4).foregroundStyle(AM.ink3) + .padding(.leading, 8).padding(.bottom, 2) + content() + } + } + + private func sidebarRow(_ label: String, selected: Bool, _ action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(label).font(.system(size: 12, weight: selected ? .semibold : .regular)) + .foregroundStyle(selected ? AM.ink : AM.ink2) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 5).padding(.horizontal, 8) + .background(RoundedRectangle(cornerRadius: 6).fill(selected ? AM.hairline : .clear)) + } + .buttonStyle(.plain) + } + + // MARK: Detail + + private var detail: some View { + VStack(alignment: .leading, spacing: AM.Space.l) { + HStack { + Text(lang.tr("Usage statistics", "用量統計")).font(.system(size: 13, weight: .semibold)) + Spacer() + rangeTabs + } + if pane == .overview { overview } else { byModel } + Spacer() + } + .padding(AM.Space.xl) + } + + private var rangeTabs: some View { + HStack(spacing: 2) { + ForEach(StatsRange.allCases) { r in + let on = vm.range == r + Button { vm.range = r } label: { + Text(rangeLabel(r)).font(.system(size: 10.5, weight: on ? .semibold : .regular)) + .padding(.horizontal, 8).padding(.vertical, 3) + .foregroundStyle(on ? AM.paper : AM.ink2) + .background(on ? AM.ink : .clear) + } + .buttonStyle(.plain) + } + } + .clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 7, style: .continuous).stroke(AM.hairline, lineWidth: 1)) + } + + private func rangeLabel(_ r: StatsRange) -> String { + switch r { + case .today: return lang.tr("Today", "今天") + case .week: return lang.tr("7d", "7天") + case .month: return lang.tr("30d", "30天") + case .all: return lang.tr("All", "全部") + } + } + + // MARK: Overview + + private var overview: some View { + VStack(alignment: .leading, spacing: AM.Space.xl) { + HStack(spacing: 34) { + kpi(lang.tr("Total tokens", "總 tokens"), Int(totalTokens).compactTokenString) + kpi(lang.tr("Total cost", "總花費"), moneyString(totalCost)) + kpi("Claude / Codex", "\(moneyString(cost(.claudeCode))) · \(moneyString(cost(.codex)))", small: true) + } + Rectangle().fill(AM.hairline).frame(height: 1) + sectionHeader(lang.tr("Daily usage", "每日用量")) + dailyChart + } + } + + private func kpi(_ label: String, _ value: String, small: Bool = false) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label).font(.system(size: 10.5)).foregroundStyle(AM.ink2) + Text(value).font(.system(size: small ? 18 : 28, weight: .light)).monospacedDigit().tracking(-0.5) + } + } + + private var dailyChart: some View { + let points = dailyPoints + return Group { + if points.isEmpty { + emptyHint + } else { + Chart { + ForEach(points, id: \.day) { p in + BarMark(x: .value("Day", p.day, unit: .day), y: .value("Claude", p.claude)) + .foregroundStyle(colors.color(for: .claudeCode)) + BarMark(x: .value("Day", p.day, unit: .day), y: .value("Codex", p.codex)) + .foregroundStyle(colors.color(for: .codex)) + } + } + .chartLegend(.hidden) + .frame(height: 150) + HStack(spacing: 14) { + legendDot(colors.color(for: .claudeCode), "Claude") + legendDot(colors.color(for: .codex), "Codex") + } + } + } + } + + private func legendDot(_ color: Color, _ label: String) -> some View { + HStack(spacing: 5) { + RoundedRectangle(cornerRadius: 2).fill(color).frame(width: 8, height: 8) + Text(label).font(.system(size: 10.5)).foregroundStyle(AM.ink2) + } + } + + // MARK: By model + + private var byModel: some View { + let rows = modelRows + return VStack(alignment: .leading, spacing: 0) { + sectionHeader(lang.tr("By model", "按模型")) + if rows.isEmpty { + emptyHint + } else { + ForEach(rows) { row in + HStack { + RoundedRectangle(cornerRadius: 2).fill(row.color).frame(width: 7, height: 7) + Text(row.model).font(.system(size: 12)) + Spacer() + Text("\(row.tokens.compactTokenString) tokens") + .font(.system(size: 11.5)).monospacedDigit().foregroundStyle(AM.ink2) + Text(moneyString(row.cost)).font(.system(size: 11.5, weight: .medium)).monospacedDigit() + .frame(width: 76, alignment: .trailing) + } + .padding(.vertical, 7) + .overlay(alignment: .top) { if row.id != rows.first?.id { Rectangle().fill(AM.hairline).frame(height: 1) } } + } + } + } + } + + private func sectionHeader(_ t: String) -> some View { + Text(t).font(.system(size: 11)).tracking(0.3).foregroundStyle(AM.ink2) + } + + private var emptyHint: some View { + Text(lang.tr(vm.isLoading ? "Loading…" : "No usage in this range.", + vm.isLoading ? "載入中…" : "此範圍沒有用量。")) + .font(.system(size: 12)).foregroundStyle(AM.ink3).padding(.vertical, 24) + } + + // MARK: Derived data + + private var includedHistories: [UsageHistory] { + switch service { + case .all: return [vm.claude, vm.codex].compactMap { $0 } + case .claude: return [vm.claude].compactMap { $0 } + case .codex: return [vm.codex].compactMap { $0 } + } + } + + private var totalTokens: Int { includedHistories.reduce(0) { $0 + $1.grandTotal.total } } + private var totalCost: CostEstimate { includedHistories.reduce(.zeroComplete) { $0 + $1.cost } } + private func cost(_ tool: AgentTool) -> CostEstimate { + (tool == .claudeCode ? vm.claude : vm.codex)?.cost ?? .zeroComplete + } + + private var dailyPoints: [(day: Date, claude: Int, codex: Int)] { + var byDay: [Date: (Int, Int)] = [:] + if service != .codex, let c = vm.claude { + for d in c.days { byDay[d.day, default: (0, 0)].0 += d.total.total } + } + if service != .claude, let x = vm.codex { + for d in x.days { byDay[d.day, default: (0, 0)].1 += d.total.total } + } + return byDay.keys.sorted().map { (day: $0, claude: byDay[$0]!.0, codex: byDay[$0]!.1) } + } + + private struct ModelRow: Identifiable { + let id: String, model: String, tokens: Int, cost: CostEstimate, color: Color + } + + private var modelRows: [ModelRow] { + var rows: [ModelRow] = [] + func add(_ history: UsageHistory?, color: Color) { + guard let history else { return } + for (key, bd) in history.byModel { + rows.append(ModelRow(id: history.tool.rawValue + key, model: key, + tokens: bd.total, + cost: costEstimate(bd, model: ModelKey(raw: key)), color: color)) + } + } + if service != .codex { add(vm.claude, color: colors.color(for: .claudeCode)) } + if service != .claude { add(vm.codex, color: colors.color(for: .codex)) } + return rows.sorted { $0.tokens > $1.tokens } + } + +} diff --git a/Sources/AgentMeter/Stats/StatsWindowController.swift b/Sources/AgentMeter/Stats/StatsWindowController.swift new file mode 100644 index 0000000..11d7f63 --- /dev/null +++ b/Sources/AgentMeter/Stats/StatsWindowController.swift @@ -0,0 +1,39 @@ +import AppKit +import SwiftUI + +/// Owns the single Stats window (an AppKit `NSWindow` hosting SwiftUI). Opening +/// from a `.accessory` app requires going `.regular` via the coordinator. +@MainActor +final class StatsWindowController: NSObject, NSWindowDelegate { + static let shared = StatsWindowController() + private var window: NSWindow? + private let nav = MainWindowModel() + + func show(tab: AppTab) { + nav.selection = tab + if window == nil { + let root = RootTabView() + .environmentObject(UsageStore.shared) + .environmentObject(LanguageStore.shared) + .environmentObject(ServiceColorStore.shared) + .environmentObject(nav) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 780, height: 560), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, defer: false) + window.title = "AgentMeter" + window.contentView = NSHostingView(rootView: root) + window.isReleasedWhenClosed = false + window.delegate = self + window.setFrameAutosaveName("AgentMeterMain") + window.center() + self.window = window + } + ActivationPolicyCoordinator.shared.enter() + window?.makeKeyAndOrderFront(nil) + } + + func windowWillClose(_ notification: Notification) { + ActivationPolicyCoordinator.shared.leave() + } +} diff --git a/Sources/AgentMeter/UsageStore.swift b/Sources/AgentMeter/UsageStore.swift index 9984c78..dae4f52 100644 --- a/Sources/AgentMeter/UsageStore.swift +++ b/Sources/AgentMeter/UsageStore.swift @@ -5,9 +5,17 @@ import AgentMeterCore /// Reading happens off the main actor; published values are updated on it. @MainActor final class UsageStore: ObservableObject { + static let shared = UsageStore() + @Published private(set) var claude = ToolUsage(tool: .claudeCode, available: false) @Published private(set) var codex = ToolUsage(tool: .codex, available: false) @Published private(set) var claudeQuota = ClaudeQuota(available: false) + // Networked Codex quota (only when the opt-in is enabled). + @Published private(set) var codexFiveHour: QuotaWindow? + @Published private(set) var codexWeekly: QuotaWindow? + // Logged-in accounts (only when "show accounts" opt-in is enabled). + @Published private(set) var claudeAccount: ServiceAccount? + @Published private(set) var codexAccount: ServiceAccount? @Published private(set) var lastRefresh: Date? @Published private(set) var isRefreshing = false @@ -21,9 +29,9 @@ final class UsageStore: ObservableObject { startTimer() } - /// Combined "billable" (cache-read-excluded) tokens used today. - var combinedTodayBillable: Int { - claude.today.billableTotal + codex.today.billableTotal + /// Combined total tokens used today (includes cache reads, to match the stats view). + var combinedTodayTotal: Int { + claude.today.total + codex.today.total } func setInterval(_ seconds: TimeInterval) { @@ -42,10 +50,26 @@ final class UsageStore: ObservableObject { let codexUsage = (try? CodexReader().read(now: now)) ?? ToolUsage(tool: .codex, available: false, lastUpdated: now) let quota = ClaudeStatusReader().read() + + // Opt-in: read logged-in accounts from local credential files. + let showAccounts = NetworkFeature.showAccounts.isEnabled + let reader = CredentialReader() + let claudeAcct = showAccounts ? reader.claude()?.account.nonEmpty : nil + let codexAcct = showAccounts ? reader.codex()?.account.nonEmpty : nil + + // Opt-in: networked Codex 5h/weekly quota. + var codex5h: QuotaWindow? + var codexWk: QuotaWindow? + if case let .ok(f, w) = await CodexQuotaClient().fetch() { codex5h = f; codexWk = w } + await MainActor.run { self.claude = claudeUsage self.codex = codexUsage self.claudeQuota = quota + self.codexFiveHour = codex5h + self.codexWeekly = codexWk + self.claudeAccount = claudeAcct + self.codexAccount = codexAcct self.lastRefresh = now self.isRefreshing = false } diff --git a/Sources/AgentMeterCore/ClaudeCodeReader.swift b/Sources/AgentMeterCore/ClaudeCodeReader.swift index 46db4ff..4b20833 100644 --- a/Sources/AgentMeterCore/ClaudeCodeReader.swift +++ b/Sources/AgentMeterCore/ClaudeCodeReader.swift @@ -35,6 +35,7 @@ public struct ClaudeCodeReader: UsageReader { var today = TokenBreakdown() var rolling = TokenBreakdown() var messageCount = 0 + var todayByModel: [String: TokenBreakdown] = [:] var seen = Set() // Track the single most-recent assistant turn for the context-window gauge. var latestTimestamp: Date? @@ -70,6 +71,8 @@ public struct ClaudeCodeReader: UsageReader { if windows.isToday(timestamp) { today += breakdown messageCount += 1 + let key = ModelKey(raw: line.message?.model ?? "").id + todayByModel[key, default: TokenBreakdown()] += breakdown } if windows.isInRollingWindow(timestamp) { rolling += breakdown @@ -95,8 +98,52 @@ public struct ClaudeCodeReader: UsageReader { return ToolUsage(tool: .claudeCode, available: true, today: today, rolling5h: rolling, - messageCount: messageCount, contextWindow: contextWindow, - lastUpdated: now) + messageCount: messageCount, todayByModel: todayByModel, + contextWindow: contextWindow, lastUpdated: now) + } +} + +extension ClaudeCodeReader { + /// Per-day, per-model usage over `range`. Independent of `read(now:)` to keep + /// the live panel path untouched; both parse rows identically. + public func history(range: DateInterval) throws -> UsageHistory { + var acc = HistoryAccumulator(tool: .claudeCode, range: range, calendar: calendar) + guard directoryExists(projectsDirectory) else { return acc.finish() } + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + var seen = Set() + + for file in jsonlFiles(in: projectsDirectory) { + // Lines are appended in time order, so a file last touched before the + // range start cannot contain rows inside the range. + if modificationDate(of: file) < range.start { continue } + guard let content = try? String(contentsOf: file, encoding: .utf8) else { continue } + + for rawLine in content.split(separator: "\n", omittingEmptySubsequences: true) { + guard let data = rawLine.data(using: .utf8), + let line = try? decoder.decode(ClaudeLine.self, from: data), + let usage = line.message?.usage, + let tsString = line.timestamp, + let timestamp = parseISOTimestamp(tsString), + acc.contains(timestamp) else { continue } + + if let id = line.message?.id { + let key = "\(id)|\(line.requestId ?? "")" + if !seen.insert(key).inserted { continue } + } + + let breakdown = TokenBreakdown( + input: usage.inputTokens ?? 0, + output: usage.outputTokens ?? 0, + cacheCreation: usage.cacheCreationInputTokens ?? 0, + cacheRead: usage.cacheReadInputTokens ?? 0 + ) + acc.add(breakdown, modelKey: ModelKey(raw: line.message?.model ?? "").id, at: timestamp) + acc.addMessage(at: timestamp) + } + } + return acc.finish() } } diff --git a/Sources/AgentMeterCore/CodexReader.swift b/Sources/AgentMeterCore/CodexReader.swift index 8d13571..8325134 100644 --- a/Sources/AgentMeterCore/CodexReader.swift +++ b/Sources/AgentMeterCore/CodexReader.swift @@ -85,6 +85,52 @@ public struct CodexReader: UsageReader { } } +extension CodexReader { + /// Per-day, per-model usage over `range`. `token_count` lines carry no model, + /// so we track the latest `turn_context.model` *within each file* and attribute + /// following deltas to it (`.unknown` if none seen yet). Files are processed + /// independently and lines kept in file order — never globally sorted. + public func history(range: DateInterval) throws -> UsageHistory { + var acc = HistoryAccumulator(tool: .codex, range: range, calendar: calendar) + guard directoryExists(sessionsDirectory) else { return acc.finish() } + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + + for file in jsonlFiles(in: sessionsDirectory) { + if modificationDate(of: file) < range.start { continue } + guard let content = try? String(contentsOf: file, encoding: .utf8) else { continue } + + var currentModel = ModelKey.unknown.id // reset per file + for rawLine in content.split(separator: "\n", omittingEmptySubsequences: true) { + guard let data = rawLine.data(using: .utf8), + let line = try? decoder.decode(CodexLine.self, from: data) else { continue } + let kind = line.payload?.type ?? line.type + + if kind == "turn_context", let m = line.payload?.model { + currentModel = ModelKey(raw: m).id + continue + } + + guard let tsString = line.timestamp, + let timestamp = parseISOTimestamp(tsString), + acc.contains(timestamp) else { continue } + + switch kind { + case "token_count": + guard let delta = line.payload?.info?.lastTokenUsage else { continue } + acc.add(delta.breakdown, modelKey: currentModel, at: timestamp) + case "agent_message": + acc.addMessage(at: timestamp) + default: + continue + } + } + } + return acc.finish() + } +} + // MARK: - Rollout line shapes (only the fields we need) private struct CodexLine: Decodable { @@ -96,6 +142,8 @@ private struct CodexLine: Decodable { private struct CodexPayload: Decodable { let type: String? let info: CodexInfo? + /// Present on `turn_context` lines — the model in use for the turns that follow. + let model: String? } private struct CodexInfo: Decodable { diff --git a/Sources/AgentMeterCore/Credentials.swift b/Sources/AgentMeterCore/Credentials.swift new file mode 100644 index 0000000..ba267ee --- /dev/null +++ b/Sources/AgentMeterCore/Credentials.swift @@ -0,0 +1,166 @@ +import Foundation + +/// Logged-in account identity for a service — decoded entirely from local files +/// (no network). Used to show "who's logged in" and the plan tier. +public struct ServiceAccount: Equatable, Sendable { + public var email: String? + public var plan: String? + public init(email: String? = nil, plan: String? = nil) { + self.email = email + self.plan = plan + } + public var isEmpty: Bool { email == nil && plan == nil } + public var nonEmpty: ServiceAccount? { isEmpty ? nil : self } +} + +/// Local JWT payload decode (no signature validation) — the id_token's middle +/// segment is base64url-encoded JSON. +public enum JWT { + public static func payload(_ token: String) -> [String: Any]? { + let parts = token.split(separator: ".") + guard parts.count >= 2 else { return nil } + var s = String(parts[1]) + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + while s.count % 4 != 0 { s += "=" } + guard let data = Data(base64Encoded: s), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + return obj + } +} + +// MARK: - Claude + +/// Parsed from `~/.claude/.credentials.json`. The access token is what the +/// networked quota client would send as a Bearer header (network is opt-in). +public struct ClaudeCredentials: Equatable, Sendable { + public var accessToken: String? + public var refreshToken: String? + public var expiresAtMillis: Double? + public var account: ServiceAccount + + public init(accessToken: String?, refreshToken: String?, + expiresAtMillis: Double?, account: ServiceAccount) { + self.accessToken = accessToken + self.refreshToken = refreshToken + self.expiresAtMillis = expiresAtMillis + self.account = account + } + + public func isExpired(now: Date = Date(), skew: TimeInterval = 30) -> Bool { + guard let ms = expiresAtMillis else { return false } + return now.timeIntervalSince1970 + skew >= ms / 1000 + } +} + +public enum ClaudeCredentialParser { + public static func parse(credentialsJSON: Data) -> ClaudeCredentials? { + guard let root = try? JSONSerialization.jsonObject(with: credentialsJSON) as? [String: Any], + let oauth = root["claudeAiOauth"] as? [String: Any] else { return nil } + return ClaudeCredentials( + accessToken: oauth["accessToken"] as? String, + refreshToken: oauth["refreshToken"] as? String, + expiresAtMillis: (oauth["expiresAt"] as? NSNumber)?.doubleValue, + account: ServiceAccount(email: oauth["emailAddress"] as? String, + plan: oauth["subscriptionType"] as? String)) + } + + /// Email fallback from `~/.claude.json` → `oauthAccount.emailAddress`. + public static func email(fromClaudeConfigJSON data: Data) -> String? { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let acct = root["oauthAccount"] as? [String: Any] else { return nil } + return acct["emailAddress"] as? String + } + + /// Account (email + plan) from `~/.claude.json`. This file is present even when + /// the token lives in the Keychain, so it's how we show the Claude account + /// without reading the Keychain. Plan is derived from `organizationType` + /// (e.g. "claude_team" → "team"). + public static func account(fromClaudeConfigJSON data: Data) -> ServiceAccount? { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let acct = root["oauthAccount"] as? [String: Any] else { return nil } + var plan = acct["organizationType"] as? String + if let p = plan, p.hasPrefix("claude_") { plan = String(p.dropFirst("claude_".count)) } + return ServiceAccount(email: acct["emailAddress"] as? String, plan: plan).nonEmpty + } +} + +// MARK: - Codex + +/// Parsed from `~/.codex/auth.json`. Account identity is decoded from the +/// id_token JWT locally. +public struct CodexCredentials: Equatable, Sendable { + public var accessToken: String? + public var refreshToken: String? + public var idToken: String? + public var accountId: String? + public var account: ServiceAccount + + public init(accessToken: String?, refreshToken: String?, idToken: String?, + accountId: String?, account: ServiceAccount) { + self.accessToken = accessToken + self.refreshToken = refreshToken + self.idToken = idToken + self.accountId = accountId + self.account = account + } +} + +// MARK: - File reader (local only — never the Keychain) + +/// Reads credential FILES from disk (no Keychain access, preserving that +/// privacy promise). Only invoked when the user opted into account display or a +/// networked feature. +public struct CredentialReader { + private let home: URL + public init(home: URL = FileManager.default.homeDirectoryForCurrentUser) { self.home = home } + + public func claude() -> ClaudeCredentials? { + // The token may be in ~/.claude/.credentials.json (older setups) — read it + // if present. macOS keeps it in the Keychain instead, which we never read; + // the account (email/plan) then comes from ~/.claude.json below. + let credsURL = home.appendingPathComponent(".claude/.credentials.json") + var creds = (try? Data(contentsOf: credsURL)).flatMap(ClaudeCredentialParser.parse(credentialsJSON:)) + + if let cfg = try? Data(contentsOf: home.appendingPathComponent(".claude.json")), + let acct = ClaudeCredentialParser.account(fromClaudeConfigJSON: cfg) { + if creds == nil { + creds = ClaudeCredentials(accessToken: nil, refreshToken: nil, + expiresAtMillis: nil, account: acct) + } else { + if creds!.account.email == nil { creds!.account.email = acct.email } + if creds!.account.plan == nil { creds!.account.plan = acct.plan } + } + } + return creds + } + + public func codex() -> CodexCredentials? { + let url = home.appendingPathComponent(".codex/auth.json") + guard let data = try? Data(contentsOf: url) else { return nil } + return CodexCredentialParser.parse(authJSON: data) + } +} + +public enum CodexCredentialParser { + public static func parse(authJSON: Data) -> CodexCredentials? { + guard let root = try? JSONSerialization.jsonObject(with: authJSON) as? [String: Any], + let tokens = root["tokens"] as? [String: Any] else { return nil } + let idToken = tokens["id_token"] as? String + return CodexCredentials( + accessToken: tokens["access_token"] as? String, + refreshToken: tokens["refresh_token"] as? String, + idToken: idToken, + accountId: tokens["account_id"] as? String, + account: idToken.flatMap(account(fromIDToken:)) ?? ServiceAccount()) + } + + private static func account(fromIDToken token: String) -> ServiceAccount { + guard let claims = JWT.payload(token) else { return ServiceAccount() } + let email = claims["email"] as? String + // Plan may be a namespaced nested object or a flat claim. + var plan = (claims["https://api.openai.com/auth"] as? [String: Any])?["chatgpt_plan_type"] as? String + plan = plan ?? claims["chatgpt_plan_type"] as? String + return ServiceAccount(email: email, plan: plan) + } +} diff --git a/Sources/AgentMeterCore/HexColor.swift b/Sources/AgentMeterCore/HexColor.swift new file mode 100644 index 0000000..58ddea1 --- /dev/null +++ b/Sources/AgentMeterCore/HexColor.swift @@ -0,0 +1,23 @@ +import Foundation + +/// Pure string<->RGB conversion for `#RRGGBB` hex colours. The SwiftUI `Color` +/// wrapper lives in the UI layer; this part is here so it can be unit-tested +/// without AppKit. +public enum HexColor { + /// Parse `#RRGGBB` or `RRGGBB` (case-insensitive) into 0...255 components. + /// Returns nil for anything that isn't exactly six hex digits. + public static func rgb(_ hex: String) -> (r: Int, g: Int, b: Int)? { + var s = hex.trimmingCharacters(in: .whitespaces) + if s.hasPrefix("#") { s.removeFirst() } + guard s.count == 6, let value = UInt32(s, radix: 16) else { return nil } + return (Int((value >> 16) & 0xFF), + Int((value >> 8) & 0xFF), + Int(value & 0xFF)) + } + + /// Format components (clamped to 0...255) as an uppercase `#RRGGBB` string. + public static func string(r: Int, g: Int, b: Int) -> String { + func clamp(_ v: Int) -> Int { min(255, max(0, v)) } + return String(format: "#%02X%02X%02X", clamp(r), clamp(g), clamp(b)) + } +} diff --git a/Sources/AgentMeterCore/ModelIdentity.swift b/Sources/AgentMeterCore/ModelIdentity.swift new file mode 100644 index 0000000..4c08d08 --- /dev/null +++ b/Sources/AgentMeterCore/ModelIdentity.swift @@ -0,0 +1,50 @@ +import Foundation + +/// A normalized model identifier, shared by pricing and by-model aggregation so +/// the two always bucket on the same key. Folds dated snapshots +/// (`claude-haiku-4-5-20251001` → `claude-haiku-4-5`), strips bracket suffixes +/// (`claude-opus-4-8[1m]` → `claude-opus-4-8`), and maps `` / empty +/// to `.unknown`. +public struct ModelKey: Hashable, Sendable { + public enum Vendor: Sendable { case anthropic, openai, unknown } + + public let id: String + public let vendor: Vendor + + public static let unknown = ModelKey(id: "unknown", vendor: .unknown) + + private init(id: String, vendor: Vendor) { + self.id = id + self.vendor = vendor + } + + public init(raw: String) { + var s = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + // Strip a trailing bracketed segment, e.g. "[1m]". + if let open = s.lastIndex(of: "["), s.hasSuffix("]") { + s = String(s[.. Vendor { + if id.hasPrefix("claude") { return .anthropic } + if id.hasPrefix("gpt") || id.hasPrefix("codex") || id.hasPrefix("o1") || id.hasPrefix("o3") { + return .openai + } + return .unknown + } +} diff --git a/Sources/AgentMeterCore/Pricing.swift b/Sources/AgentMeterCore/Pricing.swift new file mode 100644 index 0000000..7fd293c --- /dev/null +++ b/Sources/AgentMeterCore/Pricing.swift @@ -0,0 +1,81 @@ +import Foundation + +/// Per-model USD rates, expressed per 1,000,000 tokens. Cache-read defaults to +/// 0.1× input and cache-write to 1.25× input (Anthropic's published multipliers); +/// reasoning bills at the output rate. +public struct ModelPricing: Sendable, Equatable { + public let inputPerMTok: Double + public let outputPerMTok: Double + public let cacheWritePerMTok: Double + public let cacheReadPerMTok: Double + public let reasoningPerMTok: Double + + public init(input: Double, + output: Double, + cacheWrite: Double? = nil, + cacheRead: Double? = nil, + reasoning: Double? = nil) { + self.inputPerMTok = input + self.outputPerMTok = output + self.cacheWritePerMTok = cacheWrite ?? input * 1.25 + self.cacheReadPerMTok = cacheRead ?? input * 0.1 + self.reasoningPerMTok = reasoning ?? output + } +} + +/// A local cost estimate. `isComplete` is false when any tokens belonged to a +/// model with no entry in the pricing table, so the UI can render `≈$X+`. +public struct CostEstimate: Sendable, Equatable { + public let amountUSD: Double + public let isComplete: Bool + + public static let zeroComplete = CostEstimate(amountUSD: 0, isComplete: true) + + public static func + (lhs: CostEstimate, rhs: CostEstimate) -> CostEstimate { + CostEstimate(amountUSD: lhs.amountUSD + rhs.amountUSD, + isComplete: lhs.isComplete && rhs.isComplete) + } +} + +/// Built-in pricing table. Anthropic list prices (per the claude-api reference, +/// verified 2026-06); cache/reasoning multipliers applied by `ModelPricing`. +/// OpenAI / Codex models are intentionally absent — add verified rates here to +/// enable their local cost estimate; until then their tokens flip `isComplete`. +public enum PricingTable { + public static let version = "2026-06" + + public static let builtIn: [String: ModelPricing] = [ + "claude-fable-5": .init(input: 10, output: 50), + "claude-mythos-5": .init(input: 10, output: 50), + "claude-opus-4-8": .init(input: 5, output: 25), + "claude-opus-4-7": .init(input: 5, output: 25), + "claude-opus-4-6": .init(input: 5, output: 25), + "claude-opus-4-5": .init(input: 5, output: 25), + "claude-sonnet-4-6": .init(input: 3, output: 15), + "claude-sonnet-4-5": .init(input: 3, output: 15), + "claude-haiku-4-5": .init(input: 1, output: 5), + ] + + public static func pricing(for key: ModelKey) -> ModelPricing? { builtIn[key.id] } +} + +/// Local cost estimate for one model's token breakdown. +public func costEstimate(_ tokens: TokenBreakdown, + model: ModelKey, + using table: [String: ModelPricing] = PricingTable.builtIn) -> CostEstimate { + guard let p = table[model.id] else { return CostEstimate(amountUSD: 0, isComplete: false) } + let usd = (Double(tokens.input) * p.inputPerMTok + + Double(tokens.output) * p.outputPerMTok + + Double(tokens.cacheCreation) * p.cacheWritePerMTok + + Double(tokens.cacheRead) * p.cacheReadPerMTok + + Double(tokens.reasoning) * p.reasoningPerMTok) / 1_000_000 + return CostEstimate(amountUSD: usd, isComplete: true) +} + +/// Sum the cost across a per-model token map (e.g. one day's usage). +public func costEstimate(byModel: [String: TokenBreakdown], + using table: [String: ModelPricing] = PricingTable.builtIn) -> CostEstimate { + byModel.reduce(.zeroComplete) { acc, entry in + acc + costEstimate(entry.value, model: ModelKey(raw: entry.key), using: table) + } +} diff --git a/Sources/AgentMeterCore/StatusLevel.swift b/Sources/AgentMeterCore/StatusLevel.swift new file mode 100644 index 0000000..a791a9b --- /dev/null +++ b/Sources/AgentMeterCore/StatusLevel.swift @@ -0,0 +1,24 @@ +import Foundation + +/// Urgency of a quota/usage meter, driven purely by how much *remains*. +/// This is deliberately separate from a service's identity colour: identity +/// answers "which tool?", status answers "how much is left?". +public enum StatusLevel: Sendable, Equatable { + case normal // plenty remains + case warning // getting tight + case low // nearly out + case empty // effectively exhausted + + /// Classify from a *used* percentage (0...100). Values are clamped. + /// remaining = 100 - used: + /// remaining >= 50 normal · >= 25 warning · >= 10 low · else empty. + public static func forUsed(percent used: Double) -> StatusLevel { + let remaining = 100 - min(100, max(0, used)) + switch remaining { + case 50...: return .normal + case 25..<50: return .warning + case 10..<25: return .low + default: return .empty + } + } +} diff --git a/Sources/AgentMeterCore/UsageHistory.swift b/Sources/AgentMeterCore/UsageHistory.swift new file mode 100644 index 0000000..bbd488c --- /dev/null +++ b/Sources/AgentMeterCore/UsageHistory.swift @@ -0,0 +1,85 @@ +import Foundation + +/// One calendar day's usage, split by normalized model key. +public struct DayBucket: Sendable, Equatable { + public let day: Date // start-of-day in the query calendar + public var byModel: [String: TokenBreakdown] + public var messageCount: Int + + public init(day: Date, byModel: [String: TokenBreakdown] = [:], messageCount: Int = 0) { + self.day = day + self.byModel = byModel + self.messageCount = messageCount + } + + /// Sum across every model used that day. + public var total: TokenBreakdown { byModel.values.reduce(TokenBreakdown(), +) } +} + +/// Aggregated usage for one tool over a date range — the data the stats window +/// renders (daily chart, by-model table, KPI totals). +public struct UsageHistory: Sendable, Equatable { + public let tool: AgentTool + public let range: DateInterval + /// Ascending by day; sparse (only days with usage appear). + public var days: [DayBucket] + /// Whole-range rollup keyed by normalized model id. + public var byModel: [String: TokenBreakdown] + public var messageCount: Int + + public init(tool: AgentTool, range: DateInterval, + days: [DayBucket] = [], byModel: [String: TokenBreakdown] = [:], + messageCount: Int = 0) { + self.tool = tool + self.range = range + self.days = days + self.byModel = byModel + self.messageCount = messageCount + } + + public var grandTotal: TokenBreakdown { byModel.values.reduce(TokenBreakdown(), +) } + + /// Total local cost estimate across the range (incomplete if any model is unpriced). + public var cost: CostEstimate { costEstimate(byModel: byModel) } +} + +/// Accumulates rows into per-day, per-model buckets. Shared by both readers' +/// `history(range:)` so Claude and Codex bucket identically. +struct HistoryAccumulator { + let tool: AgentTool + let range: DateInterval + let calendar: Calendar + private var days: [Date: DayBucket] = [:] + private(set) var byModel: [String: TokenBreakdown] = [:] + private(set) var messageCount = 0 + + init(tool: AgentTool, range: DateInterval, calendar: Calendar) { + self.tool = tool + self.range = range + self.calendar = calendar + } + + func contains(_ ts: Date) -> Bool { ts >= range.start && ts < range.end } + + mutating func add(_ breakdown: TokenBreakdown, modelKey: String, at ts: Date) { + let day = calendar.startOfDay(for: ts) + var bucket = days[day] ?? DayBucket(day: day) + bucket.byModel[modelKey, default: TokenBreakdown()] += breakdown + days[day] = bucket + byModel[modelKey, default: TokenBreakdown()] += breakdown + } + + mutating func addMessage(at ts: Date) { + let day = calendar.startOfDay(for: ts) + var bucket = days[day] ?? DayBucket(day: day) + bucket.messageCount += 1 + days[day] = bucket + messageCount += 1 + } + + func finish() -> UsageHistory { + UsageHistory(tool: tool, range: range, + days: days.values.sorted { $0.day < $1.day }, + byModel: byModel, messageCount: messageCount) + } +} diff --git a/Sources/AgentMeterCore/UsageModels.swift b/Sources/AgentMeterCore/UsageModels.swift index 806bb1d..71a589c 100644 --- a/Sources/AgentMeterCore/UsageModels.swift +++ b/Sources/AgentMeterCore/UsageModels.swift @@ -121,6 +121,9 @@ public struct ToolUsage: Equatable, Sendable { public var today: TokenBreakdown public var rolling5h: TokenBreakdown public var messageCount: Int + /// Today's tokens split by normalized model key (`ModelKey.id`), used for a + /// per-model local cost estimate. Empty when the reader can't attribute models. + public var todayByModel: [String: TokenBreakdown] /// Fullness of the most-recent session's context window, if known. public var contextWindow: ContextWindow? public var lastUpdated: Date @@ -130,6 +133,7 @@ public struct ToolUsage: Equatable, Sendable { today: TokenBreakdown = .init(), rolling5h: TokenBreakdown = .init(), messageCount: Int = 0, + todayByModel: [String: TokenBreakdown] = [:], contextWindow: ContextWindow? = nil, lastUpdated: Date = Date(timeIntervalSince1970: 0)) { self.tool = tool @@ -137,6 +141,7 @@ public struct ToolUsage: Equatable, Sendable { self.today = today self.rolling5h = rolling5h self.messageCount = messageCount + self.todayByModel = todayByModel self.contextWindow = contextWindow self.lastUpdated = lastUpdated } diff --git a/Tests/AgentMeterCoreTests/ClaudeCodeReaderTests.swift b/Tests/AgentMeterCoreTests/ClaudeCodeReaderTests.swift index 5bef418..6f0e7e1 100644 --- a/Tests/AgentMeterCoreTests/ClaudeCodeReaderTests.swift +++ b/Tests/AgentMeterCoreTests/ClaudeCodeReaderTests.swift @@ -59,6 +59,28 @@ final class ClaudeCodeReaderTests: XCTestCase { XCTAssertEqual(usage.rolling5h, TokenBreakdown(input: 100, output: 50, cacheCreation: 10, cacheRead: 20)) } + func testTodayByModelBucketsByNormalizedKeyAndSumsToToday() throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + + let opus = assistantLine(ts: utc(2026, 6, 14, 1), id: "m1", req: "r1", + input: 100, output: 50, cc: 10, cr: 20, model: "claude-opus-4-8") + // dated snapshot must normalize to the base key "claude-haiku-4-5" + let haiku = assistantLine(ts: utc(2026, 6, 14, 1, 10), id: "m2", req: "r2", + input: 200, output: 100, cc: 0, cr: 0, model: "claude-haiku-4-5-20251001") + try writeLines([opus, haiku], to: dir.appendingPathComponent("p/s.jsonl")) + + let usage = try reader(dir).read(now: now) + + XCTAssertEqual(usage.todayByModel["claude-opus-4-8"], + TokenBreakdown(input: 100, output: 50, cacheCreation: 10, cacheRead: 20)) + XCTAssertEqual(usage.todayByModel["claude-haiku-4-5"], + TokenBreakdown(input: 200, output: 100)) + // parity: per-model buckets sum to the flat `today` total + let summed = usage.todayByModel.values.reduce(TokenBreakdown(), +) + XCTAssertEqual(summed, usage.today) + } + func testContextWindowFromLatestTurnUses1MForOneMillionModel() throws { let dir = try makeTempDir() defer { try? FileManager.default.removeItem(at: dir) } diff --git a/Tests/AgentMeterCoreTests/CredentialsTests.swift b/Tests/AgentMeterCoreTests/CredentialsTests.swift new file mode 100644 index 0000000..14e0f6b --- /dev/null +++ b/Tests/AgentMeterCoreTests/CredentialsTests.swift @@ -0,0 +1,76 @@ +import XCTest +@testable import AgentMeterCore + +final class CredentialsTests: XCTestCase { + /// Build a fake JWT (`header.payload.sig`) with base64url-encoded claims. + private func makeJWT(_ claims: [String: Any]) -> String { + let data = try! JSONSerialization.data(withJSONObject: claims) + let b64 = data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + return "eyJ.\(b64).sig" + } + + func testJWTDecodesPayloadClaims() { + let token = makeJWT(["email": "x@y.com", "n": 7]) + let payload = JWT.payload(token) + XCTAssertEqual(payload?["email"] as? String, "x@y.com") + XCTAssertEqual(payload?["n"] as? Int, 7) + } + + func testJWTRejectsGarbage() { + XCTAssertNil(JWT.payload("not-a-jwt")) + XCTAssertNil(JWT.payload("")) + } + + func testClaudeCredentialsParse() { + let json = """ + {"claudeAiOauth":{"emailAddress":"u@x.com","subscriptionType":"pro", + "accessToken":"AT","refreshToken":"RT","expiresAt":1800000000000}} + """.data(using: .utf8)! + let c = ClaudeCredentialParser.parse(credentialsJSON: json) + XCTAssertEqual(c?.accessToken, "AT") + XCTAssertEqual(c?.refreshToken, "RT") + XCTAssertEqual(c?.account.email, "u@x.com") + XCTAssertEqual(c?.account.plan, "pro") + XCTAssertEqual(c?.expiresAtMillis, 1_800_000_000_000) + } + + func testClaudeEmailFallbackFromConfig() { + let json = #"{"oauthAccount":{"emailAddress":"fallback@x.com"},"other":1}"#.data(using: .utf8)! + XCTAssertEqual(ClaudeCredentialParser.email(fromClaudeConfigJSON: json), "fallback@x.com") + } + + func testClaudeAccountFromConfigDerivesPlan() { + // Real shape: token lives in the Keychain, but ~/.claude.json has the account. + let json = #"{"oauthAccount":{"emailAddress":"a@b.com","organizationType":"claude_team"}}"#.data(using: .utf8)! + let a = ClaudeCredentialParser.account(fromClaudeConfigJSON: json) + XCTAssertEqual(a?.email, "a@b.com") + XCTAssertEqual(a?.plan, "team") + } + + func testCodexCredentialsParseDecodesIdToken() { + let idToken = makeJWT([ + "email": "c@x.com", + "https://api.openai.com/auth": ["chatgpt_plan_type": "plus"], + ]) + let json = """ + {"tokens":{"access_token":"CAT","refresh_token":"CRT","id_token":"\(idToken)","account_id":"acc-1"}} + """.data(using: .utf8)! + let c = CodexCredentialParser.parse(authJSON: json) + XCTAssertEqual(c?.accessToken, "CAT") + XCTAssertEqual(c?.accountId, "acc-1") + XCTAssertEqual(c?.account.email, "c@x.com") + XCTAssertEqual(c?.account.plan, "plus") + } + + func testExpiryFromMillis() { + let future = ClaudeCredentials(accessToken: "a", refreshToken: nil, + expiresAtMillis: 2_000_000_000_000, account: .init()) + XCTAssertFalse(future.isExpired(now: Date(timeIntervalSince1970: 1_000_000_000))) + let past = ClaudeCredentials(accessToken: "a", refreshToken: nil, + expiresAtMillis: 1_000_000, account: .init()) + XCTAssertTrue(past.isExpired(now: Date(timeIntervalSince1970: 1_000_000_000))) + } +} diff --git a/Tests/AgentMeterCoreTests/HexColorTests.swift b/Tests/AgentMeterCoreTests/HexColorTests.swift new file mode 100644 index 0000000..25eb232 --- /dev/null +++ b/Tests/AgentMeterCoreTests/HexColorTests.swift @@ -0,0 +1,39 @@ +import XCTest +@testable import AgentMeterCore + +final class HexColorTests: XCTestCase { + func testParsesSixDigitWithHash() { + let c = HexColor.rgb("#D97757") + XCTAssertEqual(c?.r, 217) + XCTAssertEqual(c?.g, 119) + XCTAssertEqual(c?.b, 87) + } + + func testParsesWithoutHashAndLowercase() { + let c = HexColor.rgb("6c6c70") + XCTAssertEqual(c?.r, 108) + XCTAssertEqual(c?.g, 108) + XCTAssertEqual(c?.b, 112) + } + + func testRejectsInvalid() { + XCTAssertNil(HexColor.rgb("nope")) + XCTAssertNil(HexColor.rgb("#12345")) // wrong length + XCTAssertNil(HexColor.rgb("")) + } + + func testFormatsUppercaseWithHash() { + XCTAssertEqual(HexColor.string(r: 217, g: 119, b: 87), "#D97757") + } + + func testFormatClampsOutOfRange() { + XCTAssertEqual(HexColor.string(r: 300, g: -5, b: 0), "#FF0000") + } + + func testRoundTrip() { + for hex in ["#D97757", "#6C6C70", "#000000", "#FFFFFF"] { + let c = HexColor.rgb(hex)! + XCTAssertEqual(HexColor.string(r: c.r, g: c.g, b: c.b), hex) + } + } +} diff --git a/Tests/AgentMeterCoreTests/ModelIdentityTests.swift b/Tests/AgentMeterCoreTests/ModelIdentityTests.swift new file mode 100644 index 0000000..405d60f --- /dev/null +++ b/Tests/AgentMeterCoreTests/ModelIdentityTests.swift @@ -0,0 +1,37 @@ +import XCTest +@testable import AgentMeterCore + +final class ModelIdentityTests: XCTestCase { + func testStripsDateSuffix() { + XCTAssertEqual(ModelKey(raw: "claude-haiku-4-5-20251001").id, "claude-haiku-4-5") + } + + func testStripsBracketSuffix() { + XCTAssertEqual(ModelKey(raw: "claude-opus-4-8[1m]").id, "claude-opus-4-8") + } + + func testStripsBothBracketAndDate() { + XCTAssertEqual(ModelKey(raw: "claude-haiku-4-5-20251001[1m]").id, "claude-haiku-4-5") + } + + func testVendorClassification() { + XCTAssertEqual(ModelKey(raw: "claude-opus-4-8").vendor, .anthropic) + XCTAssertEqual(ModelKey(raw: "gpt-5.5").vendor, .openai) + XCTAssertEqual(ModelKey(raw: "codex-auto-review").vendor, .openai) + XCTAssertEqual(ModelKey(raw: "mimo-v2.5").vendor, .unknown) + } + + func testPlainOpenAIKept() { + XCTAssertEqual(ModelKey(raw: "gpt-5.5").id, "gpt-5.5") + } + + func testSyntheticAndEmptyBecomeUnknown() { + XCTAssertEqual(ModelKey(raw: ""), .unknown) + XCTAssertEqual(ModelKey(raw: " "), .unknown) + XCTAssertEqual(ModelKey(raw: ""), .unknown) + } + + func testEqualityByNormalizedId() { + XCTAssertEqual(ModelKey(raw: "claude-opus-4-8[1m]"), ModelKey(raw: "claude-opus-4-8")) + } +} diff --git a/Tests/AgentMeterCoreTests/PricingTests.swift b/Tests/AgentMeterCoreTests/PricingTests.swift new file mode 100644 index 0000000..c8714bb --- /dev/null +++ b/Tests/AgentMeterCoreTests/PricingTests.swift @@ -0,0 +1,40 @@ +import XCTest +@testable import AgentMeterCore + +final class PricingTests: XCTestCase { + func testKnownModelCost() { + // 1M input + 1M output on Opus 4.8 ($5 in / $25 out per MTok) = $30. + let t = TokenBreakdown(input: 1_000_000, output: 1_000_000) + let est = costEstimate(t, model: ModelKey(raw: "claude-opus-4-8")) + XCTAssertEqual(est.amountUSD, 30, accuracy: 1e-6) + XCTAssertTrue(est.isComplete) + } + + func testCacheRatesApplied() { + // cache read defaults to 0.1x input, cache write to 1.25x input. + // 1M cacheRead on Opus 4.8 = 1M * (0.1 * $5) = $0.50; 1M cacheCreation = $6.25. + let t = TokenBreakdown(cacheCreation: 1_000_000, cacheRead: 1_000_000) + let est = costEstimate(t, model: ModelKey(raw: "claude-opus-4-8")) + XCTAssertEqual(est.amountUSD, 6.75, accuracy: 1e-6) + } + + func testHaikuCheaperThanOpus() { + let t = TokenBreakdown(input: 1_000_000) + XCTAssertEqual(costEstimate(t, model: ModelKey(raw: "claude-haiku-4-5")).amountUSD, 1, accuracy: 1e-6) + XCTAssertEqual(costEstimate(t, model: ModelKey(raw: "claude-fable-5")).amountUSD, 10, accuracy: 1e-6) + } + + func testUnknownModelIsIncompleteAndZero() { + let t = TokenBreakdown(input: 1_000_000, output: 1_000_000) + let est = costEstimate(t, model: ModelKey(raw: "gpt-5.5")) + XCTAssertEqual(est.amountUSD, 0) + XCTAssertFalse(est.isComplete) + } + + func testDatedSnapshotResolvesToBasePrice() { + let t = TokenBreakdown(input: 1_000_000) + let est = costEstimate(t, model: ModelKey(raw: "claude-haiku-4-5-20251001")) + XCTAssertEqual(est.amountUSD, 1, accuracy: 1e-6) + XCTAssertTrue(est.isComplete) + } +} diff --git a/Tests/AgentMeterCoreTests/StatusLevelTests.swift b/Tests/AgentMeterCoreTests/StatusLevelTests.swift new file mode 100644 index 0000000..2a673ae --- /dev/null +++ b/Tests/AgentMeterCoreTests/StatusLevelTests.swift @@ -0,0 +1,38 @@ +import XCTest +@testable import AgentMeterCore + +final class StatusLevelTests: XCTestCase { + // Status is driven by how much quota REMAINS (remaining = 100 - used): + // remaining >= 50 -> normal (used <= 50) + // 25 <= remaining < 50 -> warning (50 < used <= 75) + // 10 <= remaining < 25 -> low (75 < used <= 90) + // remaining < 10 -> empty (used > 90) + + func testNormalWhenPlentyRemains() { + XCTAssertEqual(StatusLevel.forUsed(percent: 0), .normal) + XCTAssertEqual(StatusLevel.forUsed(percent: 29), .normal) + XCTAssertEqual(StatusLevel.forUsed(percent: 50), .normal) + } + + func testWarningBand() { + XCTAssertEqual(StatusLevel.forUsed(percent: 50.1), .warning) + XCTAssertEqual(StatusLevel.forUsed(percent: 64), .warning) + XCTAssertEqual(StatusLevel.forUsed(percent: 75), .warning) + } + + func testLowBand() { + XCTAssertEqual(StatusLevel.forUsed(percent: 75.1), .low) + XCTAssertEqual(StatusLevel.forUsed(percent: 88), .low) + XCTAssertEqual(StatusLevel.forUsed(percent: 90), .low) + } + + func testEmptyWhenNearlyExhausted() { + XCTAssertEqual(StatusLevel.forUsed(percent: 90.1), .empty) + XCTAssertEqual(StatusLevel.forUsed(percent: 100), .empty) + } + + func testClampsOutOfRange() { + XCTAssertEqual(StatusLevel.forUsed(percent: -10), .normal) + XCTAssertEqual(StatusLevel.forUsed(percent: 150), .empty) + } +} diff --git a/Tests/AgentMeterCoreTests/UsageHistoryTests.swift b/Tests/AgentMeterCoreTests/UsageHistoryTests.swift new file mode 100644 index 0000000..3c63730 --- /dev/null +++ b/Tests/AgentMeterCoreTests/UsageHistoryTests.swift @@ -0,0 +1,103 @@ +import XCTest +@testable import AgentMeterCore + +final class UsageHistoryTests: XCTestCase { + private let range = DateInterval(start: utc(2026, 6, 10), end: utc(2026, 6, 20)) + + // MARK: Claude + + private func claudeLine(ts: Date, id: String, input: Int, output: Int, model: String) -> String { + """ + {"type":"assistant","timestamp":"\(iso(ts))","requestId":"\(id)","message":{"id":"\(id)","role":"assistant","model":"\(model)","usage":{"input_tokens":\(input),"output_tokens":\(output),"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}} + """ + } + + func testClaudeHistoryBucketsByDayAndModelWithDedup() throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + + let d14a = claudeLine(ts: utc(2026, 6, 14, 1), id: "a", input: 100, output: 10, model: "claude-opus-4-8") + let d14aDup = d14a // resume duplicate, must not double-count + let d14b = claudeLine(ts: utc(2026, 6, 14, 3), id: "b", input: 200, output: 20, + model: "claude-haiku-4-5-20251001") // normalizes to claude-haiku-4-5 + let d13 = claudeLine(ts: utc(2026, 6, 13, 1), id: "c", input: 50, output: 5, model: "claude-opus-4-8") + let outOfRange = claudeLine(ts: utc(2026, 6, 1, 1), id: "z", input: 9, output: 9, model: "claude-opus-4-8") + + try writeLines([d14a, d14aDup, d14b, d13, outOfRange], + to: dir.appendingPathComponent("p/s.jsonl")) + + let h = try ClaudeCodeReader(projectsDirectory: dir, calendar: taipeiCalendar).history(range: range) + + // out-of-range excluded; 3 distinct rows across 2 Taipei days + XCTAssertEqual(h.days.count, 2) + XCTAssertEqual(h.messageCount, 3) + XCTAssertEqual(h.byModel["claude-opus-4-8"], TokenBreakdown(input: 150, output: 15)) + XCTAssertEqual(h.byModel["claude-haiku-4-5"], TokenBreakdown(input: 200, output: 20)) + // ascending by day + XCTAssertTrue(h.days[0].day < h.days[1].day) + // the later day has both models + let day14 = h.days[1] + XCTAssertEqual(day14.byModel["claude-opus-4-8"], TokenBreakdown(input: 100, output: 10)) + XCTAssertEqual(day14.byModel["claude-haiku-4-5"], TokenBreakdown(input: 200, output: 20)) + } + + // MARK: Codex + + private func turnContext(ts: Date, model: String) -> String { + """ + {"timestamp":"\(iso(ts))","type":"turn_context","payload":{"type":"turn_context","model":"\(model)"}} + """ + } + + private func tokenCount(ts: Date, input: Int, output: Int) -> String { + """ + {"timestamp":"\(iso(ts))","type":"event_msg","payload":{"type":"token_count","info":{"model_context_window":272000,"last_token_usage":{"input_tokens":\(input),"cached_input_tokens":0,"output_tokens":\(output),"reasoning_output_tokens":0,"total_tokens":\(input + output)}}}} + """ + } + + func testCodexHistoryAttributesTokensToCurrentModel() throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + + // One file, two models in sequence — the state machine must partition them. + let lines = [ + turnContext(ts: utc(2026, 6, 14, 0, 50), model: "gpt-5.5"), + tokenCount(ts: utc(2026, 6, 14, 1), input: 1000, output: 100), + turnContext(ts: utc(2026, 6, 14, 1, 30), model: "gpt-5.4"), + tokenCount(ts: utc(2026, 6, 14, 2), input: 500, output: 50), + ] + try writeLines(lines, to: dir.appendingPathComponent("2026/06/14/rollout-a.jsonl")) + + let h = try CodexReader(sessionsDirectory: dir, calendar: taipeiCalendar).history(range: range) + + XCTAssertEqual(h.byModel["gpt-5.5"], TokenBreakdown(input: 1000, output: 100)) + XCTAssertEqual(h.byModel["gpt-5.4"], TokenBreakdown(input: 500, output: 50)) + } + + func testCodexHistoryTokenCountBeforeAnyTurnContextIsUnknown() throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + + try writeLines([tokenCount(ts: utc(2026, 6, 14, 1), input: 300, output: 30)], + to: dir.appendingPathComponent("2026/06/14/rollout-b.jsonl")) + + let h = try CodexReader(sessionsDirectory: dir, calendar: taipeiCalendar).history(range: range) + XCTAssertEqual(h.byModel[ModelKey.unknown.id], TokenBreakdown(input: 300, output: 30)) + } + + func testCodexModelStateResetsPerFile() throws { + let dir = try makeTempDir() + defer { try? FileManager.default.removeItem(at: dir) } + + // File 1 establishes gpt-5.5; file 2 must NOT inherit it. + try writeLines([turnContext(ts: utc(2026, 6, 13, 1), model: "gpt-5.5"), + tokenCount(ts: utc(2026, 6, 13, 1, 5), input: 10, output: 1)], + to: dir.appendingPathComponent("2026/06/13/rollout-1.jsonl")) + try writeLines([tokenCount(ts: utc(2026, 6, 14, 1), input: 20, output: 2)], + to: dir.appendingPathComponent("2026/06/14/rollout-2.jsonl")) + + let h = try CodexReader(sessionsDirectory: dir, calendar: taipeiCalendar).history(range: range) + XCTAssertEqual(h.byModel["gpt-5.5"], TokenBreakdown(input: 10, output: 1)) + XCTAssertEqual(h.byModel[ModelKey.unknown.id], TokenBreakdown(input: 20, output: 2)) + } +}