chore: add TokenLens sources and ignore rules

This commit is contained in:
2026-06-12 15:45:58 +08:00
parent 0b48e618d8
commit 887b75b790
37 changed files with 13907 additions and 0 deletions

View File

@@ -0,0 +1,93 @@
import Combine
import SwiftUI
import Foundation
import AppKit
enum AutoCollapseDelay: Int, CaseIterable, Identifiable {
case three = 3
case five = 5
case ten = 10
var id: Int { rawValue }
var label: String { "\(rawValue)" }
}
enum FloatingAppearance: String, CaseIterable, Identifiable {
case system = "跟随主题"
case light = "亮色"
case dark = "暗色"
var id: String { rawValue }
var colorScheme: ColorScheme? {
switch self {
case .system:
return nil
case .light:
return .light
case .dark:
return .dark
}
}
var nsAppearance: NSAppearance? {
switch self {
case .system:
return nil
case .light:
return NSAppearance(named: .aqua)
case .dark:
return NSAppearance(named: .darkAqua)
}
}
}
@MainActor
final class AppSettings: ObservableObject {
static let shared = AppSettings()
@Published var showFloatingWindow: Bool {
didSet { UserDefaults.standard.set(showFloatingWindow, forKey: Self.showFloatingWindowKey) }
}
@Published var showStatusBarIndicator: Bool {
didSet { UserDefaults.standard.set(showStatusBarIndicator, forKey: Self.showStatusBarIndicatorKey) }
}
@Published var autoCollapseSeconds: Int {
didSet { UserDefaults.standard.set(autoCollapseSeconds, forKey: Self.autoCollapseSecondsKey) }
}
@Published var floatingAppearance: FloatingAppearance {
didSet { UserDefaults.standard.set(floatingAppearance.rawValue, forKey: Self.floatingAppearanceKey) }
}
private static let showFloatingWindowKey = "showFloatingWindow"
private static let showStatusBarIndicatorKey = "showStatusBarIndicator"
private static let autoCollapseSecondsKey = "autoCollapseSeconds"
private static let floatingAppearanceKey = "floatingAppearance"
private init() {
let defaults = UserDefaults.standard
if defaults.object(forKey: Self.showFloatingWindowKey) == nil {
defaults.set(true, forKey: Self.showFloatingWindowKey)
}
if defaults.object(forKey: Self.showStatusBarIndicatorKey) == nil {
defaults.set(true, forKey: Self.showStatusBarIndicatorKey)
}
if defaults.object(forKey: Self.autoCollapseSecondsKey) == nil {
defaults.set(AutoCollapseDelay.five.rawValue, forKey: Self.autoCollapseSecondsKey)
}
if defaults.object(forKey: Self.floatingAppearanceKey) == nil {
defaults.set(FloatingAppearance.system.rawValue, forKey: Self.floatingAppearanceKey)
}
showFloatingWindow = defaults.bool(forKey: Self.showFloatingWindowKey)
showStatusBarIndicator = defaults.bool(forKey: Self.showStatusBarIndicatorKey)
let collapseSeconds = defaults.integer(forKey: Self.autoCollapseSecondsKey)
autoCollapseSeconds = AutoCollapseDelay.allCases.map(\.rawValue).contains(collapseSeconds)
? collapseSeconds
: AutoCollapseDelay.five.rawValue
let appearanceRawValue = defaults.string(forKey: Self.floatingAppearanceKey) ?? FloatingAppearance.system.rawValue
floatingAppearance = FloatingAppearance(rawValue: appearanceRawValue) ?? .system
}
}

View File

@@ -0,0 +1,79 @@
import SwiftUI
struct BrandMark: View {
let phase: Double
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 13, style: .continuous)
.fill(
LinearGradient(
colors: [
Color(red: 0.09, green: 0.30, blue: 0.82),
Color(red: 0.44, green: 0.20, blue: 0.94),
Color(red: 0.11, green: 0.71, blue: 0.94)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.overlay {
RoundedRectangle(cornerRadius: 13, style: .continuous)
.stroke(.white.opacity(0.28), lineWidth: 1)
}
.shadow(color: .blue.opacity(0.28), radius: 14, y: 8)
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(.white.opacity(0.18))
.frame(width: 34, height: 16)
.blur(radius: 10)
.offset(x: -8, y: -12)
HStack(alignment: .bottom, spacing: 4) {
Capsule()
.frame(width: 5, height: barHeight(base: 18, amplitude: 5, offset: 0))
Capsule()
.frame(width: 5, height: barHeight(base: 27, amplitude: 7, offset: 0.8))
Capsule()
.frame(width: 5, height: barHeight(base: 14, amplitude: 4, offset: 1.6))
}
.foregroundStyle(.white.opacity(0.94))
.offset(x: -6, y: 4)
Circle()
.stroke(.white.opacity(0.92), lineWidth: 3.2)
.frame(width: 17, height: 17)
.offset(x: 12, y: 6)
Capsule()
.fill(.white.opacity(0.92))
.frame(width: 13, height: 3.2)
.rotationEffect(.degrees(-45))
.offset(x: 21, y: 15)
}
.rotation3DEffect(.degrees(sin(phase) * 2.4), axis: (x: 0, y: 1, z: 0))
.scaleEffect(1 + sin(phase * 0.7) * 0.018)
}
private func barHeight(base: Double, amplitude: Double, offset: Double) -> CGFloat {
CGFloat(base + sin(phase + offset) * amplitude)
}
}
struct AnimatedAppearance: ViewModifier {
let appeared: Bool
let delay: Double
func body(content: Content) -> some View {
content
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 18)
.scaleEffect(appeared ? 1 : 0.985)
.animation(.smooth(duration: 0.52).delay(delay), value: appeared)
}
}
extension View {
func animatedAppearance(_ appeared: Bool, delay: Double) -> some View {
modifier(AnimatedAppearance(appeared: appeared, delay: delay))
}
}

View File

@@ -0,0 +1,816 @@
import Charts
import SwiftUI
import TokenLensCore
struct TrendChart: View {
let summary: UsageSummary
let mode: TrendMode
private var points: [DailyPoint] {
summary.trendPoints(mode: mode)
}
private var chartPoints: [ProviderTrendPoint] {
points.flatMap { point in
Provider.allCases.map { provider in
ProviderTrendPoint(
mode: mode,
date: point.date,
provider: provider,
tokens: point.values[provider, default: 0]
)
}
}
}
private var maxValue: Int {
max(1, chartPoints.map(\.tokens).max() ?? 1)
}
var body: some View {
Chart(chartPoints) { point in
LineMark(
x: .value("日期", point.date),
y: .value("Tokens", point.tokens),
series: .value("工具", point.provider.rawValue)
)
.interpolationMethod(.linear)
.lineStyle(StrokeStyle(lineWidth: 3.5, lineCap: .round, lineJoin: .round))
.foregroundStyle(by: .value("工具", point.provider.rawValue))
AreaMark(
x: .value("日期", point.date),
y: .value("Tokens", point.tokens),
series: .value("工具", point.provider.rawValue)
)
.interpolationMethod(.linear)
.foregroundStyle(by: .value("工具", point.provider.rawValue))
.opacity(point.provider == .codex ? 0.10 : 0.03)
}
.chartForegroundStyleScale(providerStyleScale)
.chartLegend(position: .bottom, alignment: .center, spacing: 18)
.chartYScale(domain: 0...(Double(maxValue) * 1.08))
.chartPlotStyle { plot in
plot.clipped()
}
.chartYAxis {
AxisMarks(position: .leading, values: .automatic(desiredCount: 5)) { value in
AxisGridLine(stroke: StrokeStyle(lineWidth: 1))
.foregroundStyle(.white.opacity(0.08))
AxisValueLabel {
if let tokens = value.as(Double.self) {
Text(TokenFormatter.format(Int(tokens)))
.foregroundStyle(.secondary)
}
}
}
}
.chartXAxis {
AxisMarks(values: .automatic(desiredCount: mode == .daily ? 6 : 5)) { value in
AxisGridLine()
.foregroundStyle(.clear)
AxisValueLabel {
if let date = value.as(Date.self) {
Text(axisLabel(for: date))
.foregroundStyle(.secondary)
}
}
}
}
.frame(height: 235)
.animation(.spring(response: 0.42, dampingFraction: 0.88), value: mode)
}
private func axisLabel(for date: Date) -> String {
switch mode {
case .daily:
return date.formatted(.dateTime.hour(.twoDigits(amPM: .omitted)))
case .weekly:
return date.formatted(.dateTime.month(.twoDigits).day(.twoDigits))
case .monthly:
return date.formatted(.dateTime.month(.twoDigits).day(.twoDigits))
case .yearly:
return date.formatted(.dateTime.year(.twoDigits).month(.twoDigits))
}
}
}
struct UsageShare: View {
let summary: UsageSummary
var total: Int {
Provider.allCases.reduce(0) { $0 + (summary.providerTotals[$1]?.totalTokens ?? 0) }
}
private var visibleProviders: [Provider] {
let active = Provider.allCases.filter {
(summary.providerTotals[$0]?.totalTokens ?? 0) > 0
}
return active.isEmpty ? Provider.allCases : active
}
var body: some View {
HStack(spacing: 28) {
VStack(spacing: 10) {
PieChartView(summary: summary)
.frame(width: 190, height: 190)
HStack(spacing: 8) {
Text("本月")
.foregroundStyle(.secondary)
Text(TokenFormatter.format(total))
.fontWeight(.bold)
.monospacedDigit()
}
.font(.system(size: 14, weight: .semibold, design: .rounded))
}
VStack(spacing: 12) {
ForEach(visibleProviders, id: \.self) { provider in
let providerTotal = summary.providerTotals[provider]?.totalTokens ?? 0
let percent = total > 0 ? Int((Double(providerTotal) / Double(total) * 100).rounded()) : 0
HStack {
Circle().fill(provider.color).frame(width: 10, height: 10)
VStack(alignment: .leading, spacing: 2) {
Text(provider.rawValue)
.font(.system(size: 15, weight: .semibold))
Text("\(TokenFormatter.format(providerTotal)) Tokens")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.secondary)
}
Spacer()
Text("\(percent)%")
.font(.system(size: 19, weight: .bold, design: .rounded))
}
}
}
}
.frame(height: 235)
}
}
struct PieChartView: View {
let summary: UsageSummary
var total: Int {
Provider.allCases.reduce(0) { $0 + (summary.providerTotals[$1]?.totalTokens ?? 0) }
}
private var slices: [ProviderSlice] {
Provider.allCases.map { provider in
ProviderSlice(
provider: provider,
tokens: summary.providerTotals[provider]?.totalTokens ?? 0
)
}
}
var body: some View {
Chart(slices) { slice in
SectorMark(
angle: .value("Tokens", max(slice.tokens, 0)),
angularInset: 1.8
)
.cornerRadius(6)
.foregroundStyle(by: .value("工具", slice.provider.rawValue))
.opacity(total > 0 && slice.tokens > 0 ? 1 : 0)
}
.chartForegroundStyleScale(providerStyleScale)
.chartLegend(.hidden)
.chartBackground { _ in
if total == 0 {
Circle()
.fill(.white.opacity(0.10))
}
}
.shadow(color: .purple.opacity(0.24), radius: 18, y: 12)
}
}
struct ToolBreakdown: View {
let rows: [ToolRow]
@Binding var selection: String
private let visibleRowLimit = ToolBreakdownLayout.visibleRowLimit
private var visibleRowCount: Int {
min(max(rows.count, 1), visibleRowLimit)
}
private var rowViewportHeight: CGFloat {
CGFloat(visibleRowCount) * ToolBreakdownLayout.rowHeight
}
private var providerSignature: String {
rows.map { $0.provider.rawValue }.joined(separator: "|")
}
var body: some View {
GlassCard {
VStack(spacing: 0) {
HStack {
Text("工具明细")
.font(.system(size: 21, weight: .bold, design: .rounded))
Spacer()
NativeMenuPicker(selection: $selection, options: ["全部工具"] + Provider.allCases.map(\.rawValue))
.frame(width: 142)
}
.padding(.bottom, 12)
VStack(spacing: 0) {
ToolBreakdownHeader()
ScrollView(showsIndicators: rows.count > visibleRowLimit) {
LazyVStack(spacing: 0) {
if rows.isEmpty {
Text("暂无工具数据")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.frame(height: ToolBreakdownLayout.rowHeight)
} else {
ForEach(rows) { row in
ToolBreakdownRow(row: row)
if row.id != rows.last?.id {
Divider()
.overlay(.white.opacity(0.08))
}
}
}
}
}
.id(providerSignature)
.frame(height: rowViewportHeight)
}
.padding(.horizontal, 12)
.padding(.vertical, 5)
.background(.white.opacity(0.035), in: RoundedRectangle(cornerRadius: 18, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: 18, style: .continuous).stroke(.white.opacity(0.08)))
}
.frame(maxHeight: .infinity, alignment: .top)
}
}
}
private enum ToolBreakdownLayout {
static let visibleRowLimit = 3
static let rowHeight: CGFloat = 54
static let headerHeight: CGFloat = 38
}
private enum ToolBreakdownColumns {
static let spacing: CGFloat = 16
static func widths(totalWidth: CGFloat) -> ToolBreakdownColumnWidths {
let contentWidth = max(0, totalWidth - spacing * 5)
return ToolBreakdownColumnWidths(
tool: contentWidth * 0.24,
metric: contentWidth * 0.15,
trend: contentWidth * 0.16
)
}
}
private struct ToolBreakdownColumnWidths {
let tool: CGFloat
let metric: CGFloat
let trend: CGFloat
}
private struct ToolBreakdownHeader: View {
var body: some View {
GeometryReader { proxy in
let widths = ToolBreakdownColumns.widths(totalWidth: proxy.size.width)
HStack(spacing: ToolBreakdownColumns.spacing) {
header("工具名称", width: widths.tool, alignment: .leading)
header("输入", width: widths.metric)
header("输出", width: widths.metric)
header("缓存", width: widths.metric)
header("总量", width: widths.metric)
header("趋势", width: widths.trend)
}
.frame(width: proxy.size.width, height: proxy.size.height)
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 14)
.frame(height: ToolBreakdownLayout.headerHeight)
.overlay(alignment: .bottom) {
Rectangle()
.fill(.white.opacity(0.10))
.frame(height: 1)
}
}
private func header(_ text: String, width: CGFloat, alignment: Alignment = .center) -> some View {
Text(text)
.font(.system(size: 13, weight: .bold))
.foregroundStyle(.secondary)
.frame(width: width, alignment: alignment)
}
}
private struct ToolBreakdownRow: View {
let row: ToolRow
var body: some View {
GeometryReader { proxy in
let widths = ToolBreakdownColumns.widths(totalWidth: proxy.size.width)
HStack(spacing: ToolBreakdownColumns.spacing) {
providerCell(row.provider)
.frame(width: widths.tool, alignment: .leading)
metric(TokenFormatter.format(row.inputTokens), width: widths.metric)
metric(TokenFormatter.format(row.outputTokens), width: widths.metric)
metric(TokenFormatter.format(row.cachedTokens), width: widths.metric)
metric(TokenFormatter.format(row.totalTokens), width: widths.metric)
Sparkline(provider: row.provider, values: row.trendTokens)
.frame(width: 112, height: 30)
.frame(width: widths.trend, alignment: .center)
}
.frame(width: proxy.size.width, height: proxy.size.height)
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 14)
.frame(height: ToolBreakdownLayout.rowHeight)
}
private func metric(_ text: String, width: CGFloat) -> some View {
Text(text)
.font(.system(size: 16, weight: .bold, design: .rounded))
.monospacedDigit()
.lineLimit(1)
.minimumScaleFactor(0.68)
.frame(width: width, alignment: .center)
}
private func providerBadge(_ provider: Provider) -> some View {
RoundedRectangle(cornerRadius: 9)
.fill(provider.color.gradient)
.frame(width: 30, height: 30)
.overlay {
Text(String(provider.rawValue.prefix(1)))
.font(.system(size: 13, weight: .bold))
}
}
private func providerCell(_ provider: Provider) -> some View {
HStack(spacing: 12) {
providerBadge(provider)
Text(provider.rawValue)
.font(.system(size: 15, weight: .semibold))
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct Sparkline: View {
let provider: Provider
let values: [Int]
var body: some View {
Chart(points) { point in
LineMark(
x: .value("序号", point.index),
y: .value("趋势", point.value)
)
.interpolationMethod(.linear)
.lineStyle(StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round))
.foregroundStyle(provider.color)
}
.chartXAxis(.hidden)
.chartYAxis(.hidden)
.chartLegend(.hidden)
.chartPlotStyle { plot in
plot.background(.clear)
}
.chartYScale(domain: yDomain)
}
private var points: [SparkPoint] {
let source = values.isEmpty ? [0, 0] : values
let padded = source.count == 1 ? [0, source[0]] : source
return padded.enumerated().map {
SparkPoint(index: $0.offset, value: $0.element)
}
}
private var yDomain: ClosedRange<Int> {
let maxValue = max(1, points.map(\.value).max() ?? 1)
return 0...maxValue
}
}
private struct ProviderTrendPoint: Identifiable, Hashable {
var id: String {
"\(mode.rawValue)-\(Int(date.timeIntervalSince1970))-\(provider.rawValue)"
}
let mode: TrendMode
let date: Date
let provider: Provider
let tokens: Int
}
private struct ProviderSlice: Identifiable, Hashable {
let id = UUID()
let provider: Provider
let tokens: Int
}
private struct SparkPoint: Identifiable, Hashable {
let id = UUID()
let index: Int
let value: Int
}
private var providerStyleScale: KeyValuePairs<String, Color> {
[
Provider.claude.rawValue: Provider.claude.color,
Provider.codex.rawValue: Provider.codex.color,
Provider.gemini.rawValue: Provider.gemini.color,
Provider.hermes.rawValue: Provider.hermes.color,
Provider.opencode.rawValue: Provider.opencode.color
]
}
struct TokenActivityHeatmap: View {
let summary: UsageSummary
@State private var mode = "每日"
@State private var hoveredDay: HeatmapDay?
@State private var tooltipPosition: CGPoint?
private let cellWidth: CGFloat = 10
private let cellHeight: CGFloat = 18
private let cellSpacing: CGFloat = 3
private let modes = ["每日", "每周", "累计"]
private var weeks: [[HeatmapDay]] {
makeHeatmapWeeks(mode: mode)
}
private var maxValue: Int {
max(1, weeks.flatMap { $0 }.map(\.value).max() ?? 1)
}
private var selectedDay: HeatmapDay? {
hoveredDay ?? weeks.flatMap { $0 }.last { !$0.isFuture }
}
private var visibleDays: [HeatmapDay] {
weeks.flatMap { $0 }.filter { !$0.isFuture }
}
private var activeDays: Int {
visibleDays.filter { $0.dailyTotal > 0 }.count
}
private var averageTokens: Int {
guard !visibleDays.isEmpty else { return 0 }
return visibleDays.reduce(0) { $0 + $1.dailyTotal } / visibleDays.count
}
private var peakTokens: Int {
visibleDays.map(\.dailyTotal).max() ?? 0
}
var body: some View {
GlassCard {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 3) {
Text("Token 活动")
.font(.system(size: 21, weight: .bold, design: .rounded))
if let selectedDay {
Text("\(selectedDay.date.formatted(.dateTime.month().day())) · \(TokenFormatter.format(selectedDay.dailyTotal)) Tokens")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.secondary)
.monospacedDigit()
}
}
Spacer()
HStack(spacing: 12) {
ForEach(modes, id: \.self) { item in
Button(item) {
withAnimation(.smooth(duration: 0.22)) {
mode = item
}
}
.buttonStyle(.plain)
.font(.system(size: 13, weight: mode == item ? .bold : .semibold))
.foregroundStyle(mode == item ? .primary : .secondary)
}
}
}
heatmapGrid
.frame(maxWidth: .infinity, alignment: .center)
.animation(.smooth(duration: 0.25), value: weeks)
HStack {
Text(monthRangeText)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.secondary)
Spacer()
HeatmapLegend { value in
color(for: HeatmapDay(date: summary.generatedAt, value: value, dailyTotal: value, isFuture: false))
}
}
HStack(spacing: 8) {
HeatmapStat(title: "活跃", value: "\(activeDays)")
HeatmapStat(title: "日均", value: TokenFormatter.format(averageTokens))
HeatmapStat(title: "峰值", value: TokenFormatter.format(peakTokens))
}
Spacer(minLength: 0)
}
.frame(maxHeight: .infinity, alignment: .top)
}
}
private var heatmapGrid: some View {
let gridWidth = CGFloat(weeks.count) * cellWidth + CGFloat(max(weeks.count - 1, 0)) * cellSpacing
let gridHeight = CGFloat(7) * cellHeight + CGFloat(6) * cellSpacing
return ZStack(alignment: .topLeading) {
HStack(alignment: .top, spacing: cellSpacing) {
ForEach(Array(weeks.enumerated()), id: \.offset) { weekIndex, week in
VStack(spacing: cellSpacing) {
ForEach(Array(week.enumerated()), id: \.element.id) { dayIndex, day in
RoundedRectangle(cornerRadius: 5, style: .continuous)
.fill(color(for: day))
.frame(width: cellWidth, height: cellHeight)
.overlay {
RoundedRectangle(cornerRadius: 5, style: .continuous)
.stroke(.white.opacity(day.dailyTotal > 0 ? 0.18 : 0.03), lineWidth: hoveredDay?.id == day.id ? 1.2 : 0.6)
}
.opacity(day.isFuture ? 0 : 1)
.scaleEffect(hoveredDay?.id == day.id ? 1.16 : 1)
.onHover { isInside in
if isInside && !day.isFuture {
hoveredDay = day
tooltipPosition = CGPoint(
x: CGFloat(weekIndex) * (cellWidth + cellSpacing) + cellWidth / 2,
y: CGFloat(dayIndex) * (cellHeight + cellSpacing)
)
} else if hoveredDay?.id == day.id {
hoveredDay = nil
tooltipPosition = nil
}
}
}
}
}
}
.frame(width: gridWidth, height: gridHeight, alignment: .topLeading)
if let hoveredDay, let tooltipPosition {
HeatmapTooltip(day: hoveredDay, mode: mode)
.fixedSize()
.position(
x: min(max(tooltipPosition.x, 94), gridWidth - 94),
y: max(-20, tooltipPosition.y - 18)
)
.transition(.scale(scale: 0.96).combined(with: .opacity))
.zIndex(10)
}
}
.frame(width: gridWidth, height: gridHeight, alignment: .topLeading)
.animation(.smooth(duration: 0.16), value: hoveredDay)
}
private var monthRangeText: String {
let flat = weeks.flatMap { $0 }.filter { !$0.isFuture }
guard let first = flat.first?.date, let last = flat.last?.date else { return "最近活动" }
return "\(first.formatted(.dateTime.month())) - \(last.formatted(.dateTime.month()))"
}
private func color(for day: HeatmapDay) -> Color {
guard day.value > 0 else { return .white.opacity(0.06) }
let ratio = min(1, sqrt(Double(day.value) / Double(maxValue)))
return Color.cyan.opacity(0.18 + ratio * 0.72)
}
private func makeHeatmapWeeks(mode: String) -> [[HeatmapDay]] {
var calendar = Calendar.current
calendar.firstWeekday = 2
let end = calendar.startOfDay(for: summary.generatedAt)
let rawStart = calendar.date(byAdding: .day, value: -167, to: end) ?? end
let weekday = calendar.component(.weekday, from: rawStart)
let daysFromMonday = (weekday + 5) % 7
let start = calendar.date(byAdding: .day, value: -daysFromMonday, to: rawStart) ?? rawStart
let details = Dictionary(uniqueKeysWithValues: summary.dailyUsageDetails(calendar: calendar).map { ($0.date, $0) })
var days: [HeatmapDay] = []
var cumulative = 0
var cursor = start
while cursor <= (calendar.date(byAdding: .day, value: 6, to: end) ?? end) {
let detail = details[calendar.startOfDay(for: cursor)]
let dailyTotal = detail?.totalTokens ?? 0
cumulative += cursor <= end ? dailyTotal : 0
let value: Int
switch mode {
case "每周":
value = weeklyTotal(for: cursor, details: details, calendar: calendar)
case "累计":
value = cumulative
default:
value = dailyTotal
}
days.append(HeatmapDay(date: cursor, value: value, dailyTotal: dailyTotal, isFuture: cursor > end))
guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break }
cursor = next
}
return stride(from: 0, to: days.count, by: 7).map { startIndex in
Array(days[startIndex..<min(startIndex + 7, days.count)])
}
}
private func weeklyTotal(
for date: Date,
details: [Date: DailyUsageDetail],
calendar: Calendar
) -> Int {
guard let interval = calendar.dateInterval(of: .weekOfYear, for: date) else { return 0 }
return details.values.reduce(0) { total, detail in
interval.contains(detail.date) ? total + detail.totalTokens : total
}
}
}
private struct HeatmapDay: Identifiable, Hashable {
var id: Date { date }
let date: Date
let value: Int
let dailyTotal: Int
let isFuture: Bool
}
private struct HeatmapTooltip: View {
let day: HeatmapDay
let mode: String
var body: some View {
HStack(spacing: 8) {
Text(day.date.formatted(.dateTime.month().day()))
.fontWeight(.bold)
Text(tooltipText)
}
.font(.system(size: 15, weight: .semibold, design: .rounded))
.foregroundStyle(.white)
.monospacedDigit()
.padding(.horizontal, 14)
.frame(height: 38)
.background {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(red: 0.13, green: 0.13, blue: 0.14).opacity(0.96))
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(.white.opacity(0.16), lineWidth: 1)
}
.shadow(color: .black.opacity(0.32), radius: 16, y: 10)
}
}
private var tooltipText: String {
switch mode {
case "每周":
return "所在周使用了 \(TokenFormatter.format(day.value)) 个 Token"
case "累计":
return "累计使用了 \(TokenFormatter.format(day.value)) 个 Token"
default:
return "使用了 \(TokenFormatter.format(day.dailyTotal)) 个 Token"
}
}
}
private struct HeatmapLegend: View {
let color: (Int) -> Color
var body: some View {
HStack(spacing: 5) {
Text("")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.secondary)
ForEach([0, 1, 2, 3], id: \.self) { index in
RoundedRectangle(cornerRadius: 3, style: .continuous)
.fill(color(index + 1))
.frame(width: 10, height: 10)
}
Text("")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.secondary)
}
}
}
private struct HeatmapStat: View {
let title: String
let value: String
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.system(size: 10, weight: .bold))
.foregroundStyle(.secondary)
Text(value)
.font(.system(size: 13, weight: .bold, design: .rounded))
.monospacedDigit()
}
.padding(.horizontal, 10)
.frame(maxWidth: .infinity, minHeight: 38, alignment: .leading)
.background(.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.white.opacity(0.08)))
}
}
struct InsightsPanel: View {
let insights: [Insight]
var body: some View {
GlassCard {
VStack(spacing: 12) {
HStack {
Text("洞察提醒")
.font(.system(size: 21, weight: .bold, design: .rounded))
Spacer()
Text("查看全部")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.blue)
}
ForEach(insights) { insight in
HStack(alignment: .top, spacing: 12) {
RoundedRectangle(cornerRadius: 10)
.fill(insight.tone.color.opacity(0.2))
.frame(width: 36, height: 36)
.overlay {
Image(systemName: insight.tone.symbol)
.font(.system(size: 17, weight: .bold))
.foregroundStyle(insight.tone.color)
}
VStack(alignment: .leading, spacing: 4) {
Text(insight.title)
.font(.system(size: 15, weight: .bold))
Text(insight.message)
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(12)
.background(insight.tone.color.opacity(0.08), in: RoundedRectangle(cornerRadius: 16))
.overlay(RoundedRectangle(cornerRadius: 16).stroke(insight.tone.color.opacity(0.2)))
}
}
}
}
}
struct AmbientBackground: View {
var body: some View {
ZStack {
LinearGradient(colors: [Color(red: 0.03, green: 0.06, blue: 0.11), Color(red: 0.04, green: 0.09, blue: 0.15)], startPoint: .topLeading, endPoint: .bottomTrailing)
Circle()
.fill(.blue.opacity(0.16))
.frame(width: 520, height: 520)
.blur(radius: 90)
.offset(x: -380, y: -260)
Circle()
.fill(.cyan.opacity(0.12))
.frame(width: 430, height: 430)
.blur(radius: 80)
.offset(x: 420, y: -210)
Circle()
.fill(.purple.opacity(0.14))
.frame(width: 520, height: 520)
.blur(radius: 110)
.offset(x: 280, y: 350)
}
.ignoresSafeArea()
}
}
extension Provider {
var color: Color {
switch self {
case .claude: .blue
case .codex: .purple
case .gemini: .cyan
case .hermes: .green
case .opencode: .orange
}
}
}
extension Insight.Tone {
var color: Color {
switch self {
case .warning: .orange
case .positive: .green
case .accent: .purple
}
}
var symbol: String {
switch self {
case .warning: "exclamationmark"
case .positive: "checkmark"
case .accent: "arrow.up.right"
}
}
}

View File

@@ -0,0 +1,866 @@
import AppKit
import SwiftUI
import TokenLensCore
struct DashboardView: View {
@StateObject private var store = UsageStore.shared
@State private var appeared = false
@State private var showingDetails = false
@State private var trendMode = "每日"
@State private var shareMode = "按 Tokens"
@State private var toolFilter = "全部工具"
var filteredRows: [ToolRow] {
if toolFilter == "全部工具" { return store.summary.toolRows }
return store.summary.toolRows.filter { $0.provider.rawValue == toolFilter }
}
private var bottomPanelHeight: CGFloat {
336
}
private var sidePanelWidth: CGFloat {
420
}
var body: some View {
ZStack {
AmbientBackground()
if showingDetails {
DailyUsageDetailPage(summary: store.summary) {
withAnimation(pageAnimation) {
showingDetails = false
}
}
.transition(.asymmetric(
insertion: .move(edge: .trailing).combined(with: .opacity),
removal: .move(edge: .trailing).combined(with: .opacity)
))
} else {
dashboardContent
.transition(.asymmetric(
insertion: .move(edge: .leading).combined(with: .opacity),
removal: .move(edge: .leading).combined(with: .opacity)
))
}
}
.animation(pageAnimation, value: showingDetails)
.overlay(alignment: .top) {
TitlebarDoubleClickZone()
.frame(height: 32)
.ignoresSafeArea(edges: .top)
}
.task {
appeared = true
store.start()
}
}
private var pageAnimation: Animation {
.spring(response: 0.42, dampingFraction: 0.90, blendDuration: 0.08)
}
private var dashboardContent: some View {
VStack(spacing: 0) {
HeaderBar(store: store) {
withAnimation(pageAnimation) {
showingDetails = true
}
}
.animatedAppearance(appeared, delay: 0.02)
VStack(spacing: 0) {
ScrollView(showsIndicators: false) {
VStack(spacing: 18) {
MetricGrid(cards: store.summary.cards)
.animatedAppearance(appeared, delay: 0.08)
HStack(spacing: 18) {
GlassPanel(title: "Tokens 使用趋势") {
TrendChart(summary: store.summary, mode: TrendMode(rawValue: trendMode) ?? .daily)
} accessory: {
NativeMenuPicker(selection: $trendMode, options: TrendMode.allCases.map(\.rawValue))
.frame(width: 126)
}
.frame(maxWidth: .infinity)
GlassPanel(title: "使用占比") {
UsageShare(summary: store.summary)
} accessory: {
NativeMenuPicker(selection: $shareMode, options: ["按 Tokens", "按占比"])
.frame(width: 154)
}
.frame(width: sidePanelWidth)
}
.animatedAppearance(appeared, delay: 0.15)
HStack(alignment: .top, spacing: 18) {
ToolBreakdown(rows: filteredRows, selection: $toolFilter)
.frame(maxWidth: .infinity)
.frame(height: bottomPanelHeight, alignment: .top)
TokenActivityHeatmap(summary: store.summary)
.frame(width: sidePanelWidth)
.frame(height: bottomPanelHeight, alignment: .top)
}
.frame(height: bottomPanelHeight)
.animatedAppearance(appeared, delay: 0.22)
}
.padding(.horizontal, 26)
.padding(.top, 22)
.padding(.bottom, 26)
}
}
}
}
}
private struct TitlebarDoubleClickZone: NSViewRepresentable {
func makeNSView(context: Context) -> NSView {
TitlebarHitView()
}
func updateNSView(_ nsView: NSView, context: Context) {}
}
private final class TitlebarHitView: NSView {
override var acceptsFirstResponder: Bool { true }
override func mouseDown(with event: NSEvent) {
guard event.clickCount >= 2 else {
window?.performDrag(with: event)
return
}
if let window {
WindowActions.toggleMaximize(window)
}
}
}
struct HeaderBar: View {
@ObservedObject var store: UsageStore
let onShowDetails: () -> Void
var body: some View {
HStack(spacing: 16) {
BrandMark(phase: 0)
.frame(width: 42, height: 42)
Text("TokenLens 用量统计")
.font(.system(size: 24, weight: .bold, design: .rounded))
StatusPill(isRefreshing: store.isRefreshing, phase: 0)
Spacer()
Button(action: onShowDetails) {
Label("查看详情", systemImage: "list.bullet.rectangle.portrait")
.labelStyle(.titleAndIcon)
}
.buttonStyle(GlassButtonStyle())
VStack(alignment: .leading, spacing: 2) {
Text("上次更新 \(store.summary.generatedAt.formatted(date: .omitted, time: .shortened))")
Text("已刷新 \(store.refreshCount)")
.foregroundStyle(.secondary)
}
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.secondary)
}
.padding(.leading, 26)
.padding(.trailing, 28)
.frame(height: 78)
.background(.ultraThinMaterial)
.overlay(alignment: .bottom) {
Rectangle()
.fill(.white.opacity(0.08))
.frame(height: 1)
}
}
}
struct DailyUsageDetailPage: View {
let summary: UsageSummary
let onBack: () -> Void
@State private var selectedYear = ""
@State private var selectedMonth = ""
@State private var selectedDay = "全部"
private var details: [DailyUsageDetail] {
summary.dailyUsageDetails()
}
private var calendar: Calendar {
var calendar = Calendar.current
calendar.firstWeekday = 2
return calendar
}
private var yearOptions: [String] {
let years = Set(details.map { calendar.component(.year, from: $0.date) })
let fallbackYear = calendar.component(.year, from: summary.generatedAt)
let sortedYears = years.isEmpty ? [fallbackYear] : years.sorted(by: >)
return sortedYears.map { "\($0)" }
}
private var monthLabels: [String] {
(1...12).map { "\($0)" }
}
private var dayLabels: [String] {
guard let monthDate = selectedMonthDate,
let range = calendar.range(of: .day, in: .month, for: monthDate)
else {
return ["全部"]
}
return ["全部"] + range.map { "\($0)" }
}
private var selectedYearValue: Int? {
Int(selectedYear.replacingOccurrences(of: "", with: ""))
}
private var selectedMonthValue: Int? {
Int(selectedMonth.replacingOccurrences(of: "", with: ""))
}
private var selectedDayValue: Int? {
guard selectedDay != "全部" else { return nil }
return Int(selectedDay.replacingOccurrences(of: "", with: ""))
}
private var selectedMonthDate: Date? {
guard let selectedYearValue, let selectedMonthValue else { return nil }
var components = DateComponents()
components.year = selectedYearValue
components.month = selectedMonthValue
components.day = 1
return calendar.date(from: components)
}
private var filteredDetails: [DailyUsageDetail] {
guard let selectedYearValue, let selectedMonthValue else { return [] }
return details.filter {
let components = calendar.dateComponents([.year, .month, .day], from: $0.date)
guard components.year == selectedYearValue, components.month == selectedMonthValue else {
return false
}
if let selectedDayValue {
return components.day == selectedDayValue
}
return true
}
}
private var selectedRangeTotal: Int {
filteredDetails.reduce(0) { $0 + $1.totalTokens }
}
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 14) {
Button(action: onBack) {
Label("返回", systemImage: "chevron.left")
.labelStyle(.titleAndIcon)
}
.buttonStyle(GlassButtonStyle())
VStack(alignment: .leading, spacing: 3) {
Text("每日用量详情")
.font(.system(size: 24, weight: .bold, design: .rounded))
Text("按本地日期汇总输入、输出、缓存和每个工具的 Tokens")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.secondary)
}
Spacer()
VStack(alignment: .trailing, spacing: 3) {
Text("筛选日期")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.secondary)
HStack(spacing: 12) {
DateFilterControl(
year: $selectedYear,
month: $selectedMonth,
day: $selectedDay,
yearOptions: yearOptions,
monthOptions: monthLabels,
dayOptions: dayLabels
)
VStack(alignment: .trailing, spacing: 1) {
Text("合计")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.secondary)
Text(TokenFormatter.format(selectedRangeTotal))
.font(.system(size: 18, weight: .bold, design: .rounded))
.monospacedDigit()
}
}
}
}
.padding(.horizontal, 26)
.frame(height: 78)
.background(.ultraThinMaterial)
.overlay(alignment: .bottom) {
Rectangle()
.fill(.white.opacity(0.08))
.frame(height: 1)
}
ScrollView(showsIndicators: false) {
VStack(spacing: 14) {
DailyUsageHeader()
ForEach(filteredDetails) { detail in
DailyUsageRow(detail: detail)
}
if filteredDetails.isEmpty {
EmptyDailyUsageState()
}
}
.padding(.horizontal, 26)
.padding(.top, 22)
.padding(.bottom, 28)
}
}
.onAppear(perform: ensureDateSelection)
.onChange(of: summary.cards.totalTokens) { _, _ in
ensureDateSelection()
}
.onChange(of: selectedYear) { _, _ in
ensureDaySelection()
}
.onChange(of: selectedMonth) { _, newValue in
guard !newValue.isEmpty else { return }
ensureDaySelection()
}
.onChange(of: selectedDay) { _, newValue in
if !dayLabels.contains(newValue) {
selectedDay = "全部"
}
}
}
private func ensureDateSelection() {
let latestDate = details.first?.date ?? summary.generatedAt
let components = calendar.dateComponents([.year, .month], from: latestDate)
let fallbackYear = "\(components.year ?? calendar.component(.year, from: summary.generatedAt))"
let fallbackMonth = "\(components.month ?? calendar.component(.month, from: summary.generatedAt))"
if selectedYear.isEmpty || !yearOptions.contains(selectedYear) {
selectedYear = yearOptions.first ?? fallbackYear
}
if selectedMonth.isEmpty || !monthLabels.contains(selectedMonth) {
selectedMonth = fallbackMonth
}
ensureDaySelection()
}
private func ensureDaySelection() {
if selectedDay.isEmpty || !dayLabels.contains(selectedDay) {
selectedDay = "全部"
}
}
}
private struct DailyUsageHeader: View {
var body: some View {
HStack(spacing: 18) {
Text("日期").frame(width: DailyUsageColumnLayout.date, alignment: .center)
Text("总量").frame(width: DailyUsageColumnLayout.total, alignment: .center)
Text("输入").frame(width: DailyUsageColumnLayout.metric, alignment: .center)
Text("输出").frame(width: DailyUsageColumnLayout.metric, alignment: .center)
Text("缓存").frame(width: DailyUsageColumnLayout.metric, alignment: .center)
Text("工具分布").frame(maxWidth: .infinity, alignment: .center)
}
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.secondary)
.padding(.horizontal, 22)
.frame(height: 44)
.background(.white.opacity(0.055), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(.white.opacity(0.08)))
}
}
private struct DateFilterControl: View {
@Binding var year: String
@Binding var month: String
@Binding var day: String
let yearOptions: [String]
let monthOptions: [String]
let dayOptions: [String]
var body: some View {
HStack(spacing: 0) {
PrettyFilterMenu(title: "", selection: $year, options: yearOptions)
.frame(width: 104)
Divider()
.frame(height: 22)
.overlay(.white.opacity(0.10))
PrettyFilterMenu(title: "", selection: $month, options: monthOptions)
.frame(width: 86)
Divider()
.frame(height: 22)
.overlay(.white.opacity(0.10))
PrettyFilterMenu(title: "", selection: $day, options: dayOptions)
.frame(width: 86)
}
.frame(height: 42)
.background {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(.thinMaterial)
.overlay {
LinearGradient(
colors: [.white.opacity(0.20), .white.opacity(0.04)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(.white.opacity(0.18)))
.shadow(color: .black.opacity(0.12), radius: 12, y: 8)
}
}
private struct PrettyFilterMenu: View {
let title: String
@Binding var selection: String
let options: [String]
@State private var showingOptions = false
var body: some View {
Button {
showingOptions.toggle()
} label: {
HStack(spacing: 7) {
Text(title)
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.secondary)
Text(selection)
.font(.system(size: 14, weight: .bold, design: .rounded))
.foregroundStyle(.primary)
.lineLimit(1)
.minimumScaleFactor(0.82)
Image(systemName: "chevron.down")
.font(.system(size: 9, weight: .black))
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.popover(isPresented: $showingOptions, arrowEdge: .bottom) {
DropdownOptionList(selection: selection, options: options) { option in
selection = option
showingOptions = false
}
}
}
}
private struct EmptyDailyUsageState: View {
var body: some View {
VStack(spacing: 10) {
Image(systemName: "calendar.badge.exclamationmark")
.font(.system(size: 28, weight: .semibold))
.foregroundStyle(.secondary)
Text("这个日期范围还没有统计数据")
.font(.system(size: 16, weight: .bold))
Text("TokenLens 会继续扫描本地日志,有新记录后这里会自动刷新。")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 42)
}
}
private struct DailyUsageRow: View {
let detail: DailyUsageDetail
@State private var hovering = false
var body: some View {
GlassCard {
HStack(spacing: 18) {
VStack(alignment: .center, spacing: 4) {
Text(detail.date.formatted(.dateTime.month(.twoDigits).day(.twoDigits)))
.font(.system(size: 19, weight: .bold, design: .rounded))
Text(detail.date.formatted(.dateTime.weekday(.wide)))
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.secondary)
}
.frame(width: DailyUsageColumnLayout.date, alignment: .center)
metric(TokenFormatter.format(detail.totalTokens), tint: .primary, width: DailyUsageColumnLayout.total)
metric(TokenFormatter.format(detail.inputTokens), tint: .blue, width: DailyUsageColumnLayout.metric)
metric(TokenFormatter.format(detail.outputTokens), tint: .green, width: DailyUsageColumnLayout.metric)
metric(TokenFormatter.format(detail.cachedTokens), tint: .orange, width: DailyUsageColumnLayout.metric)
ProviderUsageDistribution(detail: detail)
.frame(maxWidth: .infinity)
}
}
.scaleEffect(hovering ? 1.006 : 1)
.animation(.smooth(duration: 0.18), value: hovering)
.onHover { hovering = $0 }
}
private func metric(_ text: String, tint: Color, width: CGFloat) -> some View {
Text(text)
.font(.system(size: 18, weight: .bold, design: .rounded))
.foregroundStyle(tint)
.monospacedDigit()
.lineLimit(1)
.minimumScaleFactor(0.76)
.frame(width: width, alignment: .center)
}
}
private enum DailyUsageColumnLayout {
static let date: CGFloat = 112
static let total: CGFloat = 98
static let metric: CGFloat = 88
}
private struct ProviderUsageDistribution: View {
let detail: DailyUsageDetail
var body: some View {
VStack(alignment: .leading, spacing: 9) {
GeometryReader { proxy in
HStack(spacing: 3) {
ForEach(activeProviders, id: \.self) { provider in
RoundedRectangle(cornerRadius: 5, style: .continuous)
.fill(provider.color.gradient)
.frame(width: segmentWidths(totalWidth: proxy.size.width)[provider, default: 0])
}
}
.frame(width: proxy.size.width, alignment: .leading)
.clipped()
}
.frame(height: 11)
.background(.white.opacity(0.055), in: RoundedRectangle(cornerRadius: 5, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: 5, style: .continuous).stroke(.white.opacity(0.08)))
HStack(spacing: 10) {
ForEach(Provider.allCases, id: \.self) { provider in
providerCell(provider)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(.white.opacity(0.045), in: RoundedRectangle(cornerRadius: 15, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: 15, style: .continuous).stroke(.white.opacity(0.09)))
}
private func providerCell(_ provider: Provider) -> some View {
let value = detail.providerTotals[provider, default: 0]
let percent = detail.totalTokens > 0 ? Int((Double(value) / Double(detail.totalTokens) * 100).rounded()) : 0
return HStack(spacing: 7) {
Circle()
.fill(provider.color)
.frame(width: 7, height: 7)
VStack(alignment: .leading, spacing: 1) {
Text(provider.rawValue)
.font(.system(size: 10, weight: .bold))
.foregroundStyle(.secondary)
Text("\(TokenFormatter.format(value)) · \(percent)%")
.font(.system(size: 11, weight: .bold, design: .rounded))
.monospacedDigit()
.lineLimit(1)
.minimumScaleFactor(0.72)
}
.frame(minWidth: 0, alignment: .leading)
}
}
private var activeProviders: [Provider] {
let providers = Provider.allCases.filter { detail.providerTotals[$0, default: 0] > 0 }
return providers.isEmpty ? Provider.allCases : providers
}
private func segmentWidths(totalWidth: CGFloat) -> [Provider: CGFloat] {
let providers = activeProviders
guard totalWidth > 0, !providers.isEmpty else { return [:] }
let spacing: CGFloat = 3
let totalSpacing = spacing * CGFloat(max(providers.count - 1, 0))
let availableWidth = max(0, totalWidth - totalSpacing)
guard detail.totalTokens > 0 else {
let width = availableWidth / CGFloat(providers.count)
return Dictionary(uniqueKeysWithValues: providers.map { ($0, width) })
}
let minWidth: CGFloat = 8
let minTotal = minWidth * CGFloat(providers.count)
if minTotal >= availableWidth {
let width = availableWidth / CGFloat(providers.count)
return Dictionary(uniqueKeysWithValues: providers.map { ($0, width) })
}
let remainingWidth = availableWidth - minTotal
return Dictionary(uniqueKeysWithValues: providers.map { provider in
let value = CGFloat(detail.providerTotals[provider, default: 0])
let proportional = remainingWidth * value / CGFloat(detail.totalTokens)
return (provider, minWidth + proportional)
})
}
}
struct StatusPill: View {
let isRefreshing: Bool
let phase: Double
var body: some View {
HStack(spacing: 8) {
Circle()
.fill(isRefreshing ? Color.orange : Color.green)
.frame(width: 8, height: 8)
.shadow(color: isRefreshing ? .orange.opacity(0.45) : .green.opacity(0.35), radius: 6)
Text(isRefreshing ? "正在刷新" : "每 15 秒自动刷新")
.font(.system(size: 13, weight: .semibold))
}
.padding(.horizontal, 12)
.frame(height: 30)
.background(.thinMaterial, in: Capsule())
.overlay(Capsule().stroke(.white.opacity(0.12)))
}
}
struct MetricGrid: View {
let cards: SummaryCards
var body: some View {
HStack(spacing: 18) {
MetricCard(title: "今日 Tokens", value: TokenFormatter.format(cards.todayTokens), tint: .blue, icon: "square.stack.3d.down.right.fill", footnote: "实时 本地今日已统计", delay: 0)
MetricCard(title: "本月 Tokens", value: TokenFormatter.format(cards.monthTokens), tint: .green, icon: "sparkles", footnote: "月度 按本地时区汇总", delay: 0.06)
MetricCard(title: "历史总量", value: TokenFormatter.format(cards.totalTokens), tint: .purple, icon: "checkmark", footnote: "全部 已扫描 \(cards.sessionCount) 个会话", delay: 0.12)
MetricCard(title: "缓存命中", value: "\(cards.cacheHitRate)%", tint: .orange, icon: "arrow.up.right", footnote: "缓存 来自缓存上下文", delay: 0.18)
}
}
}
struct MetricCard: View {
let title: String
let value: String
let tint: Color
let icon: String
let footnote: String
let delay: Double
@State private var hovering = false
var body: some View {
GlassCard(padding: 18, cornerRadius: 22) {
HStack(spacing: 16) {
ZStack {
Circle()
.fill(tint.gradient)
Circle()
.stroke(.white.opacity(0.30), lineWidth: 1)
Image(systemName: icon)
.font(.system(size: 23, weight: .bold))
.foregroundStyle(.white.opacity(0.92))
}
.frame(width: 54, height: 54)
.shadow(color: tint.opacity(0.24), radius: 12, y: 6)
.rotation3DEffect(.degrees(hovering ? 6 : 0), axis: (x: 0, y: 1, z: 0))
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 5) {
Text(title)
Image(systemName: "info.circle")
}
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.secondary)
Text(value)
.font(.system(size: 32, weight: .bold, design: .rounded))
.monospacedDigit()
.lineLimit(1)
.minimumScaleFactor(0.72)
.contentTransition(.numericText())
Text(footnote)
.font(.system(size: 13, weight: .semibold))
.lineLimit(1)
.minimumScaleFactor(0.80)
.foregroundStyle(tint, .secondary)
}
Spacer(minLength: 0)
}
}
.scaleEffect(hovering ? 1.01 : 1)
.offset(y: hovering ? -2 : 0)
.animation(.smooth(duration: 0.18), value: hovering)
.frame(height: 124)
.onHover { hovering = $0 }
}
}
struct GlassPanel<Content: View, Accessory: View>: View {
let title: String
@ViewBuilder let content: Content
@ViewBuilder let accessory: Accessory
var body: some View {
GlassCard {
VStack(spacing: 14) {
HStack {
Text(title)
.font(.system(size: 21, weight: .bold, design: .rounded))
Spacer()
accessory
}
content
}
}
}
}
struct GlassCard<Content: View>: View {
@ViewBuilder let content: Content
var padding: CGFloat = 22
var cornerRadius: CGFloat = 24
@State private var hovering = false
init(
padding: CGFloat = 22,
cornerRadius: CGFloat = 24,
@ViewBuilder content: () -> Content
) {
self.padding = padding
self.cornerRadius = cornerRadius
self.content = content()
}
var body: some View {
content
.padding(padding)
.background {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.fill(.ultraThinMaterial)
.overlay {
LinearGradient(
colors: [.white.opacity(0.18), .white.opacity(0.04), .clear],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
}
.overlay {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.stroke(.white.opacity(hovering ? 0.28 : 0.16), lineWidth: 1)
}
.shadow(color: .black.opacity(0.22), radius: hovering ? 32 : 28, y: hovering ? 20 : 18)
}
.animation(.smooth(duration: 0.18), value: hovering)
.onHover { hovering = $0 }
}
}
struct NativeMenuPicker: View {
@Binding var selection: String
let options: [String]
@State private var showingOptions = false
var body: some View {
Button {
showingOptions.toggle()
} label: {
HStack(spacing: 9) {
Text(selection)
.lineLimit(1)
.minimumScaleFactor(0.84)
Spacer()
ZStack {
Circle()
.fill(.white.opacity(0.10))
.frame(width: 20, height: 20)
Image(systemName: showingOptions ? "chevron.up" : "chevron.down")
.font(.system(size: 9, weight: .black))
.foregroundStyle(.secondary)
}
}
.font(.system(size: 14, weight: .bold, design: .rounded))
.padding(.horizontal, 14)
.frame(height: 42)
.background {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(.thinMaterial)
.overlay {
LinearGradient(
colors: [.white.opacity(0.22), .white.opacity(0.05)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(.white.opacity(0.20)))
.shadow(color: .black.opacity(0.12), radius: 10, y: 7)
}
.buttonStyle(.plain)
.popover(isPresented: $showingOptions, arrowEdge: .bottom) {
DropdownOptionList(selection: selection, options: options) { option in
selection = option
showingOptions = false
}
}
}
}
private struct DropdownOptionList: View {
let selection: String
let options: [String]
let onSelect: (String) -> Void
var body: some View {
VStack(spacing: 4) {
if options.isEmpty {
Text("暂无选项")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, minHeight: 34)
} else {
ForEach(options, id: \.self) { option in
Button {
onSelect(option)
} label: {
HStack(spacing: 9) {
Text(option)
.lineLimit(1)
Spacer()
if option == selection {
Image(systemName: "checkmark")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.blue)
}
}
.font(.system(size: 13, weight: option == selection ? .bold : .semibold, design: .rounded))
.padding(.horizontal, 10)
.frame(height: 34)
.background(
option == selection ? Color.blue.opacity(0.16) : Color.white.opacity(0.001),
in: RoundedRectangle(cornerRadius: 10, style: .continuous)
)
}
.buttonStyle(.plain)
}
}
}
.padding(6)
.frame(minWidth: 136)
.background(.ultraThinMaterial)
.preferredColorScheme(.dark)
}
}
struct GlassButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 15, weight: .semibold))
.padding(.horizontal, 16)
.frame(height: 42)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 13, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: 13).stroke(.white.opacity(0.16)))
.scaleEffect(configuration.isPressed ? 0.97 : 1)
.animation(.smooth(duration: 0.16), value: configuration.isPressed)
}
}

View File

@@ -0,0 +1,248 @@
import AppKit
import SwiftUI
import TokenLensCore
struct FloatingTokenWindow: View {
@ObservedObject var store: UsageStore
let onSizeChange: (CGSize) -> Void
@Environment(\.colorScheme) private var colorScheme
@StateObject private var settings = AppSettings.shared
@State private var expanded = false
@State private var collapseTask: Task<Void, Never>?
private var todayText: String {
TokenFormatter.format(store.summary.cards.todayTokens)
}
private var targetWidth: CGFloat {
let digitWidth = CGFloat(max(todayText.count - 3, 0)) * 11
return expanded ? max(338, 282 + digitWidth) : min(220, max(142, 120 + digitWidth))
}
private var visibleProviders: [Provider] {
Provider.allCases.filter {
(store.summary.todayProviderTotals[$0]?.totalTokens ?? 0) > 0
}
}
private var providerRowCount: Int {
max(visibleProviders.count, 1)
}
private var activeProviderSignature: String {
visibleProviders.map(\.rawValue).joined(separator: "|")
}
private var targetHeight: CGFloat {
expanded ? 84 + CGFloat(providerRowCount) * 62 + CGFloat(max(providerRowCount - 1, 0)) * 8 : 36
}
private var cornerRadius: CGFloat { expanded ? 18 : targetHeight / 2 }
private var expansionAnimation: Animation {
.spring(response: 0.38, dampingFraction: 0.92, blendDuration: 0.10)
}
var body: some View {
ZStack(alignment: .topLeading) {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.fill(panelFill)
.overlay {
LinearGradient(
colors: [.white.opacity(colorScheme == .dark ? 0.18 : 0.46), .white.opacity(0.04)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
}
.overlay {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.stroke(.white.opacity(colorScheme == .dark ? 0.18 : 0.54), lineWidth: 1)
}
.shadow(color: .black.opacity(colorScheme == .dark ? 0.32 : 0.16), radius: expanded ? 16 : 10, y: expanded ? 10 : 5)
content
.padding(expanded ? EdgeInsets(top: 14, leading: 12, bottom: 14, trailing: 12) : EdgeInsets(top: 7, leading: 7, bottom: 7, trailing: 7))
}
.frame(width: targetWidth, height: targetHeight, alignment: .topLeading)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.animation(expansionAnimation, value: expanded)
.animation(.smooth(duration: 0.22), value: store.summary.cards.totalTokens)
.contentShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.onTapGesture {
toggleExpanded()
}
.onChange(of: settings.autoCollapseSeconds) { _, _ in
if expanded {
scheduleAutoCollapse()
}
}
.onAppear(perform: reportSize)
.onChange(of: expanded) { _, _ in reportSize() }
.onChange(of: todayText) { _, _ in reportSize() }
.onChange(of: activeProviderSignature) { _, _ in reportSize() }
.preferredColorScheme(settings.floatingAppearance.colorScheme)
}
private var panelFill: Color {
if colorScheme == .dark {
return Color(red: 0.08, green: 0.09, blue: 0.11).opacity(0.86)
}
return Color.white.opacity(0.88)
}
@ViewBuilder
private var content: some View {
VStack(alignment: .leading, spacing: expanded ? 12 : 0) {
if expanded {
HStack(spacing: 8) {
FloatingTrendIcon()
.frame(width: 32, height: 32)
VStack(alignment: .leading, spacing: 1) {
Text("今日 Tokens")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.secondary)
Text(todayText)
.font(.system(size: 22, weight: .bold, design: .rounded))
.foregroundStyle(.primary)
.monospacedDigit()
.contentTransition(.numericText())
}
Spacer(minLength: 0)
VStack(alignment: .trailing, spacing: 1) {
Text("实时")
.font(.system(size: 11, weight: .bold))
Text("15 秒刷新")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.secondary)
}
}
VStack(spacing: 8) {
if visibleProviders.isEmpty {
Text("今日暂无工具流量")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.frame(height: 54)
.background(.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 11, style: .continuous))
} else {
ForEach(visibleProviders, id: \.self) { provider in
FloatingProviderRow(
provider: provider,
bucket: store.summary.todayProviderTotals[provider] ?? TokenBucket(provider: provider)
)
}
}
}
.frame(maxWidth: .infinity)
} else {
HStack(spacing: 6) {
FloatingTrendIcon()
.frame(width: 22, height: 22)
Text("今日")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.secondary)
Text(todayText)
.font(.system(size: 15, weight: .bold, design: .rounded))
.foregroundStyle(.primary)
.monospacedDigit()
.contentTransition(.numericText())
Spacer(minLength: 0)
}
}
}
}
private func toggleExpanded() {
collapseTask?.cancel()
withAnimation(expansionAnimation) {
expanded.toggle()
}
if expanded {
scheduleAutoCollapse()
}
}
private func scheduleAutoCollapse() {
collapseTask?.cancel()
let delay = UInt64(max(1, settings.autoCollapseSeconds)) * 1_000_000_000
collapseTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: delay)
guard !Task.isCancelled else { return }
withAnimation(expansionAnimation) {
expanded = false
}
}
}
private func reportSize() {
let size = CGSize(width: targetWidth, height: targetHeight)
DispatchQueue.main.async {
onSizeChange(size)
}
}
}
private struct FloatingTrendIcon: View {
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 9, style: .continuous)
.fill(
LinearGradient(
colors: [.blue.opacity(0.88), .purple.opacity(0.82)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.overlay {
RoundedRectangle(cornerRadius: 9, style: .continuous)
.stroke(.white.opacity(0.26), lineWidth: 1)
}
Image(systemName: "chart.line.uptrend.xyaxis")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(.white)
}
.shadow(color: .blue.opacity(0.24), radius: 8, y: 4)
}
}
private struct FloatingProviderRow: View {
let provider: Provider
let bucket: TokenBucket
var body: some View {
VStack(spacing: 5) {
HStack(spacing: 8) {
Circle()
.fill(provider.color)
.frame(width: 7, height: 7)
Text(provider.rawValue)
.font(.system(size: 12, weight: .bold))
Spacer()
Text(TokenFormatter.format(bucket.totalTokens))
.font(.system(size: 13, weight: .bold, design: .rounded))
.monospacedDigit()
}
HStack(spacing: 8) {
detail("输入", bucket.inputTokens)
detail("输出", bucket.outputTokens)
detail("缓存", bucket.cachedTokens)
}
}
.padding(.horizontal, 8)
.padding(.vertical, 7)
.background(provider.color.opacity(0.08), in: RoundedRectangle(cornerRadius: 11, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: 11, style: .continuous).stroke(provider.color.opacity(0.16)))
}
private func detail(_ label: String, _ value: Int) -> some View {
HStack(spacing: 3) {
Text(label)
.foregroundStyle(.secondary)
Text(TokenFormatter.format(value))
.fontWeight(.semibold)
.monospacedDigit()
}
.font(.system(size: 10))
.lineLimit(1)
}
}

View File

@@ -0,0 +1,114 @@
import SwiftUI
struct SettingsView: View {
@StateObject private var settings = AppSettings.shared
var body: some View {
VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 4) {
Text("TokenLens 设置")
.font(.system(size: 22, weight: .bold, design: .rounded))
Text("控制桌面辅助入口和菜单栏显示。")
.foregroundStyle(.secondary)
}
VStack(spacing: 12) {
SettingsToggleRow(
title: "显示悬浮窗",
subtitle: "在桌面上显示总 Tokens 消耗,鼠标放上去展开详情。",
systemImage: "rectangle.on.rectangle",
isOn: $settings.showFloatingWindow
)
SettingsToggleRow(
title: "显示状态栏提示窗",
subtitle: "在 macOS 菜单栏显示当前总 Tokens 数量。",
systemImage: "menubar.rectangle",
isOn: $settings.showStatusBarIndicator
)
SettingsPickerRow(
title: "自动回收",
subtitle: "悬浮窗和状态栏详情展开后的自动收起时间。",
systemImage: "timer",
selection: $settings.autoCollapseSeconds,
options: AutoCollapseDelay.allCases.map { ($0.rawValue, $0.label) }
)
SettingsPickerRow(
title: "悬浮窗皮肤",
subtitle: "控制悬浮窗亮色、暗色或跟随系统主题。",
systemImage: "circle.lefthalf.filled",
selection: Binding(
get: { settings.floatingAppearance.rawValue },
set: { settings.floatingAppearance = FloatingAppearance(rawValue: $0) ?? .system }
),
options: FloatingAppearance.allCases.map { ($0.rawValue, $0.rawValue) }
)
}
Spacer()
}
.padding(26)
.frame(width: 520, height: 430)
}
}
private struct SettingsToggleRow: View {
let title: String
let subtitle: String
let systemImage: String
@Binding var isOn: Bool
var body: some View {
HStack(spacing: 12) {
Image(systemName: systemImage)
.font(.system(size: 18, weight: .semibold))
.frame(width: 34, height: 34)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.system(size: 14, weight: .semibold))
Text(subtitle)
.font(.system(size: 12))
.foregroundStyle(.secondary)
}
Spacer()
Toggle("", isOn: $isOn)
.toggleStyle(.switch)
.labelsHidden()
}
.padding(12)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
private struct SettingsPickerRow<Value: Hashable>: View {
let title: String
let subtitle: String
let systemImage: String
@Binding var selection: Value
let options: [(Value, String)]
var body: some View {
HStack(spacing: 12) {
Image(systemName: systemImage)
.font(.system(size: 18, weight: .semibold))
.frame(width: 34, height: 34)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.system(size: 14, weight: .semibold))
Text(subtitle)
.font(.system(size: 12))
.foregroundStyle(.secondary)
}
Spacer()
Picker("", selection: $selection) {
ForEach(options, id: \.0) { value, label in
Text(label).tag(value)
}
}
.pickerStyle(.segmented)
.frame(width: 190)
}
.padding(12)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}

View File

@@ -0,0 +1,446 @@
import SwiftUI
import AppKit
import Combine
import TokenLensCore
@main
struct TokenLensApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
var body: some Scene {
Settings {
SettingsView()
.preferredColorScheme(.dark)
}
.commands {
CommandGroup(replacing: .newItem) {}
}
}
}
final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
private var dashboardWindow: NSWindow?
private var floatingPanel: NSPanel?
private var statusItem: NSStatusItem?
private var statusPopover: NSPopover?
private var statusPopoverCloseTask: Task<Void, Never>?
private var cancellables: Set<AnyCancellable> = []
private var isTerminating = false
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.regular)
UsageStore.shared.start()
bindAppState()
updateFloatingPanelVisibility()
updateStatusItemVisibility()
}
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
Task { @MainActor in
showDashboardWindow()
}
return false
}
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
isTerminating = true
return .terminateNow
}
@MainActor
func showDashboardWindow() {
closeStatusPopover()
if let dashboardWindow {
NSApp.activate(ignoringOtherApps: true)
if dashboardWindow.isMiniaturized {
dashboardWindow.deminiaturize(nil)
}
dashboardWindow.makeKeyAndOrderFront(nil)
dashboardWindow.orderFrontRegardless()
return
}
let hostingView = NSHostingView(
rootView: DashboardView()
.frame(minWidth: 1180, minHeight: 760)
.preferredColorScheme(.dark)
)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 1280, height: 860),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered,
defer: false
)
window.title = "TokenLens"
window.identifier = NSUserInterfaceItemIdentifier("main-dashboard-window")
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true
window.isMovableByWindowBackground = false
window.backgroundColor = .clear
window.isOpaque = false
window.minSize = NSSize(width: 1180, height: 760)
window.contentView = hostingView
window.delegate = self
window.center()
dashboardWindow = window
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
window.orderFrontRegardless()
}
func windowWillClose(_ notification: Notification) {
if notification.object as? NSWindow === dashboardWindow {
dashboardWindow = nil
}
}
func windowShouldClose(_ sender: NSWindow) -> Bool {
guard sender === dashboardWindow, !isTerminating else {
return true
}
sender.miniaturize(nil)
return false
}
@MainActor
private func bindAppState() {
AppSettings.shared.$showFloatingWindow
.sink { [weak self] isVisible in self?.updateFloatingPanelVisibility(isVisible: isVisible) }
.store(in: &cancellables)
AppSettings.shared.$showStatusBarIndicator
.sink { [weak self] isVisible in self?.updateStatusItemVisibility(isVisible: isVisible) }
.store(in: &cancellables)
UsageStore.shared.$summary
.sink { [weak self] _ in self?.updateStatusTitle() }
.store(in: &cancellables)
AppSettings.shared.$floatingAppearance
.sink { [weak self] _ in
self?.applyFloatingPanelAppearance()
self?.applyStatusPopoverAppearance()
}
.store(in: &cancellables)
}
@MainActor
private func updateFloatingPanelVisibility(isVisible: Bool = AppSettings.shared.showFloatingWindow) {
if isVisible {
if floatingPanel == nil {
installFloatingPanel()
}
floatingPanel?.orderFrontRegardless()
} else {
floatingPanel?.orderOut(nil)
}
}
@MainActor
private func installFloatingPanel() {
let initialFloatingSize = NSSize(width: 164, height: 36)
let panel = NSPanel(
contentRect: NSRect(origin: NSPoint(x: 80, y: 80), size: initialFloatingSize),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
panel.title = "TokenLens Floating"
panel.level = .floating
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = false
panel.isMovableByWindowBackground = true
panel.hidesOnDeactivate = false
panel.appearance = AppSettings.shared.floatingAppearance.nsAppearance
let hostingView = TransparentHostingView(
rootView: FloatingTokenWindow(store: UsageStore.shared) { [weak self] size in
Task { @MainActor in
self?.resizeFloatingPanel(to: size)
}
}
.background(Color.clear)
)
makeTransparent(hostingView)
panel.contentView = hostingView
if let contentView = panel.contentView {
makeTransparent(contentView)
}
if let screenFrame = NSScreen.main?.visibleFrame {
let x = screenFrame.maxX - 360
let y = screenFrame.maxY - 170
panel.setFrameOrigin(NSPoint(x: x, y: y))
}
panel.orderFrontRegardless()
floatingPanel = panel
}
@MainActor
private func resizeFloatingPanel(to size: CGSize) {
guard let panel = floatingPanel else { return }
let newSize = NSSize(width: ceil(size.width), height: ceil(size.height))
guard abs(panel.frame.width - newSize.width) > 0.5 || abs(panel.frame.height - newSize.height) > 0.5 else {
return
}
let currentFrame = panel.frame
let newFrame = NSRect(
x: currentFrame.minX,
y: currentFrame.maxY - newSize.height,
width: newSize.width,
height: newSize.height
)
panel.setFrame(newFrame, display: true)
}
@MainActor
private func updateStatusItemVisibility(isVisible: Bool = AppSettings.shared.showStatusBarIndicator) {
if isVisible {
if statusItem == nil {
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
item.button?.target = self
item.button?.action = #selector(statusItemClicked)
item.button?.image = NSImage(systemSymbolName: "chart.line.uptrend.xyaxis", accessibilityDescription: "Tokens")
item.button?.imagePosition = .imageLeading
statusItem = item
}
updateStatusTitle()
} else if let item = statusItem {
NSStatusBar.system.removeStatusItem(item)
statusItem = nil
}
}
@MainActor
private func updateStatusTitle() {
guard let button = statusItem?.button else { return }
button.title = " \(TokenFormatter.format(UsageStore.shared.summary.cards.todayTokens))"
button.toolTip = "TokenLens 今日 Tokens\(TokenFormatter.format(UsageStore.shared.summary.cards.todayTokens))"
}
@objc
@MainActor
private func statusItemClicked() {
guard let button = statusItem?.button else { return }
if statusPopover?.isShown == true {
closeStatusPopover()
return
}
let popover = NSPopover()
popover.behavior = .transient
popover.contentSize = statusPopoverSize()
let controller = NSHostingController(
rootView: StatusBarDetailView(store: UsageStore.shared)
)
makeTransparent(controller.view)
popover.contentViewController = controller
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
statusPopover = popover
applyStatusPopoverAppearance()
scheduleStatusPopoverClose()
}
@MainActor
private func scheduleStatusPopoverClose() {
statusPopoverCloseTask?.cancel()
let delay = UInt64(max(1, AppSettings.shared.autoCollapseSeconds)) * 1_000_000_000
statusPopoverCloseTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: delay)
guard !Task.isCancelled else { return }
closeStatusPopover()
}
}
@MainActor
private func closeStatusPopover() {
statusPopoverCloseTask?.cancel()
statusPopover?.close()
statusPopover = nil
}
@MainActor
private func statusPopoverSize() -> NSSize {
let activeProviderCount = Provider.allCases.filter {
(UsageStore.shared.summary.todayProviderTotals[$0]?.totalTokens ?? 0) > 0
}.count
let rowCount = max(activeProviderCount, 1)
return NSSize(width: 330, height: 128 + CGFloat(rowCount) * 54 + CGFloat(max(rowCount - 1, 0)) * 8)
}
@MainActor
private func applyFloatingPanelAppearance() {
floatingPanel?.appearance = AppSettings.shared.floatingAppearance.nsAppearance
if let contentView = floatingPanel?.contentView {
makeTransparent(contentView)
}
}
@MainActor
private func applyStatusPopoverAppearance() {
guard let popover = statusPopover else { return }
let appearance = AppSettings.shared.floatingAppearance.nsAppearance
popover.contentViewController?.view.window?.appearance = appearance
if let view = popover.contentViewController?.view {
makeTransparent(view)
}
}
@MainActor
private func makeTransparent(_ view: NSView) {
view.wantsLayer = true
view.layer?.backgroundColor = NSColor.clear.cgColor
view.layer?.isOpaque = false
view.layer?.masksToBounds = false
}
}
final class TransparentHostingView<Content: View>: NSHostingView<Content> {
override var isOpaque: Bool { false }
required init(rootView: Content) {
super.init(rootView: rootView)
prepareForTransparentCompositing()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
nil
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
prepareForTransparentCompositing()
window?.isOpaque = false
window?.backgroundColor = .clear
window?.hasShadow = true
}
override func draw(_ dirtyRect: NSRect) {
NSColor.clear.setFill()
dirtyRect.fill()
super.draw(dirtyRect)
}
private func prepareForTransparentCompositing() {
wantsLayer = true
layer?.backgroundColor = NSColor.clear.cgColor
layer?.isOpaque = false
layer?.masksToBounds = false
}
}
struct StatusBarDetailView: View {
@ObservedObject var store: UsageStore
@StateObject private var settings = AppSettings.shared
private var visibleProviders: [Provider] {
Provider.allCases.filter {
(store.summary.todayProviderTotals[$0]?.totalTokens ?? 0) > 0
}
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 10) {
Image(systemName: "chart.line.uptrend.xyaxis")
.font(.system(size: 20, weight: .bold))
.foregroundStyle(.white)
.frame(width: 36, height: 36)
.background(.blue.gradient, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
VStack(alignment: .leading, spacing: 2) {
Text("今日 Tokens")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.secondary)
Text(TokenFormatter.format(store.summary.cards.todayTokens))
.font(.system(size: 24, weight: .bold, design: .rounded))
.monospacedDigit()
}
Spacer()
Text("实时")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(.secondary)
}
VStack(spacing: 8) {
if visibleProviders.isEmpty {
Text("今日暂无工具流量")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.frame(height: 46)
.background(.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
} else {
ForEach(visibleProviders, id: \.self) { provider in
StatusProviderRow(
provider: provider,
bucket: store.summary.todayProviderTotals[provider] ?? TokenBucket(provider: provider)
)
}
}
}
Button {
WindowActions.showMainWindow()
} label: {
Label("打开主界面", systemImage: "rectangle.grid.2x2")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
}
.padding(14)
.frame(width: 330)
.background(.ultraThinMaterial)
.preferredColorScheme(settings.floatingAppearance.colorScheme)
}
}
private struct StatusProviderRow: View {
let provider: Provider
let bucket: TokenBucket
var body: some View {
HStack(spacing: 9) {
Circle()
.fill(provider.color)
.frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(provider.rawValue)
.font(.system(size: 13, weight: .bold))
Spacer()
Text(TokenFormatter.format(bucket.totalTokens))
.font(.system(size: 13, weight: .bold, design: .rounded))
.monospacedDigit()
}
Text("输入 \(TokenFormatter.format(bucket.inputTokens)) 输出 \(TokenFormatter.format(bucket.outputTokens)) 缓存 \(TokenFormatter.format(bucket.cachedTokens))")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(provider.color.opacity(0.10), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
}
}
struct WindowAccessor: NSViewRepresentable {
func makeNSView(context: Context) -> NSView {
let view = NSView()
DispatchQueue.main.async {
guard let window = view.window else { return }
window.title = "TokenLens"
window.identifier = NSUserInterfaceItemIdentifier("main-dashboard-window")
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true
window.isMovableByWindowBackground = false
window.backgroundColor = .clear
window.isOpaque = false
window.setContentSize(NSSize(width: 1280, height: 860))
window.minSize = NSSize(width: 1180, height: 760)
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {}
}

View File

@@ -0,0 +1,45 @@
import SwiftUI
import TokenLensCore
@MainActor
final class UsageStore: ObservableObject {
static let shared = UsageStore()
@Published var summary = UsageSummary.make(records: [])
@Published var isRefreshing = false
@Published var refreshCount = 0
private let scanner = TokenUsageScanner()
private var refreshTask: Task<Void, Never>?
private init() {}
func start() {
guard refreshTask == nil else { return }
refreshTask = Task { [weak self] in
guard let self else { return }
await self.refresh()
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(15))
await self.refresh(silent: true)
}
}
}
func refresh(silent: Bool = false) async {
if isRefreshing { return }
if !silent { isRefreshing = true }
let next = await scanner.scan()
if silent {
summary = next
refreshCount += 1
isRefreshing = false
} else {
withAnimation(.smooth(duration: 0.45)) {
summary = next
refreshCount += 1
isRefreshing = false
}
}
}
}

View File

@@ -0,0 +1,69 @@
import AppKit
enum WindowActions {
@MainActor
static func zoomActiveWindow() {
guard let window = NSApp.keyWindow ?? NSApp.mainWindow else { return }
toggleMaximize(window)
}
@MainActor
static func toggleMaximize(_ window: NSWindow) {
guard let visibleFrame = window.screen?.visibleFrame ?? NSScreen.main?.visibleFrame else {
return
}
let currentFrame = window.frame
let isMaximized =
abs(currentFrame.minX - visibleFrame.minX) < 2 &&
abs(currentFrame.minY - visibleFrame.minY) < 2 &&
abs(currentFrame.width - visibleFrame.width) < 2 &&
abs(currentFrame.height - visibleFrame.height) < 2
if isMaximized {
let restoredSize = NSSize(
width: min(max(window.minSize.width, 1280), visibleFrame.width),
height: min(max(window.minSize.height, 860), visibleFrame.height)
)
let restoredFrame = NSRect(
x: visibleFrame.midX - restoredSize.width / 2,
y: visibleFrame.midY - restoredSize.height / 2,
width: restoredSize.width,
height: restoredSize.height
)
window.setFrame(restoredFrame, display: true, animate: false)
} else {
window.setFrame(visibleFrame, display: true, animate: false)
}
}
@MainActor
static func showMainWindow() {
if let appDelegate = NSApp.delegate as? AppDelegate {
appDelegate.showDashboardWindow()
return
}
let mainWindow = NSApp.windows.first { window in
window.identifier?.rawValue == "main-dashboard-window"
} ?? NSApp.windows.first { window in
!(window is NSPanel) && window.title == "TokenLens"
} ?? NSApp.windows.first { window in
!(window is NSPanel) && !window.title.contains("设置")
}
NSApp.windows
.filter { $0 !== mainWindow && $0.title.contains("设置") }
.forEach { $0.orderOut(nil) }
NSApp.activate(ignoringOtherApps: true)
NSApp.unhide(nil)
if mainWindow?.isMiniaturized == true {
mainWindow?.deminiaturize(nil)
}
guard let mainWindow else { return }
mainWindow.makeKeyAndOrderFront(nil)
mainWindow.makeMain()
mainWindow.orderFrontRegardless()
}
}

View File

@@ -0,0 +1,7 @@
import Darwin
public enum MemoryPressure {
public static func relieve() {
_ = malloc_zone_pressure_relief(nil, 0)
}
}

View File

@@ -0,0 +1,206 @@
import Foundation
public enum Provider: String, CaseIterable, Codable, Hashable, Sendable {
case claude = "Claude"
case codex = "Codex"
case gemini = "Gemini"
case hermes = "Hermes"
case opencode = "OpenCode"
}
public struct UsageRecord: Identifiable, Hashable, Sendable {
public let id: UUID
public let provider: Provider
public let source: String
public let sourcePath: String
public let timestamp: Date
public let sessionID: String
public let model: String
public let inputTokens: Int
public let outputTokens: Int
public let cachedTokens: Int
public let reasoningTokens: Int
public let toolTokens: Int
public let totalTokens: Int
public init(
id: UUID = UUID(),
provider: Provider,
source: String,
sourcePath: String,
timestamp: Date,
sessionID: String,
model: String,
inputTokens: Int,
outputTokens: Int,
cachedTokens: Int,
reasoningTokens: Int,
toolTokens: Int,
explicitTotal: Int? = nil
) {
self.id = id
self.provider = provider
self.source = source
self.sourcePath = sourcePath
self.timestamp = timestamp
self.sessionID = sessionID
self.model = model
self.inputTokens = inputTokens
self.outputTokens = outputTokens
self.cachedTokens = cachedTokens
self.reasoningTokens = reasoningTokens
self.toolTokens = toolTokens
self.totalTokens = explicitTotal ?? inputTokens + outputTokens + cachedTokens + reasoningTokens + toolTokens
}
}
public struct TokenBucket: Hashable, Sendable {
public let provider: Provider?
public var inputTokens: Int = 0
public var outputTokens: Int = 0
public var cachedTokens: Int = 0
public var reasoningTokens: Int = 0
public var toolTokens: Int = 0
public var totalTokens: Int = 0
public var count: Int = 0
public init(provider: Provider? = nil) {
self.provider = provider
}
public mutating func add(_ record: UsageRecord) {
add(
inputTokens: record.inputTokens,
outputTokens: record.outputTokens,
cachedTokens: record.cachedTokens,
reasoningTokens: record.reasoningTokens,
toolTokens: record.toolTokens,
totalTokens: record.totalTokens
)
}
public mutating func add(
inputTokens: Int,
outputTokens: Int,
cachedTokens: Int,
reasoningTokens: Int,
toolTokens: Int,
totalTokens: Int
) {
self.inputTokens += inputTokens
self.outputTokens += outputTokens
self.cachedTokens += cachedTokens
self.reasoningTokens += reasoningTokens
self.toolTokens += toolTokens
self.totalTokens += totalTokens
count += 1
}
}
public struct SummaryCards: Hashable, Sendable {
public let todayTokens: Int
public let monthTokens: Int
public let totalTokens: Int
public let cacheHitRate: Int
public let sessionCount: Int
}
public struct DailyPoint: Identifiable, Hashable, Sendable {
public let id = UUID()
public let date: Date
public let values: [Provider: Int]
}
public enum TrendMode: String, CaseIterable, Sendable {
case daily = "每日"
case weekly = "每周"
case monthly = "每月"
case yearly = "每年"
}
public struct ToolRow: Identifiable, Hashable, Sendable {
public let id = UUID()
public let provider: Provider
public let inputTokens: Int
public let outputTokens: Int
public let cachedTokens: Int
public let totalTokens: Int
public let trendTokens: [Int]
}
public struct DailyUsageDetail: Identifiable, Hashable, Sendable {
public var id: Date { date }
public let date: Date
public var inputTokens: Int
public var outputTokens: Int
public var cachedTokens: Int
public var totalTokens: Int
public var providerTotals: [Provider: Int]
public init(
date: Date,
inputTokens: Int = 0,
outputTokens: Int = 0,
cachedTokens: Int = 0,
totalTokens: Int = 0,
providerTotals: [Provider: Int] = [:]
) {
self.date = date
self.inputTokens = inputTokens
self.outputTokens = outputTokens
self.cachedTokens = cachedTokens
self.totalTokens = totalTokens
self.providerTotals = providerTotals
}
public mutating func add(_ record: UsageRecord) {
add(
provider: record.provider,
inputTokens: record.inputTokens,
outputTokens: record.outputTokens,
cachedTokens: record.cachedTokens,
totalTokens: record.totalTokens
)
}
public mutating func add(
provider: Provider,
inputTokens: Int,
outputTokens: Int,
cachedTokens: Int,
totalTokens: Int
) {
self.inputTokens += inputTokens
self.outputTokens += outputTokens
self.cachedTokens += cachedTokens
self.totalTokens += totalTokens
providerTotals[provider, default: 0] += totalTokens
}
}
public struct Insight: Identifiable, Hashable, Sendable {
public enum Tone: String, Sendable {
case warning
case positive
case accent
}
public let id = UUID()
public let tone: Tone
public let title: String
public let message: String
}
public struct UsageSummary: Sendable {
public let generatedAt: Date
public let records: [UsageRecord]
public let cards: SummaryCards
public let providerTotals: [Provider: TokenBucket]
public let todayProviderTotals: [Provider: TokenBucket]
public let dailyTrend: [DailyPoint]
public let trendPointsByMode: [TrendMode: [DailyPoint]]
public let dailyDetails: [DailyUsageDetail]
public let toolRows: [ToolRow]
public let insights: [Insight]
}

View File

@@ -0,0 +1,581 @@
import Foundation
import SQLite3
enum SQLiteUsageCacheError: Error {
case openFailed(String)
case prepareFailed(String)
case executeFailed(String)
case bindFailed(String)
case stepFailed(String)
}
final class SQLiteUsageCache {
private static let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
private let database: OpaquePointer?
private var calendar: Calendar = {
var calendar = Calendar.current
calendar.firstWeekday = 2
return calendar
}()
init(url: URL) throws {
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true
)
var database: OpaquePointer?
let flags = SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX
guard sqlite3_open_v2(url.path, &database, flags, nil) == SQLITE_OK else {
let message = database.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "无法打开 SQLite"
sqlite3_close(database)
throw SQLiteUsageCacheError.openFailed(message)
}
self.database = database
try configure()
try migrate()
}
deinit {
sqlite3_close(database)
}
func isFileCurrent(path: String, size: Int64, modifiedAt: TimeInterval) throws -> Bool {
let statement = try prepare("SELECT size, modified_at, scanned_offset FROM files WHERE path = ? LIMIT 1")
defer { sqlite3_finalize(statement) }
try bind(path, to: statement, at: 1)
guard sqlite3_step(statement) == SQLITE_ROW else {
return false
}
let cachedSize = sqlite3_column_int64(statement, 0)
let cachedModifiedAt = sqlite3_column_double(statement, 1)
let scannedOffset = sqlite3_column_int64(statement, 2)
return cachedSize == size && scannedOffset == size && abs(cachedModifiedAt - modifiedAt) < 0.001
}
func fileState(path: String) throws -> CachedFileState? {
let statement = try prepare("""
SELECT size, modified_at, scanned_offset, record_count
FROM files
WHERE path = ?
LIMIT 1
""")
defer { sqlite3_finalize(statement) }
try bind(path, to: statement, at: 1)
guard sqlite3_step(statement) == SQLITE_ROW else {
return nil
}
return CachedFileState(
size: sqlite3_column_int64(statement, 0),
modifiedAt: sqlite3_column_double(statement, 1),
scannedOffset: sqlite3_column_int64(statement, 2),
recordCount: Int(sqlite3_column_int64(statement, 3))
)
}
func replaceFileRecords(
path: String,
size: Int64,
modifiedAt: TimeInterval,
scan: (_ insert: (UsageRecord) throws -> Void) throws -> Int64
) throws {
try execute("BEGIN IMMEDIATE TRANSACTION")
do {
try deleteRecords(path: path)
let insertStatement = try prepare("""
INSERT INTO records (
id, provider, source_path, timestamp, session_id, model,
input_tokens, output_tokens, cached_tokens, reasoning_tokens,
tool_tokens, total_tokens, day_start, hour_start, month_start
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""")
defer { sqlite3_finalize(insertStatement) }
var recordCount = 0
let scannedOffset = try scan { record in
recordCount += 1
try self.insert(record, id: "\(path)#\(recordCount)", statement: insertStatement)
}
try upsertFile(
path: path,
size: size,
modifiedAt: modifiedAt,
scannedOffset: scannedOffset,
recordCount: recordCount
)
try execute("COMMIT")
} catch {
try? execute("ROLLBACK")
throw error
}
}
func appendFileRecords(
path: String,
size: Int64,
modifiedAt: TimeInterval,
startingRecordCount: Int,
scan: (_ insert: (UsageRecord) throws -> Void) throws -> Int64
) throws {
try execute("BEGIN IMMEDIATE TRANSACTION")
do {
let insertStatement = try prepare("""
INSERT INTO records (
id, provider, source_path, timestamp, session_id, model,
input_tokens, output_tokens, cached_tokens, reasoning_tokens,
tool_tokens, total_tokens, day_start, hour_start, month_start
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""")
defer { sqlite3_finalize(insertStatement) }
var appendedCount = 0
let scannedOffset = try scan { record in
appendedCount += 1
let nextRecordIndex = startingRecordCount + appendedCount
try self.insert(record, id: "\(path)#\(nextRecordIndex)", statement: insertStatement)
}
try upsertFile(
path: path,
size: size,
modifiedAt: modifiedAt,
scannedOffset: scannedOffset,
recordCount: startingRecordCount + appendedCount
)
try execute("COMMIT")
} catch {
try? execute("ROLLBACK")
throw error
}
}
func pruneMissingFiles(currentPaths: Set<String>) throws {
let statement = try prepare("SELECT path FROM files")
defer { sqlite3_finalize(statement) }
var cachedPaths: [String] = []
while sqlite3_step(statement) == SQLITE_ROW {
cachedPaths.append(columnText(statement, at: 0))
}
for path in cachedPaths where !currentPaths.contains(path) {
try deleteRecords(path: path)
let deleteFile = try prepare("DELETE FROM files WHERE path = ?")
defer { sqlite3_finalize(deleteFile) }
try bind(path, to: deleteFile, at: 1)
try stepDone(deleteFile)
}
}
func makeSummary(now: Date = Date(), calendar: Calendar = Calendar.current) throws -> UsageSummary {
self.calendar = calendarWithMondayStart(calendar)
let providerTotals = try providerBuckets()
let todayProviderTotals = try providerBuckets(
whereClause: "timestamp >= ? AND timestamp < ?",
bindings: dayBounds(for: now).bindings
)
let dailyDetails = try dailyUsageDetails()
let dailyTrend = dailyDetails
.sorted { $0.date < $1.date }
.suffix(30)
.map { detail in
DailyPoint(date: detail.date, values: detail.providerTotals)
}
let trendPointsByMode = try Dictionary(uniqueKeysWithValues: TrendMode.allCases.map { mode in
(mode, try trendPoints(mode: mode, now: now))
})
let toolRows = Provider.allCases.compactMap { provider -> ToolRow? in
guard let bucket = providerTotals[provider], bucket.totalTokens > 0 else { return nil }
return ToolRow(
provider: provider,
inputTokens: bucket.inputTokens,
outputTokens: bucket.outputTokens,
cachedTokens: bucket.cachedTokens,
totalTokens: bucket.totalTokens,
trendTokens: dailyTrend.map { $0.values[provider, default: 0] }
)
}
.sorted { $0.totalTokens > $1.totalTokens }
let todayTokens = todayProviderTotals.values.reduce(0) { $0 + $1.totalTokens }
let monthTokens = try tokenSum(whereClause: "timestamp >= ? AND timestamp < ?", bindings: monthBounds(for: now).bindings)
let totalTokens = providerTotals.values.reduce(0) { $0 + $1.totalTokens }
let cachedTokens = providerTotals.values.reduce(0) { $0 + $1.cachedTokens }
let cacheHitRate = totalTokens > 0 ? Int((Double(cachedTokens) / Double(totalTokens) * 100).rounded()) : 0
let cards = SummaryCards(
todayTokens: todayTokens,
monthTokens: monthTokens,
totalTokens: totalTokens,
cacheHitRate: cacheHitRate,
sessionCount: try sessionCount()
)
return UsageSummary(
generatedAt: now,
records: [],
cards: cards,
providerTotals: providerTotals,
todayProviderTotals: todayProviderTotals,
dailyTrend: Array(dailyTrend),
trendPointsByMode: trendPointsByMode,
dailyDetails: dailyDetails.sorted { $0.date > $1.date },
toolRows: toolRows,
insights: UsageSummary.makeInsights(
providerTotals: providerTotals,
todayTokens: todayTokens,
cacheHitRate: cacheHitRate
)
)
}
private func configure() throws {
try execute("PRAGMA journal_mode = WAL")
try execute("PRAGMA synchronous = NORMAL")
try execute("PRAGMA temp_store = MEMORY")
try execute("PRAGMA busy_timeout = 3000")
}
private func migrate() throws {
if try recordsTableNeedsRebuild() {
try execute("DROP TABLE IF EXISTS records")
try execute("DROP TABLE IF EXISTS files")
}
try execute("""
CREATE TABLE IF NOT EXISTS files (
path TEXT PRIMARY KEY,
size INTEGER NOT NULL,
modified_at REAL NOT NULL,
scanned_offset INTEGER NOT NULL DEFAULT 0,
record_count INTEGER NOT NULL DEFAULT 0,
scanned_at REAL NOT NULL
)
""")
try addColumnIfMissing(
table: "files",
column: "scanned_offset",
definition: "scanned_offset INTEGER NOT NULL DEFAULT 0"
)
try addColumnIfMissing(
table: "files",
column: "record_count",
definition: "record_count INTEGER NOT NULL DEFAULT 0"
)
try execute("""
CREATE TABLE IF NOT EXISTS records (
id TEXT PRIMARY KEY,
provider TEXT NOT NULL,
source_path TEXT NOT NULL,
timestamp REAL NOT NULL,
session_id TEXT NOT NULL,
model TEXT NOT NULL,
input_tokens INTEGER NOT NULL,
output_tokens INTEGER NOT NULL,
cached_tokens INTEGER NOT NULL,
reasoning_tokens INTEGER NOT NULL,
tool_tokens INTEGER NOT NULL,
total_tokens INTEGER NOT NULL,
day_start REAL NOT NULL,
hour_start REAL NOT NULL,
month_start REAL NOT NULL
)
""")
try execute("CREATE INDEX IF NOT EXISTS records_provider_idx ON records(provider)")
try execute("CREATE INDEX IF NOT EXISTS records_source_path_idx ON records(source_path)")
try execute("CREATE INDEX IF NOT EXISTS records_timestamp_idx ON records(timestamp)")
try execute("CREATE INDEX IF NOT EXISTS records_day_provider_idx ON records(day_start, provider)")
try execute("CREATE INDEX IF NOT EXISTS records_hour_provider_idx ON records(hour_start, provider)")
try execute("CREATE INDEX IF NOT EXISTS records_month_provider_idx ON records(month_start, provider)")
}
private func deleteRecords(path: String) throws {
let statement = try prepare("DELETE FROM records WHERE source_path = ?")
defer { sqlite3_finalize(statement) }
try bind(path, to: statement, at: 1)
try stepDone(statement)
}
private func upsertFile(
path: String,
size: Int64,
modifiedAt: TimeInterval,
scannedOffset: Int64,
recordCount: Int
) throws {
let statement = try prepare("""
INSERT INTO files (path, size, modified_at, scanned_offset, record_count, scanned_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(path) DO UPDATE SET
size = excluded.size,
modified_at = excluded.modified_at,
scanned_offset = excluded.scanned_offset,
record_count = excluded.record_count,
scanned_at = excluded.scanned_at
""")
defer { sqlite3_finalize(statement) }
try bind(path, to: statement, at: 1)
sqlite3_bind_int64(statement, 2, size)
sqlite3_bind_double(statement, 3, modifiedAt)
sqlite3_bind_int64(statement, 4, scannedOffset)
sqlite3_bind_int64(statement, 5, Int64(recordCount))
sqlite3_bind_double(statement, 6, Date().timeIntervalSince1970)
try stepDone(statement)
}
private func insert(_ record: UsageRecord, id: String, statement: OpaquePointer?) throws {
sqlite3_reset(statement)
sqlite3_clear_bindings(statement)
try bind(id, to: statement, at: 1)
try bind(record.provider.rawValue, to: statement, at: 2)
try bind(record.sourcePath, to: statement, at: 3)
sqlite3_bind_double(statement, 4, record.timestamp.timeIntervalSince1970)
try bind(record.sessionID, to: statement, at: 5)
try bind(record.model, to: statement, at: 6)
sqlite3_bind_int64(statement, 7, Int64(record.inputTokens))
sqlite3_bind_int64(statement, 8, Int64(record.outputTokens))
sqlite3_bind_int64(statement, 9, Int64(record.cachedTokens))
sqlite3_bind_int64(statement, 10, Int64(record.reasoningTokens))
sqlite3_bind_int64(statement, 11, Int64(record.toolTokens))
sqlite3_bind_int64(statement, 12, Int64(record.totalTokens))
sqlite3_bind_double(statement, 13, calendar.startOfDay(for: record.timestamp).timeIntervalSince1970)
sqlite3_bind_double(statement, 14, hourStart(for: record.timestamp).timeIntervalSince1970)
sqlite3_bind_double(statement, 15, monthStart(for: record.timestamp).timeIntervalSince1970)
try stepDone(statement)
}
private func providerBuckets(
whereClause: String? = nil,
bindings: [TimeInterval] = []
) throws -> [Provider: TokenBucket] {
var buckets = Dictionary(uniqueKeysWithValues: Provider.allCases.map {
($0, TokenBucket(provider: $0))
})
var sql = """
SELECT provider,
SUM(input_tokens), SUM(output_tokens), SUM(cached_tokens),
SUM(reasoning_tokens), SUM(tool_tokens), SUM(total_tokens), COUNT(*)
FROM records
"""
if let whereClause {
sql += " WHERE \(whereClause)"
}
sql += " GROUP BY provider"
let statement = try prepare(sql)
defer { sqlite3_finalize(statement) }
try bindIntervals(bindings, to: statement)
while sqlite3_step(statement) == SQLITE_ROW {
guard let provider = Provider(rawValue: columnText(statement, at: 0)) else { continue }
var bucket = TokenBucket(provider: provider)
bucket.inputTokens = Int(sqlite3_column_int64(statement, 1))
bucket.outputTokens = Int(sqlite3_column_int64(statement, 2))
bucket.cachedTokens = Int(sqlite3_column_int64(statement, 3))
bucket.reasoningTokens = Int(sqlite3_column_int64(statement, 4))
bucket.toolTokens = Int(sqlite3_column_int64(statement, 5))
bucket.totalTokens = Int(sqlite3_column_int64(statement, 6))
bucket.count = Int(sqlite3_column_int64(statement, 7))
buckets[provider] = bucket
}
return buckets
}
private func dailyUsageDetails() throws -> [DailyUsageDetail] {
let statement = try prepare("""
SELECT day_start, provider,
SUM(input_tokens), SUM(output_tokens), SUM(cached_tokens), SUM(total_tokens)
FROM records
GROUP BY day_start, provider
ORDER BY day_start DESC
""")
defer { sqlite3_finalize(statement) }
var details: [Date: DailyUsageDetail] = [:]
while sqlite3_step(statement) == SQLITE_ROW {
guard let provider = Provider(rawValue: columnText(statement, at: 1)) else { continue }
let date = Date(timeIntervalSince1970: sqlite3_column_double(statement, 0))
if details[date] == nil {
details[date] = DailyUsageDetail(date: date)
}
details[date]?.add(
provider: provider,
inputTokens: Int(sqlite3_column_int64(statement, 2)),
outputTokens: Int(sqlite3_column_int64(statement, 3)),
cachedTokens: Int(sqlite3_column_int64(statement, 4)),
totalTokens: Int(sqlite3_column_int64(statement, 5))
)
}
return details.values.sorted { $0.date > $1.date }
}
private func trendPoints(mode: TrendMode, now: Date) throws -> [DailyPoint] {
let reference = mode.periodStart(for: now, calendar: calendar)
let start = mode.startDate(reference: reference, calendar: calendar)
let column = mode.periodColumn
let statement = try prepare("""
SELECT \(column), provider, SUM(total_tokens)
FROM records
WHERE \(column) >= ? AND \(column) <= ?
GROUP BY \(column), provider
""")
defer { sqlite3_finalize(statement) }
sqlite3_bind_double(statement, 1, start.timeIntervalSince1970)
sqlite3_bind_double(statement, 2, reference.timeIntervalSince1970)
var buckets: [Date: [Provider: Int]] = [:]
while sqlite3_step(statement) == SQLITE_ROW {
guard let provider = Provider(rawValue: columnText(statement, at: 1)) else { continue }
let date = Date(timeIntervalSince1970: sqlite3_column_double(statement, 0))
buckets[date, default: Provider.zeroTokenMap][provider, default: 0] = Int(sqlite3_column_int64(statement, 2))
}
var points: [DailyPoint] = []
var cursor = start
while cursor <= reference {
points.append(DailyPoint(date: cursor, values: buckets[cursor, default: Provider.zeroTokenMap]))
guard let next = calendar.date(byAdding: mode.calendarComponent, value: 1, to: cursor) else { break }
cursor = next
}
return points
}
private func tokenSum(whereClause: String, bindings: [TimeInterval]) throws -> Int {
let statement = try prepare("SELECT COALESCE(SUM(total_tokens), 0) FROM records WHERE \(whereClause)")
defer { sqlite3_finalize(statement) }
try bindIntervals(bindings, to: statement)
guard sqlite3_step(statement) == SQLITE_ROW else { return 0 }
return Int(sqlite3_column_int64(statement, 0))
}
private func sessionCount() throws -> Int {
let statement = try prepare("""
SELECT COUNT(DISTINCT CASE WHEN session_id = '' THEN source_path ELSE session_id END)
FROM records
""")
defer { sqlite3_finalize(statement) }
guard sqlite3_step(statement) == SQLITE_ROW else { return 0 }
return Int(sqlite3_column_int64(statement, 0))
}
private func recordsTableNeedsRebuild() throws -> Bool {
let columns = try tableColumns("records")
return !columns.isEmpty && !columns.contains("day_start")
}
private func addColumnIfMissing(table: String, column: String, definition: String) throws {
let columns = try tableColumns(table)
guard !columns.isEmpty, !columns.contains(column) else { return }
try execute("ALTER TABLE \(table) ADD COLUMN \(definition)")
}
private func tableColumns(_ table: String) throws -> Set<String> {
let statement = try prepare("PRAGMA table_info(\(table))")
defer { sqlite3_finalize(statement) }
var columns = Set<String>()
while sqlite3_step(statement) == SQLITE_ROW {
columns.insert(columnText(statement, at: 1))
}
return columns
}
private func bindIntervals(_ values: [TimeInterval], to statement: OpaquePointer?) throws {
for (index, value) in values.enumerated() {
sqlite3_bind_double(statement, Int32(index + 1), value)
}
}
private func dayBounds(for date: Date) -> QueryBounds {
let start = calendar.startOfDay(for: date)
let end = calendar.date(byAdding: .day, value: 1, to: start) ?? date
return QueryBounds(start: start, end: end)
}
private func monthBounds(for date: Date) -> QueryBounds {
let start = monthStart(for: date)
let end = calendar.date(byAdding: .month, value: 1, to: start) ?? date
return QueryBounds(start: start, end: end)
}
private func hourStart(for date: Date) -> Date {
let components = calendar.dateComponents([.year, .month, .day, .hour], from: date)
return calendar.date(from: components) ?? calendar.startOfDay(for: date)
}
private func monthStart(for date: Date) -> Date {
let components = calendar.dateComponents([.year, .month], from: date)
return calendar.date(from: components) ?? calendar.startOfDay(for: date)
}
private func calendarWithMondayStart(_ calendar: Calendar) -> Calendar {
var copy = calendar
copy.firstWeekday = 2
return copy
}
private func prepare(_ sql: String) throws -> OpaquePointer? {
var statement: OpaquePointer?
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else {
throw SQLiteUsageCacheError.prepareFailed(errorMessage)
}
return statement
}
private func execute(_ sql: String) throws {
guard sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK else {
throw SQLiteUsageCacheError.executeFailed(errorMessage)
}
}
private func bind(_ text: String, to statement: OpaquePointer?, at index: Int32) throws {
let result = text.withCString {
sqlite3_bind_text(statement, index, $0, -1, Self.transient)
}
guard result == SQLITE_OK else {
throw SQLiteUsageCacheError.bindFailed(errorMessage)
}
}
private func stepDone(_ statement: OpaquePointer?) throws {
guard sqlite3_step(statement) == SQLITE_DONE else {
throw SQLiteUsageCacheError.stepFailed(errorMessage)
}
}
private func columnText(_ statement: OpaquePointer?, at index: Int32) -> String {
guard let text = sqlite3_column_text(statement, index) else {
return ""
}
return String(cString: UnsafeRawPointer(text).assumingMemoryBound(to: CChar.self))
}
private var errorMessage: String {
database.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "SQLite 操作失败"
}
}
struct CachedFileState {
let size: Int64
let modifiedAt: TimeInterval
let scannedOffset: Int64
let recordCount: Int
}
private struct QueryBounds {
let start: Date
let end: Date
var bindings: [TimeInterval] {
[start.timeIntervalSince1970, end.timeIntervalSince1970]
}
}

View File

@@ -0,0 +1,19 @@
import Foundation
public enum TokenFormatter {
public static func format(_ value: Int) -> String {
if value >= 100_000_000 {
return adaptive(Double(value) / 100_000_000) + "亿"
}
if value >= 10_000 {
return adaptive(Double(value) / 10_000) + ""
}
return String(value)
}
private static func adaptive(_ value: Double) -> String {
let integerDigits = String(Int(abs(value.rounded(.towardZero)))).count
let digits = integerDigits > 3 ? 1 : 3
return String(format: "%.\(digits)f", value)
}
}

View File

@@ -0,0 +1,105 @@
import Foundation
public enum TokenUsageParser {
public static func parseClaudeLine(_ line: String, sourcePath: String) -> UsageRecord? {
guard let object = parseJSONObject(line) else { return nil }
let message = object["message"] as? [String: Any]
let usage = message?["usage"] as? [String: Any] ?? object["usage"] as? [String: Any]
guard let usage else { return nil }
guard let timestamp = parseDate(object["timestamp"] ?? object["createdAt"] ?? object["date"]) else { return nil }
return UsageRecord(
provider: .claude,
source: sourceLabel(sourcePath: sourcePath, provider: .claude),
sourcePath: sourcePath,
timestamp: timestamp,
sessionID: stringValue(object["sessionId"]),
model: stringValue(message?["model"]) == "" ? "Claude" : stringValue(message?["model"]),
inputTokens: intValue(usage["input_tokens"]),
outputTokens: intValue(usage["output_tokens"]),
cachedTokens: intValue(usage["cache_read_input_tokens"]) + intValue(usage["cache_creation_input_tokens"]),
reasoningTokens: 0,
toolTokens: 0
)
}
public static func parseCodexLine(_ line: String, sourcePath: String) -> UsageRecord? {
guard let object = parseJSONObject(line),
let payload = object["payload"] as? [String: Any],
let info = payload["info"] as? [String: Any],
let usage = info["last_token_usage"] as? [String: Any],
let timestamp = parseDate(object["timestamp"])
else { return nil }
return UsageRecord(
provider: .codex,
source: sourceLabel(sourcePath: sourcePath, provider: .codex),
sourcePath: sourcePath,
timestamp: timestamp,
sessionID: stringValue(payload["id"] ?? payload["thread_id"]),
model: stringValue(payload["model"]) == "" ? "Codex" : stringValue(payload["model"]),
inputTokens: intValue(usage["input_tokens"]),
outputTokens: intValue(usage["output_tokens"]),
cachedTokens: intValue(usage["cached_input_tokens"]),
reasoningTokens: intValue(usage["reasoning_output_tokens"]),
toolTokens: 0
)
}
public static func parseGeminiLine(_ line: String, sourcePath: String) -> UsageRecord? {
guard let object = parseJSONObject(line),
let tokens = object["tokens"] as? [String: Any],
let timestamp = parseDate(object["timestamp"])
else { return nil }
return UsageRecord(
provider: .gemini,
source: sourceLabel(sourcePath: sourcePath, provider: .gemini),
sourcePath: sourcePath,
timestamp: timestamp,
sessionID: stringValue(object["id"]),
model: stringValue(object["model"]) == "" ? "Gemini" : stringValue(object["model"]),
inputTokens: intValue(tokens["input"]),
outputTokens: intValue(tokens["output"]),
cachedTokens: intValue(tokens["cached"]),
reasoningTokens: intValue(tokens["thoughts"]),
toolTokens: intValue(tokens["tool"]),
explicitTotal: intValue(tokens["total"]) == 0 ? nil : intValue(tokens["total"])
)
}
static func parseDate(_ value: Any?) -> Date? {
guard let text = value as? String else { return nil }
let isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let fallbackISOFormatter = ISO8601DateFormatter()
fallbackISOFormatter.formatOptions = [.withInternetDateTime]
return isoFormatter.date(from: text) ?? fallbackISOFormatter.date(from: text)
}
private static func parseJSONObject(_ line: String) -> [String: Any]? {
guard let data = line.data(using: .utf8) else { return nil }
return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
}
private static func stringValue(_ value: Any?) -> String {
value as? String ?? ""
}
private static func intValue(_ value: Any?) -> Int {
if let int = value as? Int { return int }
if let double = value as? Double { return Int(double) }
if let string = value as? String { return Int(string) ?? 0 }
return 0
}
private static func sourceLabel(sourcePath: String, provider: Provider) -> String {
switch provider {
case .claude: return "projects/*.jsonl"
case .codex: return "sessions/*.jsonl"
case .gemini: return "tmp/chats/*.jsonl"
case .hermes: return "state.db/sessions"
case .opencode: return "opencode.db/session"
}
}
}

View File

@@ -0,0 +1,525 @@
import Foundation
import SQLite3
public actor TokenUsageScanner {
public let homeDirectory: URL
public let cacheURL: URL
private let chunkSize = 256 * 1024
private var lastSignature: FileSignature?
private var lastSummary: UsageSummary?
public init(
homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser,
cacheURL: URL? = nil
) {
self.homeDirectory = homeDirectory
self.cacheURL = cacheURL ?? homeDirectory
.appending(path: "Library/Application Support/TokenLens")
.appending(path: "usage-cache.sqlite")
}
public func scan(now: Date = Date()) async -> UsageSummary {
defer { MemoryPressure.relieve() }
if let summary = scanWithCache(now: now) {
return summary
}
var accumulator = UsageSummaryAccumulator(now: now)
scanClaude(into: &accumulator)
scanCodex(into: &accumulator)
scanGemini(into: &accumulator)
scanHermes(into: &accumulator)
scanOpenCode(into: &accumulator)
return accumulator.makeSummary()
}
private func scanWithCache(now: Date) -> UsageSummary? {
do {
let snapshots = try logFileSnapshots()
let signature = FileSignature(snapshots: snapshots)
if signature == lastSignature,
let lastSummary,
isSameDisplayWindow(lastSummary.generatedAt, now) {
let updated = lastSummary.updatingGeneratedAt(now)
self.lastSummary = updated
return updated
}
let cache = try SQLiteUsageCache(url: cacheURL)
let currentPaths = try refreshCache(cache, snapshots: snapshots)
try cache.pruneMissingFiles(currentPaths: currentPaths)
let summary = try cache.makeSummary(now: now)
lastSignature = signature
lastSummary = summary
return summary
} catch {
return nil
}
}
private func refreshCache(_ cache: SQLiteUsageCache, snapshots: [LogFileSnapshot]) throws -> Set<String> {
var currentPaths = Set<String>()
for snapshot in snapshots {
currentPaths.insert(snapshot.path)
let state = try cache.fileState(path: snapshot.path)
let isCurrent = try cache.isFileCurrent(
path: snapshot.path,
size: snapshot.size,
modifiedAt: snapshot.modifiedAt
)
if isCurrent && (snapshot.scanner.supportsZeroRecordCache || (state?.recordCount ?? 0) > 0) {
continue
}
if let state,
snapshot.size > state.size,
state.size == state.scannedOffset,
state.scannedOffset > 0,
snapshot.scanner.supportsIncrementalScan {
try cache.appendFileRecords(
path: snapshot.path,
size: snapshot.size,
modifiedAt: snapshot.modifiedAt,
startingRecordCount: state.recordCount
) { insert in
try scanFile(
snapshot.url,
startingAt: UInt64(state.scannedOffset),
scanner: snapshot.scanner,
consume: insert
).scannedOffset
}
} else {
try cache.replaceFileRecords(
path: snapshot.path,
size: snapshot.size,
modifiedAt: snapshot.modifiedAt
) { insert in
try scanFile(snapshot.url, scanner: snapshot.scanner, consume: insert).scannedOffset
}
}
}
return currentPaths
}
private func scanClaude(into accumulator: inout UsageSummaryAccumulator) {
scanJSONLFiles(
root: homeDirectory.appending(path: ".claude/projects"),
include: { $0.pathExtension == "jsonl" },
parse: TokenUsageParser.parseClaudeLine,
into: &accumulator
)
}
private func scanCodex(into accumulator: inout UsageSummaryAccumulator) {
scanJSONLFiles(
root: homeDirectory.appending(path: ".codex/sessions"),
include: { $0.pathExtension == "jsonl" },
parse: TokenUsageParser.parseCodexLine,
into: &accumulator
)
}
private func scanGemini(into accumulator: inout UsageSummaryAccumulator) {
scanJSONLFiles(
root: homeDirectory.appending(path: ".gemini"),
include: { url in
url.pathExtension == "jsonl" && url.pathComponents.contains("chats")
},
parse: TokenUsageParser.parseGeminiLine,
into: &accumulator
)
}
private func scanHermes(into accumulator: inout UsageSummaryAccumulator) {
for file in files(root: homeDirectory.appending(path: ".hermes"), include: { $0.lastPathComponent == "state.db" }) {
_ = try? scanHermesStateDB(file) { record in
accumulator.add(record)
}
}
}
private func scanOpenCode(into accumulator: inout UsageSummaryAccumulator) {
for file in files(root: homeDirectory.appending(path: ".local/share/opencode"), include: { $0.lastPathComponent == "opencode.db" }) {
_ = try? scanOpenCodeDB(file) { record in
accumulator.add(record)
}
}
}
private func scanJSONLFiles(
root: URL,
include: (URL) -> Bool,
parse: (String, String) -> UsageRecord?,
into accumulator: inout UsageSummaryAccumulator
) {
for file in files(root: root, include: include) {
_ = try? scanJSONLFile(file, parse: parse) { record in
accumulator.add(record)
}
}
}
private func scanFile(
_ file: URL,
startingAt offset: UInt64 = 0,
scanner: SourceScanner,
consume: (UsageRecord) throws -> Void
) throws -> ScanProgress {
switch scanner {
case let .jsonl(parse):
return try scanJSONLFile(file, startingAt: offset, parse: parse, consume: consume)
case .hermesStateDB:
return try scanHermesStateDB(file, consume: consume)
case .openCodeDB:
return try scanOpenCodeDB(file, consume: consume)
}
}
private func scanJSONLFile(
_ file: URL,
startingAt offset: UInt64 = 0,
parse: (String, String) -> UsageRecord?,
consume: (UsageRecord) throws -> Void
) throws -> ScanProgress {
guard let handle = try? FileHandle(forReadingFrom: file) else {
return ScanProgress(scannedOffset: Int64(offset), recordCount: 0)
}
defer { try? handle.close() }
try handle.seek(toOffset: offset)
var pendingLine = Data()
var scannedOffset = Int64(offset)
var recordCount = 0
while true {
let chunk = (try? handle.read(upToCount: chunkSize)) ?? Data()
if chunk.isEmpty { break }
var lineStart = chunk.startIndex
for index in chunk.indices where chunk[index] == 0x0A {
let lineData = chunk[lineStart..<index]
if pendingLine.isEmpty {
scannedOffset += Int64(lineData.count + 1)
try autoreleasepool {
if try appendRecord(from: lineData, sourcePath: file.path, parse: parse, consume: consume) {
recordCount += 1
}
}
} else {
pendingLine.append(contentsOf: lineData)
scannedOffset += Int64(pendingLine.count + 1)
try autoreleasepool {
if try appendRecord(from: pendingLine, sourcePath: file.path, parse: parse, consume: consume) {
recordCount += 1
}
}
pendingLine.removeAll(keepingCapacity: true)
}
lineStart = chunk.index(after: index)
}
if lineStart < chunk.endIndex {
pendingLine.append(contentsOf: chunk[lineStart..<chunk.endIndex])
}
}
if !pendingLine.isEmpty {
try autoreleasepool {
if try appendRecord(from: pendingLine, sourcePath: file.path, parse: parse, consume: consume) {
recordCount += 1
scannedOffset += Int64(pendingLine.count)
} else if isCompleteJSONLine(pendingLine) {
scannedOffset += Int64(pendingLine.count)
}
}
}
return ScanProgress(scannedOffset: scannedOffset, recordCount: recordCount)
}
private func appendRecord<LineData: Collection>(
from lineData: LineData,
sourcePath: String,
parse: (String, String) -> UsageRecord?,
consume: (UsageRecord) throws -> Void
) throws -> Bool where LineData.Element == UInt8 {
guard !lineData.isEmpty,
let line = String(bytes: lineData, encoding: .utf8),
let record = parse(line, sourcePath)
else {
return false
}
try consume(record)
return true
}
private func isCompleteJSONLine(_ lineData: Data) -> Bool {
guard !lineData.isEmpty else { return true }
return (try? JSONSerialization.jsonObject(with: lineData)) != nil
}
private func scanHermesStateDB(
_ file: URL,
consume: (UsageRecord) throws -> Void
) throws -> ScanProgress {
let sql = """
SELECT id, started_at, COALESCE(model, ''),
input_tokens, output_tokens, cache_read_tokens,
cache_write_tokens, reasoning_tokens
FROM sessions
WHERE input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + reasoning_tokens > 0
"""
return try scanSQLiteRows(file, sql: sql) { statement in
UsageRecord(
provider: .hermes,
source: "state.db/sessions",
sourcePath: file.path,
timestamp: Date(timeIntervalSince1970: sqlite3_column_double(statement, 1)),
sessionID: textColumn(statement, 0),
model: textColumn(statement, 2).isEmpty ? "Hermes" : textColumn(statement, 2),
inputTokens: intColumn(statement, 3),
outputTokens: intColumn(statement, 4),
cachedTokens: intColumn(statement, 5) + intColumn(statement, 6),
reasoningTokens: intColumn(statement, 7),
toolTokens: 0
)
} consume: {
try consume($0)
}
}
private func scanOpenCodeDB(
_ file: URL,
consume: (UsageRecord) throws -> Void
) throws -> ScanProgress {
let sql = """
SELECT id, COALESCE(NULLIF(time_updated, 0), time_created), COALESCE(model, ''),
tokens_input, tokens_output, tokens_reasoning,
tokens_cache_read, tokens_cache_write
FROM session
WHERE tokens_input + tokens_output + tokens_reasoning + tokens_cache_read + tokens_cache_write > 0
"""
return try scanSQLiteRows(file, sql: sql) { statement in
UsageRecord(
provider: .opencode,
source: "opencode.db/session",
sourcePath: file.path,
timestamp: Date(timeIntervalSince1970: sqlite3_column_double(statement, 1) / 1000),
sessionID: textColumn(statement, 0),
model: openCodeModelName(textColumn(statement, 2)),
inputTokens: intColumn(statement, 3),
outputTokens: intColumn(statement, 4),
cachedTokens: intColumn(statement, 6) + intColumn(statement, 7),
reasoningTokens: intColumn(statement, 5),
toolTokens: 0
)
} consume: {
try consume($0)
}
}
private func scanSQLiteRows(
_ file: URL,
sql: String,
makeRecord: (OpaquePointer?) -> UsageRecord,
consume: (UsageRecord) throws -> Void
) throws -> ScanProgress {
var database: OpaquePointer?
let flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX
guard sqlite3_open_v2(file.path, &database, flags, nil) == SQLITE_OK else {
sqlite3_close(database)
return ScanProgress(scannedOffset: fileSize(file), recordCount: 0)
}
defer { sqlite3_close(database) }
sqlite3_busy_timeout(database, 1000)
var statement: OpaquePointer?
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else {
sqlite3_finalize(statement)
return ScanProgress(scannedOffset: fileSize(file), recordCount: 0)
}
defer { sqlite3_finalize(statement) }
var recordCount = 0
while sqlite3_step(statement) == SQLITE_ROW {
try consume(makeRecord(statement))
recordCount += 1
}
return ScanProgress(scannedOffset: fileSize(file), recordCount: recordCount)
}
private func fileSize(_ file: URL) -> Int64 {
((try? file.resourceValues(forKeys: [.fileSizeKey]).fileSize).flatMap { Int64($0) }) ?? 0
}
private func textColumn(_ statement: OpaquePointer?, _ index: Int32) -> String {
guard let text = sqlite3_column_text(statement, index) else { return "" }
return String(cString: UnsafeRawPointer(text).assumingMemoryBound(to: CChar.self))
}
private func intColumn(_ statement: OpaquePointer?, _ index: Int32) -> Int {
Int(sqlite3_column_int64(statement, index))
}
private func openCodeModelName(_ rawModel: String) -> String {
guard !rawModel.isEmpty,
let data = rawModel.data(using: .utf8),
let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
else {
return rawModel.isEmpty ? "OpenCode" : rawModel
}
let provider = object["providerID"] as? String
let model = object["id"] as? String
var parts: [String] = []
if let provider, !provider.isEmpty {
parts.append(provider)
}
if let model, !model.isEmpty {
parts.append(model)
}
return parts.isEmpty ? "OpenCode" : parts.joined(separator: "/")
}
private func files(root: URL, include: (URL) -> Bool) -> [URL] {
guard let enumerator = FileManager.default.enumerator(
at: root,
includingPropertiesForKeys: [.isRegularFileKey],
options: [.skipsHiddenFiles]
) else {
return []
}
var result: [URL] = []
for case let file as URL in enumerator where include(file) {
result.append(file)
}
return result
}
private var logSources: [LogSource] {
[
LogSource(
root: homeDirectory.appending(path: ".claude/projects"),
include: { $0.pathExtension == "jsonl" },
scanner: .jsonl(TokenUsageParser.parseClaudeLine)
),
LogSource(
root: homeDirectory.appending(path: ".codex/sessions"),
include: { $0.pathExtension == "jsonl" },
scanner: .jsonl(TokenUsageParser.parseCodexLine)
),
LogSource(
root: homeDirectory.appending(path: ".gemini"),
include: { url in
url.pathExtension == "jsonl" && url.pathComponents.contains("chats")
},
scanner: .jsonl(TokenUsageParser.parseGeminiLine)
),
LogSource(
root: homeDirectory.appending(path: ".hermes"),
include: {
$0.lastPathComponent == "state.db" &&
$0.deletingLastPathComponent().lastPathComponent == ".hermes"
},
scanner: .hermesStateDB
),
LogSource(
root: homeDirectory.appending(path: ".local/share/opencode"),
include: {
$0.lastPathComponent == "opencode.db" &&
$0.deletingLastPathComponent().lastPathComponent == "opencode"
},
scanner: .openCodeDB
)
]
}
private func logFileSnapshots() throws -> [LogFileSnapshot] {
var snapshots: [LogFileSnapshot] = []
for source in logSources {
for file in files(root: source.root, include: source.include) {
let metadata = try fileMetadata(file)
snapshots.append(LogFileSnapshot(
url: file,
path: file.path,
size: metadata.size,
modifiedAt: metadata.modifiedAt,
scanner: source.scanner
))
}
}
return snapshots.sorted { $0.path < $1.path }
}
private func fileMetadata(_ file: URL) throws -> FileMetadata {
let values = try file.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey])
return FileMetadata(
size: Int64(values.fileSize ?? 0),
modifiedAt: values.contentModificationDate?.timeIntervalSince1970 ?? 0
)
}
private func isSameDisplayWindow(_ lhs: Date, _ rhs: Date) -> Bool {
var calendar = Calendar.current
calendar.firstWeekday = 2
return calendar.isDate(lhs, equalTo: rhs, toGranularity: .hour)
}
}
private struct LogSource {
let root: URL
let include: (URL) -> Bool
let scanner: SourceScanner
}
private enum SourceScanner {
case jsonl((String, String) -> UsageRecord?)
case hermesStateDB
case openCodeDB
var supportsIncrementalScan: Bool {
if case .jsonl = self { return true }
return false
}
var supportsZeroRecordCache: Bool {
if case .jsonl = self { return true }
return false
}
}
private struct ScanProgress {
let scannedOffset: Int64
let recordCount: Int
}
private struct FileMetadata {
let size: Int64
let modifiedAt: TimeInterval
}
private struct LogFileSnapshot {
let url: URL
let path: String
let size: Int64
let modifiedAt: TimeInterval
let scanner: SourceScanner
}
private struct FileSignature: Equatable {
let entries: [Entry]
init(snapshots: [LogFileSnapshot]) {
entries = snapshots.map {
Entry(path: $0.path, size: $0.size, modifiedAt: $0.modifiedAt)
}
}
struct Entry: Equatable {
let path: String
let size: Int64
let modifiedAt: TimeInterval
}
}

View File

@@ -0,0 +1,330 @@
import Foundation
public struct UsageSummaryAccumulator {
private let now: Date
private var calendar: Calendar
private let today: Date
private let month: DateComponents
private let trendWindows: [TrendMode: TrendWindow]
private var providerTotals: [Provider: TokenBucket]
private var todayProviderTotals: [Provider: TokenBucket]
private var dailyBuckets: [Date: [Provider: Int]] = [:]
private var dailyDetails: [Date: DailyUsageDetail] = [:]
private var trendBuckets: [TrendMode: [Date: [Provider: Int]]] = [:]
private var todayTokens = 0
private var monthTokens = 0
private var totalTokens = 0
private var cachedTokens = 0
private var sessions = Set<String>()
public init(
now: Date = Date(),
calendar baseCalendar: Calendar = Calendar.current
) {
self.now = now
var calendar = baseCalendar
calendar.firstWeekday = 2
self.calendar = calendar
today = calendar.startOfDay(for: now)
month = calendar.dateComponents([.year, .month], from: now)
providerTotals = Dictionary(uniqueKeysWithValues: Provider.allCases.map {
($0, TokenBucket(provider: $0))
})
todayProviderTotals = Dictionary(uniqueKeysWithValues: Provider.allCases.map {
($0, TokenBucket(provider: $0))
})
trendWindows = Dictionary(uniqueKeysWithValues: TrendMode.allCases.map { mode in
let reference = mode.periodStart(for: now, calendar: calendar)
return (mode, TrendWindow(
reference: reference,
start: mode.startDate(reference: reference, calendar: calendar),
component: mode.calendarComponent
))
})
}
public mutating func add(_ record: UsageRecord) {
add(
provider: record.provider,
sourcePath: record.sourcePath,
timestamp: record.timestamp,
sessionID: record.sessionID,
inputTokens: record.inputTokens,
outputTokens: record.outputTokens,
cachedTokens: record.cachedTokens,
reasoningTokens: record.reasoningTokens,
toolTokens: record.toolTokens,
totalTokens: record.totalTokens
)
}
public mutating func add(
provider: Provider,
sourcePath: String,
timestamp: Date,
sessionID: String,
inputTokens: Int,
outputTokens: Int,
cachedTokens: Int,
reasoningTokens: Int,
toolTokens: Int,
totalTokens: Int
) {
self.totalTokens += totalTokens
self.cachedTokens += cachedTokens
sessions.insert(sessionID.isEmpty ? sourcePath : sessionID)
providerTotals[provider, default: TokenBucket(provider: provider)].add(
inputTokens: inputTokens,
outputTokens: outputTokens,
cachedTokens: cachedTokens,
reasoningTokens: reasoningTokens,
toolTokens: toolTokens,
totalTokens: totalTokens
)
let recordDay = calendar.startOfDay(for: timestamp)
dailyBuckets[recordDay, default: Provider.zeroTokenMap][provider, default: 0] += totalTokens
if dailyDetails[recordDay] == nil {
dailyDetails[recordDay] = DailyUsageDetail(date: recordDay)
}
dailyDetails[recordDay]?.add(
provider: provider,
inputTokens: inputTokens,
outputTokens: outputTokens,
cachedTokens: cachedTokens,
totalTokens: totalTokens
)
if recordDay == today {
todayTokens += totalTokens
todayProviderTotals[provider, default: TokenBucket(provider: provider)].add(
inputTokens: inputTokens,
outputTokens: outputTokens,
cachedTokens: cachedTokens,
reasoningTokens: reasoningTokens,
toolTokens: toolTokens,
totalTokens: totalTokens
)
}
let recordMonth = calendar.dateComponents([.year, .month], from: timestamp)
if recordMonth.year == month.year && recordMonth.month == month.month {
monthTokens += totalTokens
}
for mode in TrendMode.allCases {
guard let window = trendWindows[mode] else { continue }
let period = mode.periodStart(for: timestamp, calendar: calendar)
guard period >= window.start && period <= window.reference else { continue }
trendBuckets[mode, default: [:]][period, default: Provider.zeroTokenMap][provider, default: 0] += totalTokens
}
}
public func makeSummary() -> UsageSummary {
let dailyTrend = dailyBuckets
.sorted { $0.key < $1.key }
.suffix(30)
.map { DailyPoint(date: $0.key, values: $0.value) }
let sortedDailyDetails = dailyDetails.values.sorted { $0.date > $1.date }
let trendPointsByMode = Dictionary(uniqueKeysWithValues: TrendMode.allCases.map { mode in
(mode, makeTrendPoints(mode: mode))
})
let toolRows = Provider.allCases.compactMap { provider -> ToolRow? in
guard let bucket = providerTotals[provider], bucket.totalTokens > 0 else { return nil }
let trendTokens = dailyTrend.map { $0.values[provider, default: 0] }
return ToolRow(
provider: provider,
inputTokens: bucket.inputTokens,
outputTokens: bucket.outputTokens,
cachedTokens: bucket.cachedTokens,
totalTokens: bucket.totalTokens,
trendTokens: trendTokens
)
}
.sorted { $0.totalTokens > $1.totalTokens }
let cacheHitRate = totalTokens > 0 ? Int((Double(cachedTokens) / Double(totalTokens) * 100).rounded()) : 0
let cards = SummaryCards(
todayTokens: todayTokens,
monthTokens: monthTokens,
totalTokens: totalTokens,
cacheHitRate: cacheHitRate,
sessionCount: sessions.count
)
return UsageSummary(
generatedAt: now,
records: [],
cards: cards,
providerTotals: providerTotals,
todayProviderTotals: todayProviderTotals,
dailyTrend: Array(dailyTrend),
trendPointsByMode: trendPointsByMode,
dailyDetails: sortedDailyDetails,
toolRows: toolRows,
insights: UsageSummary.makeInsights(
providerTotals: providerTotals,
todayTokens: todayTokens,
cacheHitRate: cacheHitRate
)
)
}
private func makeTrendPoints(mode: TrendMode) -> [DailyPoint] {
guard let window = trendWindows[mode] else { return [] }
let buckets = trendBuckets[mode, default: [:]]
var points: [DailyPoint] = []
var cursor = window.start
while cursor <= window.reference {
points.append(DailyPoint(date: cursor, values: buckets[cursor, default: Provider.zeroTokenMap]))
guard let next = calendar.date(byAdding: window.component, value: 1, to: cursor) else { break }
cursor = next
}
return points
}
}
private struct TrendWindow {
let reference: Date
let start: Date
let component: Calendar.Component
}
public extension UsageSummary {
static func make(
records: [UsageRecord],
now: Date = Date(),
calendar baseCalendar: Calendar = Calendar.current
) -> UsageSummary {
var accumulator = UsageSummaryAccumulator(now: now, calendar: baseCalendar)
for record in records {
accumulator.add(record)
}
return accumulator.makeSummary()
}
static func makeInsights(
providerTotals: [Provider: TokenBucket],
todayTokens: Int,
cacheHitRate: Int
) -> [Insight] {
let top = providerTotals.values
.filter { $0.totalTokens > 0 }
.sorted { $0.totalTokens > $1.totalTokens }
.first
var insights: [Insight] = []
if let top, let provider = top.provider {
insights.append(Insight(
tone: .warning,
title: "\(provider.rawValue) 是当前主要用量来源",
message: "已从本地日志统计 \(TokenFormatter.format(top.totalTokens)) Tokens。"
))
}
insights.append(Insight(
tone: .positive,
title: "检测到缓存 Tokens",
message: "当前 \(cacheHitRate)% 的统计用量来自缓存上下文。"
))
insights.append(Insight(
tone: .accent,
title: "今日用量快照已更新",
message: "当前本地日期已统计 \(TokenFormatter.format(todayTokens)) Tokens。"
))
return insights
}
func trendPoints(
mode: TrendMode,
calendar baseCalendar: Calendar = Calendar.current
) -> [DailyPoint] {
if let points = trendPointsByMode[mode] {
return points
}
return []
}
func dailyUsageDetails(calendar baseCalendar: Calendar = Calendar.current) -> [DailyUsageDetail] {
dailyDetails
}
func updatingGeneratedAt(_ date: Date) -> UsageSummary {
UsageSummary(
generatedAt: date,
records: records,
cards: cards,
providerTotals: providerTotals,
todayProviderTotals: todayProviderTotals,
dailyTrend: dailyTrend,
trendPointsByMode: trendPointsByMode,
dailyDetails: dailyDetails,
toolRows: toolRows,
insights: insights
)
}
}
extension Provider {
static var zeroTokenMap: [Provider: Int] {
Dictionary(uniqueKeysWithValues: Provider.allCases.map { ($0, 0) })
}
}
extension TrendMode {
var calendarComponent: Calendar.Component {
switch self {
case .daily:
return .hour
case .weekly:
return .day
case .monthly:
return .day
case .yearly:
return .month
}
}
var periodColumn: String {
switch self {
case .daily:
return "hour_start"
case .weekly, .monthly:
return "day_start"
case .yearly:
return "month_start"
}
}
func periodStart(for date: Date, calendar: Calendar) -> Date {
switch self {
case .daily:
let components = calendar.dateComponents([.year, .month, .day, .hour], from: date)
return calendar.date(from: components) ?? calendar.startOfDay(for: date)
case .weekly:
return calendar.startOfDay(for: date)
case .monthly:
return calendar.startOfDay(for: date)
case .yearly:
let components = calendar.dateComponents([.year, .month], from: date)
return calendar.date(from: components) ?? calendar.startOfDay(for: date)
}
}
func startDate(reference: Date, calendar: Calendar) -> Date {
switch self {
case .daily:
return calendar.startOfDay(for: reference)
case .weekly:
return calendar.dateInterval(of: .weekOfYear, for: reference)?.start ?? calendar.startOfDay(for: reference)
case .monthly:
let components = calendar.dateComponents([.year, .month], from: reference)
return calendar.date(from: components) ?? calendar.startOfDay(for: reference)
case .yearly:
let components = calendar.dateComponents([.year], from: reference)
return calendar.date(from: components) ?? calendar.startOfDay(for: reference)
}
}
}