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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
- LiteLLM: show personal and team spend amounts directly on budget rows while suppressing duplicate budget sections. Thanks @hololee!

### Fixed
- Menu: align cost and utilization rows with provider content and use native bottom action items. Thanks @elijahfriedman!
- Cost history: keep chart date labels aligned with their bars and visible without clipping. Thanks @elijahfriedman!
- Claude settings: dim and disable Avoid Keychain prompts while global Keychain access is disabled. Thanks @Zihao-Qi!
- Linux CLI: read OpenCode Go local SQLite usage in automatic mode and allow Command Code billing with a configured manual cookie.
Expand Down
7 changes: 5 additions & 2 deletions Sources/CodexBar/MenuDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ struct MenuDescriptor {
}

enum MenuActionSystemImage: String {
case installUpdate = "arrow.down.circle"
case refresh = "arrow.clockwise"
case dashboard = "chart.bar"
case statusPage = "waveform.path.ecg"
Expand Down Expand Up @@ -760,8 +761,10 @@ private enum AccountFormatter {
extension MenuDescriptor.MenuAction {
var systemImageName: String? {
switch self {
case .installUpdate, .settings, .about, .quit:
nil
case .installUpdate: MenuDescriptor.MenuActionSystemImage.installUpdate.rawValue
case .settings: MenuDescriptor.MenuActionSystemImage.settings.rawValue
case .about: MenuDescriptor.MenuActionSystemImage.about.rawValue
case .quit: MenuDescriptor.MenuActionSystemImage.quit.rawValue
case .refresh: MenuDescriptor.MenuActionSystemImage.refresh.rawValue
case .refreshAugmentSession: MenuDescriptor.MenuActionSystemImage.refresh.rawValue
case .dashboard: MenuDescriptor.MenuActionSystemImage.dashboard.rawValue
Expand Down
8 changes: 6 additions & 2 deletions Sources/CodexBar/StatusItemController+Actions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate {
self.startManualRefresh(for: nil)
}

@objc func refreshMenuItem(_ sender: NSMenuItem) {
self.refreshMenuProviderNow(in: sender.menu)
}

func refreshMenuProviderNow(in menu: NSMenu?) {
guard let provider = self.manualRefreshProvider(for: menu) else {
self.startManualRefresh(for: nil)
Expand Down Expand Up @@ -125,7 +129,7 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate {
self.manualRefreshTask = nil
self.manualRefreshProvider = nil
self.menuCardRefreshMonitor.endManualRefresh()
self.updatePersistentRefreshRowsInProgress()
self.updatePersistentRefreshItemsEnabled()
self.prepareAttachedClosedMenusIfNeeded()
}
guard !Task.isCancelled, !self.hasPreparedForAppShutdown else { return }
Expand All @@ -151,7 +155,7 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate {
self.manualRefreshProvider = provider
self.manualRefreshTask = task
self.menuCardRefreshMonitor.beginManualRefresh(frozenModels: frozenModels, provider: provider)
self.updatePersistentRefreshRowsInProgress()
self.updatePersistentRefreshItemsEnabled()
}

private func manualRefreshProvider(for menu: NSMenu?) -> UsageProvider? {
Expand Down
2 changes: 1 addition & 1 deletion Sources/CodexBar/StatusItemController+CostMenuCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ private struct CostMenuCardRowView: View {
.truncationMode(.tail)
}
}
.padding(.leading, 14)
.padding(.leading, 20)
.padding(.trailing, 28)
.padding(.vertical, 6)
.frame(width: self.width, alignment: .leading)
Expand Down
67 changes: 5 additions & 62 deletions Sources/CodexBar/StatusItemController+Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -796,15 +796,6 @@ extension StatusItemController {
menu.addItem(item)
case let .action(title, action):
let localizedTitle = L(title)
if self.usesPersistentMenuActionItem(for: action) {
menu.addItem(self.makePersistentMenuActionItem(
title: localizedTitle,
action: action,
menu: captureMenu ?? menu,
width: width))
continue
}

let (selector, represented) = self.selector(for: action)
let item = NSMenuItem(title: localizedTitle, action: selector, keyEquivalent: "")
item.target = self
Expand All @@ -831,6 +822,11 @@ extension StatusItemController {
item.isEnabled = false
self.applySubtitle(subtitle, to: item, title: localizedTitle)
}
if action == .refresh {
let targetMenu = captureMenu ?? menu
item.isEnabled = !self.isRefreshActionInFlight(for: targetMenu)
self.persistentRefreshItems.add(item)
}
menu.addItem(item)
case let .submenu(title, systemImageName, submenuItems):
let item = NSMenuItem(title: title, action: nil, keyEquivalent: "")
Expand Down Expand Up @@ -867,59 +863,6 @@ extension StatusItemController {
}
}

private func makePersistentMenuActionItem(
title: String,
action: MenuDescriptor.MenuAction,
menu: NSMenu,
width: CGFloat) -> NSMenuItem
{
let shortcut = self.shortcut(for: action)
let row = PersistentMenuActionItemView(
title: title,
systemImageName: self.persistentMenuActionSystemImageName(for: action),
shortcutText: shortcut.map { self.shortcutLabel(for: $0) },
width: width,
onClick: { [weak self, weak menu] in
self?.performPersistentMenuAction(action, in: menu)
})

if action == .refresh {
row.setInProgress(self.isRefreshActionInFlight(for: menu))
self.persistentRefreshRows.add(row)
}

let item = NSMenuItem(title: title, action: nil, keyEquivalent: shortcut?.key ?? "")
item.keyEquivalentModifierMask = shortcut?.modifiers ?? NSEvent.ModifierFlags()
item.isEnabled = true
item.view = row
item.toolTip = title
if action != .refresh {
let (selector, represented) = self.selector(for: action)
item.action = selector
item.target = self
item.representedObject = represented
}
return item
}

private func shortcutLabel(for shortcut: (key: String, modifiers: NSEvent.ModifierFlags)) -> String {
var label = ""
if shortcut.modifiers.contains(.control) {
label += "^"
}
if shortcut.modifiers.contains(.option) {
label += "⌥"
}
if shortcut.modifiers.contains(.shift) {
label += "⇧"
}
if shortcut.modifiers.contains(.command) {
label += "⌘"
}
label += shortcut.key.uppercased()
return label
}

private func makeWrappedSecondaryTextItem(text: String, width: CGFloat) -> NSMenuItem {
let item = NSMenuItem(title: "", action: nil, keyEquivalent: "")
let view = self.makeWrappedSecondaryTextView(text: text)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ extension StatusItemController {
func selector(for action: MenuDescriptor.MenuAction) -> (Selector, Any?) {
switch action {
case .installUpdate: (#selector(self.installUpdate), nil)
case .refresh: (#selector(self.refreshNow), nil)
case .refresh: (#selector(self.refreshMenuItem(_:)), nil)
case .refreshAugmentSession: (#selector(self.refreshAugmentSession), nil)
case .dashboard: (#selector(self.openDashboard), nil)
case .statusPage: (#selector(self.openStatusPage), nil)
Expand Down
167 changes: 0 additions & 167 deletions Sources/CodexBar/StatusItemController+MenuPresentation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -229,170 +229,3 @@ struct MenuCardSectionContainerView<Content: View>: View {
}
}
}

@MainActor
final class PersistentMenuActionItemView: NSView, MenuCardHighlighting {
static let rowHeight: CGFloat = 28

private let backgroundView = NSView()
private let imageView = NSImageView()
private let progressIndicator = NSProgressIndicator()
private let titleField: NSTextField
private let shortcutField: NSTextField
private let onClick: () -> Void
private var highlighted = false
private var isInProgress = false

override var intrinsicContentSize: NSSize {
NSSize(width: self.frame.width > 0 ? self.frame.width : NSView.noIntrinsicMetric, height: Self.rowHeight)
}

override var fittingSize: NSSize {
NSSize(width: self.frame.width, height: Self.rowHeight)
}

override func setFrameSize(_ newSize: NSSize) {
super.setFrameSize(NSSize(width: newSize.width, height: Self.rowHeight))
}

init(
title: String,
systemImageName: String?,
shortcutText: String?,
width: CGFloat,
onClick: @escaping () -> Void)
{
self.titleField = NSTextField(labelWithString: title)
self.shortcutField = NSTextField(labelWithString: shortcutText ?? "")
self.onClick = onClick
super.init(frame: NSRect(origin: .zero, size: NSSize(width: width, height: Self.rowHeight)))
self.setupView(systemImageName: systemImageName)
self.setHighlighted(false)
}

@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
true
}

override func mouseUp(with event: NSEvent) {
guard event.type == .leftMouseUp else { return }
guard !self.isInProgress else { return }
self.onClick()
}

func setHighlighted(_ highlighted: Bool) {
self.highlighted = highlighted
self.updateVisualState()
}

/// Gives the persistent Refresh row static, in-place feedback without an animated view inside
/// the vibrant menu, which would continuously trigger WindowServer recompositing.
func setInProgress(_ inProgress: Bool) {
guard self.isInProgress != inProgress else { return }
self.isInProgress = inProgress
self.progressIndicator.stopAnimation(nil)
self.progressIndicator.isHidden = true
self.updateVisualState()
}

private func updateVisualState() {
let primaryColor = self.highlighted ? NSColor.selectedMenuItemTextColor : NSColor.controlTextColor
let secondaryColor = self.highlighted ? NSColor.selectedMenuItemTextColor : NSColor.secondaryLabelColor
let alpha: CGFloat = self.isInProgress ? 0.62 : 1
self.backgroundView.isHidden = !self.highlighted
self.titleField.textColor = primaryColor
self.shortcutField.textColor = secondaryColor
self.imageView.contentTintColor = primaryColor
self.titleField.alphaValue = alpha
self.shortcutField.alphaValue = alpha
self.imageView.alphaValue = alpha
}

#if DEBUG
var isInProgressForTesting: Bool {
self.isInProgress
}

var isProgressIndicatorHiddenForTesting: Bool {
self.progressIndicator.isHidden
}
#endif

private func setupView(systemImageName: String?) {
self.backgroundView.wantsLayer = true
self.backgroundView.layer?.cornerRadius = 6
self.backgroundView.layer?.backgroundColor = NSColor.selectedContentBackgroundColor.cgColor
self.backgroundView.translatesAutoresizingMaskIntoConstraints = false
self.addSubview(self.backgroundView)

if let systemImageName,
let image = NSImage(systemSymbolName: systemImageName, accessibilityDescription: nil)
{
image.isTemplate = true
image.size = NSSize(width: 16, height: 16)
self.imageView.image = image
}
self.imageView.translatesAutoresizingMaskIntoConstraints = false

self.progressIndicator.style = .spinning
self.progressIndicator.controlSize = .small
self.progressIndicator.isIndeterminate = true
self.progressIndicator.isDisplayedWhenStopped = false
self.progressIndicator.isHidden = true
self.progressIndicator.translatesAutoresizingMaskIntoConstraints = false

self.titleField.font = NSFont.menuFont(ofSize: NSFont.systemFontSize)
self.titleField.lineBreakMode = .byTruncatingTail
self.titleField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
self.titleField.translatesAutoresizingMaskIntoConstraints = false

self.shortcutField.font = NSFont.menuFont(ofSize: NSFont.smallSystemFontSize)
self.shortcutField.alignment = .right
self.shortcutField.lineBreakMode = .byTruncatingTail
self.shortcutField.setContentHuggingPriority(.required, for: .horizontal)
self.shortcutField.setContentCompressionResistancePriority(.required, for: .horizontal)
self.shortcutField.translatesAutoresizingMaskIntoConstraints = false

let spacer = NSView()
spacer.translatesAutoresizingMaskIntoConstraints = false
spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)

let stack = NSStackView()
stack.orientation = .horizontal
stack.alignment = .centerY
stack.spacing = 8
stack.translatesAutoresizingMaskIntoConstraints = false
stack.addArrangedSubview(self.imageView)
stack.addArrangedSubview(self.titleField)
stack.addArrangedSubview(spacer)
stack.addArrangedSubview(self.shortcutField)
self.addSubview(stack)
// The spinner overlaps the icon's fixed slot so toggling it never changes row metrics.
self.addSubview(self.progressIndicator)

NSLayoutConstraint.activate([
self.backgroundView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 6),
self.backgroundView.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -6),
self.backgroundView.topAnchor.constraint(equalTo: self.topAnchor, constant: 2),
self.backgroundView.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -2),

self.imageView.widthAnchor.constraint(equalToConstant: 18),
self.imageView.heightAnchor.constraint(equalToConstant: 18),
self.shortcutField.widthAnchor.constraint(equalToConstant: 38),

self.progressIndicator.centerXAnchor.constraint(equalTo: self.imageView.centerXAnchor),
self.progressIndicator.centerYAnchor.constraint(equalTo: self.imageView.centerYAnchor),
self.progressIndicator.widthAnchor.constraint(equalToConstant: 16),
self.progressIndicator.heightAnchor.constraint(equalToConstant: 16),

stack.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 12),
stack.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -12),
stack.centerYAnchor.constraint(equalTo: self.centerYAnchor),
])
}
}
3 changes: 3 additions & 0 deletions Sources/CodexBar/StatusItemController+MenuReconcile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,9 @@ extension StatusItemController {
if #available(macOS 14.4, *) {
liveItem.subtitle = newItem.subtitle
}
if liveItem.action == #selector(self.refreshMenuItem(_:)) {
self.persistentRefreshItems.add(liveItem)
}
}

private func swapMenuItemContents(_ liveItem: NSMenuItem, _ cachedItem: NSMenuItem) {
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ extension StatusItemController {
menu,
fromIndex: contentStartIndex,
with: cachedItems)
// Cached items may have changed refresh state while detached from a menu.
self.updatePersistentRefreshItemsEnabled()
self.cacheMergedSwitcherContent(
displacedItems,
in: menu,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,9 @@ extension StatusItemController {
for item in items {
menu.addItem(item)
}
// Detached Refresh items cannot observe a completed manual refresh. Recompute only
// after AppKit has restored their menu so provider-scoped busy state is available.
self.updatePersistentRefreshItemsEnabled()
return true
}
}
Loading