Files
clawdbot/apps/macos/Sources/Clawdis/ContextMenuCardView.swift
2025-12-13 03:03:08 +00:00

132 lines
4.3 KiB
Swift

import Foundation
import SwiftUI
/// Context usage card shown at the top of the menubar menu.
struct ContextMenuCardView: View {
private let width: CGFloat
private let padding: CGFloat = 10
private let barHeight: CGFloat = 4
@State private var rows: [SessionRow] = []
@State private var activeCount: Int = 0
private let activeWindowSeconds: TimeInterval = 24 * 60 * 60
init(width: CGFloat) {
self.width = width
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline) {
Text("Context")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Spacer(minLength: 10)
Text(self.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
if self.rows.isEmpty {
Text("No active sessions")
.font(.caption)
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 8) {
ForEach(self.rows) { row in
self.sessionRow(row)
}
}
}
}
.padding(self.padding)
.frame(width: self.width, alignment: .leading)
.background {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.white.opacity(0.04))
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.white.opacity(0.06), lineWidth: 1)
}
}
.task { await self.reload() }
}
private var subtitle: String {
let count = self.activeCount
if count == 1 { return "1 session · 24h" }
return "\(count) sessions · 24h"
}
private var contentWidth: CGFloat {
max(1, self.width - (self.padding * 2))
}
@ViewBuilder
private func sessionRow(_ row: SessionRow) -> some View {
let width = self.contentWidth
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(row.key)
.font(.caption.weight(row.key == "main" ? .semibold : .regular))
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
Spacer(minLength: 8)
Text(row.tokens.contextSummaryShort)
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
.layoutPriority(2)
}
.frame(width: width)
ContextUsageBar(
usedTokens: row.tokens.total,
contextTokens: row.tokens.contextTokens,
width: width,
height: self.barHeight)
}
.frame(width: width)
}
@MainActor
private func reload() async {
let hints = SessionLoader.configHints()
let store = SessionLoader.resolveStorePath(override: hints.storePath)
let defaults = SessionDefaults(
model: hints.model ?? SessionLoader.fallbackModel,
contextTokens: hints.contextTokens ?? SessionLoader.fallbackContextTokens)
guard let loaded = try? await SessionLoader.loadRows(at: store, defaults: defaults) else {
self.rows = []
self.activeCount = 0
return
}
let now = Date()
let active = loaded.filter { row in
guard let updatedAt = row.updatedAt else { return false }
return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds
}
let main = loaded.first(where: { $0.key == "main" })
var merged = active
if let main, !merged.contains(where: { $0.key == "main" }) {
merged.insert(main, at: 0)
}
merged.sort { lhs, rhs in
if lhs.key == "main" { return true }
if rhs.key == "main" { return false }
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
}
self.rows = merged
self.activeCount = active.count
}
}