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

31
.gitignore vendored Normal file
View File

@@ -0,0 +1,31 @@
# macOS 本地文件
.DS_Store
# Node / Electron 依赖与产物
node_modules/
dist/
release/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# SwiftPM / Xcode 构建缓存
.build/
NativeTokenLens/.build/
DerivedData/
*.xcuserstate
# 原生应用打包产物
NativeTokenLens/TokenLens.app/
NativeTokenLens/TokenLens.dmg
NativeTokenLens/Assets/AppIcon.iconset/
native-release/
# 本地环境与截图
.env
.env.*
!.env.example
interface.png
design/*.png

31
.ignore Normal file
View File

@@ -0,0 +1,31 @@
# macOS 本地文件
.DS_Store
# Node / Electron 依赖与产物
node_modules/
dist/
release/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# SwiftPM / Xcode 构建缓存
.build/
NativeTokenLens/.build/
DerivedData/
*.xcuserstate
# 原生应用打包产物
NativeTokenLens/TokenLens.app/
NativeTokenLens/TokenLens.dmg
NativeTokenLens/Assets/AppIcon.iconset/
native-release/
# 本地环境与截图
.env
.env.*
!.env.example
interface.png
design/*.png

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>zh_CN</string>
<key>CFBundleExecutable</key>
<string>TokenLens</string>
<key>CFBundleIdentifier</key>
<string>com.caoxiaozhu.TokenLensNative</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>TokenLens</string>
<key>CFBundleDisplayName</key>
<string>TokenLens</string>
<key>CFBundleIconFile</key>
<string>TokenLensIcon</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.2.0</string>
<key>CFBundleVersion</key>
<string>2</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.productivity</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSSupportsAutomaticGraphicsSwitching</key>
<true/>
</dict>
</plist>

Binary file not shown.

View File

@@ -0,0 +1,29 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "NativeTokenLens",
platforms: [
.macOS(.v14)
],
products: [
.library(name: "TokenLensCore", targets: ["TokenLensCore"]),
.executable(name: "TokenLens", targets: ["TokenLens"])
],
targets: [
.target(
name: "TokenLensCore",
linkerSettings: [
.linkedLibrary("sqlite3")
]
),
.executableTarget(
name: "TokenLens",
dependencies: ["TokenLensCore"]
),
.testTarget(
name: "TokenLensCoreTests",
dependencies: ["TokenLensCore"]
)
]
)

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)
}
}
}

View File

@@ -0,0 +1,312 @@
import Testing
import Foundation
import SQLite3
@testable import TokenLensCore
@Test func formatTokensUsesChineseMagnitude() {
#expect(TokenFormatter.format(9_999) == "9999")
#expect(TokenFormatter.format(10_000) == "1.000万")
#expect(TokenFormatter.format(71_400) == "7.140万")
#expect(TokenFormatter.format(6_600_000) == "660.000万")
#expect(TokenFormatter.format(12_345_678) == "1234.6万")
#expect(TokenFormatter.format(196_700_000) == "1.967亿")
#expect(TokenFormatter.format(1_020_000_000) == "10.200亿")
#expect(TokenFormatter.format(100_000_000_000) == "1000.0亿")
}
@Test func parseClaudeUsageLineSkipsConversationContent() throws {
let line = """
{"timestamp":"2026-06-11T02:20:00.000Z","sessionId":"s1","cwd":"/tmp","message":{"model":"claude-3-5-sonnet","content":"private text","usage":{"input_tokens":120,"output_tokens":30,"cache_read_input_tokens":50,"cache_creation_input_tokens":10}}}
"""
let record = try #require(TokenUsageParser.parseClaudeLine(line, sourcePath: "/tmp/a.jsonl"))
#expect(record.provider == .claude)
#expect(record.model == "claude-3-5-sonnet")
#expect(record.inputTokens == 120)
#expect(record.outputTokens == 30)
#expect(record.cachedTokens == 60)
#expect(record.totalTokens == 210)
}
@Test func parseCodexUsesLastTokenUsageOnly() throws {
let line = """
{"timestamp":"2026-06-11T03:10:00.000Z","type":"event_msg","payload":{"info":{"total_token_usage":{"input_tokens":1000,"output_tokens":300,"cached_input_tokens":200,"reasoning_output_tokens":40,"total_tokens":1300},"last_token_usage":{"input_tokens":100,"output_tokens":30,"cached_input_tokens":20,"reasoning_output_tokens":4,"total_tokens":130}}}}
"""
let record = try #require(TokenUsageParser.parseCodexLine(line, sourcePath: "/tmp/rollout.jsonl"))
#expect(record.provider == .codex)
#expect(record.inputTokens == 100)
#expect(record.outputTokens == 30)
#expect(record.cachedTokens == 20)
#expect(record.reasoningTokens == 4)
#expect(record.totalTokens == 154)
}
@Test func parseGeminiTokensLine() throws {
let line = """
{"timestamp":"2026-06-11T04:00:00.000Z","type":"response","model":"gemini-2.5-pro","tokens":{"input":70,"output":20,"cached":5,"thoughts":3,"tool":2,"total":100}}
"""
let record = try #require(TokenUsageParser.parseGeminiLine(line, sourcePath: "/tmp/session.jsonl"))
#expect(record.provider == .gemini)
#expect(record.model == "gemini-2.5-pro")
#expect(record.inputTokens == 70)
#expect(record.outputTokens == 20)
#expect(record.cachedTokens == 5)
#expect(record.reasoningTokens == 3)
#expect(record.toolTokens == 2)
#expect(record.totalTokens == 100)
}
@Test func summaryAggregatesTodayMonthProvidersAndSources() {
let calendar = Calendar(identifier: .gregorian)
let now = ISO8601DateFormatter().date(from: "2026-06-11T04:00:00Z")!
let records = [
usage(.claude, "2026-06-11T01:00:00Z", 100, 20, 10),
usage(.codex, "2026-06-10T01:00:00Z", 80, 20, 0),
usage(.gemini, "2026-05-31T01:00:00Z", 50, 10, 0)
]
let summary = UsageSummary.make(records: records, now: now, calendar: calendar)
#expect(summary.cards.todayTokens == 130)
#expect(summary.cards.monthTokens == 230)
#expect(summary.cards.totalTokens == 290)
#expect(summary.providerTotals[.claude]?.totalTokens == 130)
#expect(summary.providerTotals[.codex]?.totalTokens == 100)
#expect(summary.providerTotals[.gemini]?.totalTokens == 60)
#expect(summary.todayProviderTotals[.claude]?.totalTokens == 130)
#expect(summary.todayProviderTotals[.codex]?.totalTokens == 0)
#expect(summary.todayProviderTotals[.gemini]?.totalTokens == 0)
#expect(summary.toolRows.count == 3)
}
@Test func toolRowsKeepProviderSpecificTrends() {
let calendar = Calendar(identifier: .gregorian)
let now = ISO8601DateFormatter().date(from: "2026-06-11T04:00:00Z")!
let records = [
usage(.claude, "2026-06-09T01:00:00Z", 10, 0, 0),
usage(.claude, "2026-06-11T01:00:00Z", 30, 0, 0),
usage(.codex, "2026-06-10T01:00:00Z", 20, 0, 0),
usage(.gemini, "2026-06-11T02:00:00Z", 5, 0, 0)
]
let summary = UsageSummary.make(records: records, now: now, calendar: calendar)
let trends = Dictionary(uniqueKeysWithValues: summary.toolRows.map { ($0.provider, $0.trendTokens) })
#expect(trends[.claude] == [10, 0, 30])
#expect(trends[.codex] == [0, 20, 0])
#expect(trends[.gemini] == [0, 0, 5])
}
@Test func trendPointsRegroupBySelectedMode() {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
calendar.firstWeekday = 2
let now = ISO8601DateFormatter().date(from: "2026-06-11T12:30:00Z")!
let records = [
usage(.claude, "2026-06-11T01:00:00Z", 10, 0, 0),
usage(.codex, "2026-06-11T10:00:00Z", 20, 0, 0),
usage(.claude, "2026-06-11T01:00:00Z", 30, 0, 0),
usage(.gemini, "2026-05-11T01:00:00Z", 5, 0, 0),
usage(.codex, "2026-04-01T01:00:00Z", 7, 0, 0)
]
let summary = UsageSummary.make(records: records, now: now, calendar: calendar)
let daily = summary.trendPoints(mode: .daily, calendar: calendar)
#expect(daily.count == 13)
#expect(daily[1].values[.claude] == 40)
#expect(daily[10].values[.codex] == 20)
let weekly = summary.trendPoints(mode: .weekly, calendar: calendar)
#expect(weekly.count == 4)
#expect(weekly.last?.values[.claude] == 40)
#expect(weekly.last?.values[.codex] == 20)
let monthly = summary.trendPoints(mode: .monthly, calendar: calendar)
#expect(monthly.count == 11)
#expect(monthly.last?.values[.claude] == 40)
#expect(monthly.last?.values[.codex] == 20)
let yearly = summary.trendPoints(mode: .yearly, calendar: calendar)
#expect(yearly.count == 6)
#expect(yearly[3].values[.codex] == 7)
#expect(yearly[4].values[.gemini] == 5)
#expect(yearly[5].values[.claude] == 40)
}
@Test func dailyUsageDetailsAggregateProviderTotals() {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
let now = ISO8601DateFormatter().date(from: "2026-06-11T12:30:00Z")!
let records = [
usage(.claude, "2026-06-11T01:00:00Z", 10, 2, 1),
usage(.codex, "2026-06-11T10:00:00Z", 20, 3, 0),
usage(.gemini, "2026-06-10T01:00:00Z", 5, 1, 0)
]
let summary = UsageSummary.make(records: records, now: now, calendar: calendar)
let details = summary.dailyUsageDetails(calendar: calendar)
#expect(details.count == 2)
#expect(details[0].totalTokens == 36)
#expect(details[0].inputTokens == 30)
#expect(details[0].outputTokens == 5)
#expect(details[0].cachedTokens == 1)
#expect(details[0].providerTotals[.claude] == 13)
#expect(details[0].providerTotals[.codex] == 23)
#expect(details[1].totalTokens == 6)
#expect(details[1].providerTotals[.gemini] == 6)
}
@Test func scannerIndexesLogsThroughSQLiteCache() async throws {
let root = FileManager.default.temporaryDirectory
.appending(path: "TokenLensTests-\(UUID().uuidString)")
defer { try? FileManager.default.removeItem(at: root) }
let sessions = root.appending(path: ".codex/sessions")
try FileManager.default.createDirectory(at: sessions, withIntermediateDirectories: true)
let file = sessions.appending(path: "rollout.jsonl")
let line = """
{"timestamp":"2026-06-11T03:10:00.000Z","type":"event_msg","payload":{"info":{"last_token_usage":{"input_tokens":100,"output_tokens":30,"cached_input_tokens":20,"reasoning_output_tokens":4}}}}
"""
try line.write(to: file, atomically: true, encoding: .utf8)
let scanner = TokenUsageScanner(
homeDirectory: root,
cacheURL: root.appending(path: "Library/Application Support/TokenLens/test-cache.sqlite")
)
let now = ISO8601DateFormatter().date(from: "2026-06-11T12:30:00Z")!
let first = await scanner.scan(now: now)
let second = await scanner.scan(now: now)
let appendedLine = """
{"timestamp":"2026-06-11T03:12:00.000Z","type":"event_msg","payload":{"info":{"last_token_usage":{"input_tokens":200,"output_tokens":60,"cached_input_tokens":40,"reasoning_output_tokens":8}}}}
"""
let handle = try FileHandle(forWritingTo: file)
try handle.seekToEnd()
try handle.write(contentsOf: Data(("\n" + appendedLine).utf8))
try handle.close()
let third = await scanner.scan(now: now)
#expect(first.cards.totalTokens == 154)
#expect(second.cards.totalTokens == 154)
#expect(second.providerTotals[.codex]?.totalTokens == 154)
#expect(third.cards.totalTokens == 462)
#expect(third.providerTotals[.codex]?.totalTokens == 462)
}
@Test func scannerIndexesHermesAndOpenCodeSQLiteSources() async throws {
let root = FileManager.default.temporaryDirectory
.appending(path: "TokenLensSQLiteSources-\(UUID().uuidString)")
defer { try? FileManager.default.removeItem(at: root) }
let hermes = root.appending(path: ".hermes")
try FileManager.default.createDirectory(at: hermes, withIntermediateDirectories: true)
try makeHermesStateDB(at: hermes.appending(path: "state.db"))
let opencode = root.appending(path: ".local/share/opencode")
try FileManager.default.createDirectory(at: opencode, withIntermediateDirectories: true)
try makeOpenCodeDB(at: opencode.appending(path: "opencode.db"))
let scanner = TokenUsageScanner(
homeDirectory: root,
cacheURL: root.appending(path: "Library/Application Support/TokenLens/test-cache.sqlite")
)
let now = Date(timeIntervalSince1970: 1_780_938_000)
let summary = await scanner.scan(now: now)
#expect(summary.providerTotals[.hermes]?.totalTokens == 23)
#expect(summary.providerTotals[.opencode]?.totalTokens == 41)
#expect(summary.cards.totalTokens == 64)
#expect(summary.toolRows.map(\.provider).contains(.hermes))
#expect(summary.toolRows.map(\.provider).contains(.opencode))
}
private func usage(_ provider: Provider, _ timestamp: String, _ input: Int, _ output: Int, _ cached: Int) -> UsageRecord {
UsageRecord(
provider: provider,
source: "\(provider.rawValue) source",
sourcePath: "/tmp/\(provider.rawValue).jsonl",
timestamp: ISO8601DateFormatter().date(from: timestamp)!,
sessionID: "",
model: provider.rawValue,
inputTokens: input,
outputTokens: output,
cachedTokens: cached,
reasoningTokens: 0,
toolTokens: 0
)
}
private func makeHermesStateDB(at url: URL) throws {
let statements = [
"""
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
model TEXT,
started_at REAL NOT NULL,
input_tokens INTEGER DEFAULT 0,
output_tokens INTEGER DEFAULT 0,
cache_read_tokens INTEGER DEFAULT 0,
cache_write_tokens INTEGER DEFAULT 0,
reasoning_tokens INTEGER DEFAULT 0
)
""",
"""
INSERT INTO sessions (
id, source, model, started_at, input_tokens, output_tokens,
cache_read_tokens, cache_write_tokens, reasoning_tokens
) VALUES ('h1', 'cli', 'gpt-5.5', 1780937894.0, 10, 5, 3, 2, 3)
"""
]
try writeSQLiteDatabase(at: url, statements: statements)
}
private func makeOpenCodeDB(at url: URL) throws {
let statements = [
"""
CREATE TABLE session (
id TEXT PRIMARY KEY,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
model TEXT,
tokens_input INTEGER DEFAULT 0 NOT NULL,
tokens_output INTEGER DEFAULT 0 NOT NULL,
tokens_reasoning INTEGER DEFAULT 0 NOT NULL,
tokens_cache_read INTEGER DEFAULT 0 NOT NULL,
tokens_cache_write INTEGER DEFAULT 0 NOT NULL
)
""",
"""
INSERT INTO session (
id, time_created, time_updated, model, tokens_input, tokens_output,
tokens_reasoning, tokens_cache_read, tokens_cache_write
) VALUES (
'o1', 1780937894000, 1780937895000,
'{"id":"MiniMax-M3","providerID":"minimax-cn"}',
11, 7, 5, 13, 5
)
"""
]
try writeSQLiteDatabase(at: url, statements: statements)
}
private func writeSQLiteDatabase(at url: URL, statements: [String]) throws {
var database: OpaquePointer?
guard sqlite3_open_v2(url.path, &database, SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE, nil) == SQLITE_OK else {
sqlite3_close(database)
struct OpenError: Error {}
throw OpenError()
}
defer { sqlite3_close(database) }
for statement in statements {
guard sqlite3_exec(database, statement, nil, nil, nil) == SQLITE_OK else {
struct ExecuteError: Error {}
throw ExecuteError()
}
}
}

View File

@@ -0,0 +1,175 @@
import AppKit
import Foundation
struct IconRenderer {
let size: CGFloat
func render(to url: URL) throws {
guard
let bitmap = NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: Int(size),
pixelsHigh: Int(size),
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: .deviceRGB,
bytesPerRow: 0,
bitsPerPixel: 0
),
let graphics = NSGraphicsContext(bitmapImageRep: bitmap)
else { return }
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = graphics
defer { NSGraphicsContext.restoreGraphicsState() }
let context = graphics.cgContext
context.setShouldAntialias(true)
context.setAllowsAntialiasing(true)
let rect = CGRect(x: 0, y: 0, width: size, height: size)
let scale = size / 1024
let radius = 226 * scale
let base = CGPath(
roundedRect: rect.insetBy(dx: 44 * scale, dy: 44 * scale),
cornerWidth: radius,
cornerHeight: radius,
transform: nil
)
context.addPath(base)
context.clip()
let background = CGGradient(
colorsSpace: CGColorSpaceCreateDeviceRGB(),
colors: [
NSColor(calibratedRed: 0.04, green: 0.12, blue: 0.24, alpha: 1).cgColor,
NSColor(calibratedRed: 0.12, green: 0.20, blue: 0.42, alpha: 1).cgColor,
NSColor(calibratedRed: 0.58, green: 0.16, blue: 0.88, alpha: 1).cgColor
] as CFArray,
locations: [0, 0.52, 1]
)!
context.drawLinearGradient(
background,
start: CGPoint(x: rect.minX, y: rect.maxY),
end: CGPoint(x: rect.maxX, y: rect.minY),
options: []
)
drawGlow(context, color: NSColor.systemBlue, center: CGPoint(x: 260 * scale, y: 760 * scale), radius: 330 * scale)
drawGlow(context, color: NSColor.systemPurple, center: CGPoint(x: 785 * scale, y: 285 * scale), radius: 430 * scale)
drawGlassHighlight(context, rect: rect, scale: scale)
drawBars(context, scale: scale)
drawLens(context, scale: scale)
context.resetClip()
context.addPath(base)
context.setStrokeColor(NSColor.white.withAlphaComponent(0.22).cgColor)
context.setLineWidth(12 * scale)
context.strokePath()
guard let png = bitmap.representation(using: .png, properties: [:]) else { return }
try png.write(to: url)
}
private func drawGlow(_ context: CGContext, color: NSColor, center: CGPoint, radius: CGFloat) {
let gradient = CGGradient(
colorsSpace: CGColorSpaceCreateDeviceRGB(),
colors: [
color.withAlphaComponent(0.55).cgColor,
color.withAlphaComponent(0.0).cgColor
] as CFArray,
locations: [0, 1]
)!
context.drawRadialGradient(
gradient,
startCenter: center,
startRadius: 0,
endCenter: center,
endRadius: radius,
options: []
)
}
private func drawGlassHighlight(_ context: CGContext, rect: CGRect, scale: CGFloat) {
let highlight = CGGradient(
colorsSpace: CGColorSpaceCreateDeviceRGB(),
colors: [
NSColor.white.withAlphaComponent(0.32).cgColor,
NSColor.white.withAlphaComponent(0.06).cgColor,
NSColor.white.withAlphaComponent(0.0).cgColor
] as CFArray,
locations: [0, 0.36, 1]
)!
let path = CGPath(
roundedRect: CGRect(x: 120 * scale, y: 610 * scale, width: 790 * scale, height: 268 * scale),
cornerWidth: 130 * scale,
cornerHeight: 130 * scale,
transform: nil
)
context.saveGState()
context.addPath(path)
context.clip()
context.drawLinearGradient(
highlight,
start: CGPoint(x: rect.minX, y: rect.maxY),
end: CGPoint(x: rect.maxX, y: rect.minY),
options: []
)
context.restoreGState()
}
private func drawBars(_ context: CGContext, scale: CGFloat) {
let bars = [
CGRect(x: 288, y: 286, width: 86, height: 300),
CGRect(x: 430, y: 212, width: 86, height: 448),
CGRect(x: 572, y: 330, width: 86, height: 252)
].map { CGRect(x: $0.minX * scale, y: $0.minY * scale, width: $0.width * scale, height: $0.height * scale) }
for (index, bar) in bars.enumerated() {
let color: NSColor = index == 1 ? .white : NSColor(calibratedWhite: 1, alpha: 0.88)
let path = CGPath(
roundedRect: bar,
cornerWidth: 44 * scale,
cornerHeight: 44 * scale,
transform: nil
)
context.addPath(path)
context.setFillColor(color.cgColor)
context.fillPath()
}
}
private func drawLens(_ context: CGContext, scale: CGFloat) {
let ring = CGRect(x: 682 * scale, y: 228 * scale, width: 170 * scale, height: 170 * scale)
context.setStrokeColor(NSColor.white.withAlphaComponent(0.92).cgColor)
context.setLineWidth(34 * scale)
context.strokeEllipse(in: ring)
context.setLineCap(.round)
context.move(to: CGPoint(x: 805 * scale, y: 275 * scale))
context.addLine(to: CGPoint(x: 892 * scale, y: 188 * scale))
context.strokePath()
}
}
let outputRoot = URL(fileURLWithPath: CommandLine.arguments.dropFirst().first ?? "NativeTokenLens/Assets/AppIcon.iconset")
try FileManager.default.createDirectory(at: outputRoot, withIntermediateDirectories: true)
let specs: [(String, CGFloat)] = [
("icon_16x16.png", 16),
("icon_16x16@2x.png", 32),
("icon_32x32.png", 32),
("icon_32x32@2x.png", 64),
("icon_128x128.png", 128),
("icon_128x128@2x.png", 256),
("icon_256x256.png", 256),
("icon_256x256@2x.png", 512),
("icon_512x512.png", 512),
("icon_512x512@2x.png", 1024)
]
for spec in specs {
try IconRenderer(size: spec.1).render(to: outputRoot.appendingPathComponent(spec.0))
}

View File

@@ -0,0 +1,922 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>TokenLens Dashboard Concept</title>
<style>
:root {
--bg: #08111d;
--panel: rgba(19, 31, 47, 0.88);
--panel-2: rgba(16, 26, 40, 0.94);
--line: rgba(151, 174, 205, 0.18);
--line-strong: rgba(163, 188, 224, 0.28);
--text: #f2f6ff;
--muted: #95a3b8;
--blue: #2e83ff;
--cyan: #2dd4cf;
--violet: #9b5cf6;
--green: #2ed06e;
--orange: #ff9a31;
--red: #ff6363;
--shadow: 0 24px 80px rgba(0, 0, 0, 0.38);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
width: 1440px;
height: 900px;
overflow: hidden;
color: var(--text);
background:
radial-gradient(circle at 28% 14%, rgba(46, 131, 255, 0.18), transparent 28%),
radial-gradient(circle at 72% 22%, rgba(45, 212, 207, 0.12), transparent 26%),
linear-gradient(135deg, #070d17 0%, #0a1422 48%, #07111d 100%);
}
.app {
width: 1440px;
height: 900px;
display: grid;
grid-template-columns: 220px 1fr;
border: 1px solid rgba(162, 186, 220, 0.22);
border-radius: 18px;
overflow: hidden;
box-shadow: var(--shadow);
background: rgba(8, 17, 29, 0.9);
}
aside {
position: relative;
padding: 22px 0;
border-right: 1px solid var(--line);
background:
linear-gradient(180deg, rgba(14, 28, 48, 0.98), rgba(8, 17, 29, 0.98)),
radial-gradient(circle at 0 0, rgba(46, 131, 255, 0.18), transparent 38%);
}
.traffic {
display: flex;
gap: 12px;
padding: 12px 24px 30px;
}
.dot {
width: 16px;
height: 16px;
border-radius: 50%;
}
.red { background: #ff5f57; }
.yellow { background: #ffbd2e; }
.green { background: #28c840; }
.nav {
display: grid;
gap: 8px;
padding: 0 0 0 6px;
}
.nav-item {
height: 54px;
display: flex;
align-items: center;
gap: 14px;
padding: 0 22px;
color: #c7d1df;
font-size: 16px;
font-weight: 560;
letter-spacing: 0;
border-left: 3px solid transparent;
}
.nav-item svg {
width: 23px;
height: 23px;
stroke: currentColor;
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0.92;
}
.nav-item.active {
color: #fff;
border-left-color: var(--blue);
background: linear-gradient(90deg, rgba(46, 131, 255, 0.56), rgba(46, 131, 255, 0.2));
border-radius: 8px 8px 8px 0;
box-shadow: inset 0 0 0 1px rgba(81, 143, 255, 0.2);
}
.side-foot {
position: absolute;
left: 24px;
right: 22px;
bottom: 42px;
display: grid;
gap: 14px;
color: var(--muted);
font-size: 13px;
}
.health,
.updated {
display: flex;
align-items: center;
gap: 10px;
white-space: nowrap;
}
.pulse {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--green);
box-shadow: 0 0 0 5px rgba(46, 208, 110, 0.1);
}
main {
min-width: 0;
display: grid;
grid-template-rows: 74px 1fr;
background: linear-gradient(180deg, rgba(9, 18, 31, 0.62), rgba(8, 17, 29, 0.84));
}
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 28px 0 32px;
border-bottom: 1px solid var(--line);
background: rgba(9, 17, 29, 0.68);
}
.brand {
display: flex;
align-items: center;
gap: 13px;
font-size: 20px;
font-weight: 700;
}
.brand-mark {
width: 24px;
height: 24px;
display: grid;
place-items: center;
color: #f9fbff;
}
.toolbar {
display: flex;
align-items: center;
gap: 14px;
}
.control,
.search,
.avatar {
height: 42px;
border: 1px solid var(--line-strong);
background: rgba(13, 24, 38, 0.8);
color: #e9eef8;
border-radius: 8px;
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
padding: 0 14px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.control.primary {
background: rgba(33, 49, 70, 0.92);
}
.search {
width: 238px;
justify-content: flex-start;
color: #8f9bb0;
}
.kbd {
margin-left: auto;
padding: 2px 6px;
border: 1px solid rgba(167, 185, 215, 0.18);
border-radius: 5px;
font-size: 12px;
color: #aeb9ca;
background: rgba(255, 255, 255, 0.04);
}
.avatar {
width: 78px;
padding: 0 10px;
justify-content: space-between;
border-color: transparent;
background: transparent;
}
.avatar .circle {
width: 40px;
height: 40px;
border-radius: 50%;
display: grid;
place-items: center;
font-weight: 800;
background: linear-gradient(135deg, #7c4dff, #bc75ff);
box-shadow: 0 8px 24px rgba(124, 77, 255, 0.32);
}
.content {
padding: 24px 20px 24px 18px;
display: grid;
grid-template-rows: 145px 325px 1fr;
gap: 16px;
}
.cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
}
.card,
.panel {
border: 1px solid var(--line-strong);
border-radius: 11px;
background:
linear-gradient(145deg, rgba(24, 38, 57, 0.88), rgba(13, 24, 38, 0.88)),
radial-gradient(circle at top left, rgba(255, 255, 255, 0.08), transparent 35%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.card {
display: grid;
grid-template-columns: 74px 1fr;
align-items: center;
padding: 0 22px;
min-width: 0;
}
.metric-icon {
width: 58px;
height: 58px;
border-radius: 50%;
display: grid;
place-items: center;
color: white;
box-shadow: inset 0 0 28px rgba(255, 255, 255, 0.08);
}
.metric-icon.blue { background: radial-gradient(circle at 35% 24%, #408dff, #123c88); }
.metric-icon.green { background: radial-gradient(circle at 35% 24%, #37d48b, #126545); }
.metric-icon.violet { background: radial-gradient(circle at 35% 24%, #a970ff, #42217b); }
.metric-icon.orange { background: radial-gradient(circle at 35% 24%, #e28b35, #6e3a0d); }
.metric-label {
display: flex;
gap: 6px;
align-items: center;
color: #c3ccda;
font-size: 14px;
margin-bottom: 6px;
}
.metric-value {
font-size: 35px;
line-height: 1;
font-weight: 780;
letter-spacing: 0;
margin-bottom: 14px;
}
.delta {
font-size: 14px;
color: #aab4c5;
}
.delta .up-blue { color: #3d91ff; font-weight: 720; }
.delta .up-green { color: var(--green); font-weight: 720; }
.delta .up-violet { color: #b36cff; font-weight: 720; }
.delta .up-orange { color: var(--orange); font-weight: 720; }
.middle {
display: grid;
grid-template-columns: 1.35fr 1fr;
gap: 12px;
}
.panel {
padding: 18px 22px;
min-width: 0;
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.panel-title {
font-size: 17px;
font-weight: 760;
}
.select {
height: 32px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 11px;
border: 1px solid var(--line);
border-radius: 7px;
color: #dce4ef;
font-size: 13px;
background: rgba(8, 17, 29, 0.35);
}
.trend {
height: 220px;
position: relative;
}
.trend svg {
height: 220px;
}
.legend {
display: flex;
align-items: center;
justify-content: center;
gap: 38px;
margin-top: 0;
color: #cdd6e4;
font-size: 14px;
}
.legend span,
.share-row span {
display: inline-flex;
align-items: center;
gap: 8px;
}
.key {
width: 11px;
height: 11px;
border-radius: 50%;
display: inline-block;
}
.key.blue { background: var(--blue); box-shadow: 0 0 10px rgba(46, 131, 255, 0.7); }
.key.violet { background: var(--violet); box-shadow: 0 0 10px rgba(155, 92, 246, 0.7); }
.key.cyan { background: var(--cyan); box-shadow: 0 0 10px rgba(45, 212, 207, 0.7); }
.share-body {
display: grid;
grid-template-columns: 210px 1fr;
align-items: center;
gap: 18px;
height: 225px;
}
.donut {
width: 205px;
height: 205px;
border-radius: 50%;
display: grid;
place-items: center;
background:
conic-gradient(var(--blue) 0 151deg, var(--violet) 151deg 252deg, var(--cyan) 252deg 360deg);
box-shadow: 0 0 34px rgba(46, 131, 255, 0.18);
position: relative;
}
.donut::before {
content: "";
position: absolute;
width: 108px;
height: 108px;
border-radius: 50%;
background: linear-gradient(145deg, #101f31, #0b1726);
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.12);
}
.donut-text {
position: relative;
text-align: center;
z-index: 1;
}
.donut-text strong {
display: block;
font-size: 25px;
margin-bottom: 4px;
}
.donut-text small {
color: #a8b4c5;
font-size: 14px;
}
.share-list {
display: grid;
gap: 22px;
padding-right: 14px;
}
.share-row {
display: grid;
grid-template-columns: 1fr 58px;
align-items: center;
gap: 10px;
font-size: 15px;
}
.share-row strong {
font-size: 19px;
text-align: right;
}
.share-row small {
grid-column: 1 / -1;
padding-left: 21px;
color: #9ca8ba;
margin-top: -14px;
font-size: 14px;
}
.bottom {
display: grid;
grid-template-columns: 1.55fr 0.72fr;
gap: 12px;
min-height: 0;
}
.table-panel {
padding: 0;
overflow: hidden;
}
.table-head {
height: 52px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 22px;
border-bottom: 1px solid var(--line);
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 14px;
}
th {
height: 37px;
color: #9aa7b9;
font-weight: 600;
text-align: left;
border-bottom: 1px solid var(--line);
}
th:first-child,
td:first-child { padding-left: 22px; }
td {
height: 42px;
color: #d4dce8;
border-bottom: 1px solid rgba(151, 174, 205, 0.1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.provider {
display: flex;
align-items: center;
gap: 9px;
font-weight: 650;
color: #eef4ff;
min-width: 0;
}
.logo {
width: 28px;
height: 28px;
border-radius: 7px;
display: grid;
place-items: center;
font-size: 13px;
font-weight: 850;
}
.claude-logo { background: linear-gradient(135deg, #fb8947, #df5e2f); }
.codex-logo { background: #05070b; border: 1px solid rgba(255,255,255,0.14); }
.gemini-logo { background: conic-gradient(from 45deg, #3debd4, #395fff, #ffb34d, #3debd4); }
.spark {
width: 74px;
height: 22px;
}
.more {
color: #7e8aa0;
font-size: 20px;
text-align: center;
}
.table-foot {
height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 22px;
color: #9ba7b8;
font-size: 14px;
}
.link {
color: #4b99ff;
font-weight: 650;
}
.alerts {
padding: 0;
overflow: hidden;
}
.alerts .table-head {
border-bottom: 0;
}
.alert-list {
display: grid;
gap: 7px;
padding: 0 10px 10px;
}
.alert {
min-height: 66px;
border: 1px solid rgba(151, 174, 205, 0.15);
border-radius: 8px;
padding: 10px 12px 10px 54px;
position: relative;
display: grid;
gap: 4px;
}
.alert.warn { background: linear-gradient(90deg, rgba(255, 154, 49, 0.13), rgba(255, 154, 49, 0.04)); border-color: rgba(255,154,49,0.24); }
.alert.good { background: linear-gradient(90deg, rgba(46, 208, 110, 0.13), rgba(46, 208, 110, 0.04)); border-color: rgba(46,208,110,0.2); }
.alert.spike { background: linear-gradient(90deg, rgba(155, 92, 246, 0.13), rgba(155, 92, 246, 0.04)); border-color: rgba(155,92,246,0.22); }
.alert-icon {
position: absolute;
left: 14px;
top: 12px;
width: 31px;
height: 31px;
border-radius: 8px;
display: grid;
place-items: center;
font-weight: 900;
font-size: 18px;
}
.warn .alert-icon { color: var(--orange); background: rgba(255, 154, 49, 0.16); }
.good .alert-icon { color: var(--green); background: rgba(46, 208, 110, 0.14); }
.spike .alert-icon { color: var(--violet); background: rgba(155, 92, 246, 0.14); }
.alert-title {
font-size: 14px;
line-height: 1.35;
font-weight: 740;
padding-right: 42px;
}
.alert-copy {
font-size: 13px;
color: #aab5c6;
line-height: 1.35;
}
.time {
position: absolute;
top: 14px;
right: 12px;
font-size: 12px;
color: #94a0b3;
}
svg.icon {
width: 18px;
height: 18px;
stroke: currentColor;
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.grid-line { stroke: rgba(151, 174, 205, 0.14); stroke-width: 1; }
.axis-text { fill: #93a0b3; font-size: 13px; }
.line-blue { fill: none; stroke: var(--blue); stroke-width: 3; }
.line-violet { fill: none; stroke: var(--violet); stroke-width: 3; }
.line-cyan { fill: none; stroke: var(--cyan); stroke-width: 3; }
.point-blue { fill: var(--blue); stroke: rgba(255,255,255,0.28); }
.point-violet { fill: var(--violet); stroke: rgba(255,255,255,0.28); }
.point-cyan { fill: var(--cyan); stroke: rgba(255,255,255,0.28); }
</style>
</head>
<body>
<div class="app">
<aside>
<div class="traffic">
<span class="dot red"></span>
<span class="dot yellow"></span>
<span class="dot green"></span>
</div>
<nav class="nav" aria-label="主导航">
<div class="nav-item active">
<svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
Dashboard
</div>
<div class="nav-item">
<svg viewBox="0 0 24 24"><path d="M4 19V9"/><path d="M10 19V5"/><path d="M16 19v-7"/><path d="M22 19H2"/></svg>
Usage
</div>
<div class="nav-item">
<svg viewBox="0 0 24 24"><path d="M12 3l8 4v10l-8 4-8-4V7z"/><path d="M12 8v8"/><path d="M9.5 10.5c0-1.2 1-2 2.5-2s2.5.8 2.5 2c0 2.8-5 1.4-5 4 0 1.2 1 2 2.5 2s2.5-.8 2.5-2"/></svg>
Cost View
</div>
<div class="nav-item">
<svg viewBox="0 0 24 24"><path d="M12 3a4 4 0 014 4v1a4 4 0 01-8 0V7a4 4 0 014-4z"/><path d="M4 15a8 8 0 0016 0"/><path d="M12 19v2"/></svg>
Sources
</div>
<div class="nav-item">
<svg viewBox="0 0 24 24"><path d="M3 12a9 9 0 109-9"/><path d="M3 5v7h7"/></svg>
History
</div>
<div class="nav-item">
<svg viewBox="0 0 24 24"><path d="M12 15.5a3.5 3.5 0 100-7 3.5 3.5 0 000 7z"/><path d="M19.4 15a1.8 1.8 0 00.36 1.98l.04.04a2 2 0 01-2.83 2.83l-.04-.04A1.8 1.8 0 0015 19.4a1.8 1.8 0 00-1 .6 1.8 1.8 0 00-.4 1.1V21a2 2 0 01-4 0v-.09A1.8 1.8 0 008 19.4a1.8 1.8 0 00-1.98.36l-.04.04a2 2 0 01-2.83-2.83l.04-.04A1.8 1.8 0 003.6 15a1.8 1.8 0 00-.6-1 1.8 1.8 0 00-1.1-.4H1.8a2 2 0 010-4h.09A1.8 1.8 0 003.6 8a1.8 1.8 0 00-.36-1.98l-.04-.04a2 2 0 012.83-2.83l.04.04A1.8 1.8 0 008 3.6a1.8 1.8 0 001-.6A1.8 1.8 0 009.4 1.9V1.8a2 2 0 014 0v.09A1.8 1.8 0 0015 3.6a1.8 1.8 0 001.98-.36l.04-.04a2 2 0 012.83 2.83l-.04.04A1.8 1.8 0 0019.4 8c.22.38.58.74 1 .9.34.13.7.2 1.1.2h.1a2 2 0 010 4h-.1a1.8 1.8 0 00-2.1 1.9z"/></svg>
Settings
</div>
</nav>
<div class="side-foot">
<div class="health"><span class="pulse"></span>Local scan healthy</div>
<div class="updated">
<svg class="icon" viewBox="0 0 24 24"><path d="M21 12a9 9 0 11-2.64-6.36"/><path d="M21 3v7h-7"/></svg>
Updated just now
</div>
</div>
</aside>
<main>
<header>
<div class="brand">
<div class="brand-mark">
<svg class="icon" viewBox="0 0 24 24"><path d="M4 19V5"/><path d="M9 19V9"/><path d="M14 19V3"/><path d="M19 19v-6"/><path d="M3 19h18"/></svg>
</div>
TokenLens
</div>
<div class="toolbar">
<div class="control">
<svg class="icon" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>
Last 30 Days
<svg class="icon" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg>
</div>
<div class="control primary">
<svg class="icon" viewBox="0 0 24 24"><path d="M21 12a9 9 0 11-2.64-6.36"/><path d="M21 3v7h-7"/></svg>
Sync
</div>
<div class="search">
<svg class="icon" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
Search...
<span class="kbd">⌘K</span>
</div>
<div class="avatar">
<div class="circle">JD</div>
<svg class="icon" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg>
</div>
</div>
</header>
<section class="content">
<div class="cards">
<div class="card">
<div class="metric-icon blue">
<svg class="icon" viewBox="0 0 24 24"><path d="M12 3l8 4-8 4-8-4z"/><path d="M4 12l8 4 8-4"/><path d="M4 17l8 4 8-4"/></svg>
</div>
<div>
<div class="metric-label">今日 Tokens ⓘ</div>
<div class="metric-value">4.8M</div>
<div class="delta"><span class="up-blue">↑ 12.8%</span> vs. yesterday</div>
</div>
</div>
<div class="card">
<div class="metric-icon green">
<svg class="icon" viewBox="0 0 24 24"><path d="M12 2v20"/><path d="M17 5H9.5a3.5 3.5 0 000 7H14a3.5 3.5 0 010 7H6"/></svg>
</div>
<div>
<div class="metric-label">本月 Tokens ⓘ</div>
<div class="metric-value">128.4M</div>
<div class="delta"><span class="up-green">↑ 9.7%</span> vs. last month</div>
</div>
</div>
<div class="card">
<div class="metric-icon violet">
<svg class="icon" viewBox="0 0 24 24"><path d="M20 6L9 17l-5-5"/></svg>
</div>
<div>
<div class="metric-label">历史总量 ⓘ</div>
<div class="metric-value">1.02B</div>
<div class="delta"><span class="up-violet">3,842</span> sessions scanned</div>
</div>
</div>
<div class="card">
<div class="metric-icon orange">
<svg class="icon" viewBox="0 0 24 24"><path d="M4 19l6-6 4 4 6-9"/><path d="M15 8h5v5"/></svg>
</div>
<div>
<div class="metric-label">缓存命中 ⓘ</div>
<div class="metric-value">42%</div>
<div class="delta"><span class="up-orange">↑ 8.6%</span> saved context load</div>
</div>
</div>
</div>
<div class="middle">
<div class="panel">
<div class="panel-head">
<div class="panel-title">Token Usage Trend</div>
<div class="select">Daily <svg class="icon" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg></div>
</div>
<div class="trend">
<svg width="100%" height="250" viewBox="0 0 640 250" preserveAspectRatio="none">
<line class="grid-line" x1="58" y1="15" x2="622" y2="15"/>
<line class="grid-line" x1="58" y1="66" x2="622" y2="66"/>
<line class="grid-line" x1="58" y1="117" x2="622" y2="117"/>
<line class="grid-line" x1="58" y1="168" x2="622" y2="168"/>
<line class="grid-line" x1="58" y1="219" x2="622" y2="219"/>
<text class="axis-text" x="10" y="20">10M</text>
<text class="axis-text" x="18" y="71">8M</text>
<text class="axis-text" x="18" y="122">6M</text>
<text class="axis-text" x="18" y="173">4M</text>
<text class="axis-text" x="26" y="224">0</text>
<text class="axis-text" x="58" y="243">May 12</text>
<text class="axis-text" x="178" y="243">May 18</text>
<text class="axis-text" x="305" y="243">May 24</text>
<text class="axis-text" x="432" y="243">May 30</text>
<text class="axis-text" x="555" y="243">Jun 06</text>
<polyline class="line-blue" points="64,100 84,82 104,94 124,76 144,65 164,98 184,86 204,68 224,78 244,100 264,78 284,57 304,45 324,62 344,86 364,104 384,70 404,50 424,78 444,88 464,54 484,38 504,50 524,46 544,34 564,80 584,60 604,70 622,52"/>
<polyline class="line-violet" points="64,165 84,150 104,164 124,152 144,142 164,158 184,150 204,139 224,157 244,173 264,152 284,132 304,149 324,153 344,137 364,144 384,162 404,154 424,138 444,126 464,142 484,146 504,134 524,124 544,113 564,130 584,142 604,128 622,143"/>
<polyline class="line-cyan" points="64,190 84,183 104,196 124,188 144,174 164,190 184,182 204,196 224,190 244,201 264,192 284,168 304,190 324,181 344,192 364,186 384,164 404,183 424,195 444,180 464,168 484,184 504,190 524,182 544,173 564,184 584,178 604,192 622,169"/>
<g>
<circle class="point-blue" cx="64" cy="100" r="4"/><circle class="point-blue" cx="144" cy="65" r="4"/><circle class="point-blue" cx="304" cy="45" r="4"/><circle class="point-blue" cx="484" cy="38" r="4"/><circle class="point-blue" cx="622" cy="52" r="4"/>
<circle class="point-violet" cx="64" cy="165" r="4"/><circle class="point-violet" cx="284" cy="132" r="4"/><circle class="point-violet" cx="444" cy="126" r="4"/><circle class="point-violet" cx="544" cy="113" r="4"/><circle class="point-violet" cx="622" cy="143" r="4"/>
<circle class="point-cyan" cx="64" cy="190" r="4"/><circle class="point-cyan" cx="284" cy="168" r="4"/><circle class="point-cyan" cx="384" cy="164" r="4"/><circle class="point-cyan" cx="484" cy="184" r="4"/><circle class="point-cyan" cx="622" cy="169" r="4"/>
</g>
</svg>
</div>
<div class="legend">
<span><i class="key blue"></i>Claude</span>
<span><i class="key violet"></i>Codex</span>
<span><i class="key cyan"></i>Gemini</span>
</div>
</div>
<div class="panel">
<div class="panel-head">
<div class="panel-title">Usage Share</div>
<div class="select">By Tokens <svg class="icon" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg></div>
</div>
<div class="share-body">
<div class="donut">
<div class="donut-text">
<strong>128.4M</strong>
<small>This Month</small>
</div>
</div>
<div class="share-list">
<div class="share-row">
<span><i class="key blue"></i>Claude</span>
<strong>42%</strong>
<small>53.9M tokens</small>
</div>
<div class="share-row">
<span><i class="key violet"></i>Codex</span>
<strong>31%</strong>
<small>39.8M tokens</small>
</div>
<div class="share-row">
<span><i class="key cyan"></i>Gemini</span>
<strong>27%</strong>
<small>34.7M tokens</small>
</div>
</div>
</div>
</div>
</div>
<div class="bottom">
<div class="panel table-panel">
<div class="table-head">
<div class="panel-title">Provider Breakdown</div>
<div class="select">All sources <svg class="icon" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg></div>
</div>
<table>
<thead>
<tr>
<th style="width: 105px;">Provider</th>
<th style="width: 165px;">Source</th>
<th>Input</th>
<th>Output</th>
<th>Cached</th>
<th>Total</th>
<th style="width: 88px;">Trend</th>
<th style="width: 38px;"></th>
</tr>
</thead>
<tbody>
<tr>
<td><div class="provider"><span class="logo claude-logo">C</span>Claude</div></td>
<td>projects/*.jsonl</td>
<td>28.7M</td><td>18.2M</td><td>12.2M</td><td>59.1M</td>
<td><svg class="spark" viewBox="0 0 74 22"><polyline class="line-blue" points="1,17 8,12 15,16 22,8 29,12 36,6 43,10 50,4 57,8 64,5 72,9"/></svg></td>
<td class="more"></td>
</tr>
<tr>
<td><div class="provider"><span class="logo codex-logo"></span>Codex</div></td>
<td>sessions/*.jsonl</td>
<td>18.9M</td><td>13.4M</td><td>3.6M</td><td>35.9M</td>
<td><svg class="spark" viewBox="0 0 74 22"><polyline class="line-violet" points="1,18 8,11 15,15 22,17 29,10 36,13 43,7 50,12 57,6 64,8 72,4"/></svg></td>
<td class="more"></td>
</tr>
<tr>
<td><div class="provider"><span class="logo gemini-logo"></span>Gemini</div></td>
<td>tmp/chats/*.jsonl</td>
<td>17.2M</td><td>10.6M</td><td>4.8M</td><td>33.4M</td>
<td><svg class="spark" viewBox="0 0 74 22"><polyline class="line-cyan" points="1,16 8,16 15,10 22,18 29,14 36,9 43,12 50,6 57,5 64,8 72,8"/></svg></td>
<td class="more"></td>
</tr>
<tr>
<td><div class="provider"><span class="logo claude-logo">H</span>Claude</div></td>
<td>HUD cache</td>
<td>8.8M</td><td>5.4M</td><td>9.1M</td><td>23.3M</td>
<td><svg class="spark" viewBox="0 0 74 22"><polyline class="line-blue" points="1,13 8,14 15,10 22,12 29,8 36,13 43,9 50,6 57,10 64,7 72,5"/></svg></td>
<td class="more"></td>
</tr>
</tbody>
</table>
<div class="table-foot">
<span>Showing 4 local sources · content is never uploaded</span>
<span class="link">Open raw report →</span>
</div>
</div>
<div class="panel alerts">
<div class="table-head">
<div class="panel-title">Insights</div>
<span class="link">View all</span>
</div>
<div class="alert-list">
<div class="alert warn">
<div class="alert-icon">!</div>
<div class="time">1h</div>
<div class="alert-title">Claude usage exceeded daily average</div>
<div class="alert-copy">Today is 80% above the 7-day baseline.</div>
</div>
<div class="alert good">
<div class="alert-icon"></div>
<div class="time">2h</div>
<div class="alert-title">Gemini source parsed cleanly</div>
<div class="alert-copy">2 chat files scanned, no malformed rows.</div>
</div>
<div class="alert spike">
<div class="alert-icon"></div>
<div class="time">3h</div>
<div class="alert-title">Codex reasoning tokens spiked today</div>
<div class="alert-copy">Reasoning share is up 32% vs. yesterday.</div>
</div>
</div>
</div>
</div>
</section>
</main>
</div>
</body>
</html>

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%2308111d'/%3E%3Cpath d='M8 24V10M14 24V5M20 24v-9M26 24H6' stroke='%232e83ff' stroke-width='3' stroke-linecap='round'/%3E%3C/svg%3E" />
<title>TokenLens</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/renderer/App.jsx"></script>
</body>
</html>

5612
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

60
package.json Normal file
View File

@@ -0,0 +1,60 @@
{
"name": "tokenlens",
"version": "0.1.0",
"description": "Local Claude, Codex, and Gemini token usage dashboard for macOS.",
"main": "src/main/main.cjs",
"type": "module",
"private": true,
"scripts": {
"test": "node --test tests/*.test.cjs tests/*.test.mjs",
"dev": "vite --host 127.0.0.1",
"electron:dev": "TOKENLENS_DEV_SERVER=http://127.0.0.1:5173 electron .",
"build": "vite build",
"dist": "npm run test && npm run build && electron-builder --mac dmg"
},
"dependencies": {
"@vitejs/plugin-react": "^5.1.1",
"vite": "^7.2.7",
"typescript": "^5.9.3",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"lucide-react": "^0.561.0"
},
"devDependencies": {
"electron": "^39.2.7",
"electron-builder": "^26.0.12"
},
"build": {
"appId": "com.caoxiaozhu.tokenlens",
"productName": "TokenLens",
"directories": {
"output": "release"
},
"files": [
"dist/**",
"src/main/**",
"src/lib/**",
"package.json"
],
"mac": {
"category": "public.app-category.developer-tools",
"target": [
"dmg"
]
},
"dmg": {
"contents": [
{
"x": 130,
"y": 220
},
{
"x": 410,
"y": 220,
"type": "link",
"path": "/Applications"
}
]
}
}
}

383
src/lib/tokenUsage.cjs Normal file
View File

@@ -0,0 +1,383 @@
const fs = require("node:fs/promises");
const path = require("node:path");
const os = require("node:os");
const PROVIDERS = ["Claude", "Codex", "Gemini"];
async function scanUsage(options = {}) {
const homeDir = options.homeDir || os.homedir();
const timeZone = options.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone || "Asia/Shanghai";
const now = options.now || new Date();
const records = [];
const diagnostics = [];
await collectProvider({
root: path.join(homeDir, ".claude", "projects"),
matcher: (file) => file.endsWith(".jsonl"),
parseLine: parseClaudeLine,
records,
diagnostics
});
await collectProvider({
root: path.join(homeDir, ".codex", "sessions"),
matcher: (file) => file.endsWith(".jsonl"),
parseLine: parseCodexLine,
records,
diagnostics
});
await collectProvider({
root: path.join(homeDir, ".gemini"),
matcher: (file) => file.endsWith(".jsonl") && file.includes(`${path.sep}chats${path.sep}`),
parseLine: parseGeminiLine,
records,
diagnostics
});
const summary = summarizeRecords(records, { now, timeZone });
return {
...summary,
diagnostics
};
}
async function collectProvider({ root, matcher, parseLine, records, diagnostics }) {
const files = await listFiles(root).catch((error) => {
diagnostics.push({
level: "warn",
source: root,
message: error.code === "ENOENT" ? "目录不存在" : error.message
});
return [];
});
for (const file of files) {
if (!matcher(file)) continue;
const text = await fs.readFile(file, "utf8").catch((error) => {
diagnostics.push({ level: "warn", source: file, message: error.message });
return "";
});
for (const line of text.split(/\r?\n/)) {
if (!line.trim()) continue;
const record = parseLine(line, file);
if (record) records.push(record);
}
}
}
async function listFiles(root) {
const out = [];
async function walk(dir) {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await walk(fullPath);
} else if (entry.isFile()) {
out.push(fullPath);
}
}
}
await walk(root);
return out;
}
function parseClaudeLine(line, sourceFile) {
const obj = parseJson(line);
const usage = obj?.message?.usage || obj?.usage;
if (!usage) return null;
const inputTokens = intValue(usage.input_tokens);
const outputTokens = intValue(usage.output_tokens);
const cachedTokens =
intValue(usage.cache_read_input_tokens) +
intValue(usage.cache_creation_input_tokens);
return normalizeRecord({
provider: "Claude",
source: sourceLabel(sourceFile, "Claude"),
sourceFile,
timestamp: obj.timestamp || obj.createdAt || obj.date,
sessionId: obj.sessionId,
cwd: obj.cwd,
model: obj.message?.model || obj.model || "Claude",
inputTokens,
outputTokens,
cachedTokens,
reasoningTokens: 0,
toolTokens: 0
});
}
function parseCodexLine(line, sourceFile) {
const obj = parseJson(line);
const usage = obj?.payload?.info?.last_token_usage;
if (!usage) return null;
return normalizeRecord({
provider: "Codex",
source: sourceLabel(sourceFile, "Codex"),
sourceFile,
timestamp: obj.timestamp,
sessionId: obj.payload?.id || obj.payload?.thread_id,
cwd: obj.payload?.cwd,
model: obj.payload?.model || "Codex",
inputTokens: intValue(usage.input_tokens),
outputTokens: intValue(usage.output_tokens),
cachedTokens: intValue(usage.cached_input_tokens),
reasoningTokens: intValue(usage.reasoning_output_tokens),
toolTokens: 0
});
}
function parseGeminiLine(line, sourceFile) {
const obj = parseJson(line);
const tokens = obj?.tokens;
if (!tokens) return null;
return normalizeRecord({
provider: "Gemini",
source: sourceLabel(sourceFile, "Gemini"),
sourceFile,
timestamp: obj.timestamp,
sessionId: obj.id,
cwd: obj.cwd,
model: obj.model || "Gemini",
inputTokens: intValue(tokens.input),
outputTokens: intValue(tokens.output),
cachedTokens: intValue(tokens.cached),
reasoningTokens: intValue(tokens.thoughts),
toolTokens: intValue(tokens.tool),
explicitTotal: intValue(tokens.total)
});
}
function normalizeRecord(input) {
const timestamp = input.timestamp ? new Date(input.timestamp) : null;
if (!timestamp || Number.isNaN(timestamp.getTime())) return null;
const computedTotal =
input.inputTokens +
input.outputTokens +
input.cachedTokens +
input.reasoningTokens +
input.toolTokens;
return {
provider: input.provider,
source: input.source,
sourceFile: input.sourceFile,
timestamp: timestamp.toISOString(),
sessionId: input.sessionId || "",
cwd: input.cwd || "",
model: input.model || input.provider,
inputTokens: input.inputTokens,
outputTokens: input.outputTokens,
cachedTokens: input.cachedTokens,
reasoningTokens: input.reasoningTokens,
toolTokens: input.toolTokens,
totalTokens: input.explicitTotal || computedTotal
};
}
function summarizeRecords(records, options = {}) {
const now = options.now || new Date();
const timeZone = options.timeZone || "Asia/Shanghai";
const todayKey = dateKey(now, timeZone);
const monthKey = todayKey.slice(0, 7);
const providerTotals = Object.fromEntries(PROVIDERS.map((provider) => [provider, emptyBucket(provider)]));
const sourceBuckets = new Map();
const dailyBuckets = new Map();
let todayTokens = 0;
let monthTokens = 0;
let totalTokens = 0;
let cachedTokens = 0;
let inputTokens = 0;
let outputTokens = 0;
let reasoningTokens = 0;
for (const record of records) {
const key = dateKey(new Date(record.timestamp), timeZone);
const recordMonth = key.slice(0, 7);
const total = record.totalTokens;
totalTokens += total;
cachedTokens += record.cachedTokens;
inputTokens += record.inputTokens;
outputTokens += record.outputTokens;
reasoningTokens += record.reasoningTokens;
if (key === todayKey) todayTokens += total;
if (recordMonth === monthKey) monthTokens += total;
addToBucket(providerTotals[record.provider] || (providerTotals[record.provider] = emptyBucket(record.provider)), record);
const sourceKey = `${record.provider}|${record.source}`;
if (!sourceBuckets.has(sourceKey)) {
sourceBuckets.set(sourceKey, {
provider: record.provider,
source: record.source,
model: record.model,
sourceFile: record.sourceFile,
...emptyTokenFields()
});
}
addToBucket(sourceBuckets.get(sourceKey), record);
if (!dailyBuckets.has(key)) {
dailyBuckets.set(key, Object.fromEntries(PROVIDERS.map((provider) => [provider, 0])));
}
dailyBuckets.get(key)[record.provider] = (dailyBuckets.get(key)[record.provider] || 0) + total;
}
const dailyTrend = Array.from(dailyBuckets.entries())
.sort(([a], [b]) => a.localeCompare(b))
.slice(-30)
.map(([date, values]) => ({ date, ...values }));
const sourceRows = Array.from(sourceBuckets.values())
.sort((a, b) => b.totalTokens - a.totalTokens)
.slice(0, 8);
const cacheHitRate = totalTokens > 0 ? Math.round((cachedTokens / totalTokens) * 100) : 0;
return {
generatedAt: now.toISOString(),
timeZone,
records,
cards: {
todayTokens,
monthTokens,
totalTokens,
cacheHitRate,
inputTokens,
outputTokens,
cachedTokens,
reasoningTokens,
sessionCount: new Set(records.map((record) => record.sessionId || record.sourceFile)).size
},
providerTotals,
dailyTrend,
sourceRows,
insights: buildInsights({ todayTokens, monthTokens, totalTokens, cacheHitRate, providerTotals, records })
};
}
function buildInsights({ todayTokens, cacheHitRate, providerTotals, records }) {
const topProvider = Object.values(providerTotals)
.filter((bucket) => bucket.totalTokens > 0)
.sort((a, b) => b.totalTokens - a.totalTokens)[0];
const insights = [];
if (topProvider) {
insights.push({
tone: "warn",
title: `${topProvider.provider} 是当前主要用量来源`,
copy: `已从本地日志统计 ${formatTokens(topProvider.totalTokens)} Tokens。`
});
}
if (cacheHitRate > 0) {
insights.push({
tone: "good",
title: "检测到缓存 Tokens",
copy: `当前 ${cacheHitRate}% 的统计用量来自缓存上下文。`
});
}
insights.push({
tone: "spike",
title: "今日用量快照已更新",
copy: `当前本地日期已统计 ${formatTokens(todayTokens)} Tokens。`
});
insights.push({
tone: "info",
title: "仅执行本地扫描",
copy: `已解析 ${records.length} 条用量记录,未上传对话内容。`
});
return insights.slice(0, 4);
}
function addToBucket(bucket, record) {
bucket.inputTokens += record.inputTokens;
bucket.outputTokens += record.outputTokens;
bucket.cachedTokens += record.cachedTokens;
bucket.reasoningTokens += record.reasoningTokens;
bucket.toolTokens += record.toolTokens;
bucket.totalTokens += record.totalTokens;
bucket.count += 1;
}
function emptyBucket(provider) {
return {
provider,
...emptyTokenFields()
};
}
function emptyTokenFields() {
return {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
reasoningTokens: 0,
toolTokens: 0,
totalTokens: 0,
count: 0
};
}
function dateKey(date, timeZone) {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit"
}).formatToParts(date);
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
return `${values.year}-${values.month}-${values.day}`;
}
function sourceLabel(sourceFile, provider) {
if (provider === "Claude" && sourceFile.includes(`${path.sep}.claude${path.sep}projects${path.sep}`)) {
return "projects/*.jsonl";
}
if (provider === "Codex" && sourceFile.includes(`${path.sep}.codex${path.sep}sessions${path.sep}`)) {
return "sessions/*.jsonl";
}
if (provider === "Gemini" && sourceFile.includes(`${path.sep}chats${path.sep}`)) {
return "tmp/chats/*.jsonl";
}
return path.basename(sourceFile);
}
function parseJson(line) {
try {
return JSON.parse(line);
} catch {
return null;
}
}
function intValue(value) {
return Number.isFinite(Number(value)) ? Number(value) : 0;
}
function formatTokens(value) {
if (value >= 100_000_000) return trimNumber(value / 100_000_000, 2) + "亿";
if (value >= 10_000) return trimNumber(value / 10_000, 1) + "万";
return String(value);
}
function trimNumber(value, digits) {
return value.toFixed(digits).replace(/\.0+$/, "").replace(/(\.\d*[1-9])0+$/, "$1");
}
module.exports = {
parseClaudeLine,
parseCodexLine,
parseGeminiLine,
summarizeRecords,
scanUsage,
formatTokens
};

44
src/main/main.cjs Normal file
View File

@@ -0,0 +1,44 @@
const { app, BrowserWindow, ipcMain } = require("electron");
const path = require("node:path");
const { scanUsage } = require("../lib/tokenUsage.cjs");
let mainWindow;
function createWindow() {
mainWindow = new BrowserWindow({
width: 1440,
height: 980,
minWidth: 1180,
minHeight: 820,
title: "TokenLens",
backgroundColor: "#08111d",
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 20, y: 20 },
webPreferences: {
preload: path.join(__dirname, "preload.cjs"),
contextIsolation: true,
nodeIntegration: false
}
});
const devServer = process.env.TOKENLENS_DEV_SERVER;
if (devServer) {
mainWindow.loadURL(devServer);
} else {
mainWindow.loadFile(path.join(__dirname, "../../dist/index.html"));
}
}
ipcMain.handle("usage:scan", async () => {
return scanUsage();
});
app.whenReady().then(createWindow);
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});

5
src/main/preload.cjs Normal file
View File

@@ -0,0 +1,5 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("tokenLens", {
scanUsage: () => ipcRenderer.invoke("usage:scan")
});

460
src/renderer/App.jsx Normal file
View File

@@ -0,0 +1,460 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import {
BarChart3,
CalendarDays,
ChevronDown,
Check,
Info,
Layers3,
RefreshCcw,
Sparkles,
TrendingUp
} from "lucide-react";
import { sampleSummary } from "./sampleData";
import { formatTokensZh } from "./displayFormat";
import "./styles.css";
const providers = ["Claude", "Codex", "Gemini"];
const providerColors = {
Claude: "#2e83ff",
Codex: "#9b5cf6",
Gemini: "#2dd4cf"
};
function App() {
const [summary, setSummary] = useState(sampleSummary);
const [status, setStatus] = useState("loading");
const [lastUpdated, setLastUpdated] = useState("");
const [refreshCount, setRefreshCount] = useState(0);
const [range, setRange] = useState("近 30 天");
const [trendMode, setTrendMode] = useState("每日");
const [shareMode, setShareMode] = useState("按 Tokens");
const [toolFilter, setToolFilter] = useState("全部工具");
const scanningRef = useRef(false);
const refresh = useCallback(async ({ silent = false } = {}) => {
if (scanningRef.current) return;
scanningRef.current = true;
if (!silent) setStatus("loading");
try {
if (window.tokenLens?.scanUsage) {
const data = await window.tokenLens.scanUsage();
setSummary(data);
setStatus("ready");
setLastUpdated(new Date(data.generatedAt).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }));
setRefreshCount((count) => count + 1);
} else {
setSummary(sampleSummary);
setStatus("preview");
setLastUpdated("预览数据");
}
} catch (error) {
setStatus("error");
setLastUpdated("扫描失败");
console.error(error);
} finally {
scanningRef.current = false;
}
}, []);
useEffect(() => {
refresh();
const timer = window.setInterval(() => {
refresh({ silent: true });
}, 15_000);
return () => window.clearInterval(timer);
}, [refresh]);
const totalByProvider = useMemo(() => providers.reduce((sum, provider) => {
return sum + (summary.providerTotals?.[provider]?.totalTokens || 0);
}, 0), [summary]);
const sourceRows = summary.sourceRows?.length ? summary.sourceRows : sampleSummary.sourceRows;
const filteredRows = toolFilter === "全部工具" ? sourceRows : sourceRows.filter((row) => row.provider === toolFilter);
const insights = summary.insights?.length ? summary.insights.slice(0, 3) : sampleSummary.insights;
return (
<div className="app-shell">
<main className="main-pane">
<TopBar
onRefresh={() => refresh()}
status={status}
lastUpdated={lastUpdated}
refreshCount={refreshCount}
range={range}
setRange={setRange}
/>
<section className="dashboard">
<MetricCards summary={summary} />
<section className="middle-grid">
<Panel
title="Tokens 使用趋势"
control={<Dropdown value={trendMode} options={["每日", "每周"]} onChange={setTrendMode} />}
>
<TrendChart data={summary.dailyTrend || []} />
</Panel>
<Panel
title="使用占比"
control={<Dropdown value={shareMode} options={["按 Tokens", "按占比"]} onChange={setShareMode} />}
>
<UsageShare providerTotals={summary.providerTotals || {}} total={totalByProvider} />
</Panel>
</section>
<section className="bottom-grid">
<ProviderBreakdown rows={filteredRows} toolFilter={toolFilter} setToolFilter={setToolFilter} />
<Insights insights={insights} />
</section>
</section>
</main>
</div>
);
}
function TopBar({ onRefresh, status, lastUpdated, refreshCount, range, setRange }) {
return (
<header className="topbar">
<div className="brand">
<BarChart3 size={23} />
<span>TokenLens 用量统计</span>
<span className={`live-pill ${status}`}>
<i />
{status === "error" ? "扫描异常" : "每 15 秒自动刷新"}
</span>
</div>
<div className="toolbar">
<Dropdown
value={range}
options={["近 7 天", "近 30 天", "本月", "全部"]}
onChange={setRange}
icon={<CalendarDays size={18} />}
className="toolbar-dropdown"
/>
<button className="toolbar-button primary" onClick={onRefresh}>
<RefreshCcw size={18} className={status === "loading" ? "spin" : ""} />
同步
</button>
<div className="refresh-meta">
<span>{lastUpdated ? `上次更新 ${lastUpdated}` : "准备扫描"}</span>
<small>已刷新 {refreshCount} </small>
</div>
</div>
</header>
);
}
function MetricCards({ summary }) {
const cards = [
{
label: "今日 Tokens",
value: formatTokensZh(summary.cards?.todayTokens || 0),
detail: "本地今日已统计",
trend: "实时",
tone: "blue",
icon: Layers3
},
{
label: "本月 Tokens",
value: formatTokensZh(summary.cards?.monthTokens || 0),
detail: "按本地时区汇总",
trend: "月度",
tone: "green",
icon: Sparkles
},
{
label: "历史总量",
value: formatTokensZh(summary.cards?.totalTokens || 0),
detail: `已扫描 ${summary.cards?.sessionCount || 0} 个会话`,
trend: "全部",
tone: "violet",
icon: Check
},
{
label: "缓存命中",
value: `${summary.cards?.cacheHitRate || 0}%`,
detail: "来自缓存上下文",
trend: "缓存",
tone: "orange",
icon: TrendingUp
}
];
return (
<section className="metric-grid">
{cards.map((card, index) => {
const Icon = card.icon;
return (
<article className="metric-card" key={card.label} style={{ "--i": index }}>
<div className={`metric-icon ${card.tone}`}>
<Icon size={25} />
</div>
<div className="metric-copy">
<div className="metric-label">{card.label}<Info size={14} /></div>
<div className="metric-value">{card.value}</div>
<div className="metric-detail">
<strong className={card.tone}>{card.trend}</strong>
<span>{card.detail}</span>
</div>
</div>
</article>
);
})}
</section>
);
}
function Panel({ title, control, children }) {
return (
<article className="panel">
<div className="panel-header">
<h2>{title}</h2>
{control}
</div>
{children}
</article>
);
}
function Dropdown({ value, options, onChange, icon, className = "" }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
function close(event) {
if (!ref.current?.contains(event.target)) setOpen(false);
}
document.addEventListener("pointerdown", close);
return () => document.removeEventListener("pointerdown", close);
}, []);
return (
<div className={`dropdown ${className}`} ref={ref}>
<button className="dropdown-trigger" onClick={() => setOpen((current) => !current)}>
{icon}
{value}
<ChevronDown size={16} className={open ? "chevron open" : "chevron"} />
</button>
{open ? (
<div className="dropdown-menu">
{options.map((option) => (
<button
className={option === value ? "selected" : ""}
key={option}
onClick={() => {
onChange(option);
setOpen(false);
}}
>
{option}
</button>
))}
</div>
) : null}
</div>
);
}
function TrendChart({ data }) {
const chartData = data.length ? data.slice(-24) : sampleSummary.dailyTrend;
const width = 640;
const height = 235;
const left = 58;
const right = 18;
const top = 18;
const bottom = 34;
const maxValue = Math.max(1, ...chartData.flatMap((item) => providers.map((provider) => item[provider] || 0)));
const plotWidth = width - left - right;
const plotHeight = height - top - bottom;
const x = (index) => left + (index / Math.max(chartData.length - 1, 1)) * plotWidth;
const y = (value) => top + plotHeight - (value / maxValue) * plotHeight;
const lines = providers.map((provider) => ({
provider,
points: chartData.map((item, index) => `${x(index)},${y(item[provider] || 0)}`).join(" ")
}));
const labelIndexes = [0, 6, 12, 18, chartData.length - 1].filter((index, pos, arr) => index >= 0 && arr.indexOf(index) === pos);
return (
<>
<svg className="trend-chart" viewBox={`0 0 ${width} ${height}`} role="img" aria-label="Tokens 使用趋势">
{[0, 0.25, 0.5, 0.75, 1].map((step) => {
const lineY = top + plotHeight * step;
const value = maxValue * (1 - step);
return (
<g key={step}>
<line className="grid-line" x1={left} y1={lineY} x2={width - right} y2={lineY} />
<text className="axis-label" x="12" y={lineY + 4}>{formatTokensZh(value)}</text>
</g>
);
})}
{labelIndexes.map((index) => (
<text className="axis-label" key={index} x={x(index) - 20} y={height - 8}>
{shortDate(chartData[index]?.date)}
</text>
))}
{lines.map((line) => (
<polyline
key={line.provider}
className="trend-line"
points={line.points}
style={{ stroke: providerColors[line.provider] }}
/>
))}
{lines.map((line) => chartData.filter((_, index) => index % 5 === 0 || index === chartData.length - 1).map((item, dotIndex) => (
<circle
key={`${line.provider}-${dotIndex}`}
cx={x(chartData.indexOf(item))}
cy={y(item[line.provider] || 0)}
r="4"
fill={providerColors[line.provider]}
/>
)))}
</svg>
<Legend />
</>
);
}
function UsageShare({ providerTotals, total }) {
const safeTotal = total || 1;
let cursor = 0;
const gradient = providers.map((provider) => {
const share = ((providerTotals[provider]?.totalTokens || 0) / safeTotal) * 360;
const start = cursor;
cursor += share;
return `${providerColors[provider]} ${start}deg ${cursor}deg`;
}).join(", ");
return (
<div className="share-body">
<div className="donut" style={{ background: `conic-gradient(${gradient})` }}>
<div className="donut-center">
<strong>{formatTokensZh(total)}</strong>
<span>本月</span>
</div>
</div>
<div className="share-list">
{providers.map((provider) => {
const providerTotal = providerTotals[provider]?.totalTokens || 0;
const percent = total ? Math.round((providerTotal / total) * 100) : 0;
return (
<div className="share-row" key={provider}>
<div>
<span className="legend-name"><i style={{ background: providerColors[provider] }} />{provider}</span>
<small>{formatTokensZh(providerTotal)} Tokens</small>
</div>
<strong>{percent}%</strong>
</div>
);
})}
</div>
</div>
);
}
function ProviderBreakdown({ rows, toolFilter, setToolFilter }) {
return (
<article className="panel table-panel">
<div className="panel-header table-title">
<h2>工具明细</h2>
<Dropdown value={toolFilter} options={["全部工具", "Claude", "Codex", "Gemini"]} onChange={setToolFilter} />
</div>
<table>
<thead>
<tr>
<th>工具</th>
<th>输入</th>
<th>输出</th>
<th>缓存</th>
<th>总量</th>
<th>趋势</th>
<th />
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={`${row.provider}-${row.source}-${index}`}>
<td><ProviderName provider={row.provider} /></td>
<td>{formatTokensZh(row.inputTokens)}</td>
<td>{formatTokensZh(row.outputTokens)}</td>
<td>{formatTokensZh(row.cachedTokens)}</td>
<td>{formatTokensZh(row.totalTokens)}</td>
<td><Sparkline provider={row.provider} seed={index} /></td>
<td className="more"></td>
</tr>
))}
</tbody>
</table>
<div className="table-footer">
<span>显示 {rows.length} 个本地工具 · 不上传对话内容</span>
<button>查看原始报告 </button>
</div>
</article>
);
}
function ProviderName({ provider }) {
return (
<div className="provider-name">
<span className={`provider-logo ${provider.toLowerCase()}`}>{provider === "Gemini" ? "" : provider[0]}</span>
{provider}
</div>
);
}
function Sparkline({ provider, seed }) {
const base = [
[17, 12, 16, 8, 12, 6, 10, 4, 8, 5, 9],
[18, 11, 15, 17, 10, 13, 7, 12, 6, 8, 4],
[16, 16, 10, 18, 14, 9, 12, 6, 5, 8, 8]
][seed % 3];
const points = base.map((value, index) => `${index * 7},${value}`).join(" ");
return (
<svg className="sparkline" viewBox="0 0 74 22">
<polyline points={points} style={{ stroke: providerColors[provider] || "#4b99ff" }} />
</svg>
);
}
function Insights({ insights }) {
return (
<article className="panel insight-panel">
<div className="panel-header table-title">
<h2>洞察提醒</h2>
<button>查看全部</button>
</div>
<div className="insight-list">
{insights.map((insight, index) => (
<div className={`insight ${insight.tone}`} key={`${insight.title}-${index}`}>
<div className="insight-icon">{insight.tone === "good" ? "✓" : insight.tone === "warn" ? "!" : "↗"}</div>
<div className="insight-time">{index + 1} 小时</div>
<strong>{insight.title}</strong>
<span>{insight.copy}</span>
</div>
))}
</div>
</article>
);
}
function Legend() {
return (
<div className="legend">
{providers.map((provider) => (
<span key={provider}><i style={{ background: providerColors[provider] }} />{provider}</span>
))}
</div>
);
}
function shortDate(value) {
if (!value) return "";
const [, month, day] = value.split("-");
return `${month}/${day}`;
}
createRoot(document.getElementById("root")).render(<App />);

View File

@@ -0,0 +1,10 @@
export function formatTokensZh(value) {
const number = Number(value) || 0;
if (number >= 100_000_000) return trimNumber(number / 100_000_000, 2) + "亿";
if (number >= 10_000) return trimNumber(number / 10_000, 1) + "万";
return String(Math.round(number));
}
function trimNumber(value, digits) {
return value.toFixed(digits).replace(/\.0+$/, "").replace(/(\.\d*[1-9])0+$/, "$1");
}

113
src/renderer/sampleData.js Normal file
View File

@@ -0,0 +1,113 @@
export const sampleSummary = {
generatedAt: new Date().toISOString(),
cards: {
todayTokens: 4_800_000,
monthTokens: 128_400_000,
totalTokens: 1_020_000_000,
cacheHitRate: 42,
sessionCount: 3842
},
providerTotals: {
Claude: {
provider: "Claude",
inputTokens: 28_700_000,
outputTokens: 18_200_000,
cachedTokens: 12_200_000,
reasoningTokens: 0,
totalTokens: 59_100_000,
count: 420
},
Codex: {
provider: "Codex",
inputTokens: 18_900_000,
outputTokens: 13_400_000,
cachedTokens: 3_600_000,
reasoningTokens: 1_200_000,
totalTokens: 35_900_000,
count: 260
},
Gemini: {
provider: "Gemini",
inputTokens: 17_200_000,
outputTokens: 10_600_000,
cachedTokens: 4_800_000,
reasoningTokens: 800_000,
totalTokens: 33_400_000,
count: 180
}
},
dailyTrend: buildTrend(),
sourceRows: [
row("Claude", "projects/*.jsonl", 28_700_000, 18_200_000, 12_200_000, 59_100_000),
row("Codex", "sessions/*.jsonl", 18_900_000, 13_400_000, 3_600_000, 35_900_000),
row("Gemini", "tmp/chats/*.jsonl", 17_200_000, 10_600_000, 4_800_000, 33_400_000)
],
insights: [
{
tone: "warn",
title: "Claude Tokens 高于日均水平",
copy: "今日用量比 7 日基线高 80%。"
},
{
tone: "good",
title: "Gemini 日志解析正常",
copy: "已扫描 2 个聊天文件,未发现异常行。"
},
{
tone: "spike",
title: "Codex 推理 Tokens 今日上升",
copy: "推理占比较昨日提高 32%。"
}
],
diagnostics: []
};
function row(provider, source, inputTokens, outputTokens, cachedTokens, totalTokens) {
return {
provider,
source,
inputTokens,
outputTokens,
cachedTokens,
totalTokens,
reasoningTokens: 0,
toolTokens: 0,
count: 1
};
}
function buildTrend() {
const values = [
[6.7, 4.1, 3.1],
[7.4, 4.7, 3.4],
[7.0, 4.2, 2.9],
[8.0, 5.0, 3.7],
[6.7, 4.4, 3.1],
[7.9, 5.1, 3.4],
[7.5, 4.5, 2.9],
[6.7, 3.8, 2.7],
[7.8, 5.4, 3.1],
[8.8, 4.7, 4.0],
[7.4, 4.6, 3.1],
[6.5, 5.1, 3.4],
[8.7, 4.9, 4.2],
[7.5, 5.6, 3.5],
[7.1, 5.0, 2.9],
[8.5, 4.8, 3.5],
[9.1, 5.4, 4.0],
[8.7, 6.1, 3.4],
[8.9, 5.5, 3.2],
[9.3, 5.1, 3.5],
[7.5, 5.6, 3.8],
[8.2, 5.0, 3.4],
[7.9, 5.5, 3.6],
[8.6, 4.9, 3.0]
];
return values.map(([Claude, Codex, Gemini], index) => ({
date: `2026-06-${String(index + 1).padStart(2, "0")}`,
Claude: Claude * 1_000_000,
Codex: Codex * 1_000_000,
Gemini: Gemini * 1_000_000
}));
}

918
src/renderer/styles.css Normal file
View File

@@ -0,0 +1,918 @@
:root {
color-scheme: dark;
--bg: #08111d;
--panel: rgba(19, 31, 47, 0.9);
--panel-2: rgba(16, 26, 40, 0.96);
--line: rgba(151, 174, 205, 0.18);
--line-strong: rgba(163, 188, 224, 0.29);
--text: #f2f6ff;
--muted: #96a4b8;
--blue: #2e83ff;
--cyan: #2dd4cf;
--violet: #9b5cf6;
--green: #2ed06e;
--orange: #ff9a31;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none;
text-rendering: geometricPrecision;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 1180px;
min-height: 760px;
overflow: hidden;
color: var(--text);
background:
linear-gradient(120deg, rgba(255, 255, 255, 0.055), transparent 18%, rgba(255, 255, 255, 0.035) 31%, transparent 48%),
radial-gradient(ellipse at 28% 14%, rgba(46, 131, 255, 0.16), transparent 28%),
radial-gradient(ellipse at 72% 22%, rgba(45, 212, 207, 0.1), transparent 26%),
linear-gradient(135deg, #070d17 0%, #0a1422 48%, #07111d 100%);
}
button {
border: 0;
color: inherit;
font: inherit;
}
.app-shell {
width: 100vw;
height: 100vh;
display: grid;
grid-template-columns: minmax(0, 1fr);
overflow: hidden;
background: rgba(8, 17, 29, 0.9);
}
.sidebar {
position: relative;
padding: 22px 0;
border-right: 1px solid var(--line);
background:
linear-gradient(180deg, rgba(14, 28, 48, 0.98), rgba(8, 17, 29, 0.98)),
radial-gradient(circle at 0 0, rgba(46, 131, 255, 0.18), transparent 38%);
}
.traffic-lights {
display: flex;
gap: 12px;
padding: 12px 24px 30px;
}
.traffic {
width: 16px;
height: 16px;
border-radius: 50%;
}
.traffic.red { background: #ff5f57; }
.traffic.yellow { background: #ffbd2e; }
.traffic.green { background: #28c840; }
.nav-list {
display: grid;
gap: 8px;
padding-left: 6px;
}
.nav-item {
height: 54px;
display: flex;
align-items: center;
gap: 14px;
padding: 0 22px;
color: #c7d1df;
font-size: 16px;
font-weight: 650;
background: transparent;
border-left: 3px solid transparent;
cursor: default;
}
.nav-item.active {
color: #fff;
border-left-color: var(--blue);
background: linear-gradient(90deg, rgba(46, 131, 255, 0.56), rgba(46, 131, 255, 0.2));
border-radius: 8px 8px 8px 0;
box-shadow: inset 0 0 0 1px rgba(81, 143, 255, 0.2);
}
.side-status {
position: absolute;
left: 24px;
right: 22px;
bottom: 34px;
display: grid;
gap: 14px;
color: var(--muted);
font-size: 13px;
}
.status-line {
display: flex;
align-items: center;
gap: 10px;
white-space: nowrap;
}
.status-line.muted {
color: #8593a7;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--green);
box-shadow: 0 0 0 5px rgba(46, 208, 110, 0.1);
}
.status-dot.loading {
background: var(--orange);
box-shadow: 0 0 0 5px rgba(255, 154, 49, 0.12);
}
.status-dot.error {
background: #ff6363;
box-shadow: 0 0 0 5px rgba(255, 99, 99, 0.12);
}
.main-pane {
min-width: 0;
display: grid;
grid-template-rows: 74px minmax(0, 1fr);
background: linear-gradient(180deg, rgba(9, 18, 31, 0.62), rgba(8, 17, 29, 0.84));
}
.topbar {
position: relative;
z-index: 80;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 28px 0 92px;
border-bottom: 1px solid var(--line);
background: rgba(9, 17, 29, 0.52);
backdrop-filter: blur(26px) saturate(1.35);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.08);
-webkit-app-region: drag;
}
.brand,
.toolbar,
.toolbar-button,
.search-box,
.profile {
display: flex;
align-items: center;
}
.brand {
gap: 13px;
font-size: 20px;
font-weight: 800;
}
.live-pill {
height: 28px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 10px;
border: 1px solid rgba(46, 208, 110, 0.22);
border-radius: 999px;
color: #bcebd0;
background: rgba(46, 208, 110, 0.09);
font-size: 12px;
font-weight: 700;
}
.live-pill i {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--green);
box-shadow: 0 0 0 5px rgba(46, 208, 110, 0.1);
}
.live-pill.loading {
color: #ffd7a8;
border-color: rgba(255, 154, 49, 0.25);
background: rgba(255, 154, 49, 0.09);
}
.live-pill.loading i {
background: var(--orange);
box-shadow: 0 0 0 5px rgba(255, 154, 49, 0.1);
}
.live-pill.error {
color: #ffc3c3;
border-color: rgba(255, 99, 99, 0.25);
background: rgba(255, 99, 99, 0.1);
}
.live-pill.error i {
background: #ff6363;
box-shadow: 0 0 0 5px rgba(255, 99, 99, 0.1);
}
.toolbar {
gap: 14px;
-webkit-app-region: no-drag;
}
.refresh-meta {
display: grid;
gap: 2px;
min-width: 112px;
color: #b9c5d6;
font-size: 12px;
line-height: 1.2;
}
.refresh-meta small {
color: #7f8da3;
font-size: 11px;
}
.toolbar-button,
.dropdown-trigger {
height: 42px;
gap: 10px;
padding: 0 14px;
border: 1px solid var(--line-strong);
border-radius: 8px;
color: #e9eef8;
background: linear-gradient(145deg, rgba(255,255,255,0.1), rgba(13,24,38,0.68));
backdrop-filter: blur(20px) saturate(1.28);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.12),
0 14px 30px rgba(0, 0, 0, 0.16);
transition: transform 180ms ease, border-color 180ms ease, background 180ms ease;
}
.toolbar-button.primary {
background: linear-gradient(145deg, rgba(72, 93, 124, 0.68), rgba(28, 43, 63, 0.78));
}
.toolbar-button:hover,
.dropdown-trigger:hover {
transform: translateY(-1px);
border-color: rgba(179, 205, 239, 0.45);
}
.dropdown {
position: relative;
}
.dropdown-trigger {
display: inline-flex;
align-items: center;
min-width: 104px;
justify-content: center;
}
.toolbar-dropdown .dropdown-trigger {
min-width: 138px;
}
.chevron {
transition: transform 160ms ease;
}
.chevron.open {
transform: rotate(180deg);
}
.dropdown-menu {
position: absolute;
right: 0;
top: calc(100% + 8px);
z-index: 120;
min-width: 132px;
display: grid;
gap: 4px;
padding: 7px;
border: 1px solid rgba(190, 215, 248, 0.24);
border-radius: 10px;
background: linear-gradient(145deg, rgba(30, 45, 66, 0.82), rgba(10, 20, 34, 0.78));
backdrop-filter: blur(24px) saturate(1.45);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.16),
0 22px 60px rgba(0, 0, 0, 0.34);
animation: menuIn 140ms ease-out both;
}
.dropdown-menu button {
height: 34px;
padding: 0 10px;
border-radius: 7px;
color: #d8e1ef;
background: transparent;
text-align: left;
}
.dropdown-menu button:hover,
.dropdown-menu button.selected {
color: #fff;
background: rgba(46, 131, 255, 0.2);
}
.profile {
gap: 10px;
padding: 0 4px;
background: transparent;
}
.profile span {
width: 40px;
height: 40px;
border-radius: 50%;
display: grid;
place-items: center;
font-weight: 800;
background: linear-gradient(135deg, #7c4dff, #bc75ff);
box-shadow: 0 8px 24px rgba(124, 77, 255, 0.32);
}
.dashboard {
position: relative;
z-index: 1;
min-height: 0;
overflow-y: auto;
padding: 18px 20px 14px 18px;
display: grid;
grid-template-rows: 125px 295px minmax(0, 1fr);
gap: 12px;
}
.dashboard::-webkit-scrollbar {
width: 8px;
}
.dashboard::-webkit-scrollbar-thumb {
background: rgba(151, 174, 205, 0.22);
border-radius: 999px;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.metric-card,
.panel {
position: relative;
border: 1px solid rgba(180, 205, 240, 0.3);
border-radius: 14px;
background:
linear-gradient(145deg, rgba(255,255,255,0.105), rgba(255,255,255,0.025) 38%, rgba(9,18,31,0.58)),
linear-gradient(180deg, rgba(24, 38, 57, 0.76), rgba(13, 24, 38, 0.72));
backdrop-filter: blur(28px) saturate(1.38);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.16),
inset 0 -1px 0 rgba(255, 255, 255, 0.05),
0 22px 60px rgba(0, 0, 0, 0.22);
overflow: hidden;
animation: cardIn 460ms ease-out both;
}
.metric-card::before,
.panel::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background:
linear-gradient(115deg, rgba(255,255,255,0.16), transparent 18%, transparent 74%, rgba(255,255,255,0.06)),
linear-gradient(270deg, transparent, rgba(255,255,255,0.045), transparent);
opacity: 0.78;
}
.metric-card:hover,
.panel:hover {
border-color: rgba(210, 230, 255, 0.42);
transform: translateY(-1px);
transition: transform 180ms ease, border-color 180ms ease;
}
.metric-card {
display: grid;
grid-template-columns: 66px 1fr;
align-items: center;
padding: 0 20px;
min-width: 0;
animation-delay: calc(var(--i, 0) * 55ms);
}
.metric-icon {
width: 52px;
height: 52px;
border-radius: 50%;
display: grid;
place-items: center;
color: white;
box-shadow: inset 0 0 28px rgba(255, 255, 255, 0.08);
animation: floatGlow 3.8s ease-in-out infinite;
}
.metric-icon.blue { background: radial-gradient(circle at 35% 24%, #408dff, #123c88); }
.metric-icon.green { background: radial-gradient(circle at 35% 24%, #37d48b, #126545); }
.metric-icon.violet { background: radial-gradient(circle at 35% 24%, #a970ff, #42217b); }
.metric-icon.orange { background: radial-gradient(circle at 35% 24%, #e28b35, #6e3a0d); }
.metric-copy {
min-width: 0;
}
.metric-label {
display: flex;
gap: 6px;
align-items: center;
color: #c3ccda;
font-size: 14px;
margin-bottom: 6px;
}
.metric-value {
font-size: clamp(27px, 2.5vw, 33px);
line-height: 1;
font-weight: 820;
margin-bottom: 10px;
white-space: nowrap;
}
.metric-detail {
display: flex;
gap: 7px;
color: #aab4c5;
font-size: 14px;
}
.blue { color: var(--blue); }
.green { color: var(--green); }
.violet { color: var(--violet); }
.orange { color: var(--orange); }
.middle-grid {
min-height: 0;
display: grid;
grid-template-columns: 1.35fr 1fr;
gap: 12px;
}
.bottom-grid {
min-height: 0;
display: grid;
grid-template-columns: 1.55fr 0.72fr;
gap: 12px;
}
.panel {
min-width: 0;
min-height: 0;
padding: 16px 22px;
overflow: visible;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
position: relative;
z-index: 25;
}
.panel-header h2 {
margin: 0;
font-size: 17px;
font-weight: 800;
}
.mini-select,
.table-title button,
.table-footer button {
height: 32px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 11px;
border: 1px solid var(--line);
border-radius: 7px;
color: #dce4ef;
font-size: 13px;
background: rgba(8, 17, 29, 0.35);
}
.trend-chart {
width: 100%;
height: calc(100% - 28px);
min-height: 178px;
}
.grid-line {
stroke: rgba(151, 174, 205, 0.14);
stroke-width: 1;
}
.axis-label {
fill: #93a0b3;
font-size: 13px;
}
.trend-line {
fill: none;
stroke-width: 3;
stroke-linejoin: round;
stroke-linecap: round;
stroke-dasharray: 900;
stroke-dashoffset: 900;
animation: drawLine 900ms ease-out forwards;
}
.legend {
display: flex;
align-items: center;
justify-content: center;
gap: 38px;
color: #cdd6e4;
font-size: 14px;
}
.legend span,
.legend-name {
display: inline-flex;
align-items: center;
gap: 8px;
}
.legend i,
.legend-name i {
width: 11px;
height: 11px;
border-radius: 50%;
box-shadow: 0 0 10px color-mix(in srgb, currentColor 60%, transparent);
}
.share-body {
height: calc(100% - 38px);
display: grid;
grid-template-columns: minmax(176px, 0.8fr) 1fr;
align-items: center;
gap: 18px;
}
.donut {
width: min(185px, 100%);
aspect-ratio: 1;
border-radius: 50%;
display: grid;
place-items: center;
justify-self: center;
box-shadow: 0 0 34px rgba(46, 131, 255, 0.18);
position: relative;
animation: glassPulse 4.2s ease-in-out infinite;
}
.donut::before {
content: "";
position: absolute;
width: 53%;
height: 53%;
border-radius: 50%;
background: linear-gradient(145deg, #101f31, #0b1726);
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.12);
}
.donut-center {
position: relative;
z-index: 1;
display: grid;
gap: 4px;
text-align: center;
}
.donut-center strong {
font-size: 25px;
}
.donut-center span {
color: #a8b4c5;
font-size: 14px;
}
.share-list {
display: grid;
gap: 17px;
}
.share-row {
display: grid;
grid-template-columns: 1fr 58px;
align-items: center;
gap: 10px;
}
.share-row strong {
font-size: 20px;
text-align: right;
}
.share-row small {
display: block;
margin-left: 19px;
margin-top: 2px;
color: #9ca8ba;
}
.table-panel,
.insight-panel {
padding: 0;
}
.table-title {
height: 48px;
margin: 0;
padding: 0 22px;
border-bottom: 1px solid var(--line);
position: relative;
z-index: 2;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 14px;
}
th,
td {
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
th {
height: 40px;
color: #9aa7b9;
font-weight: 650;
border-bottom: 1px solid var(--line);
}
td {
height: 58px;
color: #d4dce8;
border-bottom: 1px solid rgba(151, 174, 205, 0.1);
}
th:first-child,
td:first-child {
padding-left: 22px;
}
th:not(:first-child),
td:not(:first-child) {
padding-left: 10px;
}
th:nth-child(1) { width: 150px; }
th:nth-child(6) { width: 92px; }
th:nth-child(7) { width: 38px; }
.provider-name {
display: flex;
align-items: center;
gap: 9px;
min-width: 0;
color: #eef4ff;
font-weight: 720;
}
.provider-logo {
width: 28px;
height: 28px;
border-radius: 7px;
display: grid;
place-items: center;
flex: 0 0 auto;
font-size: 13px;
font-weight: 850;
}
.provider-logo.claude {
background: linear-gradient(135deg, #fb8947, #df5e2f);
}
.provider-logo.codex {
background: #05070b;
border: 1px solid rgba(255,255,255,0.14);
}
.provider-logo.gemini {
background: conic-gradient(from 45deg, #3debd4, #395fff, #ffb34d, #3debd4);
}
.sparkline {
width: 74px;
height: 22px;
}
.sparkline polyline {
fill: none;
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
}
.more {
color: #7e8aa0;
font-size: 20px;
text-align: center;
}
.table-footer {
height: 38px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 22px;
color: #9ba7b8;
font-size: 14px;
}
.table-footer button,
.insight-panel .table-title button {
color: #4b99ff;
font-weight: 700;
border: 0;
background: transparent;
padding: 0;
}
.insight-list {
display: grid;
gap: 7px;
padding: 8px 10px 10px;
}
.insight {
min-height: 62px;
border: 1px solid rgba(151, 174, 205, 0.15);
border-radius: 8px;
padding: 9px 12px 9px 54px;
position: relative;
display: grid;
gap: 4px;
animation: slideIn 420ms ease-out both;
}
.insight.warn {
background: linear-gradient(90deg, rgba(255, 154, 49, 0.13), rgba(255, 154, 49, 0.04));
border-color: rgba(255,154,49,0.24);
}
.insight.good {
background: linear-gradient(90deg, rgba(46, 208, 110, 0.13), rgba(46, 208, 110, 0.04));
border-color: rgba(46,208,110,0.2);
}
.insight.spike,
.insight.info {
background: linear-gradient(90deg, rgba(155, 92, 246, 0.13), rgba(155, 92, 246, 0.04));
border-color: rgba(155,92,246,0.22);
}
.insight-icon {
position: absolute;
left: 14px;
top: 12px;
width: 31px;
height: 31px;
border-radius: 8px;
display: grid;
place-items: center;
font-weight: 900;
font-size: 18px;
color: var(--orange);
background: rgba(255, 154, 49, 0.16);
}
.insight.good .insight-icon {
color: var(--green);
background: rgba(46, 208, 110, 0.14);
}
.insight.spike .insight-icon,
.insight.info .insight-icon {
color: var(--violet);
background: rgba(155, 92, 246, 0.14);
}
.insight-time {
position: absolute;
top: 13px;
right: 12px;
color: #94a0b3;
font-size: 12px;
}
.insight strong {
padding-right: 36px;
font-size: 14px;
line-height: 1.35;
}
.insight span {
color: #aab5c6;
font-size: 13px;
line-height: 1.35;
}
.spin {
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes cardIn {
from {
opacity: 0;
transform: translateY(12px) scale(0.99);
filter: blur(6px);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
filter: blur(0);
}
}
@keyframes menuIn {
from {
opacity: 0;
transform: translateY(-6px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes drawLine {
to {
stroke-dashoffset: 0;
}
}
@keyframes floatGlow {
0%, 100% {
transform: translateY(0);
filter: brightness(1);
}
50% {
transform: translateY(-2px);
filter: brightness(1.12);
}
}
@keyframes glassPulse {
0%, 100% {
filter: saturate(1);
}
50% {
filter: saturate(1.18) brightness(1.05);
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

View File

@@ -0,0 +1,12 @@
import test from "node:test";
import assert from "node:assert/strict";
import { formatTokensZh } from "../src/renderer/displayFormat.js";
test("formatTokensZh uses Chinese magnitude units instead of western suffixes", () => {
assert.equal(formatTokensZh(9999), "9999");
assert.equal(formatTokensZh(10_000), "1万");
assert.equal(formatTokensZh(71_400), "7.1万");
assert.equal(formatTokensZh(6_600_000), "660万");
assert.equal(formatTokensZh(196_700_000), "1.97亿");
assert.equal(formatTokensZh(1_020_000_000), "10.2亿");
});

182
tests/tokenUsage.test.cjs Normal file
View File

@@ -0,0 +1,182 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const {
parseClaudeLine,
parseCodexLine,
parseGeminiLine,
summarizeRecords,
scanUsage
} = require("../src/lib/tokenUsage.cjs");
test("parseClaudeLine extracts usage without reading message content", () => {
const line = JSON.stringify({
timestamp: "2026-06-11T02:20:00.000Z",
cwd: "/Users/me/Code/App",
sessionId: "claude-session",
message: {
role: "assistant",
content: "private conversation text",
model: "claude-3-5-sonnet",
usage: {
input_tokens: 120,
output_tokens: 30,
cache_read_input_tokens: 50,
cache_creation_input_tokens: 10
}
}
});
const record = parseClaudeLine(line, "/tmp/claude.jsonl");
assert.equal(record.provider, "Claude");
assert.equal(record.model, "claude-3-5-sonnet");
assert.equal(record.inputTokens, 120);
assert.equal(record.outputTokens, 30);
assert.equal(record.cachedTokens, 60);
assert.equal(record.totalTokens, 210);
assert.equal(record.contentPreview, undefined);
});
test("parseCodexLine uses last_token_usage instead of cumulative total usage", () => {
const line = JSON.stringify({
timestamp: "2026-06-11T03:10:00.000Z",
type: "event_msg",
payload: {
info: {
total_token_usage: {
input_tokens: 1000,
output_tokens: 300,
cached_input_tokens: 200,
reasoning_output_tokens: 40,
total_tokens: 1300
},
last_token_usage: {
input_tokens: 100,
output_tokens: 30,
cached_input_tokens: 20,
reasoning_output_tokens: 4,
total_tokens: 130
}
}
}
});
const record = parseCodexLine(line, "/tmp/rollout.jsonl");
assert.equal(record.provider, "Codex");
assert.equal(record.inputTokens, 100);
assert.equal(record.outputTokens, 30);
assert.equal(record.cachedTokens, 20);
assert.equal(record.reasoningTokens, 4);
assert.equal(record.totalTokens, 154);
});
test("parseGeminiLine extracts tokens from Gemini chat jsonl", () => {
const line = JSON.stringify({
timestamp: "2026-06-11T04:00:00.000Z",
type: "response",
model: "gemini-2.5-pro",
tokens: {
input: 70,
output: 20,
cached: 5,
thoughts: 3,
tool: 2,
total: 100
}
});
const record = parseGeminiLine(line, "/tmp/session.jsonl");
assert.equal(record.provider, "Gemini");
assert.equal(record.model, "gemini-2.5-pro");
assert.equal(record.inputTokens, 70);
assert.equal(record.outputTokens, 20);
assert.equal(record.cachedTokens, 5);
assert.equal(record.reasoningTokens, 3);
assert.equal(record.toolTokens, 2);
assert.equal(record.totalTokens, 100);
});
test("summarizeRecords returns today, month, total, provider, trend and insight data", () => {
const now = new Date("2026-06-11T12:00:00+08:00");
const records = [
record("Claude", "2026-06-11T01:00:00Z", 100, 20, 10),
record("Codex", "2026-06-10T01:00:00Z", 80, 20, 0),
record("Gemini", "2026-05-31T01:00:00Z", 50, 10, 0)
];
const summary = summarizeRecords(records, { now, timeZone: "Asia/Shanghai" });
assert.equal(summary.cards.todayTokens, 130);
assert.equal(summary.cards.monthTokens, 230);
assert.equal(summary.cards.totalTokens, 290);
assert.equal(summary.providerTotals.Claude.totalTokens, 130);
assert.equal(summary.providerTotals.Codex.totalTokens, 100);
assert.equal(summary.providerTotals.Gemini.totalTokens, 60);
assert.ok(summary.dailyTrend.length >= 2);
assert.ok(summary.sourceRows.length >= 3);
assert.ok(summary.insights.length >= 1);
});
test("scanUsage reads only known local usage files and aggregates them", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "tokenlens-"));
await fs.mkdir(path.join(homeDir, ".claude/projects/proj"), { recursive: true });
await fs.mkdir(path.join(homeDir, ".codex/sessions/2026/06/11"), { recursive: true });
await fs.mkdir(path.join(homeDir, ".gemini/tmp/me/chats"), { recursive: true });
await fs.writeFile(
path.join(homeDir, ".claude/projects/proj/a.jsonl"),
JSON.stringify({
timestamp: "2026-06-11T02:20:00.000Z",
message: { model: "claude", usage: { input_tokens: 10, output_tokens: 5 } }
}) + "\n"
);
await fs.writeFile(
path.join(homeDir, ".codex/sessions/2026/06/11/rollout.jsonl"),
JSON.stringify({
timestamp: "2026-06-11T02:30:00.000Z",
payload: { info: { last_token_usage: { input_tokens: 7, output_tokens: 3 } } }
}) + "\n"
);
await fs.writeFile(
path.join(homeDir, ".gemini/tmp/me/chats/session.jsonl"),
JSON.stringify({
timestamp: "2026-06-11T02:40:00.000Z",
model: "gemini",
tokens: { input: 8, output: 2, total: 10 }
}) + "\n"
);
const summary = await scanUsage({
homeDir,
now: new Date("2026-06-11T12:00:00+08:00"),
timeZone: "Asia/Shanghai"
});
assert.equal(summary.records.length, 3);
assert.equal(summary.cards.totalTokens, 35);
assert.equal(summary.providerTotals.Claude.totalTokens, 15);
assert.equal(summary.providerTotals.Codex.totalTokens, 10);
assert.equal(summary.providerTotals.Gemini.totalTokens, 10);
});
function record(provider, timestamp, inputTokens, outputTokens, cachedTokens) {
return {
provider,
source: `${provider} source`,
sourceFile: `/tmp/${provider}.jsonl`,
timestamp,
model: provider,
inputTokens,
outputTokens,
cachedTokens,
reasoningTokens: 0,
toolTokens: 0,
totalTokens: inputTokens + outputTokens + cachedTokens
};
}

11
vite.config.js Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
base: "./",
build: {
outDir: "dist",
emptyOutDir: true
}
});