feat(mac): show context usage bars
This commit is contained in:
49
apps/macos/Sources/Clawdis/ContextUsageBar.swift
Normal file
49
apps/macos/Sources/Clawdis/ContextUsageBar.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user