diff --git a/apps/macos/Sources/Clawdis/ContextUsageBar.swift b/apps/macos/Sources/Clawdis/ContextUsageBar.swift new file mode 100644 index 000000000..adb946c87 --- /dev/null +++ b/apps/macos/Sources/Clawdis/ContextUsageBar.swift @@ -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" + } +} + diff --git a/apps/macos/Sources/Clawdis/MenuContentView.swift b/apps/macos/Sources/Clawdis/MenuContentView.swift index 5d4f4d0d7..a90b37b4d 100644 --- a/apps/macos/Sources/Clawdis/MenuContentView.swift +++ b/apps/macos/Sources/Clawdis/MenuContentView.swift @@ -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 + } } diff --git a/apps/macos/Sources/Clawdis/SessionData.swift b/apps/macos/Sources/Clawdis/SessionData.swift index 4075d591a..b2db8dbcd 100644 --- a/apps/macos/Sources/Clawdis/SessionData.swift +++ b/apps/macos/Sources/Clawdis/SessionData.swift @@ -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 { diff --git a/apps/macos/Sources/Clawdis/SessionsSettings.swift b/apps/macos/Sources/Clawdis/SessionsSettings.swift index e8e35c8b8..018847031 100644 --- a/apps/macos/Sources/Clawdis/SessionsSettings.swift +++ b/apps/macos/Sources/Clawdis/SessionsSettings.swift @@ -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 {