refactor(mac): inject context card as NSMenuItem view

This commit is contained in:
Peter Steinberger
2025-12-13 03:03:08 +00:00
parent 778361686c
commit 164841f299
6 changed files with 203 additions and 334 deletions

View File

@@ -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
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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()
}
}

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -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
}
}