Usage: add cost summaries to /usage + mac menu
This commit is contained in:
99
apps/macos/Sources/Clawdbot/CostUsageMenuView.swift
Normal file
99
apps/macos/Sources/Clawdbot/CostUsageMenuView.swift
Normal file
@@ -0,0 +1,99 @@
|
||||
import Charts
|
||||
import SwiftUI
|
||||
|
||||
struct CostUsageHistoryMenuView: View {
|
||||
let summary: GatewayCostUsageSummary
|
||||
let width: CGFloat
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
self.header
|
||||
self.chart
|
||||
self.footer
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.frame(width: max(1, self.width), alignment: .leading)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
let todayKey = CostUsageMenuDateParser.format(Date())
|
||||
let todayEntry = self.summary.daily.first { $0.date == todayKey }
|
||||
let todayCost = CostUsageFormatting.formatUsd(todayEntry?.totalCost) ?? "n/a"
|
||||
let totalCost = CostUsageFormatting.formatUsd(self.summary.totals.totalCost) ?? "n/a"
|
||||
|
||||
return HStack(alignment: .firstTextBaseline, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Today")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(todayCost)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Last \(self.summary.days)d")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(totalCost)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private var chart: some View {
|
||||
let entries = self.summary.daily.compactMap { entry -> (Date, Double)? in
|
||||
guard let date = CostUsageMenuDateParser.parse(entry.date) else { return nil }
|
||||
return (date, entry.totalCost)
|
||||
}
|
||||
|
||||
return Chart(entries, id: \.0) { entry in
|
||||
BarMark(
|
||||
x: .value("Day", entry.0),
|
||||
y: .value("Cost", entry.1))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.cornerRadius(3)
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .stride(by: .day, count: 7)) {
|
||||
AxisGridLine().foregroundStyle(.clear)
|
||||
AxisValueLabel(format: .dateTime.month().day())
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) {
|
||||
AxisGridLine()
|
||||
AxisValueLabel()
|
||||
}
|
||||
}
|
||||
.frame(height: 110)
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
if self.summary.totals.missingCostEntries == 0 {
|
||||
return AnyView(EmptyView())
|
||||
}
|
||||
return AnyView(
|
||||
Text("Partial: \(self.summary.totals.missingCostEntries) entries missing cost")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary))
|
||||
}
|
||||
}
|
||||
|
||||
private enum CostUsageMenuDateParser {
|
||||
static let formatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone.current
|
||||
return formatter
|
||||
}()
|
||||
|
||||
static func parse(_ value: String) -> Date? {
|
||||
self.formatter.date(from: value)
|
||||
}
|
||||
|
||||
static func format(_ date: Date) -> String {
|
||||
self.formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,10 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
private var cachedUsageErrorText: String?
|
||||
private var usageCacheUpdatedAt: Date?
|
||||
private let usageRefreshIntervalSeconds: TimeInterval = 30
|
||||
private var cachedCostSummary: GatewayCostUsageSummary?
|
||||
private var cachedCostErrorText: String?
|
||||
private var costCacheUpdatedAt: Date?
|
||||
private let costRefreshIntervalSeconds: TimeInterval = 45
|
||||
private let nodesStore = NodesStore.shared
|
||||
#if DEBUG
|
||||
private var testControlChannelConnected: Bool?
|
||||
@@ -64,6 +68,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
guard let self else { return }
|
||||
await self.refreshCache(force: forceRefresh)
|
||||
await self.refreshUsageCache(force: forceRefresh)
|
||||
await self.refreshCostUsageCache(force: forceRefresh)
|
||||
await MainActor.run {
|
||||
guard self.isMenuOpen else { return }
|
||||
self.inject(into: menu)
|
||||
@@ -200,6 +205,7 @@ extension MenuSessionsInjector {
|
||||
}
|
||||
|
||||
cursor = self.insertUsageSection(into: menu, at: cursor, width: width)
|
||||
cursor = self.insertCostUsageSection(into: menu, at: cursor, width: width)
|
||||
|
||||
DispatchQueue.main.async { [weak self, weak headerView] in
|
||||
guard let self, let headerView else { return }
|
||||
@@ -344,6 +350,28 @@ extension MenuSessionsInjector {
|
||||
return cursor
|
||||
}
|
||||
|
||||
private func insertCostUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int {
|
||||
guard self.isControlChannelConnected else { return cursor }
|
||||
guard let submenu = self.buildCostUsageSubmenu(width: width) else { return cursor }
|
||||
var cursor = cursor
|
||||
|
||||
if cursor > 0, !menu.items[cursor - 1].isSeparatorItem {
|
||||
let separator = NSMenuItem.separator()
|
||||
separator.tag = self.tag
|
||||
menu.insertItem(separator, at: cursor)
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
let item = NSMenuItem(title: "Usage cost (30 days)", action: nil, keyEquivalent: "")
|
||||
item.tag = self.tag
|
||||
item.isEnabled = true
|
||||
item.image = NSImage(systemSymbolName: "chart.bar.xaxis", accessibilityDescription: nil)
|
||||
item.submenu = submenu
|
||||
menu.insertItem(item, at: cursor)
|
||||
cursor += 1
|
||||
return cursor
|
||||
}
|
||||
|
||||
private var selectedUsageProviderId: String? {
|
||||
guard let model = self.cachedSnapshot?.defaults.model.nonEmpty else { return nil }
|
||||
let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -393,6 +421,36 @@ extension MenuSessionsInjector {
|
||||
}
|
||||
}
|
||||
|
||||
private func buildCostUsageSubmenu(width: CGFloat) -> NSMenu? {
|
||||
if let error = self.cachedCostErrorText, !error.isEmpty, self.cachedCostSummary == nil {
|
||||
let menu = NSMenu()
|
||||
let item = NSMenuItem(title: error, action: nil, keyEquivalent: "")
|
||||
item.isEnabled = false
|
||||
menu.addItem(item)
|
||||
return menu
|
||||
}
|
||||
|
||||
guard let summary = self.cachedCostSummary else { return nil }
|
||||
guard !summary.daily.isEmpty else { return nil }
|
||||
|
||||
let menu = NSMenu()
|
||||
menu.delegate = self
|
||||
|
||||
let chartView = CostUsageHistoryMenuView(summary: summary, width: width)
|
||||
let hosting = NSHostingView(rootView: AnyView(chartView))
|
||||
let controller = NSHostingController(rootView: AnyView(chartView))
|
||||
let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude))
|
||||
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
|
||||
|
||||
let chartItem = NSMenuItem()
|
||||
chartItem.view = hosting
|
||||
chartItem.isEnabled = false
|
||||
chartItem.representedObject = "costUsageChart"
|
||||
menu.addItem(chartItem)
|
||||
|
||||
return menu
|
||||
}
|
||||
|
||||
private func gatewayEntry() -> NodeInfo? {
|
||||
let mode = AppStateStore.shared.connectionMode
|
||||
let isConnected = self.isControlChannelConnected
|
||||
@@ -581,6 +639,31 @@ extension MenuSessionsInjector {
|
||||
self.usageCacheUpdatedAt = Date()
|
||||
}
|
||||
|
||||
private func refreshCostUsageCache(force: Bool) async {
|
||||
if !force,
|
||||
let updated = self.costCacheUpdatedAt,
|
||||
Date().timeIntervalSince(updated) < self.costRefreshIntervalSeconds
|
||||
{
|
||||
return
|
||||
}
|
||||
|
||||
guard self.isControlChannelConnected else {
|
||||
self.cachedCostSummary = nil
|
||||
self.cachedCostErrorText = nil
|
||||
self.costCacheUpdatedAt = Date()
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
self.cachedCostSummary = try await CostUsageLoader.loadSummary()
|
||||
self.cachedCostErrorText = nil
|
||||
} catch {
|
||||
self.cachedCostSummary = nil
|
||||
self.cachedCostErrorText = self.compactUsageError(error)
|
||||
}
|
||||
self.costCacheUpdatedAt = Date()
|
||||
}
|
||||
|
||||
private func compactUsageError(_ error: Error) -> String {
|
||||
let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if message.isEmpty { return "Usage unavailable" }
|
||||
|
||||
60
apps/macos/Sources/Clawdbot/UsageCostData.swift
Normal file
60
apps/macos/Sources/Clawdbot/UsageCostData.swift
Normal file
@@ -0,0 +1,60 @@
|
||||
import Foundation
|
||||
|
||||
struct GatewayCostUsageTotals: Codable {
|
||||
let input: Int
|
||||
let output: Int
|
||||
let cacheRead: Int
|
||||
let cacheWrite: Int
|
||||
let totalTokens: Int
|
||||
let totalCost: Double
|
||||
let missingCostEntries: Int
|
||||
}
|
||||
|
||||
struct GatewayCostUsageDay: Codable {
|
||||
let date: String
|
||||
let input: Int
|
||||
let output: Int
|
||||
let cacheRead: Int
|
||||
let cacheWrite: Int
|
||||
let totalTokens: Int
|
||||
let totalCost: Double
|
||||
let missingCostEntries: Int
|
||||
}
|
||||
|
||||
struct GatewayCostUsageSummary: Codable {
|
||||
let updatedAt: Double
|
||||
let days: Int
|
||||
let daily: [GatewayCostUsageDay]
|
||||
let totals: GatewayCostUsageTotals
|
||||
}
|
||||
|
||||
enum CostUsageFormatting {
|
||||
static func formatUsd(_ value: Double?) -> String? {
|
||||
guard let value, value.isFinite else { return nil }
|
||||
if value >= 1 { return String(format: "$%.2f", value) }
|
||||
if value >= 0.01 { return String(format: "$%.2f", value) }
|
||||
return String(format: "$%.4f", value)
|
||||
}
|
||||
|
||||
static func formatTokenCount(_ value: Int?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let safe = max(0, value)
|
||||
if safe >= 1_000_000 { return String(format: "%.1fm", Double(safe) / 1_000_000.0) }
|
||||
if safe >= 1000 { return safe >= 10000
|
||||
? String(format: "%.0fk", Double(safe) / 1000.0)
|
||||
: String(format: "%.1fk", Double(safe) / 1000.0)
|
||||
}
|
||||
return String(safe)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum CostUsageLoader {
|
||||
static func loadSummary() async throws -> GatewayCostUsageSummary {
|
||||
let data = try await ControlChannel.shared.request(
|
||||
method: "usage.cost",
|
||||
params: nil,
|
||||
timeoutMs: 7000)
|
||||
return try JSONDecoder().decode(GatewayCostUsageSummary.self, from: data)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user