Merge remote-tracking branch 'origin/main'
This commit is contained in:
162
apps/macos/Sources/Clawdis/ContextUsageRow.swift
Normal file
162
apps/macos/Sources/Clawdis/ContextUsageRow.swift
Normal file
@@ -0,0 +1,162 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ struct MenuContent: View {
|
||||
private let contextCardPadding: CGFloat = 10
|
||||
private let contextBarHeight: CGFloat = 4
|
||||
private let contextFallbackWidth: CGFloat = 320
|
||||
private let contextSessionRowHeight: CGFloat = 18
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
@@ -316,32 +315,15 @@ struct MenuContent: View {
|
||||
@ViewBuilder
|
||||
private func contextSessionRow(_ row: SessionRow) -> some View {
|
||||
let width = self.contextPillWidth
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
ContextUsageBar(
|
||||
usedTokens: row.tokens.total,
|
||||
contextTokens: row.tokens.contextTokens,
|
||||
width: width,
|
||||
height: self.contextBarHeight)
|
||||
.padding(.bottom, 0)
|
||||
|
||||
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)
|
||||
}
|
||||
.padding(.bottom, self.contextBarHeight + 4)
|
||||
.frame(width: width)
|
||||
}
|
||||
.frame(width: width, height: self.contextSessionRowHeight, alignment: .bottomLeading)
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user