From 6af62dfacf71382aa2de50194d64f5472d19d349 Mon Sep 17 00:00:00 2001 From: TeaLance <178543322+TeaLance@users.noreply.github.com> Date: Sat, 20 Jun 2026 01:00:12 +0800 Subject: [PATCH 01/14] Editorial redesign Phase 0+1: design system, components, i18n, colors, new panel Pivot the UI away from the cc-bar-style cards/rings to an editorial-minimal language (paper/ink, hairlines, tabular numbers, thin bars, no cards). Core (TDD): - StatusLevel: 4-state status (normal/warning/low/empty) by remaining quota. - HexColor: #RRGGBB parse/format. UI foundations: - DesignSystem: editorial tokens (light+dark), single statusColor source, adaptive Color(amLight:dark:) + Color(amHex:). - Components: Hairline, ServiceSwatch, ThinBar, MetricRow, HeroNumber, SegmentedPair ([5h|wk]). - LanguageStore + tr(en,zh): live manual zh/en switch, no restart. - ServiceColorStore: per-service identity colour (brand/mono presets). Panel rebuild (MenuContentView): - Hero number per service (Claude 5h default, [5h|wk] toggle to weekly; Codex shows context%), aligned mini-rows, today tokens + messages footer. - Status colour drives values/bars; identity colour drives the swatch only. 42 Core tests pass; app builds and launches. Cost ($) deferred to pricing phase. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/AgentMeter/AgentMeterApp.swift | 6 + Sources/AgentMeter/AppSettings.swift | 6 + .../AgentMeter/Appearance/ServiceColors.swift | 45 ++++ Sources/AgentMeter/Design/Components.swift | 98 ++++++++ Sources/AgentMeter/Design/DesignSystem.swift | 62 +++++ .../Localization/Localization.swift | 44 ++++ Sources/AgentMeter/MenuContentView.swift | 235 +++++++++++------- Sources/AgentMeterCore/HexColor.swift | 23 ++ Sources/AgentMeterCore/StatusLevel.swift | 24 ++ Tests/AgentMeterCoreTests/HexColorTests.swift | 39 +++ .../StatusLevelTests.swift | 38 +++ 11 files changed, 530 insertions(+), 90 deletions(-) create mode 100644 Sources/AgentMeter/Appearance/ServiceColors.swift create mode 100644 Sources/AgentMeter/Design/Components.swift create mode 100644 Sources/AgentMeter/Design/DesignSystem.swift create mode 100644 Sources/AgentMeter/Localization/Localization.swift create mode 100644 Sources/AgentMeterCore/HexColor.swift create mode 100644 Sources/AgentMeterCore/StatusLevel.swift create mode 100644 Tests/AgentMeterCoreTests/HexColorTests.swift create mode 100644 Tests/AgentMeterCoreTests/StatusLevelTests.swift diff --git a/Sources/AgentMeter/AgentMeterApp.swift b/Sources/AgentMeter/AgentMeterApp.swift index d6ded1e..1c62243 100644 --- a/Sources/AgentMeter/AgentMeterApp.swift +++ b/Sources/AgentMeter/AgentMeterApp.swift @@ -6,11 +6,15 @@ import AgentMeterCore struct AgentMeterApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @StateObject private var store = UsageStore() + @StateObject private var lang = LanguageStore() + @StateObject private var colors = ServiceColorStore() var body: some Scene { MenuBarExtra { MenuContentView() .environmentObject(store) + .environmentObject(lang) + .environmentObject(colors) } label: { MenuBarLabel(store: store) } @@ -19,6 +23,8 @@ struct AgentMeterApp: App { Settings { SettingsView() .environmentObject(store) + .environmentObject(lang) + .environmentObject(colors) } } } diff --git a/Sources/AgentMeter/AppSettings.swift b/Sources/AgentMeter/AppSettings.swift index c812d2d..d9ef333 100644 --- a/Sources/AgentMeter/AppSettings.swift +++ b/Sources/AgentMeter/AppSettings.swift @@ -6,8 +6,14 @@ 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" } +/// 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 diff --git a/Sources/AgentMeter/Appearance/ServiceColors.swift b/Sources/AgentMeter/Appearance/ServiceColors.swift new file mode 100644 index 0000000..8c7421e --- /dev/null +++ b/Sources/AgentMeter/Appearance/ServiceColors.swift @@ -0,0 +1,45 @@ +import SwiftUI +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 { + 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) + } +} diff --git a/Sources/AgentMeter/Design/Components.swift b/Sources/AgentMeter/Design/Components.swift new file mode 100644 index 0000000..66b216e --- /dev/null +++ b/Sources/AgentMeter/Design/Components.swift @@ -0,0 +1,98 @@ +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) + } + } +} + +/// 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..2b20095 --- /dev/null +++ b/Sources/AgentMeter/Design/DesignSystem.swift @@ -0,0 +1,62 @@ +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)) +} + +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/Localization/Localization.swift b/Sources/AgentMeter/Localization/Localization.swift new file mode 100644 index 0000000..cde0c2d --- /dev/null +++ b/Sources/AgentMeter/Localization/Localization.swift @@ -0,0 +1,44 @@ +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 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/MenuContentView.swift b/Sources/AgentMeter/MenuContentView.swift index fdd99fd..e5f258e 100644 --- a/Sources/AgentMeter/MenuContentView.swift +++ b/Sources/AgentMeter/MenuContentView.swift @@ -2,157 +2,212 @@ 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 + @AppStorage(SettingsKeys.heroMetricClaude) private var claudeHeroRaw = ClaudeHero.fiveHour.rawValue @Environment(\.openSettings) private var openSettings + private var claudeHero: ClaudeHero { ClaudeHero(rawValue: claudeHeroRaw) ?? .fiveHour } + 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("gearshape") { openSettingsWindow() } + 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) 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: hero.usedPercent / 100, level: .forUsed(percent: hero.usedPercent)) } - - secondaryMetrics(store.claude) + if let cw, let ctxPct { + MetricRow(label: lang.tr("Context", "Context"), fraction: cw.fraction, + 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: other.usedPercent / 100, value: quotaMini(other), + level: .forUsed(percent: other.usedPercent)) + } + } else if let cw, let ctxPct { + heroFromPercent(ctxPct, label: lang.tr("context", "Context")) + ThinBar(fraction: cw.fraction, level: .forUsed(percent: ctxPct)) + enableQuotaLink } else { - Text("未偵測到使用資料").font(.caption).foregroundStyle(.secondary) + enableQuotaLink } } } + private var enableQuotaLink: some View { + Button { openSettingsWindow() } 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) if store.codex.available { - if let cw = store.codex.contextWindow { - MeterBar(title: "Context window", valueText: contextValue(cw), fraction: cw.fraction) + VStack(alignment: .leading, spacing: AM.Space.m) { + if let cw = store.codex.contextWindow { + heroFromPercent(cw.fraction * 100, label: lang.tr("context", "Context")) + ThinBar(fraction: cw.fraction, level: .forUsed(percent: cw.fraction * 100)) + } } - secondaryMetrics(store.codex) + footer(store.codex) } else { - Text("未偵測到使用資料").font(.caption).foregroundStyle(.secondary) + noData } } } - // 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) -> some View { + HStack(spacing: AM.Space.s) { + ServiceSwatch(color: colors.color(for: tool)) + Text(name).font(.system(size: 13.5, weight: .semibold)) 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: 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() - } + private func heroFromPercent(_ pct: Double, label: String) -> some View { + HeroNumber(percent: pct, label: label, level: .forUsed(percent: pct)) } - 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.billableTotal.compactTokenString) tokens") + footerItem(lang.tr("Messages", "訊息"), "\(u.messageCount)") Spacer() - Button("設定…") { openSettingsWindow() } - Button("結束") { NSApplication.shared.terminate(nil) } } } } + private func footerItem(_ label: String, _ value: String) -> Text { + (Text(label + " ").foregroundColor(AM.ink2) + 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, _ pct: Double) -> String { + "\(Int(pct.rounded()))% · \(cw.used.compactTokenString)" } - private func quotaValue(_ w: QuotaWindow) -> String { + private func quotaMini(_ w: QuotaWindow) -> String { let pct = "\(Int(w.usedPercent.rounded()))%" - if let reset = shortReset(until: w.resetsAt) { - return "\(pct) · resets \(reset)" - } + 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/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/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/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/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) + } +} From 18ad1aee30675cffa6a605c2583dc4da76e661cf Mon Sep 17 00:00:00 2001 From: TeaLance <178543322+TeaLance@users.noreply.github.com> Date: Sat, 20 Jun 2026 01:05:48 +0800 Subject: [PATCH 02/14] =?UTF-8?q?Phase=203:=20local=20cost=20estimate=20(M?= =?UTF-8?q?odelIdentity=20+=20Pricing)=20+=20wire=20=E2=89=88$=20into=20pa?= =?UTF-8?q?nel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core (TDD): - ModelKey: normalize model ids (strip dated -YYYYMMDD and [1m] suffixes, /empty -> unknown), vendor classification. - Pricing: ModelPricing (per-MTok, cache 0.1x/1.25x, reasoning=output), CostEstimate (amountUSD + isComplete), built-in Anthropic list prices, costEstimate(tokens,model) and costEstimate(byModel:). OpenAI/Codex models intentionally unpriced -> isComplete=false (renders "≈$X+"). - ToolUsage gains todayByModel; ClaudeCodeReader buckets today's tokens by normalized model key (parity test: buckets sum to flat today). UI: - Panel footer shows ≈$ from costEstimate(byModel:) when per-model data exists (Claude); hidden for Codex (no per-model attribution / no OpenAI pricing yet). 55 Core tests pass; app builds. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/AgentMeter/MenuContentView.swift | 13 ++- Sources/AgentMeterCore/ClaudeCodeReader.swift | 7 +- Sources/AgentMeterCore/ModelIdentity.swift | 50 ++++++++++++ Sources/AgentMeterCore/Pricing.swift | 81 +++++++++++++++++++ Sources/AgentMeterCore/UsageModels.swift | 5 ++ .../ClaudeCodeReaderTests.swift | 22 +++++ .../ModelIdentityTests.swift | 37 +++++++++ Tests/AgentMeterCoreTests/PricingTests.swift | 40 +++++++++ 8 files changed, 252 insertions(+), 3 deletions(-) create mode 100644 Sources/AgentMeterCore/ModelIdentity.swift create mode 100644 Sources/AgentMeterCore/Pricing.swift create mode 100644 Tests/AgentMeterCoreTests/ModelIdentityTests.swift create mode 100644 Tests/AgentMeterCoreTests/PricingTests.swift diff --git a/Sources/AgentMeter/MenuContentView.swift b/Sources/AgentMeter/MenuContentView.swift index e5f258e..1a165e5 100644 --- a/Sources/AgentMeter/MenuContentView.swift +++ b/Sources/AgentMeter/MenuContentView.swift @@ -173,16 +173,27 @@ struct MenuContentView: View { HStack(spacing: AM.Space.l) { footerItem(lang.tr("Today", "今日"), "\(u.today.billableTotal.compactTokenString) tokens") footerItem(lang.tr("Messages", "訊息"), "\(u.messageCount)") + if !u.todayByModel.isEmpty { + footerItem("", usdString(costEstimate(byModel: u.todayByModel))) + } Spacer() } } } private func footerItem(_ label: String, _ value: String) -> Text { - (Text(label + " ").foregroundColor(AM.ink2) + Text(value).foregroundColor(AM.ink).bold()) + let prefix = label.isEmpty ? Text("") : Text(label + " ").foregroundColor(AM.ink2) + return (prefix + Text(value).foregroundColor(AM.ink).bold()) .font(.system(size: 11.5).monospacedDigit()) } + /// "≈$1.80" — trailing "+" when some tokens came from unpriced models. + private func usdString(_ est: CostEstimate) -> String { + let amount = est.amountUSD < 0.01 && est.amountUSD > 0 + ? "<0.01" : String(format: "%.2f", est.amountUSD) + return "≈$\(amount)\(est.isComplete ? "" : "+")" + } + private var noData: some View { Text(lang.tr("No usage detected", "未偵測到使用資料")) .font(.system(size: 11)).foregroundStyle(AM.ink2) diff --git a/Sources/AgentMeterCore/ClaudeCodeReader.swift b/Sources/AgentMeterCore/ClaudeCodeReader.swift index 46db4ff..67f3278 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,8 @@ 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) } } 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/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/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) + } +} From dff7f6a5bea14eee9ccf6e126cb0e72d0dd3b968 Mon Sep 17 00:00:00 2001 From: TeaLance <178543322+TeaLance@users.noreply.github.com> Date: Sat, 20 Jun 2026 01:09:17 +0800 Subject: [PATCH 03/14] Phase 4: history data layer (per-day, per-model) + Codex model state machine Core (TDD): - UsageHistory/DayBucket types + HistoryAccumulator (shared bucketing). - ClaudeCodeReader.history(range:): per-day, per-model buckets, dedup by (id,requestId), range-bounded; independent of read(now:) for safety. - CodexReader.history(range:): turn_context -> token_count model state machine (model attributed per-file, reset per file, .unknown before any turn_context); added payload.model decoding. Tests: Claude day/model bucketing + dedup + range bounds; Codex two-models-in-one-file attribution, no-leading-turn_context -> unknown, per-file state reset. 59 Core tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/AgentMeterCore/ClaudeCodeReader.swift | 44 ++++++++ Sources/AgentMeterCore/CodexReader.swift | 48 ++++++++ Sources/AgentMeterCore/UsageHistory.swift | 85 +++++++++++++++ .../UsageHistoryTests.swift | 103 ++++++++++++++++++ 4 files changed, 280 insertions(+) create mode 100644 Sources/AgentMeterCore/UsageHistory.swift create mode 100644 Tests/AgentMeterCoreTests/UsageHistoryTests.swift diff --git a/Sources/AgentMeterCore/ClaudeCodeReader.swift b/Sources/AgentMeterCore/ClaudeCodeReader.swift index 67f3278..4b20833 100644 --- a/Sources/AgentMeterCore/ClaudeCodeReader.swift +++ b/Sources/AgentMeterCore/ClaudeCodeReader.swift @@ -103,6 +103,50 @@ public struct ClaudeCodeReader: UsageReader { } } +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() + } +} + // MARK: - Transcript line shapes (only the fields we need) private struct ClaudeLine: Decodable { 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/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/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)) + } +} From d388358a3f70f728056dab17edc6a99641258b3a Mon Sep 17 00:00:00 2001 From: TeaLance <178543322+TeaLance@users.noreply.github.com> Date: Sat, 20 Jun 2026 01:11:48 +0800 Subject: [PATCH 04/14] Phase 2: full localization + Settings reorg (language toggle, ColorPicker) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SettingsView rebuilt as a localized TabView: General (language 繁中/English, refresh interval, Claude hero metric 5h/weekly, launch-at-login), Appearance (per-service ColorPicker + brand/mono preset swatches, pricing-version note), Menu Bar (metrics multi-select + show toggles), Claude Quota (statusLine bridge). - Migrated hardcoded strings to tr(en,zh): MenuBarMetric titles, refresh-interval labels, all settings strings. Live language switch re-renders via LanguageStore. - ServiceColors: hexString(from: Color) pins sRGB so ColorPicker round-trips don't drift. App builds; bilingual + custom colors now fully operable from Settings. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/AgentMeter/AppSettings.swift | 17 +- .../AgentMeter/Appearance/ServiceColors.swift | 10 + Sources/AgentMeter/MenuBarMetric.swift | 18 +- Sources/AgentMeter/SettingsView.swift | 195 +++++++++++++----- 4 files changed, 174 insertions(+), 66 deletions(-) diff --git a/Sources/AgentMeter/AppSettings.swift b/Sources/AgentMeter/AppSettings.swift index d9ef333..cb43bbc 100644 --- a/Sources/AgentMeter/AppSettings.swift +++ b/Sources/AgentMeter/AppSettings.swift @@ -18,9 +18,14 @@ enum ClaudeHero: String { case fiveHour, weekly } 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 index 8c7421e..9f5b3b2 100644 --- a/Sources/AgentMeter/Appearance/ServiceColors.swift +++ b/Sources/AgentMeter/Appearance/ServiceColors.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppKit import AgentMeterCore /// Per-service identity colour (used for the menu-bar icon, the swatch, the @@ -43,3 +44,12 @@ final class ServiceColorStore: ObservableObject { 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/MenuBarMetric.swift b/Sources/AgentMeter/MenuBarMetric.swift index 0edd9a2..2c0858d 100644 --- a/Sources/AgentMeter/MenuBarMetric.swift +++ b/Sources/AgentMeter/MenuBarMetric.swift @@ -45,15 +45,15 @@ enum MenuBarMetric: String, CaseIterable, Identifiable { /// 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" + 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 .codexContext: return tr("Codex · context %", "Codex · Context %") + case .codexMessages: return tr("Codex · messages", "Codex · 訊息數") + case .combinedTokens: return tr("Combined · today tokens", "合計 · 今日 tokens") } } diff --git a/Sources/AgentMeter/SettingsView.swift b/Sources/AgentMeter/SettingsView.swift index 5062dc5..7a57af1 100644 --- a/Sources/AgentMeter/SettingsView.swift +++ b/Sources/AgentMeter/SettingsView.swift @@ -1,61 +1,147 @@ 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 + @EnvironmentObject private var lang: LanguageStore + @EnvironmentObject private var colors: ServiceColorStore + var body: some View { + TabView { + GeneralSettings() + .tabItem { Label(lang.tr("General", "一般"), systemImage: "gearshape") } + AppearanceSettings() + .tabItem { Label(lang.tr("Appearance", "外觀"), systemImage: "paintpalette") } + MenuBarSettings() + .tabItem { Label(lang.tr("Menu Bar", "選單列"), systemImage: "menubar.rectangle") } + BridgeSettings() + .tabItem { Label(lang.tr("Claude Quota", "Claude 額度"), systemImage: "bolt.horizontal.circle") } + } + .frame(width: 440, height: 420) + } +} + +// MARK: - General + +private struct GeneralSettings: View { + @EnvironmentObject private var store: UsageStore + @EnvironmentObject private var lang: LanguageStore + @AppStorage(SettingsKeys.interval) private var interval: Double = 30 + @AppStorage(SettingsKeys.heroMetricClaude) private var claudeHeroRaw = ClaudeHero.fiveHour.rawValue @State private var launchAtLogin = SMAppService.mainApp.status == .enabled @State private var loginError: String? - @State private var bridgeState = StatusLineBridge.shared.state() - @State private var bridgeError: String? - var body: some View { Form { - Picker("更新頻率", selection: $interval) { - ForEach(refreshIntervalOptions, id: \.seconds) { option in - Text(option.label).tag(option.seconds) - } + Picker(lang.tr("Language", "語言"), selection: $lang.language) { + Text("繁體中文").tag(AppLanguage.zh) + Text("English").tag(AppLanguage.en) } - .onChange(of: interval) { _, newValue in store.setInterval(newValue) } - Section("選單列顯示內容(可多選)") { - ForEach(MenuBarMetric.allCases) { metric in - Toggle(metric.settingsTitle, isOn: metricBinding(metric)) + 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) } - Section("下拉面板") { - Toggle("顯示 Claude Code", isOn: $showClaude) - Toggle("顯示 Codex", isOn: $showCodex) + Picker(lang.tr("Claude hero metric", "Claude 英雄指標"), selection: $claudeHeroRaw) { + Text(lang.tr("5-hour", "5 小時額度")).tag(ClaudeHero.fiveHour.rawValue) + Text(lang.tr("Weekly", "每週額度")).tag(ClaudeHero.weekly.rawValue) } Section { - Toggle("開機時自動啟動", isOn: $launchAtLogin) + Toggle(lang.tr("Launch at login", "開機時自動啟動"), isOn: $launchAtLogin) .onChange(of: launchAtLogin) { _, on in setLaunchAtLogin(on) } if let loginError { Text(loginError).font(.caption).foregroundStyle(.red) } } + } + .formStyle(.grouped) + } - Section("即時額度(Claude,實驗性)") { - Toggle("啟用即時額度(statusLine 橋接)", isOn: bridgeBinding) - Text(bridgeHelpText) - .font(.caption) - .foregroundStyle(.secondary) - if let bridgeError { - Text(bridgeError).font(.caption).foregroundStyle(.red) + private func setLaunchAtLogin(_ enabled: Bool) { + do { + if enabled { try SMAppService.mainApp.register() } + else { try SMAppService.mainApp.unregister() } + loginError = nil + } catch { + 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]) + Text(lang.tr("Used for the menu-bar icon, swatch, and floating ring. Meter colors follow remaining quota, not this.", + "用於選單列 icon、色點與浮動環。量表顏色依剩餘額度,不受此影響。")) + .font(.caption).foregroundStyle(.secondary) + } + Section { + Text(lang.tr("Cost is a local estimate (prices as of \(PricingTable.version)).", + "花費為本機估算(定價版本 \(PricingTable.version))。")) + .font(.caption).foregroundStyle(.secondary) + } + } + .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 + + var body: some View { + Form { + Section(lang.tr("Menu-bar metrics (multi-select)", "選單列顯示內容(可多選)")) { + ForEach(MenuBarMetric.allCases) { metric in + Toggle(metric.settingsTitle, isOn: metricBinding(metric)) } + Text(lang.tr("Metrics with no data are hidden; a small icon shows when all are hidden.", + "沒資料的項目會自動隱藏;全部隱藏時顯示一個小圖示。")) + .font(.caption).foregroundStyle(.secondary) + } + 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) - .frame(width: 380) } private func metricBinding(_ metric: MenuBarMetric) -> Binding { @@ -65,25 +151,46 @@ struct SettingsView: View { var set = Set(MenuBarMetric.list(fromCSV: metricsCSV)) if isOn { set.insert(metric) } else { set.remove(metric) } metricsCSV = MenuBarMetric.csv(from: set) + }) + } +} + +// MARK: - Claude quota bridge + +private struct BridgeSettings: View { + @EnvironmentObject private var lang: LanguageStore + @State private var bridgeState = StatusLineBridge.shared.state() + @State private var bridgeError: String? + + var body: some View { + Form { + Section(lang.tr("Live quota (Claude, experimental)", "即時額度(Claude,實驗性)")) { + Toggle(lang.tr("Enable live quota (statusLine bridge)", "啟用即時額度(statusLine 橋接)"), + isOn: bridgeBinding) + Text(bridgeHelpText).font(.caption).foregroundStyle(.secondary) + if let bridgeError { + Text(bridgeError).font(.caption).foregroundStyle(.red) + } } - ) + } + .formStyle(.grouped) } private var bridgeBinding: Binding { - Binding( - get: { bridgeState == .enabled }, - set: { setBridge($0) } - ) + Binding(get: { bridgeState == .enabled }, set: { setBridge($0) }) } private var bridgeHelpText: String { switch bridgeState { case .enabled: - return "已啟用。請在 Claude Code 送出一次訊息,5h/每週額度條才會出現。會在 ~/.claude/settings.json 設定 statusLine(已備份原檔)。" + return lang.tr("Enabled. Send one message in Claude Code for the 5h/weekly bars to appear. Sets statusLine in ~/.claude/settings.json (original backed up).", + "已啟用。請在 Claude Code 送出一次訊息,5h/每週額度條才會出現。會在 ~/.claude/settings.json 設定 statusLine(已備份原檔)。") case .conflict: - return "偵測到你已有自訂 statusLine,為避免覆蓋而未啟用。可手動整合或先移除既有設定。" + return lang.tr("Detected an existing custom statusLine, so it wasn't enabled (to avoid overwriting). Integrate manually or remove the existing one first.", + "偵測到你已有自訂 statusLine,為避免覆蓋而未啟用。可手動整合或先移除既有設定。") case .disabled: - return "啟用後會讀取 Claude Code 傳給 statusLine 的官方資料來顯示真實 5h/每週 %。不連網、不讀 Keychain。" + return lang.tr("When on, reads the official data Claude Code passes to statusLine to show real 5h/weekly %. No network, no Keychain.", + "啟用後會讀取 Claude Code 傳給 statusLine 的官方資料來顯示真實 5h/每週 %。不連網、不讀 Keychain。") } } @@ -97,18 +204,4 @@ struct SettingsView: View { } bridgeState = StatusLineBridge.shared.state() } - - private func setLaunchAtLogin(_ enabled: Bool) { - do { - if enabled { - try SMAppService.mainApp.register() - } else { - try SMAppService.mainApp.unregister() - } - loginError = nil - } catch { - loginError = "設定開機啟動失敗:\(error.localizedDescription)" - launchAtLogin = SMAppService.mainApp.status == .enabled - } - } } From 02f1fe5e148513b8dd50e93f14d5c186ae71d1ad Mon Sep 17 00:00:00 2001 From: TeaLance <178543322+TeaLance@users.noreply.github.com> Date: Sat, 20 Jun 2026 01:22:55 +0800 Subject: [PATCH 05/14] Phase 5: editorial stats window (sidebar, time range, daily chart, by-model) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StatsWindowController: AppKit NSWindow hosting SwiftUI; opens from .accessory via ActivationPolicyCoordinator (ref-counted .regular↔.accessory, resets on app deactivation — replaces the old willResignActive revert). - StatsViewModel: runs ClaudeCodeReader/CodexReader.history(range:) off-main per time range (Today/7d/30d/All), publishes UsageHistory. - StatsRootView (editorial): custom sidebar (service filter + Overview/By-model), range tabs, KPIs (total tokens, ≈total cost, per-service), Swift Charts stacked daily bar chart (per-service identity colors), by-model table with ≈$ per model. - Panel header gains a chart.bar button to open it. App builds and launches. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/AgentMeter/AgentMeterApp.swift | 6 +- Sources/AgentMeter/MenuContentView.swift | 4 +- .../Stats/ActivationPolicyCoordinator.swift | 21 ++ Sources/AgentMeter/Stats/StatsRootView.swift | 286 ++++++++++++++++++ .../Stats/StatsWindowController.swift | 35 +++ 5 files changed, 347 insertions(+), 5 deletions(-) create mode 100644 Sources/AgentMeter/Stats/ActivationPolicyCoordinator.swift create mode 100644 Sources/AgentMeter/Stats/StatsRootView.swift create mode 100644 Sources/AgentMeter/Stats/StatsWindowController.swift diff --git a/Sources/AgentMeter/AgentMeterApp.swift b/Sources/AgentMeter/AgentMeterApp.swift index 1c62243..ca9c8b9 100644 --- a/Sources/AgentMeter/AgentMeterApp.swift +++ b/Sources/AgentMeter/AgentMeterApp.swift @@ -50,12 +50,12 @@ 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. + // 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/MenuContentView.swift b/Sources/AgentMeter/MenuContentView.swift index 1a165e5..b64c514 100644 --- a/Sources/AgentMeter/MenuContentView.swift +++ b/Sources/AgentMeter/MenuContentView.swift @@ -47,6 +47,7 @@ struct MenuContentView: View { HStack(spacing: 2) { if store.isRefreshing { ProgressView().controlSize(.small).scaleEffect(0.7) } iconButton("arrow.clockwise") { store.refreshNow() } + iconButton("chart.bar") { StatsWindowController.shared.show(lang: lang, colors: colors) } iconButton("gearshape") { openSettingsWindow() } iconButton("power") { NSApplication.shared.terminate(nil) } } @@ -219,8 +220,7 @@ struct MenuContentView: View { } private func openSettingsWindow() { - NSApp.setActivationPolicy(.regular) - NSApp.activate(ignoringOtherApps: true) + ActivationPolicyCoordinator.shared.enter() openSettings() } } 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/StatsRootView.swift b/Sources/AgentMeter/Stats/StatsRootView.swift new file mode 100644 index 0000000..65c2a5f --- /dev/null +++ b/Sources/AgentMeter/Stats/StatsRootView.swift @@ -0,0 +1,286 @@ +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", "≈ 總花費"), usd(totalCost)) + kpi("Claude / Codex", "\(usd(cost(.claudeCode))) · \(usd(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(usd(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.billableTotal } } + 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.billableTotal } + } + if service != .claude, let x = vm.codex { + for d in x.days { byDay[d.day, default: (0, 0)].1 += d.total.billableTotal } + } + 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.billableTotal, + 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 } + } + + private func usd(_ est: CostEstimate) -> String { + let n = est.amountUSD < 0.01 && est.amountUSD > 0 ? "<0.01" : String(format: "%.2f", est.amountUSD) + return "≈$\(n)\(est.isComplete ? "" : "+")" + } +} diff --git a/Sources/AgentMeter/Stats/StatsWindowController.swift b/Sources/AgentMeter/Stats/StatsWindowController.swift new file mode 100644 index 0000000..1af8d9e --- /dev/null +++ b/Sources/AgentMeter/Stats/StatsWindowController.swift @@ -0,0 +1,35 @@ +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? + + func show(lang: LanguageStore, colors: ServiceColorStore) { + if window == nil { + let root = StatsRootView() + .environmentObject(lang) + .environmentObject(colors) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 780, height: 540), + 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("AgentMeterStats") + window.center() + self.window = window + } + ActivationPolicyCoordinator.shared.enter() + window?.makeKeyAndOrderFront(nil) + } + + func windowWillClose(_ notification: Notification) { + ActivationPolicyCoordinator.shared.leave() + } +} From f0c6d3314976bde265b08c1e99f494c0c14e81e7 Mon Sep 17 00:00:00 2001 From: TeaLance <178543322+TeaLance@users.noreply.github.com> Date: Sat, 20 Jun 2026 01:26:40 +0800 Subject: [PATCH 06/14] Phase 6: floating desktop HUD (editorial dual rings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FloatingPanel: NSPanel (.nonactivating/.borderless, .floating level, all-Spaces, movable-by-background) that snaps to the nearest screen edge on mouse-up. - FloatingHUDView: dual thin-line RingMeters — frame tinted with the identity colour, arc in the status colour, mono % centre; translucent material; dims to the configured idle opacity and brightens on hover. Live from UsageStore.shared. - FloatingPanelController: show/hide driven by the floatingEnabled default; refresh() called at launch to restore, and from the Settings toggle. - RingMeter component gains a configurable track colour. - Stores exposed as shared singletons so the HUD/launch get the live instances. - Settings gains a Floating tab (enable, per-service, idle-opacity slider). App builds and launches. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/AgentMeter/AgentMeterApp.swift | 7 +- Sources/AgentMeter/AppSettings.swift | 5 ++ .../AgentMeter/Appearance/ServiceColors.swift | 2 + Sources/AgentMeter/Design/Components.swift | 24 +++++++ .../AgentMeter/Floating/FloatingHUDView.swift | 66 +++++++++++++++++++ .../AgentMeter/Floating/FloatingPanel.swift | 23 +++++++ .../Floating/FloatingPanelController.swift | 54 +++++++++++++++ .../Localization/Localization.swift | 1 + Sources/AgentMeter/SettingsView.swift | 36 ++++++++++ Sources/AgentMeter/UsageStore.swift | 2 + 10 files changed, 217 insertions(+), 3 deletions(-) create mode 100644 Sources/AgentMeter/Floating/FloatingHUDView.swift create mode 100644 Sources/AgentMeter/Floating/FloatingPanel.swift create mode 100644 Sources/AgentMeter/Floating/FloatingPanelController.swift diff --git a/Sources/AgentMeter/AgentMeterApp.swift b/Sources/AgentMeter/AgentMeterApp.swift index ca9c8b9..2b8ee5a 100644 --- a/Sources/AgentMeter/AgentMeterApp.swift +++ b/Sources/AgentMeter/AgentMeterApp.swift @@ -5,9 +5,9 @@ import AgentMeterCore @main struct AgentMeterApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate - @StateObject private var store = UsageStore() - @StateObject private var lang = LanguageStore() - @StateObject private var colors = ServiceColorStore() + @StateObject private var store = UsageStore.shared + @StateObject private var lang = LanguageStore.shared + @StateObject private var colors = ServiceColorStore.shared var body: some Scene { MenuBarExtra { @@ -50,6 +50,7 @@ struct MenuBarLabel: View { final class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) + 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( diff --git a/Sources/AgentMeter/AppSettings.swift b/Sources/AgentMeter/AppSettings.swift index cb43bbc..d4f1191 100644 --- a/Sources/AgentMeter/AppSettings.swift +++ b/Sources/AgentMeter/AppSettings.swift @@ -8,6 +8,11 @@ enum SettingsKeys { static let showCodex = "showCodex" /// Which quota the Claude panel shows as its hero number (`ClaudeHero`). static let heroMetricClaude = "heroMetricClaude" + // Floating desktop HUD. + static let floatingEnabled = "floatingEnabled" + static let floatingShowClaude = "floatingShowClaude" + static let floatingShowCodex = "floatingShowCodex" + static let floatingIdleOpacity = "floatingIdleOpacity" } /// The Claude panel's primary "hero" metric. Default 5-hour; the panel's diff --git a/Sources/AgentMeter/Appearance/ServiceColors.swift b/Sources/AgentMeter/Appearance/ServiceColors.swift index 9f5b3b2..c5b2239 100644 --- a/Sources/AgentMeter/Appearance/ServiceColors.swift +++ b/Sources/AgentMeter/Appearance/ServiceColors.swift @@ -7,6 +7,8 @@ import AgentMeterCore /// 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" diff --git a/Sources/AgentMeter/Design/Components.swift b/Sources/AgentMeter/Design/Components.swift index 66b216e..209f73e 100644 --- a/Sources/AgentMeter/Design/Components.swift +++ b/Sources/AgentMeter/Design/Components.swift @@ -71,6 +71,30 @@ struct HeroNumber: View { } } +/// 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 diff --git a/Sources/AgentMeter/Floating/FloatingHUDView.swift b/Sources/AgentMeter/Floating/FloatingHUDView.swift new file mode 100644 index 0000000..4a27153 --- /dev/null +++ b/Sources/AgentMeter/Floating/FloatingHUDView.swift @@ -0,0 +1,66 @@ +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 + @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 { + VStack(spacing: 5) { + RingMeter(fraction: m.fraction, level: m.level, percentText: m.pct, + 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 index cde0c2d..c5894e6 100644 --- a/Sources/AgentMeter/Localization/Localization.swift +++ b/Sources/AgentMeter/Localization/Localization.swift @@ -13,6 +13,7 @@ enum AppLanguage: String, CaseIterable, Identifiable { /// 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 { diff --git a/Sources/AgentMeter/SettingsView.swift b/Sources/AgentMeter/SettingsView.swift index 7a57af1..c9d84ed 100644 --- a/Sources/AgentMeter/SettingsView.swift +++ b/Sources/AgentMeter/SettingsView.swift @@ -15,6 +15,8 @@ struct SettingsView: View { .tabItem { Label(lang.tr("Appearance", "外觀"), systemImage: "paintpalette") } MenuBarSettings() .tabItem { Label(lang.tr("Menu Bar", "選單列"), systemImage: "menubar.rectangle") } + FloatingSettings() + .tabItem { Label(lang.tr("Floating", "浮動"), systemImage: "macwindow.on.rectangle") } BridgeSettings() .tabItem { Label(lang.tr("Claude Quota", "Claude 額度"), systemImage: "bolt.horizontal.circle") } } @@ -155,6 +157,40 @@ private struct MenuBarSettings: View { } } +// 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) + } + Text(lang.tr("Always on top · drag to move · snaps to screen edge · brightens on hover.", + "永遠置頂 · 可拖曳 · 吸附螢幕邊緣 · 滑入變亮。")) + .font(.caption).foregroundStyle(.secondary) + } + .disabled(!enabled) + } + .formStyle(.grouped) + } +} + // MARK: - Claude quota bridge private struct BridgeSettings: View { diff --git a/Sources/AgentMeter/UsageStore.swift b/Sources/AgentMeter/UsageStore.swift index 9984c78..cea7066 100644 --- a/Sources/AgentMeter/UsageStore.swift +++ b/Sources/AgentMeter/UsageStore.swift @@ -5,6 +5,8 @@ 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) From 5d347feb9d7d46fab25585c77dbb21d1b13e2a58 Mon Sep 17 00:00:00 2001 From: TeaLance <178543322+TeaLance@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:27:38 +0800 Subject: [PATCH 07/14] Phase 7: network opt-in scaffolding + verifiable offline-by-default guarantee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NetworkFeature (codexQuota, accurateCost): off by default; localized title + "this needs internet" explanation; isEnabled reads the defaults flag. - Advanced settings tab: each feature toggle asks for confirmation before enabling (alert with the explanation); offline-by-default statement. - CodexQuotaClient / BillingClient: consent-gated skeletons confined to Sources/AgentMeter/Network/. EXPERIMENTAL — return .unavailable rather than fabricate data; real OpenAI/billing endpoints intentionally not wired (unverified). - Scripts/check-offline.sh + offline-check CI workflow: fail if any networking (URLSession/Network/...) appears outside Network/ — keeps the guarantee verifiable. 59 Core tests pass; app builds and launches; offline check passes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/offline-check.yml | 16 +++++ Scripts/check-offline.sh | 19 ++++++ Sources/AgentMeter/AppSettings.swift | 3 + .../AgentMeter/Network/BillingClient.swift | 22 +++++++ .../AgentMeter/Network/CodexQuotaClient.swift | 27 ++++++++ Sources/AgentMeter/Network/NetworkOptIn.swift | 41 ++++++++++++ Sources/AgentMeter/SettingsView.swift | 64 ++++++++++++++++++- 7 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/offline-check.yml create mode 100755 Scripts/check-offline.sh create mode 100644 Sources/AgentMeter/Network/BillingClient.swift create mode 100644 Sources/AgentMeter/Network/CodexQuotaClient.swift create mode 100644 Sources/AgentMeter/Network/NetworkOptIn.swift 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/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/AppSettings.swift b/Sources/AgentMeter/AppSettings.swift index d4f1191..554c441 100644 --- a/Sources/AgentMeter/AppSettings.swift +++ b/Sources/AgentMeter/AppSettings.swift @@ -13,6 +13,9 @@ enum SettingsKeys { static let floatingShowClaude = "floatingShowClaude" static let floatingShowCodex = "floatingShowCodex" static let floatingIdleOpacity = "floatingIdleOpacity" + // Network opt-in (default OFF; each gated behind a confirmation). + static let netCodexQuota = "netCodexQuota" + static let netAccurateCost = "netAccurateCost" } /// The Claude panel's primary "hero" metric. Default 5-hour; the panel's diff --git a/Sources/AgentMeter/Network/BillingClient.swift b/Sources/AgentMeter/Network/BillingClient.swift new file mode 100644 index 0000000..f95972d --- /dev/null +++ b/Sources/AgentMeter/Network/BillingClient.swift @@ -0,0 +1,22 @@ +import Foundation +import AgentMeterCore + +/// Networked fetch of real billed cost, to replace the local `≈$` estimate over +/// the covered range. Gated behind the `accurateCost` opt-in. +/// +/// EXPERIMENTAL: the billing endpoint is not wired here yet; returns `.unavailable` +/// until implemented with verified API details. Any `URLSession` use must stay in +/// this file / `CodexQuotaClient` (see Scripts/check-offline.sh). +struct BillingClient { + enum Result: Equatable { + case disabled + case unavailable + case ok(CostEstimate) + } + + func cost(for range: DateInterval) async -> Result { + guard NetworkFeature.accurateCost.isEnabled else { return .disabled } + // TODO(experimental): query the provider billing API via URLSession. + return .unavailable + } +} diff --git a/Sources/AgentMeter/Network/CodexQuotaClient.swift b/Sources/AgentMeter/Network/CodexQuotaClient.swift new file mode 100644 index 0000000..99f4918 --- /dev/null +++ b/Sources/AgentMeter/Network/CodexQuotaClient.swift @@ -0,0 +1,27 @@ +import Foundation +import AgentMeterCore + +/// Networked fetch of Codex's real 5-hour / weekly quota. Gated behind the +/// `codexQuota` opt-in — it MUST refuse to run unless the user enabled it, so a +/// code-path bug can't make an unconsented request. +/// +/// EXPERIMENTAL: the OpenAI quota endpoint + credential exchange are not wired +/// here yet (they require verified, undocumented API details). Until then this +/// reports `.unavailable` rather than fabricating numbers. When implemented, the +/// `URLSession` call belongs in this file (and only this file / `BillingClient`), +/// keeping the offline-by-default guarantee verifiable via Scripts/check-offline.sh. +struct CodexQuotaClient { + enum Result: Equatable { + case disabled // user hasn't opted in + case unavailable // opted in, but not implemented / no credentials / network error + case ok(fiveHour: QuotaWindow, weekly: QuotaWindow) + } + + func fetch() async -> Result { + guard NetworkFeature.codexQuota.isEnabled else { return .disabled } + // TODO(experimental): read ~/.codex credentials and query OpenAI here via + // URLSession, mapping the response into QuotaWindow values. Returns + // .unavailable until wired with verified endpoint details. + return .unavailable + } +} diff --git a/Sources/AgentMeter/Network/NetworkOptIn.swift b/Sources/AgentMeter/Network/NetworkOptIn.swift new file mode 100644 index 0000000..6d429b2 --- /dev/null +++ b/Sources/AgentMeter/Network/NetworkOptIn.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Opt-in network features. AgentMeter is fully offline by default; each feature +/// here is OFF until the user confirms a "this needs internet" dialog. ALL code +/// that touches the network lives under `Sources/AgentMeter/Network/` — enforced +/// by `Scripts/check-offline.sh` (run in CI) so the offline-by-default guarantee +/// stays verifiable. +enum NetworkFeature: String, CaseIterable, Identifiable { + case codexQuota + case accurateCost + var id: String { rawValue } + + var defaultsKey: String { + switch self { + case .codexQuota: return SettingsKeys.netCodexQuota + case .accurateCost: return SettingsKeys.netAccurateCost + } + } + + /// Uses the global `tr` (nonisolated); views observing `LanguageStore` re-render. + var title: String { + switch self { + case .codexQuota: return tr("Codex live quota", "Codex 即時額度") + case .accurateCost: return tr("Accurate cost", "精準花費") + } + } + + /// 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 .accurateCost: + return tr("Contacts the provider's billing API to replace the local estimate with real spend. AgentMeter is otherwise fully offline and only connects while this is on.", + "會連線供應商帳單 API,用真實花費取代本機估算。AgentMeter 平常完全離線,僅在此功能開啟時連線。") + } + } + + var isEnabled: Bool { UserDefaults.standard.bool(forKey: defaultsKey) } +} diff --git a/Sources/AgentMeter/SettingsView.swift b/Sources/AgentMeter/SettingsView.swift index c9d84ed..8d1522a 100644 --- a/Sources/AgentMeter/SettingsView.swift +++ b/Sources/AgentMeter/SettingsView.swift @@ -19,8 +19,10 @@ struct SettingsView: View { .tabItem { Label(lang.tr("Floating", "浮動"), systemImage: "macwindow.on.rectangle") } BridgeSettings() .tabItem { Label(lang.tr("Claude Quota", "Claude 額度"), systemImage: "bolt.horizontal.circle") } + AdvancedSettings() + .tabItem { Label(lang.tr("Advanced", "進階"), systemImage: "network") } } - .frame(width: 440, height: 420) + .frame(width: 440, height: 440) } } @@ -191,6 +193,66 @@ private struct FloatingSettings: View { } } +// MARK: - Advanced (network opt-in) + +private struct AdvancedSettings: View { + @EnvironmentObject private var lang: LanguageStore + @AppStorage(SettingsKeys.netCodexQuota) private var codexQuota = false + @AppStorage(SettingsKeys.netAccurateCost) private var accurateCost = false + @State private var pending: NetworkFeature? + + var body: some View { + Form { + Section(lang.tr("Network features (off by default)", "網路功能(預設關閉)")) { + featureToggle(.codexQuota) + featureToggle(.accurateCost) + } + Section { + Text(lang.tr("AgentMeter is fully offline by default — it never connects unless you enable a feature above, and each asks first. Reads only local files; never the Keychain.", + "AgentMeter 預設完全離線——除非你在上面啟用某項功能(且每項都會先詢問),否則永不連線。只讀本機檔案、不讀 Keychain。")) + .font(.caption).foregroundStyle(.secondary) + Text(lang.tr("These are experimental and may be unavailable until verified provider APIs are wired in.", + "這些為實驗性功能,在接上經驗證的供應商 API 前可能無法使用。")) + .font(.caption).foregroundStyle(.secondary) + } + } + .formStyle(.grouped) + .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 } + } message: { feature in + Text(feature.explanation) + } + } + + 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 } + })) { + HStack(spacing: 6) { + Text(feature.title) + Text(lang.tr("needs internet", "需連網")) + .font(.system(size: 9.5, weight: .semibold)) + .padding(.horizontal, 5).padding(.vertical, 1) + .background(Color.orange.opacity(0.18), in: Capsule()) + .foregroundStyle(.orange) + } + } + } + + private func binding(for feature: NetworkFeature) -> Binding { + switch feature { + case .codexQuota: return $codexQuota + case .accurateCost: return $accurateCost + } + } +} + // MARK: - Claude quota bridge private struct BridgeSettings: View { From 8c3caafc1e58a09067a6d011f551de3d624d6536 Mon Sep 17 00:00:00 2001 From: TeaLance <178543322+TeaLance@users.noreply.github.com> Date: Sat, 20 Jun 2026 17:03:08 +0800 Subject: [PATCH 08/14] Round 2 R2.1-R2.3: plain $ format, launch-at-login UX, unified window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - R2.1 Cost: shared moneyString — plain "$X.XX", no ≈/+; "—" when a service/total is entirely unpriced (no misleading $0.00). Stats KPI label "≈ 總花費" → "總花費". - R2.2 Launch-at-login: detect unbundled/dev runs (not an installed .app) and disable the toggle with a clear note instead of the raw "Invalid argument" error. - R2.3 Merge Stats + Settings into one window with cc-bar-style top tabs (用量統計 | 設定): new MainWindowRootView + MainWindowModel; both the chart and gear buttons open it to the right tab; removed the separate SwiftUI Settings scene. App builds and launches. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/AgentMeter/AgentMeterApp.swift | 7 --- Sources/AgentMeter/Design/DesignSystem.swift | 6 +++ Sources/AgentMeter/MenuContentView.swift | 20 ++----- Sources/AgentMeter/SettingsView.swift | 14 ++++- .../AgentMeter/Stats/MainWindowRootView.swift | 52 +++++++++++++++++++ Sources/AgentMeter/Stats/StatsRootView.swift | 10 ++-- .../Stats/StatsWindowController.swift | 16 +++--- 7 files changed, 87 insertions(+), 38 deletions(-) create mode 100644 Sources/AgentMeter/Stats/MainWindowRootView.swift diff --git a/Sources/AgentMeter/AgentMeterApp.swift b/Sources/AgentMeter/AgentMeterApp.swift index 2b8ee5a..3dfc574 100644 --- a/Sources/AgentMeter/AgentMeterApp.swift +++ b/Sources/AgentMeter/AgentMeterApp.swift @@ -19,13 +19,6 @@ struct AgentMeterApp: App { MenuBarLabel(store: store) } .menuBarExtraStyle(.window) - - Settings { - SettingsView() - .environmentObject(store) - .environmentObject(lang) - .environmentObject(colors) - } } } diff --git a/Sources/AgentMeter/Design/DesignSystem.swift b/Sources/AgentMeter/Design/DesignSystem.swift index 2b20095..7cc41bd 100644 --- a/Sources/AgentMeter/Design/DesignSystem.swift +++ b/Sources/AgentMeter/Design/DesignSystem.swift @@ -38,6 +38,12 @@ 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) { diff --git a/Sources/AgentMeter/MenuContentView.swift b/Sources/AgentMeter/MenuContentView.swift index b64c514..2ab8758 100644 --- a/Sources/AgentMeter/MenuContentView.swift +++ b/Sources/AgentMeter/MenuContentView.swift @@ -13,7 +13,6 @@ struct MenuContentView: View { @AppStorage(SettingsKeys.showClaude) private var showClaude = true @AppStorage(SettingsKeys.showCodex) private var showCodex = true @AppStorage(SettingsKeys.heroMetricClaude) private var claudeHeroRaw = ClaudeHero.fiveHour.rawValue - @Environment(\.openSettings) private var openSettings private var claudeHero: ClaudeHero { ClaudeHero(rawValue: claudeHeroRaw) ?? .fiveHour } @@ -47,8 +46,8 @@ struct MenuContentView: View { HStack(spacing: 2) { if store.isRefreshing { ProgressView().controlSize(.small).scaleEffect(0.7) } iconButton("arrow.clockwise") { store.refreshNow() } - iconButton("chart.bar") { StatsWindowController.shared.show(lang: lang, colors: colors) } - iconButton("gearshape") { openSettingsWindow() } + iconButton("chart.bar") { StatsWindowController.shared.show(tab: .stats) } + iconButton("gearshape") { StatsWindowController.shared.show(tab: .settings) } iconButton("power") { NSApplication.shared.terminate(nil) } } } @@ -119,7 +118,7 @@ struct MenuContentView: View { } private var enableQuotaLink: some View { - Button { openSettingsWindow() } label: { + Button { StatsWindowController.shared.show(tab: .settings) } label: { Text(lang.tr("Enable live 5h / weekly quota", "啟用即時 5h/每週額度")) .font(.system(size: 11)) } @@ -175,7 +174,7 @@ struct MenuContentView: View { footerItem(lang.tr("Today", "今日"), "\(u.today.billableTotal.compactTokenString) tokens") footerItem(lang.tr("Messages", "訊息"), "\(u.messageCount)") if !u.todayByModel.isEmpty { - footerItem("", usdString(costEstimate(byModel: u.todayByModel))) + footerItem("", moneyString(costEstimate(byModel: u.todayByModel))) } Spacer() } @@ -188,13 +187,6 @@ struct MenuContentView: View { .font(.system(size: 11.5).monospacedDigit()) } - /// "≈$1.80" — trailing "+" when some tokens came from unpriced models. - private func usdString(_ est: CostEstimate) -> String { - let amount = est.amountUSD < 0.01 && est.amountUSD > 0 - ? "<0.01" : String(format: "%.2f", est.amountUSD) - return "≈$\(amount)\(est.isComplete ? "" : "+")" - } - private var noData: some View { Text(lang.tr("No usage detected", "未偵測到使用資料")) .font(.system(size: 11)).foregroundStyle(AM.ink2) @@ -219,8 +211,4 @@ struct MenuContentView: View { return lang.tr("updated \(ago) ago", "\(ago)前已更新") } - private func openSettingsWindow() { - ActivationPolicyCoordinator.shared.enter() - openSettings() - } } diff --git a/Sources/AgentMeter/SettingsView.swift b/Sources/AgentMeter/SettingsView.swift index 8d1522a..69983b4 100644 --- a/Sources/AgentMeter/SettingsView.swift +++ b/Sources/AgentMeter/SettingsView.swift @@ -22,7 +22,6 @@ struct SettingsView: View { AdvancedSettings() .tabItem { Label(lang.tr("Advanced", "進階"), systemImage: "network") } } - .frame(width: 440, height: 440) } } @@ -58,7 +57,12 @@ private struct GeneralSettings: View { Section { Toggle(lang.tr("Launch at login", "開機時自動啟動"), isOn: $launchAtLogin) .onChange(of: launchAtLogin) { _, on in setLaunchAtLogin(on) } - if let loginError { + .disabled(!isInstalledApp) + if !isInstalledApp { + Text(lang.tr("Available only when running the installed AgentMeter.app — not via `swift run`.", + "僅在執行已安裝的 AgentMeter.app 時可用(開發模式 / `swift run` 無法設定)。")) + .font(.caption).foregroundStyle(.secondary) + } else if let loginError { Text(loginError).font(.caption).foregroundStyle(.red) } } @@ -66,6 +70,12 @@ private struct GeneralSettings: View { .formStyle(.grouped) } + /// 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() } diff --git a/Sources/AgentMeter/Stats/MainWindowRootView.swift b/Sources/AgentMeter/Stats/MainWindowRootView.swift new file mode 100644 index 0000000..722df9c --- /dev/null +++ b/Sources/AgentMeter/Stats/MainWindowRootView.swift @@ -0,0 +1,52 @@ +import SwiftUI + +enum MainTab { case stats, settings } + +/// Which tab the single main 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 tab: MainTab = .stats +} + +/// The single app window: cc-bar-style top tabs — 用量統計 | 設定 — so usage stats +/// and settings live in one place (previously two separate windows the user +/// couldn't find). +struct MainWindowRootView: View { + @EnvironmentObject private var lang: LanguageStore + @EnvironmentObject private var nav: MainWindowModel + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 4) { + tab(lang.tr("Usage", "用量統計"), .stats) + tab(lang.tr("Settings", "設定"), .settings) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(AM.paper) + Rectangle().fill(AM.hairline).frame(height: 1) + Group { + switch nav.tab { + case .stats: StatsRootView() + case .settings: SettingsView() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .background(AM.paper) + .foregroundStyle(AM.ink) + } + + private func tab(_ label: String, _ value: MainTab) -> some View { + let on = nav.tab == value + return Button { nav.tab = value } label: { + Text(label) + .font(.system(size: 12, weight: on ? .semibold : .regular)) + .foregroundStyle(on ? AM.paper : AM.ink2) + .padding(.horizontal, 12).padding(.vertical, 4) + .background(on ? AM.ink : .clear, in: Capsule()) + } + .buttonStyle(.plain) + } +} diff --git a/Sources/AgentMeter/Stats/StatsRootView.swift b/Sources/AgentMeter/Stats/StatsRootView.swift index 65c2a5f..41bffa7 100644 --- a/Sources/AgentMeter/Stats/StatsRootView.swift +++ b/Sources/AgentMeter/Stats/StatsRootView.swift @@ -150,8 +150,8 @@ struct StatsRootView: 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", "≈ 總花費"), usd(totalCost)) - kpi("Claude / Codex", "\(usd(cost(.claudeCode))) · \(usd(cost(.codex)))", small: true) + 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", "每日用量")) @@ -213,7 +213,7 @@ struct StatsRootView: View { Spacer() Text("\(row.tokens.compactTokenString) tokens") .font(.system(size: 11.5)).monospacedDigit().foregroundStyle(AM.ink2) - Text(usd(row.cost)).font(.system(size: 11.5, weight: .medium)).monospacedDigit() + Text(moneyString(row.cost)).font(.system(size: 11.5, weight: .medium)).monospacedDigit() .frame(width: 76, alignment: .trailing) } .padding(.vertical, 7) @@ -279,8 +279,4 @@ struct StatsRootView: View { return rows.sorted { $0.tokens > $1.tokens } } - private func usd(_ est: CostEstimate) -> String { - let n = est.amountUSD < 0.01 && est.amountUSD > 0 ? "<0.01" : String(format: "%.2f", est.amountUSD) - return "≈$\(n)\(est.isComplete ? "" : "+")" - } } diff --git a/Sources/AgentMeter/Stats/StatsWindowController.swift b/Sources/AgentMeter/Stats/StatsWindowController.swift index 1af8d9e..b0dab87 100644 --- a/Sources/AgentMeter/Stats/StatsWindowController.swift +++ b/Sources/AgentMeter/Stats/StatsWindowController.swift @@ -7,21 +7,25 @@ import SwiftUI final class StatsWindowController: NSObject, NSWindowDelegate { static let shared = StatsWindowController() private var window: NSWindow? + private let nav = MainWindowModel() - func show(lang: LanguageStore, colors: ServiceColorStore) { + func show(tab: MainTab) { + nav.tab = tab if window == nil { - let root = StatsRootView() - .environmentObject(lang) - .environmentObject(colors) + let root = MainWindowRootView() + .environmentObject(UsageStore.shared) + .environmentObject(LanguageStore.shared) + .environmentObject(ServiceColorStore.shared) + .environmentObject(nav) let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 780, height: 540), + 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("AgentMeterStats") + window.setFrameAutosaveName("AgentMeterMain") window.center() self.window = window } From 6eb93e026094fd1016e4c52c68c18a126b516bbd Mon Sep 17 00:00:00 2001 From: TeaLance <178543322+TeaLance@users.noreply.github.com> Date: Sat, 20 Jun 2026 17:10:21 +0800 Subject: [PATCH 09/14] Round 2 R2.4: real logged-in accounts + networked Codex quota (cc-bar endpoints) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core (TDD, file-only — never the Keychain): - Credentials.swift: JWT.payload (base64url), ClaudeCredentialParser (.credentials.json + .claude.json email fallback), CodexCredentialParser (auth.json + id_token JWT for email/plan), ServiceAccount, CredentialReader. +6 tests (65 total). Network (Network/, opt-in, gated): - CodexQuotaClient: GET chatgpt.com/backend-api/wham/usage (Bearer + optional ChatGPT-Account-Id), defensive window parsing, .unavailable on mismatch. - CodexTokenRefresher: auth.openai.com/oauth/token refresh + write-back to auth.json. - NetworkFeature: codexQuota (network) + showAccounts (reads credential files, not network); dropped speculative accurateCost + BillingClient. Wiring: - UsageStore: codexFiveHour/codexWeekly + claudeAccount/codexAccount, populated on refresh when the matching opt-in is on. - Panel: service headers show plan (e.g. "Codex · Plus"); Codex hero uses real 5h when present, else context% + an "enable live quota" link. - Settings Advanced: showAccounts ("reads credentials") + codexQuota ("needs internet") toggles with confirmation; refresh on toggle. Privacy: README updated to "offline by default + opt-in"; "never reads Keychain" still holds (files only). offline check still green; 65 tests pass; app launches. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 6 +- Sources/AgentMeter/AppSettings.swift | 6 +- Sources/AgentMeter/MenuContentView.swift | 48 ++++-- .../AgentMeter/Network/BillingClient.swift | 22 --- .../AgentMeter/Network/CodexQuotaClient.swift | 65 ++++++-- .../Network/CodexTokenRefresher.swift | 46 ++++++ Sources/AgentMeter/Network/NetworkOptIn.swift | 29 ++-- Sources/AgentMeter/SettingsView.swift | 30 ++-- Sources/AgentMeter/UsageStore.swift | 22 +++ Sources/AgentMeterCore/Credentials.swift | 145 ++++++++++++++++++ .../CredentialsTests.swift | 68 ++++++++ 11 files changed, 413 insertions(+), 74 deletions(-) delete mode 100644 Sources/AgentMeter/Network/BillingClient.swift create mode 100644 Sources/AgentMeter/Network/CodexTokenRefresher.swift create mode 100644 Sources/AgentMeterCore/Credentials.swift create mode 100644 Tests/AgentMeterCoreTests/CredentialsTests.swift diff --git a/README.md b/README.md index d4ada3d..11bb83e 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,11 @@ brew install --cask TeaLance/tap/agentmeter ## 隱私 -只**讀取**本機 `~/.claude` 與 `~/.codex` 的檔案來統計用量,**不連網、不讀 Keychain、不傳送任何資料**。 +**預設完全離線**:只讀取本機 `~/.claude` 與 `~/.codex` 的用量紀錄來統計,**不連網、不讀 Keychain、不傳送任何資料**。 + +少數**選用功能**才會多做事,且**每項開啟前都會先詢問**、預設關閉: +- **顯示登入帳號**:讀取本機登入憑證「檔案」(仍**不讀 Keychain**)以顯示登入的帳號與方案;不連網。 +- **Codex 即時額度**:用本機憑證的權杖連線 OpenAI 取得真實 5h/每週額度;**只有開啟此功能時才連網**。 ## 授權 diff --git a/Sources/AgentMeter/AppSettings.swift b/Sources/AgentMeter/AppSettings.swift index 554c441..f6e7b13 100644 --- a/Sources/AgentMeter/AppSettings.swift +++ b/Sources/AgentMeter/AppSettings.swift @@ -13,9 +13,9 @@ enum SettingsKeys { static let floatingShowClaude = "floatingShowClaude" static let floatingShowCodex = "floatingShowCodex" static let floatingIdleOpacity = "floatingIdleOpacity" - // Network opt-in (default OFF; each gated behind a confirmation). - static let netCodexQuota = "netCodexQuota" - static let netAccurateCost = "netAccurateCost" + // 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 diff --git a/Sources/AgentMeter/MenuContentView.swift b/Sources/AgentMeter/MenuContentView.swift index 2ab8758..e91da8c 100644 --- a/Sources/AgentMeter/MenuContentView.swift +++ b/Sources/AgentMeter/MenuContentView.swift @@ -66,7 +66,8 @@ struct MenuContentView: View { private var claudeBlock: some View { VStack(alignment: .leading, spacing: AM.Space.s) { - serviceHeader(name: "Claude Code", tool: .claudeCode, available: store.claude.available) + serviceHeader(name: "Claude Code", tool: .claudeCode, available: store.claude.available, + plan: store.claudeAccount?.plan) if store.claude.available { claudeBody footer(store.claude) @@ -129,14 +130,10 @@ struct MenuContentView: View { private var codexBlock: some View { VStack(alignment: .leading, spacing: AM.Space.s) { - serviceHeader(name: "Codex", tool: .codex, available: store.codex.available) + serviceHeader(name: "Codex", tool: .codex, available: store.codex.available, + plan: store.codexAccount?.plan) if store.codex.available { - VStack(alignment: .leading, spacing: AM.Space.m) { - if let cw = store.codex.contextWindow { - heroFromPercent(cw.fraction * 100, label: lang.tr("context", "Context")) - ThinBar(fraction: cw.fraction, level: .forUsed(percent: cw.fraction * 100)) - } - } + codexBody footer(store.codex) } else { noData @@ -144,12 +141,45 @@ struct MenuContentView: View { } } + @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: fh.usedPercent / 100, level: .forUsed(percent: fh.usedPercent)) + if let cw, let ctxPct { + MetricRow(label: lang.tr("Context", "Context"), fraction: cw.fraction, + value: contextMini(cw, ctxPct), level: .forUsed(percent: ctxPct)) + } + if let wk = store.codexWeekly { + MetricRow(label: lang.tr("weekly", "每週"), fraction: wk.usedPercent / 100, + value: quotaMini(wk), level: .forUsed(percent: wk.usedPercent)) + } + } else if let cw, let ctxPct { + heroFromPercent(ctxPct, label: lang.tr("context", "Context")) + ThinBar(fraction: cw.fraction, level: .forUsed(percent: ctxPct)) + if !NetworkFeature.codexQuota.isEnabled { + Button { StatsWindowController.shared.show(tab: .settings) } label: { + Text(lang.tr("Enable live quota (needs internet)", "啟用即時額度(需連網)")) + .font(.system(size: 11)) + } + .buttonStyle(.link) + } + } + } + } + // MARK: Shared pieces - private func serviceHeader(name: String, tool: AgentTool, available: Bool) -> some View { + 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(amLight: "#27A35A", dark: "#34C759") : AM.ink3.opacity(0.6)) diff --git a/Sources/AgentMeter/Network/BillingClient.swift b/Sources/AgentMeter/Network/BillingClient.swift deleted file mode 100644 index f95972d..0000000 --- a/Sources/AgentMeter/Network/BillingClient.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Foundation -import AgentMeterCore - -/// Networked fetch of real billed cost, to replace the local `≈$` estimate over -/// the covered range. Gated behind the `accurateCost` opt-in. -/// -/// EXPERIMENTAL: the billing endpoint is not wired here yet; returns `.unavailable` -/// until implemented with verified API details. Any `URLSession` use must stay in -/// this file / `CodexQuotaClient` (see Scripts/check-offline.sh). -struct BillingClient { - enum Result: Equatable { - case disabled - case unavailable - case ok(CostEstimate) - } - - func cost(for range: DateInterval) async -> Result { - guard NetworkFeature.accurateCost.isEnabled else { return .disabled } - // TODO(experimental): query the provider billing API via URLSession. - return .unavailable - } -} diff --git a/Sources/AgentMeter/Network/CodexQuotaClient.swift b/Sources/AgentMeter/Network/CodexQuotaClient.swift index 99f4918..7c66a20 100644 --- a/Sources/AgentMeter/Network/CodexQuotaClient.swift +++ b/Sources/AgentMeter/Network/CodexQuotaClient.swift @@ -1,27 +1,62 @@ import Foundation import AgentMeterCore -/// Networked fetch of Codex's real 5-hour / weekly quota. Gated behind the -/// `codexQuota` opt-in — it MUST refuse to run unless the user enabled it, so a -/// code-path bug can't make an unconsented request. +/// Fetches Codex's real 5-hour / weekly quota. Gated behind the `codexQuota` +/// opt-in — refuses to run unless the user enabled it. /// -/// EXPERIMENTAL: the OpenAI quota endpoint + credential exchange are not wired -/// here yet (they require verified, undocumented API details). Until then this -/// reports `.unavailable` rather than fabricating numbers. When implemented, the -/// `URLSession` call belongs in this file (and only this file / `BillingClient`), -/// keeping the offline-by-default guarantee verifiable via Scripts/check-offline.sh. +/// 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 // user hasn't opted in - case unavailable // opted in, but not implemented / no credentials / network error - case ok(fiveHour: QuotaWindow, weekly: QuotaWindow) + case disabled // not opted in + case unavailable // no credentials / network or parse failure + case ok(fiveHour: QuotaWindow, weekly: QuotaWindow?) } - func fetch() async -> Result { + 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 } - // TODO(experimental): read ~/.codex credentials and query OpenAI here via - // URLSession, mapping the response into QuotaWindow values. Returns - // .unavailable until wired with verified endpoint details. + 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 index 6d429b2..6c9a573 100644 --- a/Sources/AgentMeter/Network/NetworkOptIn.swift +++ b/Sources/AgentMeter/Network/NetworkOptIn.swift @@ -1,27 +1,34 @@ import Foundation -/// Opt-in network features. AgentMeter is fully offline by default; each feature -/// here is OFF until the user confirms a "this needs internet" dialog. ALL code -/// that touches the network lives under `Sources/AgentMeter/Network/` — enforced -/// by `Scripts/check-offline.sh` (run in CI) so the offline-by-default guarantee -/// stays verifiable. +/// 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 - case accurateCost + /// 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 .accurateCost: return SettingsKeys.netAccurateCost + 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 .accurateCost: return tr("Accurate cost", "精準花費") + case .showAccounts: return tr("Show logged-in accounts", "顯示登入帳號") } } @@ -31,9 +38,9 @@ enum NetworkFeature: String, CaseIterable, Identifiable { 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 .accurateCost: - return tr("Contacts the provider's billing API to replace the local estimate with real spend. AgentMeter is otherwise fully offline and only connects while this is on.", - "會連線供應商帳單 API,用真實花費取代本機估算。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)以顯示登入的帳號與方案。完全離線、不連網。") } } diff --git a/Sources/AgentMeter/SettingsView.swift b/Sources/AgentMeter/SettingsView.swift index 69983b4..26bd015 100644 --- a/Sources/AgentMeter/SettingsView.swift +++ b/Sources/AgentMeter/SettingsView.swift @@ -208,21 +208,21 @@ private struct FloatingSettings: View { private struct AdvancedSettings: View { @EnvironmentObject private var lang: LanguageStore @AppStorage(SettingsKeys.netCodexQuota) private var codexQuota = false - @AppStorage(SettingsKeys.netAccurateCost) private var accurateCost = false + @AppStorage(SettingsKeys.showAccounts) private var showAccounts = false @State private var pending: NetworkFeature? var body: some View { Form { - Section(lang.tr("Network features (off by default)", "網路功能(預設關閉)")) { + Section(lang.tr("Opt-in features (off by default)", "選用功能(預設關閉)")) { + featureToggle(.showAccounts) featureToggle(.codexQuota) - featureToggle(.accurateCost) } Section { - Text(lang.tr("AgentMeter is fully offline by default — it never connects unless you enable a feature above, and each asks first. Reads only local files; never the Keychain.", - "AgentMeter 預設完全離線——除非你在上面啟用某項功能(且每項都會先詢問),否則永不連線。只讀本機檔案、不讀 Keychain。")) + Text(lang.tr("AgentMeter is fully offline by default — it never connects unless you enable a network feature above, and each asks first. It never reads the Keychain.", + "AgentMeter 預設完全離線——除非你在上面啟用網路功能(且每項都會先詢問),否則永不連線。永不讀取 Keychain。")) .font(.caption).foregroundStyle(.secondary) - Text(lang.tr("These are experimental and may be unavailable until verified provider APIs are wired in.", - "這些為實驗性功能,在接上經驗證的供應商 API 前可能無法使用。")) + Text(lang.tr("Live quota uses cc-bar's endpoints and is experimental; it may be unavailable if the response format changes.", + "即時額度使用 cc-bar 的端點,屬實驗性;若回應格式改變可能無法使用。")) .font(.caption).foregroundStyle(.secondary) } } @@ -231,7 +231,10 @@ private struct AdvancedSettings: View { 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 } + Button(lang.tr("Enable", "啟用")) { + binding(for: feature).wrappedValue = true + UsageStore.shared.refreshNow() // pick up the new data immediately + } } message: { feature in Text(feature.explanation) } @@ -242,15 +245,16 @@ private struct AdvancedSettings: View { get: { binding(for: feature).wrappedValue }, set: { newValue in if newValue { pending = feature } // confirm before enabling - else { binding(for: feature).wrappedValue = false } + else { binding(for: feature).wrappedValue = false; UsageStore.shared.refreshNow() } })) { HStack(spacing: 6) { Text(feature.title) - Text(lang.tr("needs internet", "需連網")) + Text(feature.usesNetwork ? lang.tr("needs internet", "需連網") + : lang.tr("reads credentials", "讀本機憑證")) .font(.system(size: 9.5, weight: .semibold)) .padding(.horizontal, 5).padding(.vertical, 1) - .background(Color.orange.opacity(0.18), in: Capsule()) - .foregroundStyle(.orange) + .background((feature.usesNetwork ? Color.orange : Color.secondary).opacity(0.18), in: Capsule()) + .foregroundStyle(feature.usesNetwork ? .orange : .secondary) } } } @@ -258,7 +262,7 @@ private struct AdvancedSettings: View { private func binding(for feature: NetworkFeature) -> Binding { switch feature { case .codexQuota: return $codexQuota - case .accurateCost: return $accurateCost + case .showAccounts: return $showAccounts } } } diff --git a/Sources/AgentMeter/UsageStore.swift b/Sources/AgentMeter/UsageStore.swift index cea7066..a4fe82d 100644 --- a/Sources/AgentMeter/UsageStore.swift +++ b/Sources/AgentMeter/UsageStore.swift @@ -10,6 +10,12 @@ final class UsageStore: ObservableObject { @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 @@ -44,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/Credentials.swift b/Sources/AgentMeterCore/Credentials.swift new file mode 100644 index 0000000..10ca251 --- /dev/null +++ b/Sources/AgentMeterCore/Credentials.swift @@ -0,0 +1,145 @@ +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 + } +} + +// 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? { + let credsURL = home.appendingPathComponent(".claude/.credentials.json") + guard let data = try? Data(contentsOf: credsURL), + var creds = ClaudeCredentialParser.parse(credentialsJSON: data) else { return nil } + if creds.account.email == nil, + let cfg = try? Data(contentsOf: home.appendingPathComponent(".claude.json")) { + creds.account.email = ClaudeCredentialParser.email(fromClaudeConfigJSON: cfg) + } + 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/Tests/AgentMeterCoreTests/CredentialsTests.swift b/Tests/AgentMeterCoreTests/CredentialsTests.swift new file mode 100644 index 0000000..71a29bf --- /dev/null +++ b/Tests/AgentMeterCoreTests/CredentialsTests.swift @@ -0,0 +1,68 @@ +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 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))) + } +} From 6f5ddbb65f6a80e79216adc088bca137e1bb5454 Mon Sep 17 00:00:00 2001 From: TeaLance <178543322+TeaLance@users.noreply.github.com> Date: Sat, 20 Jun 2026 18:30:30 +0800 Subject: [PATCH 10/14] Round 3: token totals, unified tabs, accounts, stacked menu bar, remaining/used MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - R3.8 Token counts now use total (incl cache reads) to match cc-bar; the ~36x gap was billable-vs-total. Cost unchanged (already weights cache reads). - R3.1 One window, one tab bar: 用量統計 is now the first tab alongside the settings panes (RootTabView + AppTab); dropped the separate pill switcher. - R3.5/R3.7 Accounts section in General: Claude/Codex email · plan · connected, so enabled accounts actually show. - R3.6 Default ON: codexQuota + showAccounts default enabled (NetworkFeature treats absent key as true) for accurate quota/accounts; README updated (still never reads Keychain; off switch in Advanced). - R3.4 Removed the offline-explainer captions in Advanced. - R3.3 Menu bar renders each metric as a stacked label/value (Stats-app style). - R3.2 New "Meters show used/remaining" setting, applied to panel + floating HUD. 65 tests pass; offline check green; app builds and launches. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 10 +-- Sources/AgentMeter/AgentMeterApp.swift | 13 +++- Sources/AgentMeter/AppSettings.swift | 2 + .../AgentMeter/Floating/FloatingHUDView.swift | 6 +- Sources/AgentMeter/MenuBarMetric.swift | 62 ++++++++-------- Sources/AgentMeter/MenuContentView.swift | 42 ++++++----- Sources/AgentMeter/Network/NetworkOptIn.swift | 3 +- Sources/AgentMeter/SettingsView.swift | 72 +++++++++++++++---- .../AgentMeter/Stats/MainWindowRootView.swift | 54 ++------------ Sources/AgentMeter/Stats/StatsRootView.swift | 8 +-- .../Stats/StatsWindowController.swift | 6 +- Sources/AgentMeter/UsageStore.swift | 6 +- 12 files changed, 154 insertions(+), 130 deletions(-) diff --git a/README.md b/README.md index 11bb83e..dcc49fd 100644 --- a/README.md +++ b/README.md @@ -39,11 +39,13 @@ brew install --cask TeaLance/tap/agentmeter ## 隱私 -**預設完全離線**:只讀取本機 `~/.claude` 與 `~/.codex` 的用量紀錄來統計,**不連網、不讀 Keychain、不傳送任何資料**。 +用量統計只讀取本機 `~/.claude` 與 `~/.codex` 的紀錄,**不讀 Keychain、不傳送任何資料給第三方**。 -少數**選用功能**才會多做事,且**每項開啟前都會先詢問**、預設關閉: -- **顯示登入帳號**:讀取本機登入憑證「檔案」(仍**不讀 Keychain**)以顯示登入的帳號與方案;不連網。 -- **Codex 即時額度**:用本機憑證的權杖連線 OpenAI 取得真實 5h/每週額度;**只有開啟此功能時才連網**。 +為了顯示登入帳號與真實額度,預設會做兩件事(都可在**設定 → 進階**關閉): +- **顯示登入帳號**:讀取本機登入憑證「檔案」(**不讀 Keychain**)解出 email/方案;不連網。 +- **Codex 即時額度**:用本機憑證的權杖連線 OpenAI 取得真實 5h/每週額度。 + +關閉以上兩項後即**完全離線**。網路碼僅限於 `Sources/AgentMeter/Network/`(有 CI `Scripts/check-offline.sh` 把關)。 ## 授權 diff --git a/Sources/AgentMeter/AgentMeterApp.swift b/Sources/AgentMeter/AgentMeterApp.swift index 3dfc574..63e62e3 100644 --- a/Sources/AgentMeter/AgentMeterApp.swift +++ b/Sources/AgentMeter/AgentMeterApp.swift @@ -29,11 +29,18 @@ struct MenuBarLabel: View { @AppStorage(SettingsKeys.menuBarMetrics) private var metricsCSV = defaultMenuBarMetricsCSV 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) + HStack(spacing: 8) { + ForEach(Array(cells.enumerated()), id: \.offset) { _, c in + VStack(spacing: 0) { + Text(c.top).font(.system(size: 8)) + Text(c.bottom).font(.system(size: 10, weight: .medium)).monospacedDigit() + } + } + } } } } diff --git a/Sources/AgentMeter/AppSettings.swift b/Sources/AgentMeter/AppSettings.swift index f6e7b13..f9e8253 100644 --- a/Sources/AgentMeter/AppSettings.swift +++ b/Sources/AgentMeter/AppSettings.swift @@ -8,6 +8,8 @@ enum SettingsKeys { 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" // Floating desktop HUD. static let floatingEnabled = "floatingEnabled" static let floatingShowClaude = "floatingShowClaude" diff --git a/Sources/AgentMeter/Floating/FloatingHUDView.swift b/Sources/AgentMeter/Floating/FloatingHUDView.swift index 4a27153..54358c7 100644 --- a/Sources/AgentMeter/Floating/FloatingHUDView.swift +++ b/Sources/AgentMeter/Floating/FloatingHUDView.swift @@ -9,6 +9,7 @@ struct FloatingHUDView: View { @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 @@ -36,8 +37,9 @@ struct FloatingHUDView: View { } private func cell(_ tool: AgentTool, _ name: String, _ m: Metric) -> some View { - VStack(spacing: 5) { - RingMeter(fraction: m.fraction, level: m.level, percentText: m.pct, + 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) diff --git a/Sources/AgentMeter/MenuBarMetric.swift b/Sources/AgentMeter/MenuBarMetric.swift index 2c0858d..e948f24 100644 --- a/Sources/AgentMeter/MenuBarMetric.swift +++ b/Sources/AgentMeter/MenuBarMetric.swift @@ -66,35 +66,31 @@ enum MenuBarMetric: String, CaseIterable, Identifiable { } } - /// 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)? { 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", "\(percent($0.usedPercent))%") } + case .claudeWeekly: return store.claudeQuota.weekly.map { ("7d", "\(percent($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", "\(Int(($0.fraction * 100).rounded()))%") } 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", "\(Int(($0.fraction * 100).rounded()))%") } + 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 } @@ -104,23 +100,29 @@ enum MenuBarMetric: String, CaseIterable, Identifiable { 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 stacked (top, bottom) cells for the selected metrics; data-less ones + /// are hidden. Tool prefix is added to the top line only when the same kind + /// spans both tools (so they can be told apart). @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). + static func cells(_ selected: [MenuBarMetric], store: UsageStore) -> [(top: String, bottom: String)] { 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) + var out: [(String, String)] = [] + for m in selected { + guard let pv = m.parts(store) else { continue } + let needsPrefix = (toolsByKind[m.kind]?.count ?? 0) > 1 + let top: String + if pv.label.isEmpty { + top = m.toolPrefix // tokens: tool tag identifies it + } else { + top = needsPrefix ? "\(m.toolPrefix) \(pv.label)" : pv.label + } + out.append((top, pv.value)) } - return parts.joined(separator: " ") + return out } // MARK: - Persistence diff --git a/Sources/AgentMeter/MenuContentView.swift b/Sources/AgentMeter/MenuContentView.swift index e91da8c..68a7b98 100644 --- a/Sources/AgentMeter/MenuContentView.swift +++ b/Sources/AgentMeter/MenuContentView.swift @@ -13,9 +13,14 @@ struct MenuContentView: View { @AppStorage(SettingsKeys.showClaude) private var showClaude = true @AppStorage(SettingsKeys.showCodex) private var showCodex = true @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: AM.Space.m) { header @@ -47,7 +52,7 @@ struct MenuContentView: View { 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: .settings) } + iconButton("gearshape") { StatsWindowController.shared.show(tab: .general) } iconButton("power") { NSApplication.shared.terminate(nil) } } } @@ -97,20 +102,20 @@ struct MenuContentView: View { set: { claudeHeroRaw = ($0 ? ClaudeHero.weekly : .fiveHour).rawValue }), leftLabel: "5h", rightLabel: lang.tr("Wk", "週")) } - ThinBar(fraction: hero.usedPercent / 100, level: .forUsed(percent: hero.usedPercent)) + ThinBar(fraction: dispFrac(hero.usedPercent), level: .forUsed(percent: hero.usedPercent)) } if let cw, let ctxPct { - MetricRow(label: lang.tr("Context", "Context"), fraction: cw.fraction, + 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: other.usedPercent / 100, value: quotaMini(other), + 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: cw.fraction, level: .forUsed(percent: ctxPct)) + ThinBar(fraction: dispFrac(ctxPct), level: .forUsed(percent: ctxPct)) enableQuotaLink } else { enableQuotaLink @@ -119,7 +124,7 @@ struct MenuContentView: View { } private var enableQuotaLink: some View { - Button { StatsWindowController.shared.show(tab: .settings) } label: { + Button { StatsWindowController.shared.show(tab: .advanced) } label: { Text(lang.tr("Enable live 5h / weekly quota", "啟用即時 5h/每週額度")) .font(.system(size: 11)) } @@ -148,20 +153,20 @@ struct MenuContentView: View { if let fh = store.codexFiveHour { // Real networked 5-hour quota. heroFromQuota(fh, label: lang.tr("5-hour", "5 小時額度")) - ThinBar(fraction: fh.usedPercent / 100, level: .forUsed(percent: fh.usedPercent)) + ThinBar(fraction: dispFrac(fh.usedPercent), level: .forUsed(percent: fh.usedPercent)) if let cw, let ctxPct { - MetricRow(label: lang.tr("Context", "Context"), fraction: cw.fraction, + 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: wk.usedPercent / 100, + 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: cw.fraction, level: .forUsed(percent: ctxPct)) + ThinBar(fraction: dispFrac(ctxPct), level: .forUsed(percent: ctxPct)) if !NetworkFeature.codexQuota.isEnabled { - Button { StatsWindowController.shared.show(tab: .settings) } label: { + Button { StatsWindowController.shared.show(tab: .advanced) } label: { Text(lang.tr("Enable live quota (needs internet)", "啟用即時額度(需連網)")) .font(.system(size: 11)) } @@ -190,18 +195,19 @@ struct MenuContentView: View { 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: w.usedPercent, label: full, level: .forUsed(percent: w.usedPercent)) + return HeroNumber(percent: disp(w.usedPercent), label: full, level: .forUsed(percent: w.usedPercent)) } - private func heroFromPercent(_ pct: Double, label: String) -> some View { - HeroNumber(percent: pct, label: label, level: .forUsed(percent: pct)) + /// `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 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.billableTotal.compactTokenString) tokens") + 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))) @@ -224,12 +230,12 @@ struct MenuContentView: View { // MARK: Formatting - private func contextMini(_ cw: ContextWindow, _ pct: Double) -> String { - "\(Int(pct.rounded()))% · \(cw.used.compactTokenString)" + private func contextMini(_ cw: ContextWindow, _ usedPct: Double) -> String { + "\(Int(disp(usedPct).rounded()))% · \(cw.used.compactTokenString)" } private func quotaMini(_ w: QuotaWindow) -> String { - let pct = "\(Int(w.usedPercent.rounded()))%" + let pct = "\(Int(disp(w.usedPercent).rounded()))%" if let r = shortReset(until: w.resetsAt) { return "\(pct) · \(r)" } return pct } diff --git a/Sources/AgentMeter/Network/NetworkOptIn.swift b/Sources/AgentMeter/Network/NetworkOptIn.swift index 6c9a573..673faa6 100644 --- a/Sources/AgentMeter/Network/NetworkOptIn.swift +++ b/Sources/AgentMeter/Network/NetworkOptIn.swift @@ -44,5 +44,6 @@ enum NetworkFeature: String, CaseIterable, Identifiable { } } - var isEnabled: Bool { UserDefaults.standard.bool(forKey: defaultsKey) } + /// 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 26bd015..36843c2 100644 --- a/Sources/AgentMeter/SettingsView.swift +++ b/Sources/AgentMeter/SettingsView.swift @@ -2,26 +2,36 @@ import SwiftUI import ServiceManagement import AgentMeterCore -struct SettingsView: View { - @EnvironmentObject private var store: UsageStore +/// The single window's content: usage stats + settings panes in one tab bar. +struct RootTabView: View { @EnvironmentObject private var lang: LanguageStore - @EnvironmentObject private var colors: ServiceColorStore + @EnvironmentObject private var nav: MainWindowModel var body: some View { - TabView { + 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) BridgeSettings() .tabItem { Label(lang.tr("Claude Quota", "Claude 額度"), systemImage: "bolt.horizontal.circle") } + .tag(AppTab.bridge) AdvancedSettings() .tabItem { Label(lang.tr("Advanced", "進階"), systemImage: "network") } + .tag(AppTab.advanced) } + .background(AM.paper) } } @@ -30,13 +40,20 @@ struct SettingsView: View { 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.heroMetricClaude) private var claudeHeroRaw = ClaudeHero.fiveHour.rawValue + @AppStorage(SettingsKeys.meterShowsRemaining) private var meterShowsRemaining = false @State private var launchAtLogin = SMAppService.mainApp.status == .enabled @State private var loginError: String? var body: some View { Form { + Section(lang.tr("Accounts", "帳號")) { + accountRow("Claude Code", provider: "Anthropic", tool: .claudeCode, account: store.claudeAccount) + accountRow("Codex", provider: "OpenAI", tool: .codex, account: store.codexAccount) + } + Picker(lang.tr("Language", "語言"), selection: $lang.language) { Text("繁體中文").tag(AppLanguage.zh) Text("English").tag(AppLanguage.en) @@ -54,6 +71,11 @@ private struct GeneralSettings: View { Text(lang.tr("Weekly", "每週額度")).tag(ClaudeHero.weekly.rawValue) } + Picker(lang.tr("Meters show", "量表顯示"), selection: $meterShowsRemaining) { + Text(lang.tr("Used", "已使用")).tag(false) + Text(lang.tr("Remaining", "剩餘")).tag(true) + } + Section { Toggle(lang.tr("Launch at login", "開機時自動啟動"), isOn: $launchAtLogin) .onChange(of: launchAtLogin) { _, on in setLaunchAtLogin(on) } @@ -70,6 +92,34 @@ private struct GeneralSettings: View { .formStyle(.grouped) } + 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 { @@ -207,24 +257,16 @@ private struct FloatingSettings: View { private struct AdvancedSettings: View { @EnvironmentObject private var lang: LanguageStore - @AppStorage(SettingsKeys.netCodexQuota) private var codexQuota = false - @AppStorage(SettingsKeys.showAccounts) private var showAccounts = false + @AppStorage(SettingsKeys.netCodexQuota) private var codexQuota = true + @AppStorage(SettingsKeys.showAccounts) private var showAccounts = true @State private var pending: NetworkFeature? var body: some View { Form { - Section(lang.tr("Opt-in features (off by default)", "選用功能(預設關閉)")) { + Section(lang.tr("Connectivity", "連線")) { featureToggle(.showAccounts) featureToggle(.codexQuota) } - Section { - Text(lang.tr("AgentMeter is fully offline by default — it never connects unless you enable a network feature above, and each asks first. It never reads the Keychain.", - "AgentMeter 預設完全離線——除非你在上面啟用網路功能(且每項都會先詢問),否則永不連線。永不讀取 Keychain。")) - .font(.caption).foregroundStyle(.secondary) - Text(lang.tr("Live quota uses cc-bar's endpoints and is experimental; it may be unavailable if the response format changes.", - "即時額度使用 cc-bar 的端點,屬實驗性;若回應格式改變可能無法使用。")) - .font(.caption).foregroundStyle(.secondary) - } } .formStyle(.grouped) .alert(pending?.title ?? "", diff --git a/Sources/AgentMeter/Stats/MainWindowRootView.swift b/Sources/AgentMeter/Stats/MainWindowRootView.swift index 722df9c..4b557f3 100644 --- a/Sources/AgentMeter/Stats/MainWindowRootView.swift +++ b/Sources/AgentMeter/Stats/MainWindowRootView.swift @@ -1,52 +1,12 @@ -import SwiftUI +import Foundation -enum MainTab { case stats, settings } +/// 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, bridge, advanced } -/// Which tab the single main window shows. Owned by StatsWindowController so the -/// menu-bar buttons can switch tabs on an already-open window. +/// 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 tab: MainTab = .stats -} - -/// The single app window: cc-bar-style top tabs — 用量統計 | 設定 — so usage stats -/// and settings live in one place (previously two separate windows the user -/// couldn't find). -struct MainWindowRootView: View { - @EnvironmentObject private var lang: LanguageStore - @EnvironmentObject private var nav: MainWindowModel - - var body: some View { - VStack(spacing: 0) { - HStack(spacing: 4) { - tab(lang.tr("Usage", "用量統計"), .stats) - tab(lang.tr("Settings", "設定"), .settings) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .background(AM.paper) - Rectangle().fill(AM.hairline).frame(height: 1) - Group { - switch nav.tab { - case .stats: StatsRootView() - case .settings: SettingsView() - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .background(AM.paper) - .foregroundStyle(AM.ink) - } - - private func tab(_ label: String, _ value: MainTab) -> some View { - let on = nav.tab == value - return Button { nav.tab = value } label: { - Text(label) - .font(.system(size: 12, weight: on ? .semibold : .regular)) - .foregroundStyle(on ? AM.paper : AM.ink2) - .padding(.horizontal, 12).padding(.vertical, 4) - .background(on ? AM.ink : .clear, in: Capsule()) - } - .buttonStyle(.plain) - } + @Published var selection: AppTab = .stats } diff --git a/Sources/AgentMeter/Stats/StatsRootView.swift b/Sources/AgentMeter/Stats/StatsRootView.swift index 41bffa7..7b17282 100644 --- a/Sources/AgentMeter/Stats/StatsRootView.swift +++ b/Sources/AgentMeter/Stats/StatsRootView.swift @@ -243,7 +243,7 @@ struct StatsRootView: View { } } - private var totalTokens: Int { includedHistories.reduce(0) { $0 + $1.grandTotal.billableTotal } } + 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 @@ -252,10 +252,10 @@ struct StatsRootView: View { 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.billableTotal } + 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.billableTotal } + 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) } } @@ -270,7 +270,7 @@ struct StatsRootView: View { guard let history else { return } for (key, bd) in history.byModel { rows.append(ModelRow(id: history.tool.rawValue + key, model: key, - tokens: bd.billableTotal, + tokens: bd.total, cost: costEstimate(bd, model: ModelKey(raw: key)), color: color)) } } diff --git a/Sources/AgentMeter/Stats/StatsWindowController.swift b/Sources/AgentMeter/Stats/StatsWindowController.swift index b0dab87..11d7f63 100644 --- a/Sources/AgentMeter/Stats/StatsWindowController.swift +++ b/Sources/AgentMeter/Stats/StatsWindowController.swift @@ -9,10 +9,10 @@ final class StatsWindowController: NSObject, NSWindowDelegate { private var window: NSWindow? private let nav = MainWindowModel() - func show(tab: MainTab) { - nav.tab = tab + func show(tab: AppTab) { + nav.selection = tab if window == nil { - let root = MainWindowRootView() + let root = RootTabView() .environmentObject(UsageStore.shared) .environmentObject(LanguageStore.shared) .environmentObject(ServiceColorStore.shared) diff --git a/Sources/AgentMeter/UsageStore.swift b/Sources/AgentMeter/UsageStore.swift index a4fe82d..dae4f52 100644 --- a/Sources/AgentMeter/UsageStore.swift +++ b/Sources/AgentMeter/UsageStore.swift @@ -29,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) { From 1b452590a7efe82ff5762746f12de0abc2faf3bd Mon Sep 17 00:00:00 2001 From: TeaLance <178543322+TeaLance@users.noreply.github.com> Date: Sat, 20 Jun 2026 18:39:34 +0800 Subject: [PATCH 11/14] Fix: detect Claude account from ~/.claude.json when token is in the Keychain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The real macOS setup keeps the Claude token in the Keychain (which we never read), so ~/.claude/.credentials.json is absent and the account showed as "not detected". ~/.claude.json still has oauthAccount (email + organizationType), so: - ClaudeCredentialParser.account(fromClaudeConfigJSON:) derives email + plan (organizationType "claude_team" → "team"). - CredentialReader.claude() no longer requires .credentials.json — it returns an account from ~/.claude.json alone (token optional; Claude quota uses the bridge). Still file-only, never the Keychain. 66 Core tests pass; offline check green. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/AgentMeterCore/Credentials.swift | 31 ++++++++++++++++--- .../CredentialsTests.swift | 8 +++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/Sources/AgentMeterCore/Credentials.swift b/Sources/AgentMeterCore/Credentials.swift index 10ca251..ba267ee 100644 --- a/Sources/AgentMeterCore/Credentials.swift +++ b/Sources/AgentMeterCore/Credentials.swift @@ -71,6 +71,18 @@ public enum ClaudeCredentialParser { 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 @@ -104,12 +116,21 @@ public struct CredentialReader { 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") - guard let data = try? Data(contentsOf: credsURL), - var creds = ClaudeCredentialParser.parse(credentialsJSON: data) else { return nil } - if creds.account.email == nil, - let cfg = try? Data(contentsOf: home.appendingPathComponent(".claude.json")) { - creds.account.email = ClaudeCredentialParser.email(fromClaudeConfigJSON: cfg) + 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 } diff --git a/Tests/AgentMeterCoreTests/CredentialsTests.swift b/Tests/AgentMeterCoreTests/CredentialsTests.swift index 71a29bf..14e0f6b 100644 --- a/Tests/AgentMeterCoreTests/CredentialsTests.swift +++ b/Tests/AgentMeterCoreTests/CredentialsTests.swift @@ -42,6 +42,14 @@ final class CredentialsTests: XCTestCase { 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", From 7943f7b6edc6c92af911b621184fb0308a617feb Mon Sep 17 00:00:00 2001 From: TeaLance <178543322+TeaLance@users.noreply.github.com> Date: Sat, 20 Jun 2026 18:47:55 +0800 Subject: [PATCH 12/14] Fix menu-bar stacked label clipping + honor used/remaining - Menu bar is ~22pt tall; 8/10pt stacked lines clipped the value. Shrink to 7/9pt with -1.5 spacing + fixedSize so both lines fit. - MenuBarMetric.parts now honors meterShowsRemaining (5h/weekly/context show remaining when set); MenuBarLabel re-renders on the setting via @AppStorage. App builds and launches. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/AgentMeter/AgentMeterApp.swift | 14 ++++++++++---- Sources/AgentMeter/MenuBarMetric.swift | 14 ++++++++------ 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/Sources/AgentMeter/AgentMeterApp.swift b/Sources/AgentMeter/AgentMeterApp.swift index 63e62e3..151ae86 100644 --- a/Sources/AgentMeter/AgentMeterApp.swift +++ b/Sources/AgentMeter/AgentMeterApp.swift @@ -27,20 +27,26 @@ struct AgentMeterApp: App { struct MenuBarLabel: View { @ObservedObject var store: UsageStore @AppStorage(SettingsKeys.menuBarMetrics) private var metricsCSV = defaultMenuBarMetricsCSV + // Re-render when the used/remaining setting changes (parts() reads it). + @AppStorage(SettingsKeys.meterShowsRemaining) private var showRemaining = false var body: some View { let cells = MenuBarMetric.cells(MenuBarMetric.list(fromCSV: metricsCSV), store: store) if cells.isEmpty { Image(systemName: "gauge.with.dots.needle.33percent") } else { - HStack(spacing: 8) { + // The menu bar is only ~22pt tall — keep both stacked lines tiny and + // tightly spaced so the value line isn't clipped. + HStack(spacing: 7) { ForEach(Array(cells.enumerated()), id: \.offset) { _, c in - VStack(spacing: 0) { - Text(c.top).font(.system(size: 8)) - Text(c.bottom).font(.system(size: 10, weight: .medium)).monospacedDigit() + VStack(spacing: -1.5) { + Text(c.top).font(.system(size: 7)).foregroundStyle(.secondary) + Text(c.bottom).font(.system(size: 9, weight: .semibold)).monospacedDigit() } + .fixedSize() } } + .fixedSize() } } } diff --git a/Sources/AgentMeter/MenuBarMetric.swift b/Sources/AgentMeter/MenuBarMetric.swift index e948f24..733ee0b 100644 --- a/Sources/AgentMeter/MenuBarMetric.swift +++ b/Sources/AgentMeter/MenuBarMetric.swift @@ -71,19 +71,23 @@ enum MenuBarMetric: String, CaseIterable, Identifiable { /// empty for token counts (where the tool tag is the top line). @MainActor 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).map { ("", $0) } case .codexTokens: return tokenString(store.codex).map { ("", $0) } case .combinedTokens: let total = store.combinedTodayTotal 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))%") } + case .claudeFiveHour: return store.claudeQuota.fiveHour.map { ("5h", pct($0.usedPercent)) } + case .claudeWeekly: return store.claudeQuota.weekly.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()))%") } + 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) } } @@ -98,8 +102,6 @@ enum MenuBarMetric: String, CaseIterable, Identifiable { (usage.available && usage.messageCount > 0) ? "\(usage.messageCount)" : nil } - private func percent(_ p: Double) -> Int { Int(p.rounded()) } - // MARK: - Cell rendering /// Build stacked (top, bottom) cells for the selected metrics; data-less ones From 44623157118410005713705a84c334c19d1b0837 Mon Sep 17 00:00:00 2001 From: TeaLance <178543322+TeaLance@users.noreply.github.com> Date: Sat, 20 Jun 2026 19:45:30 +0800 Subject: [PATCH 13/14] Menu bar: draw stacked label as a template NSImage (fixes clipped value) MenuBarExtra clips multi-line SwiftUI labels to the bar height, so the value line never showed. Render each metric as a 2-line (label/value) template NSImage at natural size; the menu bar scales it to fit and tints it for light/dark. App builds and launches. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/AgentMeter/AgentMeterApp.swift | 48 +++++++++++++++++++------- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/Sources/AgentMeter/AgentMeterApp.swift b/Sources/AgentMeter/AgentMeterApp.swift index 151ae86..539bfa0 100644 --- a/Sources/AgentMeter/AgentMeterApp.swift +++ b/Sources/AgentMeter/AgentMeterApp.swift @@ -35,20 +35,44 @@ struct MenuBarLabel: View { if cells.isEmpty { Image(systemName: "gauge.with.dots.needle.33percent") } else { - // The menu bar is only ~22pt tall — keep both stacked lines tiny and - // tightly spaced so the value line isn't clipped. - HStack(spacing: 7) { - ForEach(Array(cells.enumerated()), id: \.offset) { _, c in - VStack(spacing: -1.5) { - Text(c.top).font(.system(size: 7)).foregroundStyle(.secondary) - Text(c.bottom).font(.system(size: 9, weight: .semibold)).monospacedDigit() - } - .fixedSize() - } - } - .fixedSize() + // SwiftUI multi-line labels get clipped to the menu-bar height, so draw + // the stacked label ourselves into a template image the system scales to fit. + Image(nsImage: MenuBarLabel.render(cells)) } } + + /// Render the metric cells as a 2-line-per-cell template image (label on top, + /// value below), drawn at natural size so the menu bar scales it down to fit. + static func render(_ cells: [(top: String, bottom: String)]) -> NSImage { + let topFont = NSFont.systemFont(ofSize: 8, weight: .regular) + let botFont = NSFont.monospacedDigitSystemFont(ofSize: 10, weight: .semibold) + let cellGap: CGFloat = 8 + let attrs: (NSFont) -> [NSAttributedString.Key: Any] = { + [.font: $0, .foregroundColor: NSColor.black] + } + let pieces = cells.map { c -> (top: NSAttributedString, bot: NSAttributedString, w: CGFloat) in + let top = NSAttributedString(string: c.top, attributes: attrs(topFont)) + let bot = NSAttributedString(string: c.bottom, attributes: attrs(botFont)) + return (top, bot, max(top.size().width, bot.size().width)) + } + let botH = botFont.boundingRectForFont.height + let topH = topFont.boundingRectForFont.height + let height = ceil(botH + topH) + let width = ceil(pieces.reduce(0) { $0 + $1.w } + cellGap * CGFloat(max(0, pieces.count - 1))) + + let image = NSImage(size: NSSize(width: max(1, width), height: max(1, height))) + image.lockFocus() + var x: CGFloat = 0 + for p in pieces { + let ts = p.top.size(), bs = p.bot.size() + p.bot.draw(at: NSPoint(x: x + (p.w - bs.width) / 2, y: 0)) + p.top.draw(at: NSPoint(x: x + (p.w - ts.width) / 2, y: botH)) + x += p.w + cellGap + } + image.unlockFocus() + image.isTemplate = true // adapt to light/dark menu bar automatically + return image + } } /// Hides the Dock icon so the app lives only in the menu bar, even when launched From 3f4b812bb82248995f919c2418e58656424596e2 Mon Sep 17 00:00:00 2001 From: TeaLance <178543322+TeaLance@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:51:18 +0800 Subject: [PATCH 14/14] Round 4: menu-bar agent logos, layout options + settings cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Menu bar - Draw each service's logo before its metrics, as a monochrome template glyph (new MenuBarIconPath.swift: embedded Claude/Codex SVG paths + a minimal absolute-command path parser → NSBezierPath). Adapts to light/dark like the text. - Add vertical (stacked) / horizontal (inline) layout option, and a "show agent icon" toggle. Horizontal uses one uniform font size with a shared baseline; logo sized to match the text height. - Add Codex 5h / weekly as menu-bar metrics (data already fetched via the Codex quota endpoint). - Drop the CC/CX text prefix — the per-service logo identifies the tool; only the combined total keeps a "Σ" marker. Settings - Consolidate connectivity onto General: a single "Live quota & connectivity" section with show-accounts, Claude live quota (statusLine bridge, local) and Codex live quota (needs internet). Remove the now redundant Advanced and Claude-Quota tabs. - Split menu-bar metrics into Claude / Codex / Combined sections. - Remove the Claude hero-metric picker (the panel's 5h|週 toggle owns it) and several explanatory captions. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/AgentMeter/AgentMeterApp.swift | 96 +++++-- Sources/AgentMeter/AppSettings.swift | 4 + Sources/AgentMeter/MenuBarIconPath.swift | 92 +++++++ Sources/AgentMeter/MenuBarMetric.swift | 54 ++-- Sources/AgentMeter/MenuContentView.swift | 4 +- Sources/AgentMeter/SettingsView.swift | 249 ++++++++---------- .../AgentMeter/Stats/MainWindowRootView.swift | 2 +- 7 files changed, 304 insertions(+), 197 deletions(-) create mode 100644 Sources/AgentMeter/MenuBarIconPath.swift diff --git a/Sources/AgentMeter/AgentMeterApp.swift b/Sources/AgentMeter/AgentMeterApp.swift index 539bfa0..9b198f7 100644 --- a/Sources/AgentMeter/AgentMeterApp.swift +++ b/Sources/AgentMeter/AgentMeterApp.swift @@ -27,8 +27,10 @@ struct AgentMeterApp: App { struct MenuBarLabel: View { @ObservedObject var store: UsageStore @AppStorage(SettingsKeys.menuBarMetrics) private var metricsCSV = defaultMenuBarMetricsCSV - // Re-render when the used/remaining setting changes (parts() reads it). + // 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 cells = MenuBarMetric.cells(MenuBarMetric.list(fromCSV: metricsCSV), store: store) @@ -36,38 +38,82 @@ struct MenuBarLabel: View { Image(systemName: "gauge.with.dots.needle.33percent") } else { // SwiftUI multi-line labels get clipped to the menu-bar height, so draw - // the stacked label ourselves into a template image the system scales to fit. - Image(nsImage: MenuBarLabel.render(cells)) + // the label ourselves into a template image the system scales to fit. + Image(nsImage: MenuBarLabel.render(cells, horizontal: orientation == "horizontal", showIcon: showIcon)) } } - /// Render the metric cells as a 2-line-per-cell template image (label on top, - /// value below), drawn at natural size so the menu bar scales it down to fit. - static func render(_ cells: [(top: String, bottom: String)]) -> NSImage { - let topFont = NSFont.systemFont(ofSize: 8, weight: .regular) - let botFont = NSFont.monospacedDigitSystemFont(ofSize: 10, weight: .semibold) - let cellGap: CGFloat = 8 - let attrs: (NSFont) -> [NSAttributedString.Key: Any] = { - [.font: $0, .foregroundColor: NSColor.black] + 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 pieces = cells.map { c -> (top: NSAttributedString, bot: NSAttributedString, w: CGFloat) in - let top = NSAttributedString(string: c.top, attributes: attrs(topFont)) - let bot = NSAttributedString(string: c.bottom, attributes: attrs(botFont)) - return (top, bot, max(top.size().width, bot.size().width)) + 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 botH = botFont.boundingRectForFont.height - let topH = topFont.boundingRectForFont.height - let height = ceil(botH + topH) - let width = ceil(pieces.reduce(0) { $0 + $1.w } + cellGap * CGFloat(max(0, pieces.count - 1))) - let image = NSImage(size: NSSize(width: max(1, width), height: max(1, height))) + 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 p in pieces { - let ts = p.top.size(), bs = p.bot.size() - p.bot.draw(at: NSPoint(x: x + (p.w - bs.width) / 2, y: 0)) - p.top.draw(at: NSPoint(x: x + (p.w - ts.width) / 2, y: botH)) - x += p.w + cellGap + 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 diff --git a/Sources/AgentMeter/AppSettings.swift b/Sources/AgentMeter/AppSettings.swift index f9e8253..279ff9d 100644 --- a/Sources/AgentMeter/AppSettings.swift +++ b/Sources/AgentMeter/AppSettings.swift @@ -10,6 +10,10 @@ enum SettingsKeys { 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" 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 733ee0b..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,13 +37,22 @@ 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 } } + /// 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 + } + } + /// Label shown in the Settings multi-select list. var settingsTitle: String { switch self { @@ -51,21 +62,14 @@ enum MenuBarMetric: String, CaseIterable, Identifiable { 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") } } - /// 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 "Σ" - } - } - /// 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). @@ -83,6 +87,8 @@ enum MenuBarMetric: String, CaseIterable, Identifiable { 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", pct($0.fraction * 100)) } @@ -104,25 +110,17 @@ enum MenuBarMetric: String, CaseIterable, Identifiable { // MARK: - Cell rendering - /// Build stacked (top, bottom) cells for the selected metrics; data-less ones - /// are hidden. Tool prefix is added to the top line only when the same kind - /// spans both tools (so they can be told apart). + /// 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 cells(_ selected: [MenuBarMetric], store: UsageStore) -> [(top: String, bottom: String)] { - var toolsByKind: [Kind: Set] = [:] - for m in selected { toolsByKind[m.kind, default: []].insert(m.tool) } - - var out: [(String, String)] = [] + 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 needsPrefix = (toolsByKind[m.kind]?.count ?? 0) > 1 - let top: String - if pv.label.isEmpty { - top = m.toolPrefix // tokens: tool tag identifies it - } else { - top = needsPrefix ? "\(m.toolPrefix) \(pv.label)" : pv.label - } - out.append((top, pv.value)) + let top = pv.label.isEmpty ? (m.tool == .combined ? "Σ" : "") : pv.label + out.append((m.agentTool, top, pv.value)) } return out } diff --git a/Sources/AgentMeter/MenuContentView.swift b/Sources/AgentMeter/MenuContentView.swift index 68a7b98..2e547b0 100644 --- a/Sources/AgentMeter/MenuContentView.swift +++ b/Sources/AgentMeter/MenuContentView.swift @@ -124,7 +124,7 @@ struct MenuContentView: View { } private var enableQuotaLink: some View { - Button { StatsWindowController.shared.show(tab: .advanced) } label: { + Button { StatsWindowController.shared.show(tab: .general) } label: { Text(lang.tr("Enable live 5h / weekly quota", "啟用即時 5h/每週額度")) .font(.system(size: 11)) } @@ -166,7 +166,7 @@ struct MenuContentView: View { heroFromPercent(ctxPct, label: lang.tr("context", "Context")) ThinBar(fraction: dispFrac(ctxPct), level: .forUsed(percent: ctxPct)) if !NetworkFeature.codexQuota.isEnabled { - Button { StatsWindowController.shared.show(tab: .advanced) } label: { + Button { StatsWindowController.shared.show(tab: .general) } label: { Text(lang.tr("Enable live quota (needs internet)", "啟用即時額度(需連網)")) .font(.system(size: 11)) } diff --git a/Sources/AgentMeter/SettingsView.swift b/Sources/AgentMeter/SettingsView.swift index 36843c2..1268b94 100644 --- a/Sources/AgentMeter/SettingsView.swift +++ b/Sources/AgentMeter/SettingsView.swift @@ -24,12 +24,6 @@ struct RootTabView: View { FloatingSettings() .tabItem { Label(lang.tr("Floating", "浮動"), systemImage: "macwindow.on.rectangle") } .tag(AppTab.floating) - BridgeSettings() - .tabItem { Label(lang.tr("Claude Quota", "Claude 額度"), systemImage: "bolt.horizontal.circle") } - .tag(AppTab.bridge) - AdvancedSettings() - .tabItem { Label(lang.tr("Advanced", "進階"), systemImage: "network") } - .tag(AppTab.advanced) } .background(AM.paper) } @@ -42,8 +36,15 @@ private struct GeneralSettings: View { @EnvironmentObject private var lang: LanguageStore @EnvironmentObject private var colors: ServiceColorStore @AppStorage(SettingsKeys.interval) private var interval: Double = 30 - @AppStorage(SettingsKeys.heroMetricClaude) private var claudeHeroRaw = ClaudeHero.fiveHour.rawValue @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? @@ -66,30 +67,98 @@ private struct GeneralSettings: View { } .onChange(of: interval) { _, v in store.setInterval(v) } - Picker(lang.tr("Claude hero metric", "Claude 英雄指標"), selection: $claudeHeroRaw) { - Text(lang.tr("5-hour", "5 小時額度")).tag(ClaudeHero.fiveHour.rawValue) - Text(lang.tr("Weekly", "每週額度")).tag(ClaudeHero.weekly.rawValue) - } - Picker(lang.tr("Meters show", "量表顯示"), selection: $meterShowsRemaining) { Text(lang.tr("Used", "已使用")).tag(false) Text(lang.tr("Remaining", "剩餘")).tag(true) } + Section(lang.tr("Live quota & connectivity", "即時額度與連線")) { + featureToggle(.showAccounts) + claudeLiveQuotaRow + featureToggle(.codexQuota) + } + Section { Toggle(lang.tr("Launch at login", "開機時自動啟動"), isOn: $launchAtLogin) .onChange(of: launchAtLogin) { _, on in setLaunchAtLogin(on) } .disabled(!isInstalledApp) - if !isInstalledApp { - Text(lang.tr("Available only when running the installed AgentMeter.app — not via `swift run`.", - "僅在執行已安裝的 AgentMeter.app 時可用(開發模式 / `swift run` 無法設定)。")) - .font(.caption).foregroundStyle(.secondary) - } else if let loginError { + if isInstalledApp, let loginError { Text(loginError).font(.caption).foregroundStyle(.red) } } } .formStyle(.grouped) + .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) + } + } + + // 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 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 func binding(for feature: NetworkFeature) -> Binding { + switch feature { + case .codexQuota: return $codexQuota + case .showAccounts: return $showAccounts + } + } + + private func setBridge(_ enabled: Bool) { + do { + if enabled { try StatusLineBridge.shared.enable() } + else { try StatusLineBridge.shared.disable() } + bridgeError = nil + } catch { + bridgeError = error.localizedDescription + } + bridgeState = StatusLineBridge.shared.state() } private func accountRow(_ name: String, provider: String, tool: AgentTool, @@ -151,14 +220,6 @@ private struct AppearanceSettings: View { presets: [ServiceColorStore.claudeBrand, ServiceColorStore.mono]) colorRow("Codex", tool: .codex, presets: [ServiceColorStore.codexBrand, ServiceColorStore.mono]) - Text(lang.tr("Used for the menu-bar icon, swatch, and floating ring. Meter colors follow remaining quota, not this.", - "用於選單列 icon、色點與浮動環。量表顏色依剩餘額度,不受此影響。")) - .font(.caption).foregroundStyle(.secondary) - } - Section { - Text(lang.tr("Cost is a local estimate (prices as of \(PricingTable.version)).", - "花費為本機估算(定價版本 \(PricingTable.version))。")) - .font(.caption).foregroundStyle(.secondary) } } .formStyle(.grouped) @@ -189,16 +250,32 @@ private struct MenuBarSettings: View { @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("Menu-bar metrics (multi-select)", "選單列顯示內容(可多選)")) { - ForEach(MenuBarMetric.allCases) { metric in - Toggle(metric.settingsTitle, isOn: metricBinding(metric)) + 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") } - Text(lang.tr("Metrics with no data are hidden; a small icon shows when all are hidden.", - "沒資料的項目會自動隱藏;全部隱藏時顯示一個小圖示。")) - .font(.caption).foregroundStyle(.secondary) + Toggle(lang.tr("Show agent icon", "顯示 agent 圖示"), isOn: $showIcon) } Section(lang.tr("Dropdown panel", "下拉面板")) { Toggle(lang.tr("Show Claude Code", "顯示 Claude Code"), isOn: $showClaude) @@ -243,119 +320,9 @@ private struct FloatingSettings: View { Text("\(Int(idleOpacity * 100))%").font(.caption).monospacedDigit() .foregroundStyle(.secondary).frame(width: 36, alignment: .trailing) } - Text(lang.tr("Always on top · drag to move · snaps to screen edge · brightens on hover.", - "永遠置頂 · 可拖曳 · 吸附螢幕邊緣 · 滑入變亮。")) - .font(.caption).foregroundStyle(.secondary) } .disabled(!enabled) } .formStyle(.grouped) } } - -// MARK: - Advanced (network opt-in) - -private struct AdvancedSettings: View { - @EnvironmentObject private var lang: LanguageStore - @AppStorage(SettingsKeys.netCodexQuota) private var codexQuota = true - @AppStorage(SettingsKeys.showAccounts) private var showAccounts = true - @State private var pending: NetworkFeature? - - var body: some View { - Form { - Section(lang.tr("Connectivity", "連線")) { - featureToggle(.showAccounts) - featureToggle(.codexQuota) - } - } - .formStyle(.grouped) - .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 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) - Text(feature.usesNetwork ? lang.tr("needs internet", "需連網") - : lang.tr("reads credentials", "讀本機憑證")) - .font(.system(size: 9.5, weight: .semibold)) - .padding(.horizontal, 5).padding(.vertical, 1) - .background((feature.usesNetwork ? Color.orange : Color.secondary).opacity(0.18), in: Capsule()) - .foregroundStyle(feature.usesNetwork ? .orange : .secondary) - } - } - } - - private func binding(for feature: NetworkFeature) -> Binding { - switch feature { - case .codexQuota: return $codexQuota - case .showAccounts: return $showAccounts - } - } -} - -// MARK: - Claude quota bridge - -private struct BridgeSettings: View { - @EnvironmentObject private var lang: LanguageStore - @State private var bridgeState = StatusLineBridge.shared.state() - @State private var bridgeError: String? - - var body: some View { - Form { - Section(lang.tr("Live quota (Claude, experimental)", "即時額度(Claude,實驗性)")) { - Toggle(lang.tr("Enable live quota (statusLine bridge)", "啟用即時額度(statusLine 橋接)"), - isOn: bridgeBinding) - Text(bridgeHelpText).font(.caption).foregroundStyle(.secondary) - if let bridgeError { - Text(bridgeError).font(.caption).foregroundStyle(.red) - } - } - } - .formStyle(.grouped) - } - - private var bridgeBinding: Binding { - Binding(get: { bridgeState == .enabled }, set: { setBridge($0) }) - } - - private var bridgeHelpText: String { - switch bridgeState { - case .enabled: - return lang.tr("Enabled. Send one message in Claude Code for the 5h/weekly bars to appear. Sets statusLine in ~/.claude/settings.json (original backed up).", - "已啟用。請在 Claude Code 送出一次訊息,5h/每週額度條才會出現。會在 ~/.claude/settings.json 設定 statusLine(已備份原檔)。") - case .conflict: - return lang.tr("Detected an existing custom statusLine, so it wasn't enabled (to avoid overwriting). Integrate manually or remove the existing one first.", - "偵測到你已有自訂 statusLine,為避免覆蓋而未啟用。可手動整合或先移除既有設定。") - case .disabled: - return lang.tr("When on, reads the official data Claude Code passes to statusLine to show real 5h/weekly %. No network, no Keychain.", - "啟用後會讀取 Claude Code 傳給 statusLine 的官方資料來顯示真實 5h/每週 %。不連網、不讀 Keychain。") - } - } - - private func setBridge(_ enabled: Bool) { - do { - if enabled { try StatusLineBridge.shared.enable() } - else { try StatusLineBridge.shared.disable() } - bridgeError = nil - } catch { - bridgeError = error.localizedDescription - } - bridgeState = StatusLineBridge.shared.state() - } -} diff --git a/Sources/AgentMeter/Stats/MainWindowRootView.swift b/Sources/AgentMeter/Stats/MainWindowRootView.swift index 4b557f3..a00bc38 100644 --- a/Sources/AgentMeter/Stats/MainWindowRootView.swift +++ b/Sources/AgentMeter/Stats/MainWindowRootView.swift @@ -2,7 +2,7 @@ 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, bridge, advanced } +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.