macOS: compact context menu context rows
This commit is contained in:
@@ -3,17 +3,17 @@ import SwiftUI
|
|||||||
|
|
||||||
/// Context usage card shown at the top of the menubar menu.
|
/// Context usage card shown at the top of the menubar menu.
|
||||||
struct ContextMenuCardView: View {
|
struct ContextMenuCardView: View {
|
||||||
private let width: CGFloat
|
private let rows: [SessionRow]
|
||||||
|
private let statusText: String?
|
||||||
private let padding: CGFloat = 10
|
private let padding: CGFloat = 10
|
||||||
private let barHeight: CGFloat = 4
|
private let barHeight: CGFloat = 3
|
||||||
|
|
||||||
@State private var rows: [SessionRow] = []
|
init(
|
||||||
@State private var activeCount: Int = 0
|
rows: [SessionRow],
|
||||||
|
statusText: String? = nil
|
||||||
private let activeWindowSeconds: TimeInterval = 24 * 60 * 60
|
) {
|
||||||
|
self.rows = rows
|
||||||
init(width: CGFloat) {
|
self.statusText = statusText
|
||||||
self.width = width
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -28,7 +28,11 @@ struct ContextMenuCardView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.rows.isEmpty {
|
if let statusText {
|
||||||
|
Text(statusText)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else if self.rows.isEmpty {
|
||||||
Text("No active sessions")
|
Text("No active sessions")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@@ -41,7 +45,7 @@ struct ContextMenuCardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(self.padding)
|
.padding(self.padding)
|
||||||
.frame(width: self.width, alignment: .leading)
|
.frame(minWidth: 300, maxWidth: .infinity, alignment: .leading)
|
||||||
.background {
|
.background {
|
||||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
.fill(Color.white.opacity(0.04))
|
.fill(Color.white.opacity(0.04))
|
||||||
@@ -50,23 +54,22 @@ struct ContextMenuCardView: View {
|
|||||||
.strokeBorder(Color.white.opacity(0.06), lineWidth: 1)
|
.strokeBorder(Color.white.opacity(0.06), lineWidth: 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task { await self.reload() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var subtitle: String {
|
private var subtitle: String {
|
||||||
let count = self.activeCount
|
let count = self.rows.count
|
||||||
if count == 1 { return "1 session · 24h" }
|
if count == 1 { return "1 session · 24h" }
|
||||||
return "\(count) sessions · 24h"
|
return "\(count) sessions · 24h"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var contentWidth: CGFloat {
|
|
||||||
max(1, self.width - (self.padding * 2))
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func sessionRow(_ row: SessionRow) -> some View {
|
private func sessionRow(_ row: SessionRow) -> some View {
|
||||||
let width = self.contentWidth
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
ContextUsageBar(
|
||||||
|
usedTokens: row.tokens.total,
|
||||||
|
contextTokens: row.tokens.contextTokens,
|
||||||
|
height: self.barHeight)
|
||||||
|
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||||
Text(row.key)
|
Text(row.key)
|
||||||
.font(.caption.weight(row.key == "main" ? .semibold : .regular))
|
.font(.caption.weight(row.key == "main" ? .semibold : .regular))
|
||||||
@@ -81,51 +84,6 @@ struct ContextMenuCardView: View {
|
|||||||
.fixedSize(horizontal: true, vertical: false)
|
.fixedSize(horizontal: true, vertical: false)
|
||||||
.layoutPriority(2)
|
.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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
|
|||||||
static let shared = MenuContextCardInjector()
|
static let shared = MenuContextCardInjector()
|
||||||
|
|
||||||
private let tag = 9_415_227
|
private let tag = 9_415_227
|
||||||
private let cardWidth: CGFloat = 320
|
private let fallbackCardWidth: CGFloat = 320
|
||||||
private weak var originalDelegate: NSMenuDelegate?
|
private weak var originalDelegate: NSMenuDelegate?
|
||||||
|
private var loadTask: Task<Void, Never>?
|
||||||
|
private let activeWindowSeconds: TimeInterval = 24 * 60 * 60
|
||||||
|
|
||||||
func install(into statusItem: NSStatusItem) {
|
func install(into statusItem: NSStatusItem) {
|
||||||
// SwiftUI owns the menu, but we can inject a custom NSMenuItem.view right before display.
|
// SwiftUI owns the menu, but we can inject a custom NSMenuItem.view right before display.
|
||||||
@@ -29,10 +31,15 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
|
|||||||
|
|
||||||
guard let insertIndex = self.findInsertIndex(in: menu) else { return }
|
guard let insertIndex = self.findInsertIndex(in: menu) else { return }
|
||||||
|
|
||||||
let cardView = ContextMenuCardView(width: self.cardWidth)
|
self.loadTask?.cancel()
|
||||||
let hosting = NSHostingView(rootView: cardView)
|
|
||||||
|
let placeholder = AnyView(ContextMenuCardView(
|
||||||
|
rows: [],
|
||||||
|
statusText: "Loading sessions…"))
|
||||||
|
|
||||||
|
let hosting = NSHostingView(rootView: placeholder)
|
||||||
let size = hosting.fittingSize
|
let size = hosting.fittingSize
|
||||||
hosting.frame = NSRect(origin: .zero, size: NSSize(width: self.cardWidth, height: size.height))
|
hosting.frame = NSRect(origin: .zero, size: NSSize(width: self.initialCardWidth(for: menu), height: size.height))
|
||||||
|
|
||||||
let item = NSMenuItem()
|
let item = NSMenuItem()
|
||||||
item.tag = self.tag
|
item.tag = self.tag
|
||||||
@@ -40,10 +47,30 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
|
|||||||
item.isEnabled = false
|
item.isEnabled = false
|
||||||
|
|
||||||
menu.insertItem(item, at: insertIndex)
|
menu.insertItem(item, at: insertIndex)
|
||||||
|
|
||||||
|
// After the menu attaches the view to its window, adopt the menu's computed width.
|
||||||
|
DispatchQueue.main.async { [weak self, weak hosting] in
|
||||||
|
guard let self, let hosting else { return }
|
||||||
|
self.adoptMenuWidthIfAvailable(for: menu, hosting: hosting)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.loadTask = Task { [weak hosting] in
|
||||||
|
let view = await self.makeCardView()
|
||||||
|
await MainActor.run {
|
||||||
|
hosting?.rootView = view
|
||||||
|
hosting?.invalidateIntrinsicContentSize()
|
||||||
|
if let hosting {
|
||||||
|
self.adoptMenuWidthIfAvailable(for: menu, hosting: hosting)
|
||||||
|
let size = hosting.fittingSize
|
||||||
|
hosting.frame.size.height = size.height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func menuDidClose(_ menu: NSMenu) {
|
func menuDidClose(_ menu: NSMenu) {
|
||||||
self.originalDelegate?.menuDidClose?(menu)
|
self.originalDelegate?.menuDidClose?(menu)
|
||||||
|
self.loadTask?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
func menuNeedsUpdate(_ menu: NSMenu) {
|
func menuNeedsUpdate(_ menu: NSMenu) {
|
||||||
@@ -57,6 +84,34 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
|
|||||||
return NSRect.zero
|
return NSRect.zero
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func makeCardView() async -> AnyView {
|
||||||
|
let hints = SessionLoader.configHints()
|
||||||
|
let store = SessionLoader.resolveStorePath(override: hints.storePath)
|
||||||
|
let defaults = SessionDefaults(
|
||||||
|
model: hints.model ?? SessionLoader.fallbackModel,
|
||||||
|
contextTokens: hints.contextTokens ?? SessionLoader.fallbackContextTokens)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let loaded = try await SessionLoader.loadRows(at: store, defaults: defaults)
|
||||||
|
let now = Date()
|
||||||
|
let current = loaded.filter { row in
|
||||||
|
if row.key == "main" { return true }
|
||||||
|
guard let updatedAt = row.updatedAt else { return false }
|
||||||
|
return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
let sorted = current.sorted { lhs, rhs in
|
||||||
|
if lhs.key == "main" { return true }
|
||||||
|
if rhs.key == "main" { return false }
|
||||||
|
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
|
||||||
|
}
|
||||||
|
|
||||||
|
return AnyView(ContextMenuCardView(rows: sorted))
|
||||||
|
} catch {
|
||||||
|
return AnyView(ContextMenuCardView(rows: [], statusText: "Could not load sessions"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func findInsertIndex(in menu: NSMenu) -> Int? {
|
private func findInsertIndex(in menu: NSMenu) -> Int? {
|
||||||
// Prefer inserting before the "Send Heartbeats" toggle item.
|
// Prefer inserting before the "Send Heartbeats" toggle item.
|
||||||
if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) {
|
if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) {
|
||||||
@@ -66,4 +121,31 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
|
|||||||
if menu.items.count >= 2 { return 2 }
|
if menu.items.count >= 2 { return 2 }
|
||||||
return menu.items.count
|
return menu.items.count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func initialCardWidth(for menu: NSMenu) -> CGFloat {
|
||||||
|
let width = menu.minimumWidth
|
||||||
|
if width > 0 { return max(300, width) }
|
||||||
|
return 300
|
||||||
|
}
|
||||||
|
|
||||||
|
private func adoptMenuWidthIfAvailable(for menu: NSMenu, hosting: NSHostingView<AnyView>) {
|
||||||
|
let targetWidth: CGFloat? = {
|
||||||
|
if let contentWidth = hosting.window?.contentView?.bounds.width, contentWidth > 0 { return contentWidth }
|
||||||
|
if let superWidth = hosting.superview?.bounds.width, superWidth > 0 { return superWidth }
|
||||||
|
let minimumWidth = menu.minimumWidth
|
||||||
|
if minimumWidth > 0 { return minimumWidth }
|
||||||
|
return nil
|
||||||
|
}()
|
||||||
|
|
||||||
|
guard let targetWidth else {
|
||||||
|
if hosting.frame.width <= 0 {
|
||||||
|
hosting.frame.size.width = self.fallbackCardWidth
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let clamped = max(300, targetWidth)
|
||||||
|
if abs(hosting.frame.width - clamped) < 1 { return }
|
||||||
|
hosting.frame.size.width = clamped
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user