Skip to content

Commit 4155a40

Browse files
committed
fix: keep menu open when refreshing
1 parent dc16f9e commit 4155a40

6 files changed

Lines changed: 277 additions & 8 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## 0.25 — Unreleased
44

55
### Fixes
6+
- Menu: keep the status menu open when manually refreshing usage from the menu (#845). Thanks @OlimjonovOtabek!
67
- Augment: report the real 1-minute keepalive check/min-refresh intervals in startup logs and docs (#434). Thanks @guglielmofonda!
78

89
## 0.24 — 2026-05-06

Sources/CodexBar/StatusItemController+Actions.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import AppKit
22
import CodexBarCore
33

4-
extension StatusItemController {
4+
extension StatusItemController: StatusItemMenuPersistentActionDelegate {
55
// MARK: - Actions reachable from menus
66

77
func refreshStore(forceTokenUsage: Bool) {
@@ -19,6 +19,12 @@ extension StatusItemController {
1919
self.refreshStore(forceTokenUsage: true)
2020
}
2121

22+
nonisolated func performPersistentRefreshAction() {
23+
Task { @MainActor [weak self] in
24+
self?.refreshNow()
25+
}
26+
}
27+
2228
@objc func refreshAugmentSession() {
2329
Task {
2430
await self.store.forceRefreshAugmentSession()

Sources/CodexBar/StatusItemController+Menu.swift

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,7 @@ extension StatusItemController {
7474
guard self.shouldMergeIcons else {
7575
return self.makeMenu(for: nil)
7676
}
77-
let menu = NSMenu()
78-
menu.autoenablesItems = false
79-
menu.delegate = self
80-
return menu
77+
return self.makeBaseMenu()
8178
}
8279

8380
func menuWillOpen(_ menu: NSMenu) {
@@ -600,6 +597,11 @@ extension StatusItemController {
600597
}
601598
menu.addItem(item)
602599
case let .action(title, action):
600+
if case .refresh = action {
601+
menu.addItem(self.makePersistentMenuActionItem(title: title, action: action, width: width))
602+
continue
603+
}
604+
603605
let (selector, represented) = self.selector(for: action)
604606
let item = NSMenuItem(title: title, action: selector, keyEquivalent: "")
605607
item.target = self
@@ -662,6 +664,56 @@ extension StatusItemController {
662664
}
663665
}
664666

667+
private func makePersistentMenuActionItem(
668+
title: String,
669+
action: MenuDescriptor.MenuAction,
670+
width: CGFloat) -> NSMenuItem
671+
{
672+
let shortcut = self.shortcut(for: action)
673+
let row = PersistentMenuActionItemView(
674+
title: title,
675+
systemImageName: action.systemImageName,
676+
shortcutText: shortcut.map { self.shortcutLabel(for: $0) },
677+
width: width,
678+
onClick: { [weak self] in
679+
self?.performPersistentMenuAction(action)
680+
})
681+
682+
let item = NSMenuItem(title: title, action: nil, keyEquivalent: shortcut?.key ?? "")
683+
item.keyEquivalentModifierMask = shortcut?.modifiers ?? NSEvent.ModifierFlags()
684+
item.isEnabled = true
685+
item.view = row
686+
item.toolTip = title
687+
return item
688+
}
689+
690+
private func performPersistentMenuAction(_ action: MenuDescriptor.MenuAction) {
691+
switch action {
692+
case .refresh:
693+
self.refreshNow()
694+
default:
695+
break
696+
}
697+
}
698+
699+
private func shortcutLabel(for shortcut: (key: String, modifiers: NSEvent.ModifierFlags)) -> String {
700+
var label = ""
701+
if shortcut.modifiers.contains(.control) {
702+
label += "^"
703+
}
704+
if shortcut.modifiers.contains(.option) {
705+
label += ""
706+
}
707+
if shortcut.modifiers.contains(.shift) {
708+
label += ""
709+
}
710+
if shortcut.modifiers.contains(.command) {
711+
label += ""
712+
}
713+
label += shortcut.key.uppercased()
714+
return label
715+
}
716+
665717
private func makeWrappedSecondaryTextItem(text: String, width: CGFloat) -> NSMenuItem {
666718
let item = NSMenuItem(title: "", action: nil, keyEquivalent: "")
667719
let view = self.makeWrappedSecondaryTextView(text: text)
@@ -703,15 +755,21 @@ extension StatusItemController {
703755
}
704756

705757
func makeMenu(for provider: UsageProvider?) -> NSMenu {
706-
let menu = NSMenu()
707-
menu.autoenablesItems = false
708-
menu.delegate = self
758+
let menu = self.makeBaseMenu()
709759
if let provider {
710760
self.menuProviders[ObjectIdentifier(menu)] = provider
711761
}
712762
return menu
713763
}
714764

765+
private func makeBaseMenu() -> NSMenu {
766+
let menu = StatusItemMenu()
767+
menu.autoenablesItems = false
768+
menu.delegate = self
769+
menu.persistentActionDelegate = self
770+
return menu
771+
}
772+
715773
private func makeProviderSwitcherItem(
716774
providers: [UsageProvider],
717775
includesOverview: Bool,

Sources/CodexBar/StatusItemController+MenuPresentation.swift

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,3 +168,105 @@ struct MenuCardSectionContainerView<Content: View>: View {
168168
}
169169
}
170170
}
171+
172+
@MainActor
173+
final class PersistentMenuActionItemView: NSView, MenuCardHighlighting {
174+
private let backgroundView = NSView()
175+
private let imageView = NSImageView()
176+
private let titleField: NSTextField
177+
private let shortcutField: NSTextField?
178+
private let onClick: () -> Void
179+
180+
init(
181+
title: String,
182+
systemImageName: String?,
183+
shortcutText: String?,
184+
width: CGFloat,
185+
onClick: @escaping () -> Void)
186+
{
187+
self.titleField = NSTextField(labelWithString: title)
188+
self.shortcutField = shortcutText.map(NSTextField.init(labelWithString:))
189+
self.onClick = onClick
190+
super.init(frame: NSRect(origin: .zero, size: NSSize(width: width, height: 28)))
191+
self.setupView(systemImageName: systemImageName)
192+
self.setHighlighted(false)
193+
}
194+
195+
@available(*, unavailable)
196+
required init?(coder: NSCoder) {
197+
fatalError("init(coder:) has not been implemented")
198+
}
199+
200+
override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
201+
true
202+
}
203+
204+
override func mouseUp(with event: NSEvent) {
205+
guard event.type == .leftMouseUp else { return }
206+
self.onClick()
207+
}
208+
209+
func setHighlighted(_ highlighted: Bool) {
210+
let primaryColor = highlighted ? NSColor.selectedMenuItemTextColor : NSColor.controlTextColor
211+
let secondaryColor = highlighted ? NSColor.selectedMenuItemTextColor : NSColor.secondaryLabelColor
212+
self.backgroundView.isHidden = !highlighted
213+
self.titleField.textColor = primaryColor
214+
self.shortcutField?.textColor = secondaryColor
215+
self.imageView.contentTintColor = primaryColor
216+
}
217+
218+
private func setupView(systemImageName: String?) {
219+
self.backgroundView.wantsLayer = true
220+
self.backgroundView.layer?.cornerRadius = 6
221+
self.backgroundView.layer?.backgroundColor = NSColor.selectedContentBackgroundColor.cgColor
222+
self.backgroundView.translatesAutoresizingMaskIntoConstraints = false
223+
self.addSubview(self.backgroundView)
224+
225+
if let systemImageName,
226+
let image = NSImage(systemSymbolName: systemImageName, accessibilityDescription: nil)
227+
{
228+
image.isTemplate = true
229+
image.size = NSSize(width: 16, height: 16)
230+
self.imageView.image = image
231+
}
232+
self.imageView.translatesAutoresizingMaskIntoConstraints = false
233+
234+
self.titleField.font = NSFont.menuFont(ofSize: NSFont.systemFontSize)
235+
self.titleField.lineBreakMode = .byTruncatingTail
236+
self.titleField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
237+
self.titleField.translatesAutoresizingMaskIntoConstraints = false
238+
239+
let spacer = NSView()
240+
spacer.translatesAutoresizingMaskIntoConstraints = false
241+
spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
242+
243+
let stack = NSStackView()
244+
stack.orientation = .horizontal
245+
stack.alignment = .centerY
246+
stack.spacing = 8
247+
stack.translatesAutoresizingMaskIntoConstraints = false
248+
stack.addArrangedSubview(self.imageView)
249+
stack.addArrangedSubview(self.titleField)
250+
stack.addArrangedSubview(spacer)
251+
if let shortcutField {
252+
shortcutField.font = NSFont.menuFont(ofSize: NSFont.smallSystemFontSize)
253+
shortcutField.translatesAutoresizingMaskIntoConstraints = false
254+
stack.addArrangedSubview(shortcutField)
255+
}
256+
self.addSubview(stack)
257+
258+
NSLayoutConstraint.activate([
259+
self.backgroundView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 6),
260+
self.backgroundView.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -6),
261+
self.backgroundView.topAnchor.constraint(equalTo: self.topAnchor, constant: 2),
262+
self.backgroundView.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -2),
263+
264+
self.imageView.widthAnchor.constraint(equalToConstant: 18),
265+
self.imageView.heightAnchor.constraint(equalToConstant: 18),
266+
267+
stack.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 12),
268+
stack.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -12),
269+
stack.centerYAnchor.constraint(equalTo: self.centerYAnchor),
270+
])
271+
}
272+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import AppKit
2+
3+
protocol StatusItemMenuPersistentActionDelegate: AnyObject {
4+
func performPersistentRefreshAction()
5+
}
6+
7+
final class StatusItemMenu: NSMenu {
8+
weak var persistentActionDelegate: StatusItemMenuPersistentActionDelegate?
9+
10+
override func performKeyEquivalent(with event: NSEvent) -> Bool {
11+
if Self.isRefreshKeyEquivalent(event) {
12+
self.persistentActionDelegate?.performPersistentRefreshAction()
13+
return true
14+
}
15+
16+
return super.performKeyEquivalent(with: event)
17+
}
18+
19+
private nonisolated static func isRefreshKeyEquivalent(_ event: NSEvent) -> Bool {
20+
guard event.type == .keyDown else { return false }
21+
guard event.charactersIgnoringModifiers?.lowercased() == "r" else { return false }
22+
23+
let relevantModifiers = event.modifierFlags.intersection([.command, .option, .control, .shift])
24+
return relevantModifiers == .command
25+
}
26+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import AppKit
2+
import CodexBarCore
3+
import Testing
4+
@testable import CodexBar
5+
6+
private final class RefreshShortcutRecorder: StatusItemMenuPersistentActionDelegate {
7+
var refreshCount = 0
8+
9+
func performPersistentRefreshAction() {
10+
self.refreshCount += 1
11+
}
12+
}
13+
14+
@MainActor
15+
@Suite(.serialized)
16+
struct StatusMenuPersistentRefreshTests {
17+
private func makeSettings() -> SettingsStore {
18+
let suite = "StatusMenuPersistentRefreshTests-\(UUID().uuidString)"
19+
let defaults = UserDefaults(suiteName: suite)!
20+
defaults.removePersistentDomain(forName: suite)
21+
let configStore = testConfigStore(suiteName: suite)
22+
return SettingsStore(
23+
userDefaults: defaults,
24+
configStore: configStore,
25+
zaiTokenStore: NoopZaiTokenStore(),
26+
syntheticTokenStore: NoopSyntheticTokenStore())
27+
}
28+
29+
@Test
30+
func `refresh menu item is view backed so mouse activation keeps the menu open`() throws {
31+
let settings = self.makeSettings()
32+
settings.refreshFrequency = .manual
33+
settings.mergeIcons = false
34+
35+
let fetcher = UsageFetcher()
36+
let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
37+
let controller = StatusItemController(
38+
store: store,
39+
settings: settings,
40+
account: fetcher.loadAccountInfo(),
41+
updater: DisabledUpdaterController(),
42+
preferencesSelection: PreferencesSelection(),
43+
statusBar: .system)
44+
45+
let menu = controller.makeMenu(for: .codex)
46+
controller.menuWillOpen(menu)
47+
48+
let refreshItem = try #require(menu.items.first { $0.title == "Refresh" })
49+
#expect(refreshItem.action == nil)
50+
#expect(refreshItem.target == nil)
51+
#expect(refreshItem.view != nil)
52+
#expect(refreshItem.keyEquivalent == "r")
53+
#expect(refreshItem.keyEquivalentModifierMask == [.command])
54+
}
55+
56+
@Test
57+
func `status item menu intercepts refresh shortcut without native item selection`() throws {
58+
let menu = StatusItemMenu()
59+
let recorder = RefreshShortcutRecorder()
60+
menu.persistentActionDelegate = recorder
61+
let event = try #require(NSEvent.keyEvent(
62+
with: .keyDown,
63+
location: .zero,
64+
modifierFlags: [.command],
65+
timestamp: 0,
66+
windowNumber: 0,
67+
context: nil,
68+
characters: "r",
69+
charactersIgnoringModifiers: "r",
70+
isARepeat: false,
71+
keyCode: 15))
72+
73+
#expect(menu.performKeyEquivalent(with: event) == true)
74+
#expect(recorder.refreshCount == 1)
75+
}
76+
}

0 commit comments

Comments
 (0)