usage-cli 0.29.32__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. adapters/__init__.py +5 -0
  2. adapters/agy.py +68 -0
  3. adapters/claude.py +215 -0
  4. adapters/codex.py +209 -0
  5. adapters/rate_limits.py +76 -0
  6. adapters/registry.py +17 -0
  7. adapters/types.py +139 -0
  8. agy_disk_cache.py +135 -0
  9. agy_loader.py +416 -0
  10. agy_quota_probe.py +748 -0
  11. agy_window_keeper.py +185 -0
  12. analyzer/__init__.py +5 -0
  13. analyzer/aggregator.py +139 -0
  14. analyzer/blocks.py +80 -0
  15. analyzer/diagnoser.py +638 -0
  16. analyzer/insights.py +277 -0
  17. analyzer/persona_loader.py +199 -0
  18. analyzer/reporter.py +989 -0
  19. analyzer/subscription.py +108 -0
  20. burn_rate.py +75 -0
  21. cache_quarantine.py +50 -0
  22. codex_disk_cache.py +227 -0
  23. codex_events.py +136 -0
  24. codex_fork_replay.py +111 -0
  25. codex_loader.py +1426 -0
  26. codex_paths.py +20 -0
  27. critter_frames.py +26 -0
  28. discussion_bridge.py +1196 -0
  29. discussion_cli.py +844 -0
  30. discussion_session.py +622 -0
  31. discussion_usage.py +13 -0
  32. discussion_window.py +955 -0
  33. disk_cache_common.py +132 -0
  34. disk_cache_lifecycle.py +39 -0
  35. doctor.py +452 -0
  36. fsevents_watch.py +207 -0
  37. history_disk_cache.py +110 -0
  38. history_loader.py +416 -0
  39. i18n.py +88 -0
  40. jsonl_limits.py +17 -0
  41. jsonl_utils.py +40 -0
  42. login_item.py +154 -0
  43. main.py +387 -0
  44. menubar.py +1201 -0
  45. menubar_actions.py +204 -0
  46. menubar_agy.py +193 -0
  47. menubar_chrome.py +156 -0
  48. menubar_menu.py +169 -0
  49. menubar_notify.py +102 -0
  50. menubar_popover.py +233 -0
  51. menubar_prefs.py +118 -0
  52. menubar_refresh.py +285 -0
  53. menubar_state.py +1200 -0
  54. menubar_title.py +157 -0
  55. menubar_update.py +123 -0
  56. panel_window.py +78 -0
  57. panel_window_state.py +159 -0
  58. panels/__init__.py +186 -0
  59. panels/base.py +83 -0
  60. panels/dynamic_height.py +140 -0
  61. panels/payload.py +178 -0
  62. panels/web_panel.py +513 -0
  63. panels/window_drag.py +56 -0
  64. prefs.py +44 -0
  65. pricing.py +452 -0
  66. project_resolver.py +112 -0
  67. service_status.py +383 -0
  68. session_hooks.py +1154 -0
  69. setup_app.py +171 -0
  70. setup_hook.py +1011 -0
  71. statusline_settings.py +160 -0
  72. talent_market_bridge.py +243 -0
  73. time_utils.py +24 -0
  74. tui.py +288 -0
  75. tui_sprite.py +206 -0
  76. ui/__init__.py +5 -0
  77. ui/html_report.py +923 -0
  78. ui/report_scripts.py +251 -0
  79. ui/report_styles.py +370 -0
  80. ui/tables.py +888 -0
  81. update_checker.py +156 -0
  82. update_gate.py +66 -0
  83. update_release_notes.py +49 -0
  84. usage_cli-0.29.32.data/data/share/usage/i18n.json +2427 -0
  85. usage_cli-0.29.32.dist-info/METADATA +223 -0
  86. usage_cli-0.29.32.dist-info/RECORD +109 -0
  87. usage_cli-0.29.32.dist-info/WHEEL +5 -0
  88. usage_cli-0.29.32.dist-info/entry_points.txt +3 -0
  89. usage_cli-0.29.32.dist-info/licenses/LICENSE +663 -0
  90. usage_cli-0.29.32.dist-info/top_level.txt +80 -0
  91. usage_cli.py +827 -0
  92. usage_client.py +487 -0
  93. usage_diagnosis_snapshot.py +143 -0
  94. usage_dir_sweeper.py +100 -0
  95. usage_lang.py +79 -0
  96. usage_logging.py +75 -0
  97. usage_notifications.py +96 -0
  98. usage_rate.py +97 -0
  99. usage_session_resume.py +913 -0
  100. usage_statusline.py +810 -0
  101. usage_statusline_agy.py +397 -0
  102. usage_statusline_forwarder.py +88 -0
  103. usage_terse_mode.py +223 -0
  104. usage_terse_reminder.py +151 -0
  105. win_login_item.py +53 -0
  106. window_keeper.py +264 -0
  107. windows_watch.py +443 -0
  108. wintray.py +2014 -0
  109. wintray_menu.py +136 -0
menubar_menu.py ADDED
@@ -0,0 +1,169 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
3
+ #
4
+ # Part of "usage". Free software licensed under the GNU Affero General Public
5
+ # License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Protocol
10
+
11
+ from AppKit import NSMakePoint, NSMenu, NSMenuItem
12
+
13
+ import login_item
14
+ from i18n import _t
15
+ from menubar_prefs import (
16
+ _hide_agy_enabled,
17
+ _hide_claude_enabled,
18
+ _hide_codex_enabled,
19
+ _quota_notifications_enabled,
20
+ _window_keeper_enabled,
21
+ )
22
+
23
+
24
+ class _SwitchMenuApp(Protocol):
25
+ language: str
26
+ active_panel: Any
27
+ _switch_menu_action_taken: bool
28
+
29
+ def _resync_popover_after_menu(self) -> None: ...
30
+
31
+
32
+ def build_menu_item(
33
+ language: str,
34
+ title_key: str,
35
+ selector: str,
36
+ *,
37
+ target: Any,
38
+ represented: Any | None = None,
39
+ state: bool | None = None,
40
+ tooltip_key: str | None = None,
41
+ ) -> Any:
42
+ item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(
43
+ _t(language, title_key), selector, ""
44
+ )
45
+ item.setTarget_(target)
46
+ if represented is not None:
47
+ item.setRepresentedObject_(represented)
48
+ if state is not None:
49
+ item.setState_(1 if state else 0)
50
+ if tooltip_key is not None:
51
+ item.setToolTip_(_t(language, tooltip_key))
52
+ return item
53
+
54
+
55
+ def build_switch_menu(app: _SwitchMenuApp, sender: Any) -> None:
56
+ import panels
57
+ from menubar import _session_resume_enabled, _terse_mode_enabled
58
+
59
+ menu = NSMenu.alloc().initWithTitle_(_t(app.language, "switch_panel"))
60
+ # AI 人才市場 is a feature panel, not a cosmetic skin — it gets its own
61
+ # top-level row instead of hiding inside "面板主題 ▸" next to Matrix/Win95.
62
+ menu.addItem_(
63
+ build_menu_item(
64
+ app.language, "panel_talent_market", "toggleTalentMarket:", target=app,
65
+ represented="talent_market",
66
+ state=app.active_panel.id == "talent_market",
67
+ )
68
+ )
69
+ menu.addItem_(
70
+ build_menu_item(
71
+ app.language, "discussion_window_title", "toggleDiscussion:", target=app
72
+ )
73
+ )
74
+ menu.addItem_(
75
+ build_menu_item(app.language, "panel_ai_daily", "toggleAiDaily:", target=app)
76
+ )
77
+ menu.addItem_(NSMenuItem.separatorItem())
78
+ # Panel themes live in a submenu so the menu stays short — one "面板主題 ▸"
79
+ # row that expands on demand instead of nine inline rows. talent_market is
80
+ # excluded here since it already has its own top-level row above.
81
+ panel_submenu = NSMenu.alloc().initWithTitle_(_t(app.language, "switch_panel"))
82
+ for panel in panels.all_panels():
83
+ if panel.id == "talent_market":
84
+ continue
85
+ panel_submenu.addItem_(
86
+ build_menu_item(
87
+ app.language, panel.i18n_key, "selectPanel:", target=app,
88
+ represented=panel.id,
89
+ state=panel.id == app.active_panel.id,
90
+ )
91
+ )
92
+ panel_parent = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(
93
+ _t(app.language, "switch_panel"), "", ""
94
+ )
95
+ panel_parent.setSubmenu_(panel_submenu)
96
+ menu.addItem_(panel_parent)
97
+ # Provider visibility lives in one "Hide Sections ▸" submenu row, grouped
98
+ # with the panel-themes submenu: both are drill-in rows that shape what
99
+ # the popover shows.
100
+ hide_submenu = NSMenu.alloc().initWithTitle_(_t(app.language, "hide_sections_menu"))
101
+ hide_submenu.addItem_(
102
+ build_menu_item(
103
+ app.language, "claude_name", "toggleHideClaude:", target=app,
104
+ state=_hide_claude_enabled(),
105
+ )
106
+ )
107
+ hide_submenu.addItem_(
108
+ build_menu_item(
109
+ app.language, "codex_name", "toggleHideCodex:", target=app,
110
+ state=_hide_codex_enabled(),
111
+ )
112
+ )
113
+ hide_submenu.addItem_(
114
+ build_menu_item(
115
+ app.language, "agy_name", "toggleHideAgy:", target=app,
116
+ state=_hide_agy_enabled(),
117
+ )
118
+ )
119
+ hide_parent = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(
120
+ _t(app.language, "hide_sections_menu"), "", ""
121
+ )
122
+ hide_parent.setSubmenu_(hide_submenu)
123
+ menu.addItem_(hide_parent)
124
+ # Plain on/off switches sit together in the second group.
125
+ menu.addItem_(NSMenuItem.separatorItem())
126
+ menu.addItem_(
127
+ build_menu_item(
128
+ app.language, "launch_at_login", "toggleLaunchAtLogin:", target=app,
129
+ state=login_item.is_enabled(),
130
+ )
131
+ )
132
+ menu.addItem_(
133
+ build_menu_item(
134
+ app.language, "quota_notifications_menu", "toggleQuotaNotifications:", target=app,
135
+ state=_quota_notifications_enabled(),
136
+ )
137
+ )
138
+ menu.addItem_(
139
+ build_menu_item(
140
+ app.language, "window_keeper_menu", "toggleWindowKeeper:", target=app,
141
+ state=_window_keeper_enabled(),
142
+ tooltip_key="window_keeper_tooltip",
143
+ )
144
+ )
145
+ # Project Butler: one toggle that hands last session's progress to the next
146
+ # one. Tooltip carries the full explanation so the menu line stays short.
147
+ # Grouped with the plain switches above it — a separate section here just
148
+ # added a divider with no real category difference.
149
+ menu.addItem_(
150
+ build_menu_item(
151
+ app.language, "project_butler", "toggleSessionResume:", target=app,
152
+ state=_session_resume_enabled(),
153
+ tooltip_key="project_butler_tooltip",
154
+ )
155
+ )
156
+ menu.addItem_(
157
+ build_menu_item(
158
+ app.language, "terse_mode_menu", "toggleTerseMode:", target=app,
159
+ state=_terse_mode_enabled(),
160
+ tooltip_key="terse_mode_tooltip",
161
+ )
162
+ )
163
+ app._switch_menu_action_taken = False
164
+ menu.popUpMenuPositioningItem_atLocation_inView_(None, NSMakePoint(0, 0), sender)
165
+ # Dismissing the menu without picking anything used to close the panel:
166
+ # a transient popover was already gone by then, so closing it was just
167
+ # cleanup. The floating window survives the menu, so the same call would
168
+ # read as "cancelling the menu threw my panel away".
169
+ app._resync_popover_after_menu()
menubar_notify.py ADDED
@@ -0,0 +1,102 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
3
+ #
4
+ # Part of "usage". Free software licensed under the GNU Affero General Public
5
+ # License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ import objc
12
+
13
+ from i18n import _t
14
+ from menubar_state import PopoverState, QuotaRowState
15
+
16
+
17
+ def user_notification_center() -> tuple[Any, dict[str, int]]:
18
+ from UserNotifications import (
19
+ UNAuthorizationOptionAlert,
20
+ UNAuthorizationOptionBadge,
21
+ UNAuthorizationOptionSound,
22
+ UNUserNotificationCenter,
23
+ )
24
+ register_user_notification_block_metadata()
25
+
26
+ return (
27
+ UNUserNotificationCenter.currentNotificationCenter(),
28
+ {
29
+ "alert": int(UNAuthorizationOptionAlert),
30
+ "badge": int(UNAuthorizationOptionBadge),
31
+ "sound": int(UNAuthorizationOptionSound),
32
+ },
33
+ )
34
+
35
+
36
+ def user_notification_classes() -> tuple[Any, Any, Any]:
37
+ register_user_notification_block_metadata()
38
+ from UserNotifications import (
39
+ UNMutableNotificationContent,
40
+ UNNotificationRequest,
41
+ UNNotificationSound,
42
+ )
43
+
44
+ return UNMutableNotificationContent, UNNotificationRequest, UNNotificationSound
45
+
46
+
47
+ def register_user_notification_block_metadata() -> None:
48
+ objc.registerMetaDataForSelector(
49
+ b"UNUserNotificationCenter",
50
+ b"requestAuthorizationWithOptions:completionHandler:",
51
+ {
52
+ "arguments": {
53
+ 3: {
54
+ "callable": {
55
+ "retval": {"type": b"v"},
56
+ "arguments": {
57
+ 0: {"type": b"^v"},
58
+ 1: {"type": b"Z"},
59
+ 2: {"type": b"@"},
60
+ },
61
+ },
62
+ },
63
+ },
64
+ },
65
+ )
66
+ objc.registerMetaDataForSelector(
67
+ b"UNUserNotificationCenter",
68
+ b"addNotificationRequest:withCompletionHandler:",
69
+ {
70
+ "arguments": {
71
+ 3: {
72
+ "callable": {
73
+ "retval": {"type": b"v"},
74
+ "arguments": {
75
+ 0: {"type": b"^v"},
76
+ 1: {"type": b"@"},
77
+ },
78
+ },
79
+ },
80
+ },
81
+ },
82
+ )
83
+
84
+
85
+ def notification_tool(channel: str) -> str:
86
+ return "Claude" if channel.startswith("claude_") else "Codex"
87
+
88
+
89
+ def notification_scope(language: str, channel: str) -> str:
90
+ if channel.endswith("_session"):
91
+ return _t(language, "session_label")
92
+ return _t(language, "weekly_label")
93
+
94
+
95
+ def notification_row(state: PopoverState, channel: str) -> QuotaRowState:
96
+ rows = {
97
+ "claude_session": state.claude_session,
98
+ "claude_weekly": state.claude_weekly,
99
+ "codex_session": state.codex_session,
100
+ "codex_weekly": state.codex_weekly,
101
+ }
102
+ return rows[channel]
menubar_popover.py ADDED
@@ -0,0 +1,233 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
3
+ #
4
+ # Part of "usage". Free software licensed under the GNU Affero General Public
5
+ # License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
6
+
7
+ # mypy: disable-error-code="import-untyped,misc"
8
+ # PyObjC modules do not ship type stubs, and their base classes resolve to Any in mypy.
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ import objc
14
+ from AppKit import (
15
+ NSAnimationContext,
16
+ NSMakeSize,
17
+ NSView,
18
+ NSViewController,
19
+ NSViewHeightSizable,
20
+ NSViewWidthSizable,
21
+ )
22
+ from Quartz import CGColorCreateGenericRGB
23
+
24
+ import panel_window_state
25
+ from menubar_state import PopoverState
26
+ from panels.base import Panel as UsagePanel
27
+ from panels.base import next_panel_eviction_id
28
+
29
+ MAX_CACHED_PANEL_VIEWS = 6
30
+ PANEL_TRANSITION_TIMEOUT_SECONDS = 1.5
31
+ PANEL_TRANSITION_FADE_SECONDS = 0.18
32
+
33
+
34
+ class PopoverViewController(NSViewController):
35
+ content_view = objc.ivar()
36
+ panel = objc.ivar()
37
+ delegate = objc.ivar()
38
+ panel_views = objc.ivar()
39
+ panel_lru = objc.ivar()
40
+ transition_overlays = objc.ivar()
41
+ latest_state = objc.ivar()
42
+ pending_panel_evictions = objc.ivar()
43
+
44
+ def initWithPanel_delegate_(self, panel: UsagePanel, delegate: Any) -> PopoverViewController:
45
+ self = objc.super(PopoverViewController, self).init()
46
+ if self is None:
47
+ return None
48
+ self.panel = panel
49
+ self.delegate = delegate
50
+ self.panel_views = {}
51
+ self.panel_lru = []
52
+ self.transition_overlays = {}
53
+ self.pending_panel_evictions = set()
54
+ self.latest_state = None
55
+ self.content_view = panel.build_view(delegate)
56
+ container = NSView.alloc().initWithFrame_(self.content_view.frame())
57
+ container.setWantsLayer_(True)
58
+ self.setView_(container)
59
+ self.preparePanelView_(self.content_view)
60
+ container.addSubview_(self.content_view)
61
+ # Only cache a real web view; a failed build (ErrorPanelView, no JS
62
+ # bridge) is shown but left uncached so a later switch rebuilds it.
63
+ if hasattr(self.content_view, "evaluateJavaScript_completionHandler_"):
64
+ self.panel_views[panel.id] = self.content_view
65
+ self.panel_lru.append(panel.id)
66
+ return self
67
+
68
+ def setState_(self, state: PopoverState) -> None:
69
+ self.latest_state = state
70
+ self.view().setFrameSize_(_popover_size(state, self.panel))
71
+ self.syncPanelFrames()
72
+ self.panel.apply_state(self.content_view, state)
73
+
74
+ def switchToPanel_(self, panel: UsagePanel) -> None:
75
+ previous = self.content_view
76
+ self.panel = panel
77
+ content_view = self.panel_views.get(panel.id)
78
+ if content_view is None:
79
+ content_view = panel.build_view(self.delegate)
80
+ content_view.setHidden_(True)
81
+ self.preparePanelView_(content_view)
82
+ self.view().addSubview_(content_view)
83
+ # Only cache a real web view. A failed build returns ErrorPanelView
84
+ # (no JS bridge); caching it would pin the error even after the file
85
+ # recovers, so leave it uncached and rebuild on the next switch.
86
+ if hasattr(content_view, "evaluateJavaScript_completionHandler_"):
87
+ self.panel_views[panel.id] = content_view
88
+ self.beginPanelTransitionForPanelId_view_(panel.id, content_view)
89
+ # Drop the previously shown view if it was an uncached error fallback,
90
+ # so it doesn't linger stacked in the container behind the new panel.
91
+ if (
92
+ previous is not None
93
+ and previous is not content_view
94
+ and previous not in self.panel_views.values()
95
+ ):
96
+ if hasattr(previous, "teardown"):
97
+ previous.teardown()
98
+ previous.removeFromSuperview()
99
+ self.content_view = content_view
100
+ if panel.id in self.panel_views:
101
+ self.markPanelUsed_(panel.id)
102
+ for panel_id, view in list(self.panel_views.items()):
103
+ view.setHidden_(panel_id != panel.id)
104
+ content_view.setHidden_(False)
105
+ if self.latest_state is not None:
106
+ self.setState_(self.latest_state)
107
+ self.evictPanelViewsIfNeeded()
108
+
109
+ def currentContentView(self) -> Any:
110
+ return self.content_view
111
+
112
+ def panelDidFirstPaint_(self, view: Any) -> None:
113
+ if view is self.content_view:
114
+ self.endPanelTransitionForPanelView_(view)
115
+
116
+ def beginPanelTransitionForPanelId_view_(self, panel_id: str, view: Any) -> None:
117
+ self.removeTransitionOverlay_(panel_id)
118
+ overlay = NSView.alloc().initWithFrame_(view.bounds())
119
+ overlay.setWantsLayer_(True)
120
+ overlay.setAlphaValue_(1.0)
121
+ overlay.setAutoresizingMask_(int(NSViewWidthSizable) | int(NSViewHeightSizable))
122
+ layer = overlay.layer()
123
+ if layer is not None:
124
+ layer.setBackgroundColor_(
125
+ CGColorCreateGenericRGB(10 / 255, 15 / 255, 20 / 255, 1.0)
126
+ )
127
+ view.addSubview_(overlay)
128
+ self.transition_overlays[panel_id] = overlay
129
+ self.performSelector_withObject_afterDelay_(
130
+ "transitionTimeoutElapsed:",
131
+ overlay,
132
+ PANEL_TRANSITION_TIMEOUT_SECONDS,
133
+ )
134
+
135
+ def transitionTimeoutElapsed_(self, overlay: Any) -> None:
136
+ # Match by the overlay object itself, not the panel id: if this panel was
137
+ # evicted and rebuilt, a stale timer must not fade the new overlay. A timer
138
+ # whose overlay is already gone from the map simply finds nothing and stops.
139
+ for panel_id, current_overlay in list(self.transition_overlays.items()):
140
+ if current_overlay is overlay:
141
+ self.endPanelTransitionForPanelId_(panel_id)
142
+ return
143
+
144
+ def endPanelTransitionForPanelView_(self, view: Any) -> None:
145
+ for panel_id, panel_view in list(self.panel_views.items()):
146
+ if panel_view is view:
147
+ self.endPanelTransitionForPanelId_(panel_id)
148
+ return
149
+
150
+ def endPanelTransitionForPanelId_(self, panel_id: str) -> None:
151
+ overlay = self.transition_overlays.pop(panel_id, None)
152
+ if overlay is None:
153
+ return
154
+
155
+ def _fade(context: Any) -> None:
156
+ context.setDuration_(PANEL_TRANSITION_FADE_SECONDS)
157
+ overlay.animator().setAlphaValue_(0.0)
158
+
159
+ def _remove() -> None:
160
+ overlay.removeFromSuperview()
161
+
162
+ NSAnimationContext.runAnimationGroup_completionHandler_(_fade, _remove)
163
+
164
+ def teardown(self) -> None:
165
+ for panel_id, view in list(self.panel_views.items()):
166
+ self.removeTransitionOverlay_(panel_id)
167
+ if hasattr(view, "teardown"):
168
+ view.teardown()
169
+ view.removeFromSuperview()
170
+ self.panel_views.clear()
171
+ self.panel_lru.clear()
172
+ self.content_view = None
173
+
174
+ def preparePanelView_(self, view: Any) -> None:
175
+ view.setFrame_(self.view().bounds() if self.view() is not None else view.frame())
176
+ view.setAutoresizingMask_(int(NSViewWidthSizable) | int(NSViewHeightSizable))
177
+ view.setWantsLayer_(True)
178
+ layer = view.layer()
179
+ if layer is not None:
180
+ layer.setMasksToBounds_(True)
181
+
182
+ def syncPanelFrames(self) -> None:
183
+ bounds = self.view().bounds()
184
+ for view in self.panel_views.values():
185
+ view.setFrame_(bounds)
186
+
187
+ def markPanelUsed_(self, panel_id: str) -> None:
188
+ self.panel_lru = [cached_id for cached_id in self.panel_lru if cached_id != panel_id]
189
+ self.panel_lru.append(panel_id)
190
+
191
+ def evictPanelViewsIfNeeded(self) -> None:
192
+ self._schedulePanelEvictionIfNeeded()
193
+
194
+ def _schedulePanelEvictionIfNeeded(self) -> None:
195
+ if self.panel is None or len(self.panel_views) <= MAX_CACHED_PANEL_VIEWS:
196
+ return
197
+ evict_id = next_panel_eviction_id(
198
+ self.panel_lru,
199
+ self.panel.id,
200
+ self.pending_panel_evictions,
201
+ )
202
+ if evict_id is None:
203
+ return
204
+ self.pending_panel_evictions.add(evict_id)
205
+ self.performSelector_withObject_afterDelay_("evictPanelViewForId:", evict_id, 0.0)
206
+
207
+ def evictPanelViewForId_(self, panel_id: str) -> None:
208
+ self.pending_panel_evictions.discard(panel_id)
209
+ # A user can return to this panel before the next run-loop turn. In
210
+ # that case it remains live; schedule another LRU candidate instead.
211
+ if self.panel is None or panel_id == self.panel.id:
212
+ self._schedulePanelEvictionIfNeeded()
213
+ return
214
+ view = self.panel_views.get(panel_id)
215
+ if view is None:
216
+ self._schedulePanelEvictionIfNeeded()
217
+ return
218
+ self.panel_lru = [cached_id for cached_id in self.panel_lru if cached_id != panel_id]
219
+ self.panel_views.pop(panel_id, None)
220
+ self.removeTransitionOverlay_(panel_id)
221
+ if hasattr(view, "teardown"):
222
+ view.teardown()
223
+ view.removeFromSuperview()
224
+ self._schedulePanelEvictionIfNeeded()
225
+
226
+ def removeTransitionOverlay_(self, panel_id: str) -> None:
227
+ overlay = self.transition_overlays.pop(panel_id, None)
228
+ if overlay is not None:
229
+ overlay.removeFromSuperview()
230
+
231
+
232
+ def _popover_size(state: PopoverState, panel: UsagePanel | None = None) -> Any:
233
+ return NSMakeSize(*panel_window_state.resolve_panel_size(state, panel))
menubar_prefs.py ADDED
@@ -0,0 +1,118 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
3
+ #
4
+ # Part of "usage". Free software licensed under the GNU Affero General Public
5
+ # License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Mapping
10
+
11
+ from prefs import _load_preferences, _save_preferences
12
+
13
+ DEFAULT_QUOTA_CARD_ORDER = ("claude", "codex", "agy")
14
+ DEFAULT_PANEL_FLAVOR = "mocha"
15
+ PANEL_FLAVORS = ("latte", "frappe", "macchiato", "mocha")
16
+
17
+
18
+ def _resolved_preferences(prefs: Mapping[str, object] | None = None) -> Mapping[str, object]:
19
+ return _load_preferences() if prefs is None else prefs
20
+
21
+
22
+ def _auto_update_check_enabled(prefs: Mapping[str, object] | None = None) -> bool:
23
+ data = _resolved_preferences(prefs)
24
+ return data.get("auto_update_check") is not False
25
+
26
+
27
+ def _hide_claude_enabled(prefs: Mapping[str, object] | None = None) -> bool:
28
+ data = _resolved_preferences(prefs)
29
+ return data.get("hide_claude_section") is True
30
+
31
+
32
+ def _hide_codex_enabled(prefs: Mapping[str, object] | None = None) -> bool:
33
+ data = _resolved_preferences(prefs)
34
+ return data.get("hide_codex_section") is True
35
+
36
+
37
+ def _hide_agy_enabled(prefs: Mapping[str, object] | None = None) -> bool:
38
+ data = _resolved_preferences(prefs)
39
+ return data.get("hide_agy_section") is True
40
+
41
+
42
+ def _panel_flavor(prefs: Mapping[str, object] | None = None) -> str:
43
+ data = _resolved_preferences(prefs)
44
+ flavor = _valid_panel_flavor(data.get("panel_flavor"))
45
+ return DEFAULT_PANEL_FLAVOR if flavor is None else flavor
46
+
47
+
48
+ def _save_panel_flavor(flavor: object) -> bool:
49
+ valid_flavor = _valid_panel_flavor(flavor)
50
+ if valid_flavor is None:
51
+ return False
52
+ prefs = _load_preferences()
53
+ prefs["panel_flavor"] = valid_flavor
54
+ _save_preferences(prefs)
55
+ return True
56
+
57
+
58
+ def _valid_panel_flavor(value: object) -> str | None:
59
+ if not isinstance(value, str) or value not in PANEL_FLAVORS:
60
+ return None
61
+ return value
62
+
63
+
64
+ def _quota_card_order(prefs: Mapping[str, object] | None = None) -> tuple[str, ...]:
65
+ data = _resolved_preferences(prefs)
66
+ order = _valid_quota_card_order(data.get("quota_card_order"))
67
+ return DEFAULT_QUOTA_CARD_ORDER if order is None else order
68
+
69
+
70
+ def _save_quota_card_order(order: object) -> bool:
71
+ valid_order = _valid_quota_card_order(order)
72
+ if valid_order is None:
73
+ return False
74
+ prefs = _load_preferences()
75
+ prefs["quota_card_order"] = list(valid_order)
76
+ _save_preferences(prefs)
77
+ return True
78
+
79
+
80
+ def _valid_quota_card_order(value: object) -> tuple[str, ...] | None:
81
+ if not isinstance(value, list) or any(not isinstance(card, str) for card in value):
82
+ return None
83
+ order = tuple(value)
84
+ if len(order) != len(set(order)) or not set(order) <= set(DEFAULT_QUOTA_CARD_ORDER):
85
+ return None
86
+ # A saved order shorter than the current default means a source was added
87
+ # since it was written (e.g. a 4th quota card) — append the newcomers
88
+ # instead of discarding the user's whole preference.
89
+ missing = (card for card in DEFAULT_QUOTA_CARD_ORDER if card not in order)
90
+ return order + tuple(missing)
91
+
92
+
93
+ def _quota_notifications_enabled(prefs: Mapping[str, object] | None = None) -> bool:
94
+ data = _resolved_preferences(prefs)
95
+ return data.get("quota_notifications") is not False
96
+
97
+
98
+ def _window_keeper_enabled(prefs: Mapping[str, object] | None = None) -> bool:
99
+ # Defaults OFF (opt-in) — opposite polarity from quota_notifications, which
100
+ # is why this is `is True` rather than `is not False`.
101
+ data = _resolved_preferences(prefs)
102
+ return data.get("window_keeper") is True or data.get("agy_window_keeper") is True
103
+
104
+
105
+ def _agy_window_keeper_enabled(prefs: Mapping[str, object] | None = None) -> bool:
106
+ return _window_keeper_enabled(prefs)
107
+
108
+
109
+ def _quota_notification_thresholds(prefs: Mapping[str, object] | None = None) -> list[float]:
110
+ data = _resolved_preferences(prefs)
111
+ raw = data.get("quota_notification_thresholds")
112
+ if not isinstance(raw, list):
113
+ return [90.0]
114
+ thresholds: list[float] = []
115
+ for value in raw:
116
+ if isinstance(value, int | float) and 0 < float(value) <= 100:
117
+ thresholds.append(float(value))
118
+ return thresholds or [90.0]