Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions CopilotMonitor/CopilotMonitor/App/StatusBarController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -868,14 +868,19 @@
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm"
formatter.timeZone = TimeZone(identifier: "UTC") ?? TimeZone(secondsFromGMT: 0)!
let resetItem = NSMenuItem()
resetItem.view = createDisabledLabelView(text: "Resets: \(formatter.string(from: resetDate)) UTC", indent: 18)
submenu.addItem(resetItem)

let paceInfo = calculateMonthlyPace(usagePercent: usagePercent, resetDate: resetDate)
let paceItem = NSMenuItem()
paceItem.view = createPaceView(paceInfo: paceInfo)
submenu.addItem(paceItem)

let resetItem = NSMenuItem()
resetItem.view = createDisabledLabelView(
text: "Resets: \(formatter.string(from: resetDate)) UTC",
indent: 0,
textColor: .secondaryLabelColor
)
submenu.addItem(resetItem)
debugLog("updateMultiProviderMenu: reset row tone aligned with pace text for copilot fallback")
}

submenu.addItem(NSMenuItem.separator())
Expand Down Expand Up @@ -1590,7 +1595,8 @@
underline: Bool = false,
monospaced: Bool = false,
multiline: Bool = false,
indent: CGFloat = 0
indent: CGFloat = 0,
textColor: NSColor = .secondaryLabelColor
) -> NSView {
var leadingOffset: CGFloat = MenuDesignToken.Spacing.leadingOffset + indent
let menuWidth: CGFloat = MenuDesignToken.Dimension.menuWidth
Expand Down Expand Up @@ -1626,7 +1632,7 @@
let label = NSTextField(labelWithString: "")

var attrs: [NSAttributedString.Key: Any] = [
.foregroundColor: NSColor.secondaryLabelColor,
.foregroundColor: textColor,
.font: labelFont
]

Expand Down Expand Up @@ -2114,7 +2120,7 @@
// 3. OpenRouter - only has current cost, no daily history
// We'll include today's cost if available
if let routerResult = providerResults[.openRouter],
case .payAsYouGo(_, let cost, _) = routerResult.usage,

Check warning on line 2123 in CopilotMonitor/CopilotMonitor/App/StatusBarController.swift

View workflow job for this annotation

GitHub Actions / Build & Test

immutable value 'cost' was never used; consider replacing with '_' or removing it

Check warning on line 2123 in CopilotMonitor/CopilotMonitor/App/StatusBarController.swift

View workflow job for this annotation

GitHub Actions / Build & Test

immutable value 'cost' was never used; consider replacing with '_' or removing it
let dailyCost = routerResult.details?.dailyUsage {
let today = Calendar.current.startOfDay(for: Date())
if aggregatedDailyCosts[today] == nil {
Expand Down
201 changes: 156 additions & 45 deletions CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -755,7 +755,7 @@ extension StatusBarController {
&& (groupedUsageWindows.first?.models.count ?? 0) > 1
&& groupedUsageWindows.first?.resetDate != nil
if shouldAddWindowInfoDivider {
debugLog("\(debugContext): adding divider between model list and reset/pace info")
debugLog("\(debugContext): adding divider between model list and pace/reset info")
}

// Keep one model per row to avoid long wrapped labels while still sharing reset/pace
Expand All @@ -774,21 +774,24 @@ extension StatusBarController {
addHorizontalDivider(to: submenu)
}

let paceInfo = calculatePace(usage: grouped.usedPercent, resetTime: resetDate, windowHours: paceWindowHours)
let paceItem = NSMenuItem()
paceItem.view = createPaceView(paceInfo: paceInfo)
submenu.addItem(paceItem)

let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm zzz"
formatter.timeZone = TimeZone.current

let resetItem = NSMenuItem()
resetItem.view = createDisabledLabelView(
text: "Resets: \(formatter.string(from: resetDate))",
indent: MenuDesignToken.Spacing.submenuIndent
indent: 0,
textColor: .secondaryLabelColor
)
submenu.addItem(resetItem)

let paceInfo = calculatePace(usage: grouped.usedPercent, resetTime: resetDate, windowHours: paceWindowHours)
let paceItem = NSMenuItem()
paceItem.view = createPaceView(paceInfo: paceInfo)
submenu.addItem(paceItem)
debugLog("\(debugContext): reset row tone aligned with pace text")
debugLog("\(debugContext): group \(groupIndex) order applied -> pace row above reset row")
}

if groupIndex < groupedUsageWindows.count - 1 {
Expand Down Expand Up @@ -1127,10 +1130,9 @@ extension StatusBarController {

let view = NSView(frame: NSRect(x: 0, y: 0, width: menuWidth, height: itemHeight))

let indentedLeading: CGFloat = leadingOffset + MenuDesignToken.Spacing.submenuIndent
let paceText = paceInfo.paceRateText
debugLog("createPaceView: pace label computed: \(paceText)")
let leftTextField = NSTextField(labelWithString: "Pace: \(paceText)")
let leftTextField = NSTextField(labelWithString: "Speed: \(paceText)")
leftTextField.font = NSFont.systemFont(ofSize: fontSize)
leftTextField.textColor = .secondaryLabelColor
leftTextField.lineBreakMode = .byTruncatingTail
Expand All @@ -1144,12 +1146,13 @@ extension StatusBarController {
leftTextField.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(leftTextField)
NSLayoutConstraint.activate([
leftTextField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: indentedLeading),
leftTextField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: leadingOffset),
leftTextField.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])

let hasTooFast = paceInfo.status == .tooFast
var rightEdge = menuWidth - trailingMargin
let emphasisColor = paceInfo.status.color

if hasTooFast {
let rabbitView = createRunningRabbitView()
Expand All @@ -1163,43 +1166,56 @@ extension StatusBarController {
if let dotImage = NSImage(systemSymbolName: "circle.fill", accessibilityDescription: "Status") {
let config = NSImage.SymbolConfiguration(pointSize: statusDotSize, weight: .regular)
dotImageView.image = dotImage.withSymbolConfiguration(config)
dotImageView.contentTintColor = paceInfo.status.color
dotImageView.contentTintColor = emphasisColor
}
view.addSubview(dotImageView)
let dotSpacing = MenuDesignToken.Spacing.trailingMargin - MenuDesignToken.Dimension.statusDotSize
rightEdge -= (statusDotSize + dotSpacing)

let rightTextField = NSTextField(labelWithString: "")
let rightAttributedString = NSMutableAttributedString()
let exhaustedStatusTextField = NSTextField(labelWithString: "")
if paceInfo.isExhausted {
let waitText = formatRemainingTime(seconds: paceInfo.remainingSeconds)
debugLog("createPaceView: usage exhausted, showing wait message \(waitText)")
rightAttributedString.append(NSAttributedString(
let exhaustedStatusAttributedString = NSMutableAttributedString()
exhaustedStatusAttributedString.append(NSAttributedString(
string: "Status: ",
attributes: [.font: NSFont.systemFont(ofSize: fontSize), .foregroundColor: NSColor.disabledControlTextColor]
attributes: [.font: NSFont.systemFont(ofSize: fontSize), .foregroundColor: NSColor.secondaryLabelColor]
))
rightAttributedString.append(NSAttributedString(
exhaustedStatusAttributedString.append(NSAttributedString(
string: "Used Up",
attributes: [.font: NSFont.boldSystemFont(ofSize: fontSize), .foregroundColor: paceInfo.status.color]
attributes: [.font: NSFont.systemFont(ofSize: fontSize), .foregroundColor: emphasisColor]
))
exhaustedStatusTextField.attributedStringValue = exhaustedStatusAttributedString
exhaustedStatusTextField.isBezeled = false
exhaustedStatusTextField.isEditable = false
exhaustedStatusTextField.isSelectable = false
exhaustedStatusTextField.drawsBackground = false
exhaustedStatusTextField.lineBreakMode = .byTruncatingTail
exhaustedStatusTextField.maximumNumberOfLines = 1
exhaustedStatusTextField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
exhaustedStatusTextField.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(exhaustedStatusTextField)

rightAttributedString.append(NSAttributedString(
string: " · Wait ",
attributes: [.font: NSFont.systemFont(ofSize: fontSize), .foregroundColor: NSColor.disabledControlTextColor]
string: "Wait ",
attributes: [.font: NSFont.systemFont(ofSize: fontSize), .foregroundColor: NSColor.secondaryLabelColor]
))
rightAttributedString.append(NSAttributedString(
string: waitText,
attributes: [.font: NSFont.boldSystemFont(ofSize: fontSize), .foregroundColor: paceInfo.status.color]
attributes: [.font: NSFont.systemFont(ofSize: fontSize), .foregroundColor: emphasisColor]
))
rightTextField.isHidden = false
} else {
debugLog("createPaceView: predict label computed: \(paceInfo.predictText)")
rightAttributedString.append(NSAttributedString(
string: "Predict: ",
attributes: [.font: NSFont.systemFont(ofSize: fontSize), .foregroundColor: NSColor.disabledControlTextColor]
attributes: [.font: NSFont.systemFont(ofSize: fontSize), .foregroundColor: NSColor.secondaryLabelColor]
))
rightAttributedString.append(NSAttributedString(
string: paceInfo.predictText,
attributes: [.font: NSFont.boldSystemFont(ofSize: fontSize), .foregroundColor: paceInfo.status.color]
attributes: [.font: NSFont.systemFont(ofSize: fontSize), .foregroundColor: emphasisColor]
))
rightTextField.isHidden = false
}
Expand All @@ -1214,11 +1230,24 @@ extension StatusBarController {
rightTextField.setContentHuggingPriority(.required, for: .horizontal)
rightTextField.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(rightTextField)
NSLayoutConstraint.activate([
rightTextField.trailingAnchor.constraint(equalTo: view.leadingAnchor, constant: rightEdge),
rightTextField.centerYAnchor.constraint(equalTo: view.centerYAnchor),
leftTextField.trailingAnchor.constraint(lessThanOrEqualTo: rightTextField.leadingAnchor, constant: -dotSpacing)
])
if paceInfo.isExhausted {
rightTextField.alignment = .right
NSLayoutConstraint.activate([
exhaustedStatusTextField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: leadingOffset),
exhaustedStatusTextField.centerYAnchor.constraint(equalTo: view.centerYAnchor),
exhaustedStatusTextField.trailingAnchor.constraint(lessThanOrEqualTo: rightTextField.leadingAnchor, constant: -dotSpacing),
rightTextField.trailingAnchor.constraint(equalTo: view.leadingAnchor, constant: rightEdge),
rightTextField.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
debugLog("createPaceView: exhausted row split layout (status left, wait right)")
} else {
rightTextField.alignment = .right
NSLayoutConstraint.activate([
rightTextField.trailingAnchor.constraint(equalTo: view.leadingAnchor, constant: rightEdge),
rightTextField.centerYAnchor.constraint(equalTo: view.centerYAnchor),
leftTextField.trailingAnchor.constraint(lessThanOrEqualTo: rightTextField.leadingAnchor, constant: -dotSpacing)
])
}

return view
}
Expand Down Expand Up @@ -1253,10 +1282,73 @@ extension StatusBarController {
return view
}

private func usageColorForSummary(usagePercent: Double, paceInfo: PaceInfo?) -> NSColor {
if let paceInfo {
return paceInfo.status.color
}
if usagePercent >= 100 {
return .systemRed
}
if usagePercent >= 80 {
return .systemOrange
}
return .systemGreen
}

func createUsageSummaryView(label: String, usagePercent: Double, valueColor: NSColor) -> NSView {
let menuWidth: CGFloat = MenuDesignToken.Dimension.menuWidth
let itemHeight: CGFloat = MenuDesignToken.Dimension.itemHeight
let leadingOffset: CGFloat = MenuDesignToken.Spacing.leadingOffset
let trailingMargin: CGFloat = MenuDesignToken.Spacing.trailingMargin
let minimumGap: CGFloat = MenuDesignToken.Spacing.submenuIndent
let headerFontSize: CGFloat = 11
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

스타일 가이드 위반: AGENTS.md에 따르면 픽셀 값을 직접 하드코딩하는 것은 금지되어 있습니다 (NEVER hardcode pixel values).

MenuDesignToken.swift에 새로운 상수(예: smallFontSize)를 정의하거나 기존 MenuDesignToken 값을 사용해 주세요.


let view = NSView(frame: NSRect(x: 0, y: 0, width: menuWidth, height: itemHeight))

let leftTextField = NSTextField(labelWithString: label)
leftTextField.font = NSFont.systemFont(ofSize: headerFontSize, weight: .bold)
leftTextField.textColor = .secondaryLabelColor
leftTextField.lineBreakMode = .byTruncatingTail
leftTextField.maximumNumberOfLines = 1
leftTextField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
leftTextField.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(leftTextField)

let rightTextField = NSTextField(labelWithString: "")
let rightAttributedString = NSMutableAttributedString()
rightAttributedString.append(NSAttributedString(
string: "Used: ",
attributes: [.font: NSFont.boldSystemFont(ofSize: headerFontSize), .foregroundColor: NSColor.disabledControlTextColor]
))
rightAttributedString.append(NSAttributedString(
string: String(format: "%.0f%%", usagePercent),
attributes: [.font: NSFont.boldSystemFont(ofSize: headerFontSize), .foregroundColor: valueColor]
))
rightTextField.attributedStringValue = rightAttributedString
rightTextField.alignment = .right
rightTextField.lineBreakMode = .byTruncatingTail
rightTextField.maximumNumberOfLines = 1
rightTextField.setContentCompressionResistancePriority(.required, for: .horizontal)
rightTextField.setContentHuggingPriority(.required, for: .horizontal)
rightTextField.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(rightTextField)

NSLayoutConstraint.activate([
leftTextField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: leadingOffset),
leftTextField.centerYAnchor.constraint(equalTo: view.centerYAnchor),
rightTextField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -trailingMargin),
rightTextField.centerYAnchor.constraint(equalTo: view.centerYAnchor),
leftTextField.trailingAnchor.constraint(lessThanOrEqualTo: rightTextField.leadingAnchor, constant: -minimumGap)
])

debugLog("createUsageSummaryView: \(label) -> Used \(Int(usagePercent.rounded()))%")
return view
}

// MARK: - Shared UI Helpers for Unified Provider Menus

/// Creates unified usage window display with optional reset time and pace indicator.
/// Returns array of NSMenuItems: [usage row, reset row (optional), pace row (optional)]
/// Creates unified usage window display with optional pace indicator and reset time.
/// Returns array of NSMenuItems: [usage row, pace row (optional), reset row (optional)]
func createUsageWindowRow(
label: String,
usagePercent: Double,
Expand All @@ -1266,32 +1358,51 @@ extension StatusBarController {
) -> [NSMenuItem] {
var items: [NSMenuItem] = []

let usageItem = NSMenuItem()
usageItem.view = createDisabledLabelView(text: String(format: "%@: %.0f%% used", label, usagePercent))
items.append(usageItem)

let paceInfoForColor: PaceInfo?
if let resetDate = resetDate {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm zzz"
formatter.timeZone = TimeZone.current
let resetItem = NSMenuItem()
resetItem.view = createDisabledLabelView(text: "Resets: \(formatter.string(from: resetDate))", indent: MenuDesignToken.Spacing.submenuIndent)
items.append(resetItem)

let paceInfo: PaceInfo
if isMonthly {
paceInfo = calculateMonthlyPace(usagePercent: usagePercent, resetDate: resetDate)
paceInfoForColor = calculateMonthlyPace(usagePercent: usagePercent, resetDate: resetDate)
} else if let windowHours = windowHours {
paceInfo = calculatePace(usage: usagePercent, resetTime: resetDate, windowHours: windowHours)
paceInfoForColor = calculatePace(usage: usagePercent, resetTime: resetDate, windowHours: windowHours)
} else {
return items
paceInfoForColor = nil
}
} else {
paceInfoForColor = nil
}

let paceItem = NSMenuItem()
paceItem.view = createPaceView(paceInfo: paceInfo)
items.append(paceItem)
let usageColor = usageColorForSummary(usagePercent: usagePercent, paceInfo: paceInfoForColor)
debugLog("createUsageWindowRow: usage row \(label) = \(usagePercent)%")

let usageItem = NSMenuItem()
usageItem.view = createUsageSummaryView(label: label, usagePercent: usagePercent, valueColor: usageColor)
items.append(usageItem)

guard let resetDate = resetDate else {
return items
}

guard let paceInfo = paceInfoForColor else {
return items
}

let paceItem = NSMenuItem()
paceItem.view = createPaceView(paceInfo: paceInfo)
items.append(paceItem)

let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm zzz"
formatter.timeZone = TimeZone.current
let resetItem = NSMenuItem()
resetItem.view = createDisabledLabelView(
text: "Resets: \(formatter.string(from: resetDate))",
indent: 0,
textColor: .secondaryLabelColor
)
items.append(resetItem)
debugLog("createUsageWindowRow: reset row tone aligned with pace text for \(label)")
debugLog("createUsageWindowRow: order applied for \(label) -> usage, pace, reset")

return items
}

Expand Down
Loading