feat: add provider usage tracking

This commit is contained in:
Peter Steinberger
2026-01-07 11:42:41 +01:00
parent 4e14123edd
commit 9bf6684366
18 changed files with 1333 additions and 51 deletions

View File

@@ -22,6 +22,10 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
private var cachedErrorText: String?
private var cacheUpdatedAt: Date?
private let refreshIntervalSeconds: TimeInterval = 12
private var cachedUsageSummary: GatewayUsageSummary?
private var cachedUsageErrorText: String?
private var usageCacheUpdatedAt: Date?
private let usageRefreshIntervalSeconds: TimeInterval = 30
private let nodesStore = NodesStore.shared
#if DEBUG
private var testControlChannelConnected: Bool?
@@ -58,6 +62,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
self.loadTask = Task { [weak self] in
guard let self else { return }
await self.refreshCache(force: forceRefresh)
await self.refreshUsageCache(force: forceRefresh)
await MainActor.run {
guard self.isMenuOpen else { return }
self.inject(into: menu)
@@ -108,66 +113,70 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
guard self.isControlChannelConnected else { return }
guard let snapshot = self.cachedSnapshot else {
var cursor = insertIndex
var headerView: NSView?
if let snapshot = self.cachedSnapshot {
let now = Date()
let rows = snapshot.rows.filter { row in
if row.key == "main" { return true }
guard let updatedAt = row.updatedAt else { return false }
return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds
}.sorted { lhs, rhs in
if lhs.key == "main" { return true }
if rhs.key == "main" { return false }
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
}
let headerItem = NSMenuItem()
headerItem.tag = self.tag
headerItem.isEnabled = false
headerItem.view = self.makeHostedView(
let hosted = self.makeHostedView(
rootView: AnyView(MenuSessionsHeaderView(count: rows.count, statusText: nil)),
width: width,
highlighted: false)
headerItem.view = hosted
headerView = hosted
menu.insertItem(headerItem, at: cursor)
cursor += 1
if rows.isEmpty {
menu.insertItem(
self.makeMessageItem(text: "No active sessions", symbolName: "minus", width: width),
at: cursor)
cursor += 1
} else {
for row in rows {
let item = NSMenuItem()
item.tag = self.tag
item.isEnabled = true
item.submenu = self.buildSubmenu(for: row, storePath: snapshot.storePath)
item.view = self.makeHostedView(
rootView: AnyView(SessionMenuLabelView(row: row, width: width)),
width: width,
highlighted: true)
menu.insertItem(item, at: cursor)
cursor += 1
}
}
} else {
let headerItem = NSMenuItem()
headerItem.tag = self.tag
headerItem.isEnabled = false
let hosted = self.makeHostedView(
rootView: AnyView(MenuSessionsHeaderView(
count: 0,
statusText: self.cachedErrorText ?? "Loading sessions…")),
width: width,
highlighted: false)
menu.insertItem(headerItem, at: insertIndex)
DispatchQueue.main.async { [weak self, weak view = headerItem.view] in
guard let self, let view else { return }
self.captureMenuWidthIfAvailable(from: view)
}
return
}
let now = Date()
let rows = snapshot.rows.filter { row in
if row.key == "main" { return true }
guard let updatedAt = row.updatedAt else { return false }
return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds
}.sorted { lhs, rhs in
if lhs.key == "main" { return true }
if rhs.key == "main" { return false }
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
}
let headerItem = NSMenuItem()
headerItem.tag = self.tag
headerItem.isEnabled = false
let headerView = self.makeHostedView(
rootView: AnyView(MenuSessionsHeaderView(count: rows.count, statusText: nil)),
width: width,
highlighted: false)
headerItem.view = headerView
menu.insertItem(headerItem, at: insertIndex)
var cursor = insertIndex + 1
if rows.isEmpty {
menu.insertItem(
self.makeMessageItem(text: "No active sessions", symbolName: "minus", width: width),
at: cursor)
return
}
for row in rows {
let item = NSMenuItem()
item.tag = self.tag
item.isEnabled = true
item.submenu = self.buildSubmenu(for: row, storePath: snapshot.storePath)
item.view = self.makeHostedView(
rootView: AnyView(SessionMenuLabelView(row: row, width: width)),
width: width,
highlighted: true)
menu.insertItem(item, at: cursor)
headerItem.view = hosted
headerView = hosted
menu.insertItem(headerItem, at: cursor)
cursor += 1
}
cursor = self.insertUsageSection(into: menu, at: cursor, width: width)
DispatchQueue.main.async { [weak self, weak headerView] in
guard let self, let headerView else { return }
self.captureMenuWidthIfAvailable(from: headerView)
@@ -240,6 +249,55 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
_ = cursor
}
private func insertUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int {
let rows = self.usageRows
let errorText = self.cachedUsageErrorText
if rows.isEmpty && errorText == nil {
return cursor
}
var cursor = cursor
let headerItem = NSMenuItem()
headerItem.tag = self.tag
headerItem.isEnabled = false
headerItem.view = self.makeHostedView(
rootView: AnyView(MenuUsageHeaderView(
count: rows.count,
statusText: errorText)),
width: width,
highlighted: false)
menu.insertItem(headerItem, at: cursor)
cursor += 1
if rows.isEmpty {
menu.insertItem(
self.makeMessageItem(text: errorText ?? "No usage available", symbolName: "minus", width: width),
at: cursor)
cursor += 1
return cursor
}
for row in rows {
let item = NSMenuItem()
item.tag = self.tag
item.isEnabled = false
item.view = self.makeHostedView(
rootView: AnyView(UsageMenuLabelView(row: row, width: width)),
width: width,
highlighted: false)
menu.insertItem(item, at: cursor)
cursor += 1
}
return cursor
}
private var usageRows: [UsageRow] {
guard let summary = self.cachedUsageSummary else { return [] }
return summary.primaryRows()
}
private var isControlChannelConnected: Bool {
#if DEBUG
if let override = self.testControlChannelConnected { return override }
@@ -364,6 +422,40 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
}
}
private func refreshUsageCache(force: Bool) async {
if !force,
let updated = self.usageCacheUpdatedAt,
Date().timeIntervalSince(updated) < self.usageRefreshIntervalSeconds
{
return
}
guard self.isControlChannelConnected else {
self.cachedUsageSummary = nil
self.cachedUsageErrorText = nil
self.usageCacheUpdatedAt = Date()
return
}
do {
self.cachedUsageSummary = try await UsageLoader.loadSummary()
self.cachedUsageErrorText = nil
self.usageCacheUpdatedAt = Date()
} catch {
if self.cachedUsageSummary == nil {
self.cachedUsageErrorText = self.compactUsageError(error)
}
self.usageCacheUpdatedAt = Date()
}
}
private func compactUsageError(_ error: Error) -> String {
let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
if message.isEmpty { return "Usage unavailable" }
if message.count > 90 { return "\(message.prefix(87))" }
return message
}
private func compactError(_ error: Error) -> String {
if let loadError = error as? SessionLoadError {
switch loadError {

View File

@@ -0,0 +1,45 @@
import SwiftUI
struct MenuUsageHeaderView: View {
let count: Int
let statusText: String?
private let paddingTop: CGFloat = 8
private let paddingBottom: CGFloat = 6
private let paddingTrailing: CGFloat = 10
private let paddingLeading: CGFloat = 20
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline) {
Text("Usage")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Spacer(minLength: 10)
Text(self.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
if let statusText, !statusText.isEmpty {
Text(statusText)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
}
.padding(.top, self.paddingTop)
.padding(.bottom, self.paddingBottom)
.padding(.leading, self.paddingLeading)
.padding(.trailing, self.paddingTrailing)
.frame(minWidth: 300, maxWidth: .infinity, alignment: .leading)
.transaction { txn in txn.animation = nil }
}
private var subtitle: String {
if self.count == 1 { return "1 provider" }
return "\(self.count) providers"
}
}

View File

@@ -0,0 +1,110 @@
import Foundation
struct GatewayUsageWindow: Codable {
let label: String
let usedPercent: Double
let resetAt: Double?
}
struct GatewayUsageProvider: Codable {
let provider: String
let displayName: String
let windows: [GatewayUsageWindow]
let plan: String?
let error: String?
}
struct GatewayUsageSummary: Codable {
let updatedAt: Double
let providers: [GatewayUsageProvider]
}
struct UsageRow: Identifiable {
let id: String
let displayName: String
let plan: String?
let windowLabel: String?
let usedPercent: Double?
let resetAt: Date?
let error: String?
var titleText: String {
if let plan, !plan.isEmpty { return "\(displayName) (\(plan))" }
return displayName
}
var remainingPercent: Int? {
guard let usedPercent, usedPercent.isFinite else { return nil }
let remaining = max(0, min(100, Int(round(100 - usedPercent))))
return remaining
}
func detailText(now: Date = .init()) -> String {
if let error, !error.isEmpty { return error }
guard let remaining = self.remainingPercent else { return "No data" }
var parts = ["\(remaining)% left"]
if let windowLabel, !windowLabel.isEmpty { parts.append(windowLabel) }
if let resetAt {
let reset = UsageRow.formatResetRemaining(target: resetAt, now: now)
if let reset { parts.append("\(reset)") }
}
return parts.joined(separator: " · ")
}
private static func formatResetRemaining(target: Date, now: Date) -> String? {
let diff = target.timeIntervalSince(now)
if diff <= 0 { return "now" }
let minutes = Int(floor(diff / 60))
if minutes < 60 { return "\(minutes)m" }
let hours = minutes / 60
let mins = minutes % 60
if hours < 24 { return mins > 0 ? "\(hours)h \(mins)m" : "\(hours)h" }
let days = hours / 24
if days < 7 { return "\(days)d \(hours % 24)h" }
let formatter = DateFormatter()
formatter.dateFormat = "MMM d"
return formatter.string(from: target)
}
}
extension GatewayUsageSummary {
func primaryRows() -> [UsageRow] {
self.providers.compactMap { provider in
if let error = provider.error, provider.windows.isEmpty {
return UsageRow(
id: provider.provider,
displayName: provider.displayName,
plan: provider.plan,
windowLabel: nil,
usedPercent: nil,
resetAt: nil,
error: error)
}
guard let window = provider.windows.max(by: { $0.usedPercent < $1.usedPercent }) else {
return nil
}
return UsageRow(
id: "\(provider.provider)-\(window.label)",
displayName: provider.displayName,
plan: provider.plan,
windowLabel: window.label,
usedPercent: window.usedPercent,
resetAt: window.resetAt.map { Date(timeIntervalSince1970: $0 / 1000) },
error: provider.error)
}
}
}
@MainActor
enum UsageLoader {
static func loadSummary() async throws -> GatewayUsageSummary {
let data = try await ControlChannel.shared.request(
method: "usage.status",
params: nil,
timeoutMs: 5000)
return try JSONDecoder().decode(GatewayUsageSummary.self, from: data)
}
}

View File

@@ -0,0 +1,46 @@
import SwiftUI
struct UsageMenuLabelView: View {
let row: UsageRow
let width: CGFloat
private let paddingLeading: CGFloat = 22
private let paddingTrailing: CGFloat = 14
private let barHeight: CGFloat = 6
private var primaryTextColor: Color { .primary }
private var secondaryTextColor: Color { .secondary }
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if let used = row.usedPercent {
ContextUsageBar(
usedTokens: Int(round(used)),
contextTokens: 100,
width: max(1, self.width - (self.paddingLeading + self.paddingTrailing)),
height: self.barHeight)
}
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(row.titleText)
.font(.caption.weight(.semibold))
.foregroundStyle(self.primaryTextColor)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
Spacer(minLength: 4)
Text(row.detailText())
.font(.caption.monospacedDigit())
.foregroundStyle(self.secondaryTextColor)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
.layoutPriority(2)
}
}
.padding(.vertical, 10)
.padding(.leading, self.paddingLeading)
.padding(.trailing, self.paddingTrailing)
}
}