132 lines
4.3 KiB
Swift
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
|
|
}
|
|
}
|
|
|