feat(mac): show context usage bars

This commit is contained in:
Peter Steinberger
2025-12-12 23:33:15 +00:00
parent d5d80f4247
commit 35b7c0f558
4 changed files with 178 additions and 38 deletions

View File

@@ -0,0 +1,49 @@
import SwiftUI
struct ContextUsageBar: View {
let usedTokens: Int
let contextTokens: Int
var height: CGFloat = 6
private var clampedFractionUsed: Double {
guard self.contextTokens > 0 else { return 0 }
return min(1, max(0, Double(self.usedTokens) / Double(self.contextTokens)))
}
private var percentUsed: Int? {
guard self.contextTokens > 0, self.usedTokens > 0 else { return nil }
return min(100, Int(round(self.clampedFractionUsed * 100)))
}
private var tint: Color {
guard let pct = self.percentUsed else { return .secondary }
if pct >= 95 { return Color(nsColor: .systemRed) }
if pct >= 80 { return Color(nsColor: .systemOrange) }
if pct >= 60 { return Color(nsColor: .systemYellow) }
return Color(nsColor: .systemGreen)
}
var body: some View {
GeometryReader { proxy in
let fillWidth = proxy.size.width * self.clampedFractionUsed
ZStack(alignment: .leading) {
Capsule()
.fill(Color.secondary.opacity(0.25))
Capsule()
.fill(self.tint)
.frame(width: fillWidth)
}
}
.frame(height: self.height)
.accessibilityLabel("Context usage")
.accessibilityValue(self.accessibilityValue)
.drawingGroup()
}
private var accessibilityValue: String {
if self.contextTokens <= 0 { return "Unknown context window" }
let pct = Int(round(self.clampedFractionUsed * 100))
return "\(pct) percent used"
}
}

View File

@@ -16,6 +16,7 @@ struct MenuContent: View {
@State private var availableMics: [AudioInputDevice] = []
@State private var loadingMics = false
@State private var sessionMenu: [SessionRow] = []
@State private var mainSessionRow: SessionRow?
var body: some View {
VStack(alignment: .leading, spacing: 8) {
@@ -24,6 +25,7 @@ struct MenuContent: View {
Text(label)
}
self.statusRow
self.mainSessionContextRow
Toggle(isOn: self.heartbeatsBinding) { Text("Send Heartbeats") }
self.heartbeatStatusRow
Toggle(isOn: self.voiceWakeBinding) { Text("Voice Wake") }
@@ -182,6 +184,7 @@ struct MenuContent: View {
}
.task {
await self.reloadSessionMenu()
await self.reloadMainSessionRow()
}
.task {
VoicePushToTalkHotkey.shared.setEnabled(voiceWakeSupported && self.state.voicePushToTalkEnabled)
@@ -247,6 +250,38 @@ struct MenuContent: View {
.disabled(true)
}
@ViewBuilder
private var mainSessionContextRow: some View {
if let row = self.mainSessionRow {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Text("Context (\(row.key))")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Spacer()
Text(row.tokens.contextSummaryShort)
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
ContextUsageBar(
usedTokens: row.tokens.total,
contextTokens: row.tokens.contextTokens)
}
.padding(.vertical, 2)
} else {
HStack(spacing: 8) {
Text("Context (main)")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Spacer()
Text("")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
.padding(.vertical, 2)
}
}
private var heartbeatStatusRow: some View {
let (label, color): (String, Color) = {
if case .degraded = self.controlChannel.state {
@@ -397,4 +432,22 @@ struct MenuContent: View {
let name: String
var id: String { self.uid }
}
private func reloadMainSessionRow() 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 rows = try? await SessionLoader.loadRows(at: store, defaults: defaults) else {
self.mainSessionRow = nil
return
}
let preferred = WebChatManager.shared.preferredSessionKey()
self.mainSessionRow =
rows.first(where: { $0.key == "main" }) ??
rows.first(where: { $0.key == preferred }) ??
rows.first
}
}

View File

@@ -21,6 +21,10 @@ struct SessionTokenStats {
let total: Int
let contextTokens: Int
var contextSummaryShort: String {
"\(Self.formatKTokens(self.total))/\(Self.formatKTokens(self.contextTokens))"
}
var percentUsed: Int? {
guard self.contextTokens > 0, self.total > 0 else { return nil }
return min(100, Int(round((Double(self.total) / Double(self.contextTokens)) * 100)))
@@ -34,6 +38,13 @@ struct SessionTokenStats {
}
return text
}
static func formatKTokens(_ value: Int) -> String {
if value < 1000 { return "\(value)" }
let thousands = Double(value) / 1000
let decimals = value >= 10_000 ? 0 : 1
return String(format: "%.\(decimals)fk", thousands)
}
}
struct SessionRow: Identifiable {

View File

@@ -108,54 +108,81 @@ struct SessionsSettings: View {
.foregroundStyle(.secondary)
.padding(.top, 6)
} else {
Table(self.rows) {
TableColumn("Key") { row in
VStack(alignment: .leading, spacing: 4) {
Text(row.key)
.font(.body.weight(.semibold))
HStack(spacing: 6) {
if row.kind != .direct {
SessionKindBadge(kind: row.kind)
}
if !row.flagLabels.isEmpty {
ForEach(row.flagLabels, id: \.self) { flag in
Badge(text: flag)
}
}
}
}
List(self.rows) { row in
self.sessionRow(row)
}
.listStyle(.inset)
}
}
}
@ViewBuilder
private func sessionRow(_ row: SessionRow) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(row.key)
.font(.subheadline.bold())
.lineLimit(1)
.truncationMode(.middle)
Spacer()
Text(row.ageText)
.font(.caption)
.foregroundStyle(.secondary)
}
HStack(spacing: 6) {
if row.kind != .direct {
SessionKindBadge(kind: row.kind)
}
if !row.flagLabels.isEmpty {
ForEach(row.flagLabels, id: \.self) { flag in
Badge(text: flag)
}
.width(220)
}
}
TableColumn("Updated", value: \.ageText)
.width(70)
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Text("Context")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Spacer()
Text(row.tokens.contextSummaryShort)
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
ContextUsageBar(usedTokens: row.tokens.total, contextTokens: row.tokens.contextTokens)
}
TableColumn("Tokens") { row in
Text(row.tokens.summary)
.font(.caption)
.foregroundStyle(.secondary)
}
.width(170)
TableColumn("Model") { row in
Text(row.model ?? "")
.font(.caption)
.foregroundStyle(.secondary)
}
.width(120)
TableColumn("Session ID") { row in
Text(row.sessionId ?? "")
.font(.caption.monospaced())
HStack(spacing: 10) {
if let model = row.model, !model.isEmpty {
self.label(icon: "cpu", text: model)
}
self.label(icon: "arrow.down.left", text: "\(row.tokens.input) in")
self.label(icon: "arrow.up.right", text: "\(row.tokens.output) out")
if let sessionId = row.sessionId, !sessionId.isEmpty {
HStack(spacing: 4) {
Image(systemName: "number").foregroundStyle(.secondary).font(.caption)
Text(sessionId)
.font(.footnote.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
.help(sessionId)
}
.tableStyle(.inset(alternatesRowBackgrounds: true))
.frame(maxHeight: .infinity, alignment: .top)
}
}
.padding(.vertical, 6)
}
private func label(icon: String, text: String) -> some View {
HStack(spacing: 4) {
Image(systemName: icon).foregroundStyle(.secondary).font(.caption)
Text(text)
}
.font(.footnote)
.foregroundStyle(.secondary)
}
private func refresh() async {