From 164841f299f6be4b4336bd38d4c65d22ddf9cfa2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 13 Dec 2025 03:03:08 +0000 Subject: [PATCH] refactor(mac): inject context card as NSMenuItem view --- .../Sources/Clawdis/ContextMenuCardView.swift | 131 ++++++++++++++ .../Sources/Clawdis/ContextUsageBar.swift | 81 +++------ .../Sources/Clawdis/ContextUsageRow.swift | 162 ------------------ apps/macos/Sources/Clawdis/MenuBar.swift | 2 + .../Sources/Clawdis/MenuContentView.swift | 114 ------------ .../Clawdis/MenuContextCardInjector.swift | 47 +++++ 6 files changed, 203 insertions(+), 334 deletions(-) create mode 100644 apps/macos/Sources/Clawdis/ContextMenuCardView.swift delete mode 100644 apps/macos/Sources/Clawdis/ContextUsageRow.swift create mode 100644 apps/macos/Sources/Clawdis/MenuContextCardInjector.swift diff --git a/apps/macos/Sources/Clawdis/ContextMenuCardView.swift b/apps/macos/Sources/Clawdis/ContextMenuCardView.swift new file mode 100644 index 000000000..a7848e963 --- /dev/null +++ b/apps/macos/Sources/Clawdis/ContextMenuCardView.swift @@ -0,0 +1,131 @@ +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 + } +} + diff --git a/apps/macos/Sources/Clawdis/ContextUsageBar.swift b/apps/macos/Sources/Clawdis/ContextUsageBar.swift index 0e8beb6c6..829e11882 100644 --- a/apps/macos/Sources/Clawdis/ContextUsageBar.swift +++ b/apps/macos/Sources/Clawdis/ContextUsageBar.swift @@ -1,4 +1,3 @@ -import AppKit import SwiftUI struct ContextUsageBar: View { @@ -26,27 +25,14 @@ struct ContextUsageBar: View { } var body: some View { - // SwiftUI menus (MenuBarExtraStyle.menu) drop certain view types (including ProgressView/Canvas). - // Render the bar as an image to reliably display inside the menu. + let fraction = self.clampedFractionUsed Group { if let width = self.width, width > 0 { - Image(nsImage: Self.renderBar( - width: width, - height: self.height, - fractionUsed: self.clampedFractionUsed, - percentUsed: self.percentUsed)) - .resizable() - .interpolation(.none) + self.barBody(width: width, fraction: fraction) .frame(width: width, height: self.height) } else { GeometryReader { proxy in - Image(nsImage: Self.renderBar( - width: proxy.size.width, - height: self.height, - fractionUsed: self.clampedFractionUsed, - percentUsed: self.percentUsed)) - .resizable() - .interpolation(.none) + self.barBody(width: proxy.size.width, fraction: fraction) .frame(width: proxy.size.width, height: self.height) } .frame(height: self.height) @@ -62,48 +48,27 @@ struct ContextUsageBar: View { return "\(pct) percent used" } - private static func renderBar( - width: CGFloat, - height: CGFloat, - fractionUsed: Double, - percentUsed: Int?) -> NSImage - { - let clamped = min(1, max(0, fractionUsed)) - let size = NSSize(width: max(1, width), height: max(1, height)) - let image = NSImage(size: size) - image.isTemplate = false + @ViewBuilder + private func barBody(width: CGFloat, fraction: Double) -> some View { + let radius = self.height / 2 + let trackFill = Color.white.opacity(0.12) + let trackStroke = Color.white.opacity(0.18) + let fillWidth = max(1, floor(width * CGFloat(fraction))) - image.lockFocus() - defer { image.unlockFocus() } + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: radius, style: .continuous) + .fill(trackFill) + .overlay { + RoundedRectangle(cornerRadius: radius, style: .continuous) + .strokeBorder(trackStroke, lineWidth: 0.75) + } - let rect = NSRect(origin: .zero, size: size) - let radius = rect.height / 2 - - let background = NSColor.white.withAlphaComponent(0.12) - let stroke = NSColor.white.withAlphaComponent(0.18) - - let fill: NSColor = { - guard let pct = percentUsed else { return NSColor.secondaryLabelColor } - if pct >= 95 { return .systemRed } - if pct >= 80 { return .systemOrange } - if pct >= 60 { return .systemYellow } - return .systemGreen - }() - - let track = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius) - background.setFill() - track.fill() - stroke.setStroke() - track.lineWidth = 0.75 - track.stroke() - - let fillWidth = max(1, floor(rect.width * clamped)) - let fillRect = NSRect(x: rect.minX, y: rect.minY, width: fillWidth, height: rect.height) - let clip = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius) - clip.addClip() - fill.setFill() - NSBezierPath(rect: fillRect).fill() - - return image + RoundedRectangle(cornerRadius: radius, style: .continuous) + .fill(self.tint) + .frame(width: fillWidth) + .mask { + RoundedRectangle(cornerRadius: radius, style: .continuous) + } + } } } diff --git a/apps/macos/Sources/Clawdis/ContextUsageRow.swift b/apps/macos/Sources/Clawdis/ContextUsageRow.swift deleted file mode 100644 index a514242b3..000000000 --- a/apps/macos/Sources/Clawdis/ContextUsageRow.swift +++ /dev/null @@ -1,162 +0,0 @@ -import AppKit -import SwiftUI - -/// Single-row context usage display that stays intact inside menu rendering. -/// -/// SwiftUI menus tend to decompose view hierarchies into separate menu rows -/// (image row, text row, etc.). We render the combined layout into an image -/// so session name + numbers are guaranteed to appear on the same row. -struct ContextUsageRow: View { - let sessionKey: String - let summary: String - let usedTokens: Int - let contextTokens: Int - let width: CGFloat - var barHeight: CGFloat = 4 - var rowHeight: CGFloat = 18 - var isMain: Bool = false - - var body: some View { - Image(nsImage: Self.renderRow( - width: self.width, - rowHeight: self.rowHeight, - barHeight: self.barHeight, - sessionKey: self.sessionKey, - summary: self.summary, - usedTokens: self.usedTokens, - contextTokens: self.contextTokens, - isMain: self.isMain)) - .resizable() - .interpolation(.none) - .frame(width: self.width, height: self.rowHeight) - .accessibilityLabel("Context usage") - .accessibilityValue("\(self.sessionKey) \(self.summary)") - } - - private static func renderRow( - width: CGFloat, - rowHeight: CGFloat, - barHeight: CGFloat, - sessionKey: String, - summary: String, - usedTokens: Int, - contextTokens: Int, - isMain: Bool - ) -> NSImage { - let safeWidth = max(1, width) - let safeRowHeight = max(1, rowHeight) - let safeBarHeight = min(max(1, barHeight), safeRowHeight) - - let size = NSSize(width: safeWidth, height: safeRowHeight) - let image = NSImage(size: size) - image.isTemplate = false - - image.lockFocus() - defer { image.unlockFocus() } - - let barRect = NSRect(x: 0, y: 0, width: size.width, height: safeBarHeight) - drawBar(in: barRect, usedTokens: usedTokens, contextTokens: contextTokens) - - let textRect = NSRect( - x: 0, - y: safeBarHeight, - width: size.width, - height: size.height - safeBarHeight - ) - drawText(in: textRect, sessionKey: sessionKey, summary: summary, isMain: isMain) - - return image - } - - private static func drawText(in rect: NSRect, sessionKey: String, summary: String, isMain: Bool) { - guard rect.width > 1, rect.height > 1 else { return } - - let keyFont = NSFont.systemFont( - ofSize: NSFont.smallSystemFontSize, - weight: isMain ? .semibold : .regular - ) - let summaryFont = NSFont.monospacedDigitSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular) - - let keyParagraph = NSMutableParagraphStyle() - keyParagraph.alignment = .left - keyParagraph.lineBreakMode = .byTruncatingMiddle - - let summaryParagraph = NSMutableParagraphStyle() - summaryParagraph.alignment = .right - summaryParagraph.lineBreakMode = .byClipping - - let keyAttr = NSAttributedString( - string: sessionKey, - attributes: [ - .font: keyFont, - .foregroundColor: NSColor.labelColor, - .paragraphStyle: keyParagraph, - ] - ) - let summaryAttr = NSAttributedString( - string: summary, - attributes: [ - .font: summaryFont, - .foregroundColor: NSColor.secondaryLabelColor, - .paragraphStyle: summaryParagraph, - ] - ) - - let summarySize = summaryAttr.size() - let gap: CGFloat = 10 - let rightWidth = min(rect.width, ceil(summarySize.width)) - let leftWidth = max(1, rect.width - rightWidth - gap) - - let textHeight = max(keyAttr.size().height, summarySize.height) - let y = rect.minY + floor((rect.height - textHeight) / 2) - - let leftRect = NSRect(x: rect.minX, y: y, width: leftWidth, height: textHeight) - keyAttr.draw(with: leftRect, options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine]) - - let rightRect = NSRect( - x: rect.maxX - rightWidth, - y: y, - width: rightWidth, - height: textHeight - ) - summaryAttr.draw(with: rightRect, options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine]) - } - - private static func drawBar(in rect: NSRect, usedTokens: Int, contextTokens: Int) { - let radius = rect.height / 2 - let background = NSColor.white.withAlphaComponent(0.12) - let stroke = NSColor.white.withAlphaComponent(0.18) - - let fractionUsed: Double = { - guard contextTokens > 0 else { return 0 } - return min(1, max(0, Double(usedTokens) / Double(contextTokens))) - }() - let percentUsed: Int? = { - guard contextTokens > 0, usedTokens > 0 else { return nil } - return min(100, Int(round(fractionUsed * 100))) - }() - - let fill: NSColor = { - guard let pct = percentUsed else { return NSColor.secondaryLabelColor } - if pct >= 95 { return .systemRed } - if pct >= 80 { return .systemOrange } - if pct >= 60 { return .systemYellow } - return .systemGreen - }() - - let track = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius) - background.setFill() - track.fill() - stroke.setStroke() - track.lineWidth = 0.75 - track.stroke() - - let fillWidth = max(1, floor(rect.width * fractionUsed)) - let fillRect = NSRect(x: rect.minX, y: rect.minY, width: fillWidth, height: rect.height) - let clip = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius) - clip.addClip() - fill.setFill() - NSBezierPath(rect: fillRect).fill() - } -} - diff --git a/apps/macos/Sources/Clawdis/MenuBar.swift b/apps/macos/Sources/Clawdis/MenuBar.swift index daf06a162..a55c70ec7 100644 --- a/apps/macos/Sources/Clawdis/MenuBar.swift +++ b/apps/macos/Sources/Clawdis/MenuBar.swift @@ -15,6 +15,7 @@ struct ClawdisApp: App { @State private var statusItem: NSStatusItem? @State private var isMenuPresented = false @State private var isPanelVisible = false + @State private var menuInjector = MenuContextCardInjector.shared @MainActor private func updateStatusHighlight() { @@ -42,6 +43,7 @@ struct ClawdisApp: App { self.statusItem = item self.applyStatusItemAppearance(paused: self.state.isPaused) self.installStatusItemMouseHandler(for: item) + self.menuInjector.install(into: item) } .onChange(of: self.state.isPaused) { _, paused in self.applyStatusItemAppearance(paused: paused) diff --git a/apps/macos/Sources/Clawdis/MenuContentView.swift b/apps/macos/Sources/Clawdis/MenuContentView.swift index 02a958c4f..5d4f4d0d7 100644 --- a/apps/macos/Sources/Clawdis/MenuContentView.swift +++ b/apps/macos/Sources/Clawdis/MenuContentView.swift @@ -16,14 +16,6 @@ struct MenuContent: View { @State private var availableMics: [AudioInputDevice] = [] @State private var loadingMics = false @State private var sessionMenu: [SessionRow] = [] - @State private var contextSessions: [SessionRow] = [] - @State private var contextActiveCount: Int = 0 - @State private var contextCardWidth: CGFloat = 320 - - private let activeSessionWindowSeconds: TimeInterval = 24 * 60 * 60 - private let contextCardPadding: CGFloat = 10 - private let contextBarHeight: CGFloat = 4 - private let contextFallbackWidth: CGFloat = 320 var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -32,7 +24,6 @@ struct MenuContent: View { Text(label) } self.statusRow - self.contextCardRow Toggle(isOn: self.heartbeatsBinding) { Text("Send Heartbeats") } self.heartbeatStatusRow Toggle(isOn: self.voiceWakeBinding) { Text("Voice Wake") } @@ -191,7 +182,6 @@ struct MenuContent: View { } .task { await self.reloadSessionMenu() - await self.reloadContextSessions() } .task { VoicePushToTalkHotkey.shared.setEnabled(voiceWakeSupported && self.state.voicePushToTalkEnabled) @@ -257,75 +247,6 @@ struct MenuContent: View { .disabled(true) } - @ViewBuilder - private var contextCardRow: some View { - MenuHostedItem( - width: self.contextCardWidth, - rootView: AnyView(self.contextCardView)) - } - - private var contextPillWidth: CGFloat { - let base = self.contextCardWidth > 0 ? self.contextCardWidth : self.contextFallbackWidth - return max(1, base - (self.contextCardPadding * 2)) - } - - @ViewBuilder - private var contextCardView: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .firstTextBaseline) { - Text("Context") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - Spacer(minLength: 10) - Text(self.contextSubtitle) - .font(.caption) - .foregroundStyle(.secondary) - } - - if self.contextSessions.isEmpty { - Text("No active sessions") - .font(.caption) - .foregroundStyle(.secondary) - } else { - VStack(alignment: .leading, spacing: 8) { - ForEach(self.contextSessions) { row in - self.contextSessionRow(row) - } - } - } - } - .padding(self.contextCardPadding) - .frame(width: self.contextCardWidth, 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) - } - } - } - - private var contextSubtitle: String { - let count = self.contextActiveCount - if count == 1 { return "1 session · 24h" } - return "\(count) sessions · 24h" - } - - @ViewBuilder - private func contextSessionRow(_ row: SessionRow) -> some View { - let width = self.contextPillWidth - ContextUsageRow( - sessionKey: row.key, - summary: row.tokens.contextSummaryShort, - usedTokens: row.tokens.total, - contextTokens: row.tokens.contextTokens, - width: width, - barHeight: self.contextBarHeight, - rowHeight: 18, - isMain: row.key == "main") - } - private var heartbeatStatusRow: some View { let (label, color): (String, Color) = { if case .degraded = self.controlChannel.state { @@ -476,39 +397,4 @@ struct MenuContent: View { let name: String var id: String { self.uid } } - - private func reloadContextSessions() 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.contextSessions = [] - return - } - - let now = Date() - let active = rows.filter { row in - guard let updatedAt = row.updatedAt else { return false } - return now.timeIntervalSince(updatedAt) <= self.activeSessionWindowSeconds - } - - let activeCount = active.count - let main = rows.first(where: { $0.key == "main" }) - var merged = active - if let main, !merged.contains(where: { $0.key == "main" }) { - merged.insert(main, at: 0) - } - // Keep stable ordering: main first, then most recent. - let sorted = merged.sorted { lhs, rhs in - if lhs.key == "main" { return true } - if rhs.key == "main" { return false } - return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast) - } - - self.contextSessions = sorted - self.contextActiveCount = activeCount - } } diff --git a/apps/macos/Sources/Clawdis/MenuContextCardInjector.swift b/apps/macos/Sources/Clawdis/MenuContextCardInjector.swift new file mode 100644 index 000000000..fa6a22ca6 --- /dev/null +++ b/apps/macos/Sources/Clawdis/MenuContextCardInjector.swift @@ -0,0 +1,47 @@ +import AppKit +import SwiftUI + +@MainActor +final class MenuContextCardInjector: NSObject, NSMenuDelegate { + static let shared = MenuContextCardInjector() + + private let tag = 9_415_227 + private let cardWidth: CGFloat = 320 + + func install(into statusItem: NSStatusItem) { + // SwiftUI owns the menu, but we can inject a custom NSMenuItem.view right before display. + statusItem.menu?.delegate = self + } + + func menuWillOpen(_ menu: NSMenu) { + // Remove any previous injected card items. + for item in menu.items where item.tag == self.tag { + menu.removeItem(item) + } + + guard let insertIndex = self.findInsertIndex(in: menu) else { return } + + let cardView = ContextMenuCardView(width: self.cardWidth) + let hosting = NSHostingView(rootView: cardView) + let size = hosting.fittingSize + hosting.frame = NSRect(origin: .zero, size: NSSize(width: self.cardWidth, height: size.height)) + + let item = NSMenuItem() + item.tag = self.tag + item.view = hosting + item.isEnabled = false + + menu.insertItem(item, at: insertIndex) + } + + private func findInsertIndex(in menu: NSMenu) -> Int? { + // Prefer inserting before the "Send Heartbeats" toggle item. + if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) { + return idx + } + // Fallback: insert after the first two rows (active toggle + status). + if menu.items.count >= 2 { return 2 } + return menu.items.count + } +} +