refactor(mac): inject context card as NSMenuItem view
parent
778361686c
commit
164841f299
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import AppKit
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ContextUsageBar: View {
|
struct ContextUsageBar: View {
|
||||||
|
|
@ -26,27 +25,14 @@ struct ContextUsageBar: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
// SwiftUI menus (MenuBarExtraStyle.menu) drop certain view types (including ProgressView/Canvas).
|
let fraction = self.clampedFractionUsed
|
||||||
// Render the bar as an image to reliably display inside the menu.
|
|
||||||
Group {
|
Group {
|
||||||
if let width = self.width, width > 0 {
|
if let width = self.width, width > 0 {
|
||||||
Image(nsImage: Self.renderBar(
|
self.barBody(width: width, fraction: fraction)
|
||||||
width: width,
|
|
||||||
height: self.height,
|
|
||||||
fractionUsed: self.clampedFractionUsed,
|
|
||||||
percentUsed: self.percentUsed))
|
|
||||||
.resizable()
|
|
||||||
.interpolation(.none)
|
|
||||||
.frame(width: width, height: self.height)
|
.frame(width: width, height: self.height)
|
||||||
} else {
|
} else {
|
||||||
GeometryReader { proxy in
|
GeometryReader { proxy in
|
||||||
Image(nsImage: Self.renderBar(
|
self.barBody(width: proxy.size.width, fraction: fraction)
|
||||||
width: proxy.size.width,
|
|
||||||
height: self.height,
|
|
||||||
fractionUsed: self.clampedFractionUsed,
|
|
||||||
percentUsed: self.percentUsed))
|
|
||||||
.resizable()
|
|
||||||
.interpolation(.none)
|
|
||||||
.frame(width: proxy.size.width, height: self.height)
|
.frame(width: proxy.size.width, height: self.height)
|
||||||
}
|
}
|
||||||
.frame(height: self.height)
|
.frame(height: self.height)
|
||||||
|
|
@ -62,48 +48,27 @@ struct ContextUsageBar: View {
|
||||||
return "\(pct) percent used"
|
return "\(pct) percent used"
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func renderBar(
|
@ViewBuilder
|
||||||
width: CGFloat,
|
private func barBody(width: CGFloat, fraction: Double) -> some View {
|
||||||
height: CGFloat,
|
let radius = self.height / 2
|
||||||
fractionUsed: Double,
|
let trackFill = Color.white.opacity(0.12)
|
||||||
percentUsed: Int?) -> NSImage
|
let trackStroke = Color.white.opacity(0.18)
|
||||||
{
|
let fillWidth = max(1, floor(width * CGFloat(fraction)))
|
||||||
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
|
|
||||||
|
|
||||||
image.lockFocus()
|
ZStack(alignment: .leading) {
|
||||||
defer { image.unlockFocus() }
|
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)
|
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||||
let radius = rect.height / 2
|
.fill(self.tint)
|
||||||
|
.frame(width: fillWidth)
|
||||||
let background = NSColor.white.withAlphaComponent(0.12)
|
.mask {
|
||||||
let stroke = NSColor.white.withAlphaComponent(0.18)
|
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||||
|
}
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -15,6 +15,7 @@ struct ClawdisApp: App {
|
||||||
@State private var statusItem: NSStatusItem?
|
@State private var statusItem: NSStatusItem?
|
||||||
@State private var isMenuPresented = false
|
@State private var isMenuPresented = false
|
||||||
@State private var isPanelVisible = false
|
@State private var isPanelVisible = false
|
||||||
|
@State private var menuInjector = MenuContextCardInjector.shared
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func updateStatusHighlight() {
|
private func updateStatusHighlight() {
|
||||||
|
|
@ -42,6 +43,7 @@ struct ClawdisApp: App {
|
||||||
self.statusItem = item
|
self.statusItem = item
|
||||||
self.applyStatusItemAppearance(paused: self.state.isPaused)
|
self.applyStatusItemAppearance(paused: self.state.isPaused)
|
||||||
self.installStatusItemMouseHandler(for: item)
|
self.installStatusItemMouseHandler(for: item)
|
||||||
|
self.menuInjector.install(into: item)
|
||||||
}
|
}
|
||||||
.onChange(of: self.state.isPaused) { _, paused in
|
.onChange(of: self.state.isPaused) { _, paused in
|
||||||
self.applyStatusItemAppearance(paused: paused)
|
self.applyStatusItemAppearance(paused: paused)
|
||||||
|
|
|
||||||
|
|
@ -16,14 +16,6 @@ struct MenuContent: View {
|
||||||
@State private var availableMics: [AudioInputDevice] = []
|
@State private var availableMics: [AudioInputDevice] = []
|
||||||
@State private var loadingMics = false
|
@State private var loadingMics = false
|
||||||
@State private var sessionMenu: [SessionRow] = []
|
@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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
|
@ -32,7 +24,6 @@ struct MenuContent: View {
|
||||||
Text(label)
|
Text(label)
|
||||||
}
|
}
|
||||||
self.statusRow
|
self.statusRow
|
||||||
self.contextCardRow
|
|
||||||
Toggle(isOn: self.heartbeatsBinding) { Text("Send Heartbeats") }
|
Toggle(isOn: self.heartbeatsBinding) { Text("Send Heartbeats") }
|
||||||
self.heartbeatStatusRow
|
self.heartbeatStatusRow
|
||||||
Toggle(isOn: self.voiceWakeBinding) { Text("Voice Wake") }
|
Toggle(isOn: self.voiceWakeBinding) { Text("Voice Wake") }
|
||||||
|
|
@ -191,7 +182,6 @@ struct MenuContent: View {
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
await self.reloadSessionMenu()
|
await self.reloadSessionMenu()
|
||||||
await self.reloadContextSessions()
|
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
VoicePushToTalkHotkey.shared.setEnabled(voiceWakeSupported && self.state.voicePushToTalkEnabled)
|
VoicePushToTalkHotkey.shared.setEnabled(voiceWakeSupported && self.state.voicePushToTalkEnabled)
|
||||||
|
|
@ -257,75 +247,6 @@ struct MenuContent: View {
|
||||||
.disabled(true)
|
.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 {
|
private var heartbeatStatusRow: some View {
|
||||||
let (label, color): (String, Color) = {
|
let (label, color): (String, Color) = {
|
||||||
if case .degraded = self.controlChannel.state {
|
if case .degraded = self.controlChannel.state {
|
||||||
|
|
@ -476,39 +397,4 @@ struct MenuContent: View {
|
||||||
let name: String
|
let name: String
|
||||||
var id: String { self.uid }
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in New Issue