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.py ADDED
@@ -0,0 +1,1201 @@
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
+ import asyncio
12
+ import json
13
+ import logging
14
+ import os
15
+ import threading
16
+ import time
17
+ import tomllib
18
+ import webbrowser
19
+ from importlib import metadata
20
+ from pathlib import Path
21
+ from typing import Any, cast
22
+
23
+ import objc
24
+ from AppKit import (
25
+ NSApp,
26
+ NSApplication,
27
+ NSApplicationActivationPolicyAccessory,
28
+ NSMakePoint,
29
+ NSMakeRect,
30
+ NSMakeSize,
31
+ NSScreen,
32
+ NSStatusBar,
33
+ NSVariableStatusItemLength,
34
+ )
35
+ from Foundation import NSObject, NSRunLoop, NSRunLoopCommonModes, NSTimer
36
+
37
+ import agy_loader
38
+ import codex_loader
39
+ import critter_frames
40
+ import login_item
41
+ import menubar_actions
42
+ import menubar_menu
43
+ import menubar_notify
44
+ import menubar_refresh
45
+ import menubar_state
46
+ import menubar_title
47
+ import menubar_update
48
+ import panel_window_state
49
+ import panels
50
+ import update_checker
51
+ import update_gate
52
+ from burn_rate import BurnRateTracker
53
+ from fsevents_watch import FileEventChanges, cleanup_fsevents, setup_fsevents
54
+ from history_loader import (
55
+ UsageEntry,
56
+ )
57
+ from history_loader import (
58
+ flush_caches_on_terminate as flush_history_cache,
59
+ )
60
+ from i18n import _t, packaged_resource_path
61
+ from menubar_actions import (
62
+ show_forwarder_mode_prompt_if_needed as show_forwarder_mode_prompt_if_needed,
63
+ )
64
+ from menubar_chrome import (
65
+ _make_alert,
66
+ )
67
+ from menubar_popover import PopoverViewController, _popover_size
68
+ from menubar_prefs import (
69
+ _auto_update_check_enabled,
70
+ _hide_agy_enabled,
71
+ _hide_claude_enabled,
72
+ _hide_codex_enabled,
73
+ _quota_card_order,
74
+ _quota_notification_thresholds,
75
+ _quota_notifications_enabled,
76
+ _window_keeper_enabled,
77
+ )
78
+ from menubar_state import (
79
+ CLAUDE_COLOR as CLAUDE_COLOR,
80
+ )
81
+ from menubar_state import (
82
+ CODEX_COLOR as CODEX_COLOR,
83
+ )
84
+ from menubar_state import (
85
+ DANGER_COLOR as DANGER_COLOR,
86
+ )
87
+ from menubar_state import (
88
+ SERVICE_ALERT_GAP as SERVICE_ALERT_GAP,
89
+ )
90
+ from menubar_state import (
91
+ WARN_COLOR as WARN_COLOR,
92
+ )
93
+ from menubar_state import (
94
+ WEEKLY_FORECAST_MIN_SPAN_SECONDS,
95
+ WEEKLY_FORECAST_WINDOW_SECONDS,
96
+ CodexStaleState,
97
+ PopoverState,
98
+ QuotaRowState,
99
+ _missing_row,
100
+ _quota_row,
101
+ )
102
+ from menubar_state import (
103
+ _bar_color as _bar_color,
104
+ )
105
+ from menubar_state import (
106
+ _classify_history_load_error as _classify_history_load_error,
107
+ )
108
+ from menubar_state import (
109
+ _empty_state as _empty_state,
110
+ )
111
+ from menubar_state import (
112
+ _error_state as _error_state,
113
+ )
114
+ from menubar_state import (
115
+ _format_percent as _format_percent,
116
+ )
117
+ from menubar_state import (
118
+ _group_name as _group_name,
119
+ )
120
+ from menubar_state import (
121
+ _statusline_payload as _statusline_payload,
122
+ )
123
+ from menubar_state import (
124
+ _today_title as _today_title,
125
+ )
126
+ from menubar_state import (
127
+ format_human_time as format_human_time,
128
+ )
129
+ from panel_window import PanelWindow
130
+ from panel_window_state import save_panel_window_top_left
131
+ from panels.base import (
132
+ load_active_panel_id,
133
+ save_active_panel_id,
134
+ )
135
+ from panels.dynamic_height import clamp_content_height
136
+ from prefs import _load_preferences, _save_preferences
137
+ from pricing import warm_up_pricing
138
+ from statusline_settings import (
139
+ _claude_settings_path as _claude_settings_path,
140
+ )
141
+ from statusline_settings import (
142
+ _disable_statusline_settings as _disable_statusline_settings,
143
+ )
144
+ from statusline_settings import (
145
+ _enable_statusline_settings as _enable_statusline_settings,
146
+ )
147
+ from statusline_settings import (
148
+ _load_claude_settings as _load_claude_settings,
149
+ )
150
+ from statusline_settings import (
151
+ _save_claude_settings as _save_claude_settings,
152
+ )
153
+ from statusline_settings import (
154
+ _statusline_command_target_exists as _statusline_command_target_exists,
155
+ )
156
+ from statusline_settings import (
157
+ _statusline_enabled as _statusline_enabled,
158
+ )
159
+ from statusline_settings import (
160
+ _toggle_statusline_settings as _toggle_statusline_settings,
161
+ )
162
+ from update_release_notes import format_release_notes
163
+ from usage_client import ClaudeUsageClient, PollOutcome
164
+ from usage_lang import detect_lang
165
+ from usage_notifications import NotificationEvent, QuotaNotifier
166
+ from usage_rate import UsageRateTracker
167
+
168
+ __all__ = [
169
+ "CLAUDE_COLOR",
170
+ "CODEX_COLOR",
171
+ "DANGER_COLOR",
172
+ "WARN_COLOR",
173
+ "WEEKLY_FORECAST_MIN_SPAN_SECONDS",
174
+ "WEEKLY_FORECAST_WINDOW_SECONDS",
175
+ "CodexStaleState",
176
+ "PopoverState",
177
+ "QuotaRowState",
178
+ "_bar_color",
179
+ "_format_percent",
180
+ "_group_name",
181
+ "_missing_row",
182
+ "_quota_row",
183
+ "format_human_time",
184
+ "_auto_update_check_enabled",
185
+ "_hide_agy_enabled",
186
+ "_hide_claude_enabled",
187
+ "_hide_codex_enabled",
188
+ "_quota_card_order",
189
+ "_quota_notification_thresholds",
190
+ "_quota_notifications_enabled",
191
+ "_window_keeper_enabled",
192
+ ]
193
+
194
+ UPDATE_ALERT_BODY_LIMIT = 2000
195
+ SLOW_POLL_INTERVAL_S = 300.0
196
+
197
+ logger = logging.getLogger(__name__)
198
+
199
+
200
+ def _detect_language() -> str:
201
+ return detect_lang()
202
+
203
+
204
+ def _session_resume_enabled() -> bool:
205
+ # State lives in ~/.claude/settings.json (a hook), not in usage's prefs file.
206
+ try:
207
+ import session_hooks
208
+
209
+ return session_hooks.is_resume_enabled()
210
+ except Exception:
211
+ return False
212
+
213
+
214
+ def _terse_mode_enabled() -> bool:
215
+ try:
216
+ import session_hooks
217
+
218
+ return session_hooks.is_terse_mode_enabled()
219
+ except Exception:
220
+ return False
221
+
222
+
223
+ def _current_version() -> str:
224
+ try:
225
+ return metadata.version("usage-cli")
226
+ except metadata.PackageNotFoundError as exc:
227
+ pyproject = packaged_resource_path(
228
+ "pyproject.toml", Path(__file__).with_name("pyproject.toml")
229
+ )
230
+ data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
231
+ version = data.get("project", {}).get("version")
232
+ if isinstance(version, str):
233
+ return version
234
+ raise RuntimeError("project.version missing from pyproject.toml") from exc
235
+
236
+
237
+ _APP_DELEGATE: AppDelegate | None = None
238
+
239
+
240
+ class AppDelegate(NSObject):
241
+ status_item = objc.ivar()
242
+ popover = objc.ivar()
243
+ popover_controller = objc.ivar()
244
+ timer = objc.ivar()
245
+ timer_interval = objc.ivar()
246
+ mock = objc.ivar()
247
+ interval = objc.ivar()
248
+ tracker = objc.ivar()
249
+ codex_tracker = objc.ivar()
250
+ agy_tracker = objc.ivar()
251
+ latest_state = objc.ivar()
252
+ active_panel = objc.ivar()
253
+ codex_5h_pct = objc.ivar()
254
+ codex_model = objc.ivar()
255
+ burn_rate_trackers = objc.ivar()
256
+ _refresh_in_flight = objc.ivar()
257
+ _refresh_queued = objc.ivar()
258
+ _file_event_refresh_timer = objc.ivar()
259
+ _last_file_event_refresh_started_at = objc.ivar()
260
+ _fs_stream = objc.ivar()
261
+ _history_entries_cache = objc.ivar()
262
+ _history_entries_cache_fingerprint = objc.ivar()
263
+ _history_source_tracker = objc.ivar()
264
+ _history_load_error_key = objc.ivar()
265
+ _quota_notifier = objc.ivar()
266
+ _switch_menu_action_taken = objc.ivar()
267
+ _pre_talent_panel_id = objc.ivar()
268
+ _discussion_window_controller = objc.ivar()
269
+ _usage_client = objc.ivar()
270
+ critter_timer = objc.ivar()
271
+ critter_frame = objc.ivar()
272
+ critter_interval = objc.ivar()
273
+ critter_group = objc.ivar()
274
+ critter_last_advanced_at = objc.ivar()
275
+ dragon_frame = objc.ivar()
276
+ dragon_group = objc.ivar()
277
+ dragon_last_advanced_at = objc.ivar()
278
+ lion_frame = objc.ivar()
279
+ lion_group = objc.ivar()
280
+ lion_last_advanced_at = objc.ivar()
281
+ language = objc.ivar()
282
+
283
+ def initWithMock_interval_(self, mock: bool, interval: int) -> AppDelegate:
284
+ self = objc.super(AppDelegate, self).init()
285
+ if self is None:
286
+ return None
287
+ self.mock = mock
288
+ self.interval = max(30, interval)
289
+ self.timer = None
290
+ self.timer_interval = 0.0
291
+ self.tracker = UsageRateTracker(mock=mock)
292
+ self.codex_tracker = UsageRateTracker(mock=mock, load=codex_loader.load_entries)
293
+ self.agy_tracker = UsageRateTracker(mock=mock, load=cast(Any, agy_loader.load_entries))
294
+ self.language = _detect_language()
295
+ self.codex_5h_pct = None
296
+ self.codex_model = "unknown"
297
+ self.latest_state = _empty_state(self.language)
298
+ self.active_panel = panels.get_panel(load_active_panel_id())
299
+ self.burn_rate_trackers = {
300
+ "claude_session": BurnRateTracker(),
301
+ "claude_weekly": BurnRateTracker(),
302
+ "codex_session": BurnRateTracker(),
303
+ "codex_weekly": BurnRateTracker(),
304
+ }
305
+ self._quota_notifier = QuotaNotifier(_quota_notification_thresholds())
306
+ self._refresh_in_flight = False
307
+ self._refresh_queued = False
308
+ self._file_event_refresh_timer = None
309
+ self._last_file_event_refresh_started_at = None
310
+ self._fs_stream = None
311
+ self._history_entries_cache = None
312
+ self._history_entries_cache_fingerprint = None
313
+ self._history_source_tracker = menubar_state.HistorySourceTracker()
314
+ self._history_load_error_key = None
315
+ self._switch_menu_action_taken = False
316
+ self._pre_talent_panel_id = None
317
+ self._discussion_window_controller = None
318
+ self._usage_client = ClaudeUsageClient(mock=mock)
319
+ self.critter_timer = None
320
+ self.critter_frame = 0
321
+ self.critter_interval = 0.0
322
+ self.critter_group = 0
323
+ self.critter_last_advanced_at = 0.0
324
+ self.dragon_frame = 0
325
+ self.dragon_group = 0
326
+ self.dragon_last_advanced_at = 0.0
327
+ self.lion_frame = 0
328
+ self.lion_group = 0
329
+ self.lion_last_advanced_at = 0.0
330
+ self._menubar_text_cache: dict[str, Any] = {}
331
+ self._last_button_title_key: tuple[str, int | None, int | None, int | None] | None = None
332
+ self._last_plain_title_key: tuple[str] | None = None
333
+ return self
334
+
335
+ def applicationDidFinishLaunching_(self, notification: Any) -> None:
336
+ NSApp.setActivationPolicy_(NSApplicationActivationPolicyAccessory)
337
+ self.status_item = NSStatusBar.systemStatusBar().statusItemWithLength_(
338
+ NSVariableStatusItemLength,
339
+ )
340
+ # Do not change this string; it is the stable identity for saved menu bar position.
341
+ self.status_item.setAutosaveName_("usage")
342
+ button = self.status_item.button()
343
+ button.setTitle_("🐾 ...")
344
+ button.setTarget_(self)
345
+ button.setAction_("togglePopover:")
346
+
347
+ self.popover_controller = PopoverViewController.alloc().initWithPanel_delegate_(
348
+ self.active_panel,
349
+ self,
350
+ )
351
+ size = _popover_size(self.latest_state, self.active_panel)
352
+ self.popover = PanelWindow.alloc().initWithContentRect_(
353
+ NSMakeRect(0.0, 0.0, size.width, size.height)
354
+ )
355
+ self.popover.setRoundedContentView_(self.popover_controller.view())
356
+ self.popover.setDelegate_(self)
357
+
358
+ self._request_notification_authorization()
359
+ self._refresh()
360
+ self._reschedule_poll_timer(max(self.interval, SLOW_POLL_INTERVAL_S))
361
+ self._fs_stream = setup_fsevents(self)
362
+ self._history_source_tracker.set_incremental_enabled(self._fs_stream is not None)
363
+ warm_up_pricing(self._refresh_after_pricing_warm_up)
364
+ thread = threading.Thread(target=self._maybe_check_update_in_background, daemon=True)
365
+ thread.start()
366
+
367
+ def panelContentHeight_forView_(self, value: object, view: Any) -> None:
368
+ if view is not self.popover_controller.currentContentView():
369
+ return
370
+ screen = NSScreen.mainScreen()
371
+ maximum = (
372
+ self.active_panel.preferred_size()[1]
373
+ if screen is None
374
+ else float(screen.visibleFrame().size.height) - 24.0
375
+ )
376
+ height = clamp_content_height(value, maximum)
377
+ if height is None:
378
+ return
379
+ panel_window_state.save_panel_content_height(self.active_panel.id, height)
380
+ size = NSMakeSize(self.active_panel.preferred_size()[0], height)
381
+ self._set_panel_window_size(size)
382
+ self.popover_controller.view().setFrameSize_(size)
383
+ self.popover_controller.syncPanelFrames()
384
+
385
+ def panelBeginWindowDrag_(self, view: Any) -> None:
386
+ if view is not self.popover_controller.currentContentView():
387
+ return
388
+ event = NSApplication.sharedApplication().currentEvent()
389
+ if event is not None:
390
+ self.popover.performWindowDragWithEvent_(event)
391
+
392
+ def _refresh_after_pricing_warm_up(self) -> None:
393
+ self.performSelectorOnMainThread_withObject_waitUntilDone_(
394
+ "refreshNow:",
395
+ None,
396
+ False,
397
+ )
398
+
399
+ def timerFired_(self, timer: Any) -> None:
400
+ self._refresh()
401
+ self._clear_stale_update_cache()
402
+
403
+ def _reschedule_poll_timer(self, interval: float) -> None:
404
+ if self.timer is not None and self.timer_interval == interval:
405
+ return
406
+ self._stop_poll_timer()
407
+ scheduled_timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_
408
+ self.timer = scheduled_timer(interval, self, "timerFired:", None, True)
409
+ self.timer_interval = interval
410
+ NSRunLoop.currentRunLoop().addTimer_forMode_(self.timer, NSRunLoopCommonModes)
411
+
412
+ def _stop_poll_timer(self) -> None:
413
+ timer = self.timer
414
+ self.timer = None
415
+ self.timer_interval = 0.0
416
+ if timer is not None:
417
+ timer.invalidate()
418
+
419
+ def _panel_window_will_show(self) -> None:
420
+ self._reschedule_poll_timer(self.interval)
421
+
422
+ def _panel_window_did_show(self) -> None:
423
+ self.refreshNow_(None)
424
+
425
+ def _panel_window_did_hide(self) -> None:
426
+ self._reschedule_poll_timer(max(self.interval, SLOW_POLL_INTERVAL_S))
427
+
428
+ def windowDidMove_(self, notification: Any) -> None:
429
+ if notification.object() is self.popover and self._panel_window_is_visible():
430
+ self._save_panel_window_top_left()
431
+
432
+ def windowWillClose_(self, notification: Any) -> None:
433
+ if notification.object() is self.popover:
434
+ self._save_panel_window_top_left()
435
+ self._panel_window_did_hide()
436
+
437
+ def refreshNow_(self, sender: Any) -> None:
438
+ self._refresh(queue_if_busy=True)
439
+
440
+ def installHook_(self, sender: Any) -> None:
441
+ thread = threading.Thread(target=self._install_hook_in_background, daemon=True)
442
+ thread.start()
443
+
444
+ def toggleStatusline_(self, sender: Any) -> None:
445
+ thread = threading.Thread(target=self._toggle_statusline_in_background, daemon=True)
446
+ thread.start()
447
+
448
+ def installStatusline_(self, sender: Any) -> None:
449
+ thread = threading.Thread(
450
+ target=self._statusline_action_in_background,
451
+ args=("install",),
452
+ daemon=True,
453
+ )
454
+ thread.start()
455
+
456
+ def uninstallStatusline_(self, sender: Any) -> None:
457
+ thread = threading.Thread(
458
+ target=self._statusline_action_in_background,
459
+ args=("uninstall",),
460
+ daemon=True,
461
+ )
462
+ thread.start()
463
+
464
+ def analyzeUsage_(self, sender: Any) -> None:
465
+ period = _analysis_period_from_project_range(str(sender or "30d"))
466
+ thread = threading.Thread(
467
+ target=self._analyze_usage_in_background,
468
+ args=(period,),
469
+ daemon=True,
470
+ )
471
+ thread.start()
472
+
473
+ def quitApp_(self, sender: Any) -> None:
474
+ self._stop_poll_timer()
475
+ NSApp.terminate_(sender)
476
+
477
+ def applicationWillTerminate_(self, notification: Any) -> None:
478
+ self._stop_poll_timer()
479
+ cleanup_fsevents(self._fs_stream)
480
+ self._fs_stream = None
481
+ self._stop_critter_timer()
482
+ asyncio.run(self._usage_client.aclose())
483
+ if self._file_event_refresh_timer is not None:
484
+ self._file_event_refresh_timer.invalidate()
485
+ self._file_event_refresh_timer = None
486
+ flush_history_cache()
487
+ codex_loader.flush_caches_on_terminate()
488
+ agy_loader.flush_caches_on_terminate()
489
+ if self._discussion_window_controller is not None:
490
+ self._discussion_window_controller.shutdown()
491
+ if (
492
+ hasattr(self, "popover")
493
+ and self.popover is not None
494
+ and self._panel_window_is_visible()
495
+ ):
496
+ self._save_panel_window_top_left()
497
+ if hasattr(self, "popover_controller") and self.popover_controller is not None:
498
+ self.popover_controller.teardown()
499
+
500
+ def switchPanel_(self, sender: Any) -> None:
501
+ menubar_menu.build_switch_menu(self, sender)
502
+
503
+ def selectPanel_(self, sender: Any) -> None:
504
+ self._mark_switch_menu_action()
505
+ panel_id = str(sender.representedObject())
506
+ self._set_active_panel_id(panel_id)
507
+
508
+ def toggleTalentMarket_(self, sender: Any) -> None:
509
+ self._mark_switch_menu_action()
510
+ if self.active_panel.id != "talent_market":
511
+ self._pre_talent_panel_id = self.active_panel.id
512
+ self._set_active_panel_id("talent_market")
513
+ return
514
+
515
+ target_panel_id = self._pre_talent_panel_id or "classic"
516
+ self._set_active_panel_id(target_panel_id)
517
+
518
+ def toggleAiDaily_(self, sender: Any) -> None:
519
+ self._mark_switch_menu_action()
520
+ self._close_popover_after_menu()
521
+ webbrowser.open("https://aqua5230.github.io/ai-updates/")
522
+
523
+ def toggleDiscussion_(self, sender: Any) -> None:
524
+ from discussion_window import DiscussionWindowController
525
+
526
+ self._mark_switch_menu_action()
527
+ if self._discussion_window_controller is None:
528
+ self._discussion_window_controller = DiscussionWindowController()
529
+ self._discussion_window_controller.show(
530
+ close_popover=self._close_popover_after_menu,
531
+ )
532
+
533
+ def toggleLaunchAtLogin_(self, sender: Any) -> None:
534
+ self._mark_switch_menu_action()
535
+ try:
536
+ if login_item.is_enabled():
537
+ login_item.disable()
538
+ else:
539
+ login_item.enable()
540
+ except Exception:
541
+ if os.environ.get("USAGE_DEBUG") == "1":
542
+ logger.warning("toggle launch at login failed", exc_info=True)
543
+
544
+ def toggleHideClaude_(self, sender: Any) -> None:
545
+ self._mark_switch_menu_action()
546
+ prefs = _load_preferences()
547
+ enabled = not _hide_claude_enabled(prefs)
548
+ prefs["hide_claude_section"] = enabled
549
+ _save_preferences(prefs)
550
+ if hasattr(sender, "setState_"):
551
+ sender.setState_(1 if enabled else 0)
552
+ self.latest_state.hide_claude = enabled
553
+ self.popover_controller.setState_(self.latest_state)
554
+ menubar_title._set_button_title(self, self.latest_state)
555
+
556
+ def toggleHideCodex_(self, sender: Any) -> None:
557
+ self._mark_switch_menu_action()
558
+ prefs = _load_preferences()
559
+ enabled = not _hide_codex_enabled(prefs)
560
+ prefs["hide_codex_section"] = enabled
561
+ _save_preferences(prefs)
562
+ if hasattr(sender, "setState_"):
563
+ sender.setState_(1 if enabled else 0)
564
+ self.latest_state.hide_codex = enabled
565
+ self.popover_controller.setState_(self.latest_state)
566
+ menubar_title._set_button_title(self, self.latest_state)
567
+
568
+ def toggleHideAgy_(self, sender: Any) -> None:
569
+ self._mark_switch_menu_action()
570
+ prefs = _load_preferences()
571
+ enabled = not _hide_agy_enabled(prefs)
572
+ prefs["hide_agy_section"] = enabled
573
+ _save_preferences(prefs)
574
+ if hasattr(sender, "setState_"):
575
+ sender.setState_(1 if enabled else 0)
576
+ self.latest_state.hide_agy = enabled
577
+ self.popover_controller.setState_(self.latest_state)
578
+ menubar_title._set_button_title(self, self.latest_state)
579
+
580
+ def toggleQuotaNotifications_(self, sender: Any) -> None:
581
+ self._mark_switch_menu_action()
582
+ prefs = _load_preferences()
583
+ enabled = not _quota_notifications_enabled(prefs)
584
+ prefs["quota_notifications"] = enabled
585
+ _save_preferences(prefs)
586
+ if hasattr(sender, "setState_"):
587
+ sender.setState_(1 if enabled else 0)
588
+ if enabled:
589
+ self._request_notification_authorization()
590
+
591
+ def toggleWindowKeeper_(self, sender: Any) -> None:
592
+ self._mark_switch_menu_action()
593
+ prefs = _load_preferences()
594
+ enabled = not _window_keeper_enabled(prefs)
595
+ prefs["window_keeper"] = enabled
596
+ prefs.pop("agy_window_keeper", None)
597
+ _save_preferences(prefs)
598
+ if hasattr(sender, "setState_"):
599
+ sender.setState_(1 if enabled else 0)
600
+ if enabled:
601
+ alert = _make_alert()
602
+ alert.setMessageText_(_t(self.language, "window_keeper_sleep_title"))
603
+ alert.setInformativeText_(_t(self.language, "window_keeper_sleep_body"))
604
+ alert.runModal()
605
+
606
+ def toggleSessionResume_(self, sender: Any) -> None:
607
+ self._mark_switch_menu_action()
608
+ thread = threading.Thread(target=self._toggle_session_resume_in_background, daemon=True)
609
+ thread.start()
610
+
611
+ def _toggle_session_resume_in_background(self) -> None:
612
+ menubar_actions.toggle_session_resume_in_background(self)
613
+
614
+ def _finishSessionResume_(self, result: dict[str, Any]) -> None:
615
+ alert = _make_alert()
616
+ if result.get("ok", True):
617
+ key = "resume_enabled_restart" if result.get("enabled") else "resume_disabled_msg"
618
+ alert.setMessageText_(_t(self.language, key))
619
+ else:
620
+ alert.setMessageText_(_t(self.language, "resume_action_failed"))
621
+ alert.setInformativeText_(str(result.get("output") or ""))
622
+ alert.runModal()
623
+ self._refresh()
624
+
625
+ def toggleTerseMode_(self, sender: Any) -> None:
626
+ self._mark_switch_menu_action()
627
+ thread = threading.Thread(target=self._toggle_terse_mode_in_background, daemon=True)
628
+ thread.start()
629
+
630
+ def _toggle_terse_mode_in_background(self) -> None:
631
+ menubar_actions.toggle_terse_mode_in_background(self)
632
+
633
+ def _finishTerseMode_(self, result: dict[str, Any]) -> None:
634
+ alert = _make_alert()
635
+ if result.get("ok", True):
636
+ key = "terse_mode_enabled_msg" if result.get("enabled") else "terse_mode_disabled_msg"
637
+ alert.setMessageText_(_t(self.language, key))
638
+ else:
639
+ alert.setMessageText_(_t(self.language, "resume_action_failed"))
640
+ alert.setInformativeText_(str(result.get("output") or ""))
641
+ alert.runModal()
642
+ self._refresh()
643
+
644
+ def _clear_stale_update_cache(self) -> None:
645
+ menubar_update.clear_stale_update_cache()
646
+
647
+ def _maybe_check_update_in_background(self) -> None:
648
+ menubar_update.maybe_check_update_in_background(self)
649
+
650
+ def _check_update_in_background(
651
+ self,
652
+ *,
653
+ manual: bool,
654
+ ignore_cooldown: bool,
655
+ ignore_skipped: bool,
656
+ ) -> None:
657
+ menubar_update.check_update_in_background(
658
+ self,
659
+ manual=manual,
660
+ ignore_cooldown=ignore_cooldown,
661
+ ignore_skipped=ignore_skipped,
662
+ )
663
+
664
+ def _showUpdateAlert_(self, release: update_checker.ReleaseInfo) -> None:
665
+ alert = _make_alert()
666
+ alert.setMessageText_(_t(self.language, "update_alert_title", version=release.version))
667
+ alert.setInformativeText_(format_release_notes(release.body, UPDATE_ALERT_BODY_LIMIT))
668
+ alert.addButtonWithTitle_(_t(self.language, "update_btn_download"))
669
+ alert.addButtonWithTitle_(_t(self.language, "update_btn_later"))
670
+ alert.addButtonWithTitle_(_t(self.language, "update_btn_skip"))
671
+ result = int(alert.runModal())
672
+ action, pref_updates = update_gate.resolve_alert_choice(result, release.version)
673
+ if action == "open":
674
+ webbrowser.open(release.html_url)
675
+ return
676
+
677
+ prefs = _load_preferences()
678
+ prefs.update(pref_updates)
679
+ if action == "dismiss":
680
+ prefs["update_dismissed_at"] = time.time()
681
+ _save_preferences(prefs)
682
+
683
+ def _showNoUpdateAvailable_(self, result: Any) -> None:
684
+ alert = _make_alert()
685
+ alert.setMessageText_(_t(self.language, "update_no_new_version"))
686
+ alert.runModal()
687
+
688
+ def _showUpdateCheckFailed_(self, result: Any) -> None:
689
+ alert = _make_alert()
690
+ alert.setMessageText_(_t(self.language, "update_check_failed"))
691
+ alert.runModal()
692
+
693
+ def _set_active_panel_id(self, panel_id: str) -> None:
694
+ panel = panels.get_panel(panel_id)
695
+ save_active_panel_id(panel.id)
696
+ self.active_panel = panel
697
+ self.popover_controller.switchToPanel_(panel)
698
+ self._set_panel_window_size(_popover_size(self.latest_state, panel))
699
+ if panel.id == "talent_market":
700
+ # Talent data is fetched in the background refresh; switchToPanel_
701
+ # injected the last (talent-less) state, so kick a refresh to fill it.
702
+ self._refresh()
703
+
704
+ def _show_popover_from_button(self, button: Any) -> None:
705
+ frame = self.popover.frame()
706
+ size = (float(frame.size.width), float(frame.size.height))
707
+ if (top_left := panel_window_state.load_panel_window_top_left()) is not None:
708
+ origin: tuple[float, float] | None = (top_left[0], top_left[1] - size[1])
709
+ else:
710
+ origin = panel_window_state.load_panel_window_origin()
711
+ if origin is None:
712
+ button_window = button.window()
713
+ button_rect = button.convertRect_toView_(button.bounds(), None)
714
+ screen_rect = button_window.convertRectToScreen_(button_rect)
715
+ origin = (
716
+ float(screen_rect.origin.x)
717
+ + (float(screen_rect.size.width) - size[0]) / 2.0,
718
+ float(screen_rect.origin.y) - size[1],
719
+ )
720
+ visible_frames = [
721
+ (
722
+ float(screen.visibleFrame().origin.x),
723
+ float(screen.visibleFrame().origin.y),
724
+ float(screen.visibleFrame().size.width),
725
+ float(screen.visibleFrame().size.height),
726
+ )
727
+ for screen in NSScreen.screens()
728
+ ]
729
+ origin = panel_window_state.clamp_origin_to_visible_frames(
730
+ origin, size, visible_frames
731
+ )
732
+ self.popover.setFrameOrigin_(NSMakePoint(*origin))
733
+ self._panel_window_will_show()
734
+ self.popover.makeKeyAndOrderFront_(None)
735
+ self._panel_window_did_show()
736
+
737
+ def _set_panel_window_size(self, size: Any) -> None:
738
+ self.popover.setContentSizeKeepingTopLeft_(size)
739
+
740
+ def _panel_window_is_visible(self) -> bool:
741
+ return bool(self.popover.isVisible())
742
+
743
+ def _save_panel_window_top_left(self) -> None:
744
+ if not hasattr(self, "popover") or self.popover is None:
745
+ return
746
+ frame = self.popover.frame()
747
+ top_left = (float(frame.origin.x), float(frame.origin.y) + float(frame.size.height))
748
+ save_panel_window_top_left(top_left)
749
+
750
+ def _mark_switch_menu_action(self) -> None:
751
+ self._switch_menu_action_taken = True
752
+
753
+ def _close_popover_after_menu(self) -> None:
754
+ if not hasattr(self, "popover") or self.popover is None:
755
+ return
756
+ if not self._panel_window_is_visible():
757
+ return
758
+ self.popover.close()
759
+
760
+ def _resync_popover_after_menu(self) -> None:
761
+ if not hasattr(self, "popover") or not hasattr(self, "popover_controller"):
762
+ return
763
+ if not hasattr(self, "status_item"):
764
+ return
765
+ if self.popover is None or self.popover_controller is None or self.status_item is None:
766
+ return
767
+ if not self._panel_window_is_visible():
768
+ return
769
+ self.popover_controller.setState_(self.latest_state)
770
+ self._set_panel_window_size(_popover_size(self.latest_state, self.active_panel))
771
+
772
+ def animateCritters_(self, timer: Any) -> None:
773
+ now = time.monotonic()
774
+ intervals = self._critter_intervals()
775
+ previous = (
776
+ float(self.critter_last_advanced_at),
777
+ float(self.dragon_last_advanced_at),
778
+ float(self.lion_last_advanced_at),
779
+ )
780
+ tick = menubar_state.critter_animation_tick(now, intervals, previous)
781
+ if tick.timer_interval <= 0:
782
+ self._stop_critter_timer()
783
+ return
784
+ if tick.advance[0]:
785
+ self.critter_frame = (
786
+ int(self.critter_frame) + 1
787
+ ) % len(critter_frames.PHOENIX_FRAMES)
788
+ self.critter_last_advanced_at = previous[0] + intervals[0]
789
+ if tick.advance[1]:
790
+ self.dragon_frame = (
791
+ int(self.dragon_frame) + 1
792
+ ) % len(critter_frames.DRAGON_FRAMES)
793
+ self.dragon_last_advanced_at = previous[1] + intervals[1]
794
+ if tick.advance[2]:
795
+ self.lion_frame = (
796
+ int(self.lion_frame) + 1
797
+ ) % len(critter_frames.LION_FRAMES)
798
+ self.lion_last_advanced_at = previous[2] + intervals[2]
799
+ if any(tick.advance):
800
+ menubar_title._set_button_title(self, self.latest_state)
801
+
802
+ def _critter_intervals(self) -> tuple[float, float, float]:
803
+ return (
804
+ critter_frames.group_to_interval(int(self.critter_group)),
805
+ critter_frames.group_to_interval(int(self.dragon_group)),
806
+ critter_frames.group_to_interval(int(self.lion_group)),
807
+ )
808
+
809
+ def _sync_critter_timer(self) -> None:
810
+ intervals = self._critter_intervals()
811
+ interval = min((value for value in intervals if value > 0), default=0.0)
812
+ now = time.monotonic()
813
+ self.critter_last_advanced_at = now
814
+ self.dragon_last_advanced_at = now
815
+ self.lion_last_advanced_at = now
816
+ if intervals[0] <= 0:
817
+ self.critter_frame = 0
818
+ if intervals[1] <= 0:
819
+ self.dragon_frame = 0
820
+ if intervals[2] <= 0:
821
+ self.lion_frame = 0
822
+ if interval <= 0:
823
+ self._stop_critter_timer()
824
+ return
825
+ if self.critter_timer is not None and self.critter_interval == interval:
826
+ return
827
+ self._stop_critter_timer()
828
+ self.critter_interval = interval
829
+ scheduled_timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_
830
+ self.critter_timer = scheduled_timer(interval, self, "animateCritters:", None, True)
831
+ NSRunLoop.currentRunLoop().addTimer_forMode_(self.critter_timer, NSRunLoopCommonModes)
832
+
833
+ def _stop_critter_timer(self) -> None:
834
+ timer = self.critter_timer
835
+ self.critter_timer = None
836
+ self.critter_interval = 0.0
837
+ if timer is not None:
838
+ timer.invalidate()
839
+
840
+ def togglePopover_(self, sender: Any) -> None:
841
+ if self._panel_window_is_visible():
842
+ self.popover.close()
843
+ return
844
+ self.popover_controller.setState_(self.latest_state)
845
+ self._set_panel_window_size(_popover_size(self.latest_state, self.active_panel))
846
+ button = self.status_item.button()
847
+ self._show_popover_from_button(button)
848
+
849
+ def _refresh(self, queue_if_busy: bool = False) -> None:
850
+ if self._refresh_in_flight:
851
+ if queue_if_busy:
852
+ self._refresh_queued = True
853
+ return
854
+ self._refresh_in_flight = True
855
+ thread = threading.Thread(target=self._refresh_in_background, daemon=True)
856
+ thread.start()
857
+
858
+ def refreshFromFileEvent_(self, changes: FileEventChanges) -> None:
859
+ self._history_source_tracker.record_changes(
860
+ set(changes.paths),
861
+ needs_full_scan=changes.needs_full_scan,
862
+ )
863
+ now = time.monotonic()
864
+ decision = menubar_state.file_event_refresh_decision(
865
+ now,
866
+ self._last_file_event_refresh_started_at,
867
+ self._file_event_refresh_timer is not None,
868
+ )
869
+ if decision.refresh_now:
870
+ self._last_file_event_refresh_started_at = now
871
+ self._refresh(queue_if_busy=True)
872
+ elif decision.trailing_delay is not None:
873
+ self._file_event_refresh_timer = (
874
+ NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
875
+ decision.trailing_delay,
876
+ self,
877
+ "refreshFromTrailingFileEvent:",
878
+ None,
879
+ False,
880
+ )
881
+ )
882
+
883
+ def refreshFromTrailingFileEvent_(self, _sender: Any) -> None:
884
+ self._file_event_refresh_timer = None
885
+ self._last_file_event_refresh_started_at = time.monotonic()
886
+ self._refresh(queue_if_busy=True)
887
+
888
+ def _refresh_in_background(self) -> None:
889
+ submitted = False
890
+ try:
891
+ sources = menubar_refresh.load_sources(self)
892
+ started_at = time.monotonic() if sources.debug_timing else 0.0
893
+ self.performSelectorOnMainThread_withObject_waitUntilDone_(
894
+ "_applyCodexRefreshResult:",
895
+ sources.codex_result,
896
+ True,
897
+ )
898
+ sources.measure("main_apply_codex", started_at)
899
+ result = menubar_refresh.build_result(self, sources)
900
+ self.performSelectorOnMainThread_withObject_waitUntilDone_(
901
+ "_applyRefreshResult:",
902
+ result,
903
+ False,
904
+ )
905
+ submitted = True
906
+ finally:
907
+ if not submitted:
908
+ self.performSelectorOnMainThread_withObject_waitUntilDone_(
909
+ "_clearRefreshInFlight:",
910
+ None,
911
+ False,
912
+ )
913
+
914
+ def _applyRefreshResult_(self, result: dict[str, Any]) -> None:
915
+ started_at = time.monotonic() if os.environ.get("USAGE_DEBUG") == "1" else 0.0
916
+ should_refresh_again = False
917
+ try:
918
+ state = result["state"]
919
+ codex_5h_pct = result["codex_5h_pct"]
920
+ codex_model = result.get("codex_model", "unknown")
921
+ self.codex_5h_pct = codex_5h_pct
922
+ self.codex_model = codex_model
923
+ self.latest_state = state
924
+ animation_groups = tuple(
925
+ int(group)
926
+ for group in result.get(
927
+ "animation_groups",
928
+ (self.critter_group, self.dragon_group, self.lion_group),
929
+ )
930
+ )
931
+ previous_groups = (
932
+ int(self.critter_group),
933
+ int(self.dragon_group),
934
+ int(self.lion_group),
935
+ )
936
+ self.critter_group, self.dragon_group, self.lion_group = animation_groups
937
+ if animation_groups != previous_groups:
938
+ self._sync_critter_timer()
939
+ self._process_quota_notifications(state)
940
+ if self._panel_window_is_visible():
941
+ self.popover_controller.setState_(self.latest_state)
942
+ self._set_panel_window_size(_popover_size(state, self.active_panel))
943
+ self._inject_web_language(state.language)
944
+ menubar_title._set_button_title(self, state)
945
+ finally:
946
+ should_refresh_again = bool(self._refresh_queued)
947
+ self._refresh_queued = False
948
+ self._refresh_in_flight = False
949
+ if should_refresh_again:
950
+ self._refresh()
951
+ if started_at:
952
+ logger.debug(
953
+ "refresh_timing stage=main_apply elapsed_ms=%.1f",
954
+ (time.monotonic() - started_at) * 1000,
955
+ )
956
+
957
+ def _clearRefreshInFlight_(self, _sender: Any) -> None:
958
+ self._refresh_in_flight = False
959
+
960
+ def _load_codex_refresh_result(self) -> dict[str, Any]:
961
+ history_scan = None if self.mock else self._history_source_scan()
962
+ try:
963
+ (
964
+ codex_rows,
965
+ codex_5h_pct,
966
+ codex_model,
967
+ codex_stale,
968
+ codex_credits,
969
+ ) = menubar_state.codex_rows(
970
+ mock=self.mock,
971
+ language=self.language,
972
+ burn_rate_trackers=self.burn_rate_trackers,
973
+ jsonl_candidates=(
974
+ None
975
+ if history_scan is None
976
+ else history_scan.codex_rate_limit_candidates
977
+ ),
978
+ )
979
+ except Exception:
980
+ if os.environ.get("USAGE_DEBUG") == "1":
981
+ logger.warning("Codex quota refresh failed", exc_info=True)
982
+ codex_rows = (
983
+ _missing_row(_t(self.language, "session_label"), CODEX_COLOR, self.language),
984
+ _missing_row(_t(self.language, "weekly_label"), CODEX_COLOR, self.language),
985
+ )
986
+ codex_5h_pct = None
987
+ codex_model = "unknown"
988
+ codex_stale = None
989
+ codex_credits = None
990
+ return {
991
+ "codex_rows": codex_rows,
992
+ "codex_5h_pct": codex_5h_pct,
993
+ "codex_model": codex_model,
994
+ "codex_stale": codex_stale,
995
+ "codex_credits": codex_credits,
996
+ "_history_scan": history_scan,
997
+ }
998
+
999
+ def _applyCodexRefreshResult_(self, result: dict[str, Any]) -> None:
1000
+ started_at = time.monotonic() if os.environ.get("USAGE_DEBUG") == "1" else 0.0
1001
+ codex_rows = result["codex_rows"]
1002
+ self.latest_state.codex_session = codex_rows[0]
1003
+ self.latest_state.codex_weekly = codex_rows[1]
1004
+ self.latest_state.codex_stale = result.get("codex_stale")
1005
+ self.latest_state.codex_credits = result.get("codex_credits")
1006
+ self.codex_5h_pct = result["codex_5h_pct"]
1007
+ self.codex_model = result.get("codex_model", "unknown")
1008
+ if self._panel_window_is_visible():
1009
+ self.popover_controller.setState_(self.latest_state)
1010
+ self._set_panel_window_size(_popover_size(self.latest_state, self.active_panel))
1011
+ menubar_title._set_button_title(self, self.latest_state)
1012
+ if started_at:
1013
+ logger.debug(
1014
+ "refresh_timing stage=main_apply_codex_ui elapsed_ms=%.1f",
1015
+ (time.monotonic() - started_at) * 1000,
1016
+ )
1017
+
1018
+ def _request_notification_authorization(self) -> None:
1019
+ if self.mock or not _quota_notifications_enabled():
1020
+ return
1021
+ try:
1022
+ center, constants = menubar_notify.user_notification_center()
1023
+ options = constants["badge"] | constants["sound"] | constants["alert"]
1024
+ center.requestAuthorizationWithOptions_completionHandler_(
1025
+ options,
1026
+ lambda granted, error: None,
1027
+ )
1028
+ except Exception:
1029
+ if os.environ.get("USAGE_DEBUG") == "1":
1030
+ logger.warning("notification authorization failed", exc_info=True)
1031
+
1032
+ def _process_quota_notifications(self, state: PopoverState) -> None:
1033
+ try:
1034
+ events = self._quota_notifier.update(
1035
+ {
1036
+ "claude_session": (
1037
+ state.claude_session.percent,
1038
+ state.claude_session.available,
1039
+ ),
1040
+ "claude_weekly": (state.claude_weekly.percent, state.claude_weekly.available),
1041
+ "codex_session": (state.codex_session.percent, state.codex_session.available),
1042
+ "codex_weekly": (state.codex_weekly.percent, state.codex_weekly.available),
1043
+ }
1044
+ )
1045
+ for event in events:
1046
+ if _quota_notifications_enabled() and not self.mock:
1047
+ self._send_quota_notification(event, state)
1048
+ except Exception:
1049
+ if os.environ.get("USAGE_DEBUG") == "1":
1050
+ logger.warning("quota notification processing failed", exc_info=True)
1051
+
1052
+ def _send_quota_notification(self, event: NotificationEvent, state: PopoverState) -> None:
1053
+ try:
1054
+ center, _constants = menubar_notify.user_notification_center()
1055
+ content_cls, request_cls, sound_cls = menubar_notify.user_notification_classes()
1056
+ row = menubar_notify.notification_row(state, event.channel)
1057
+ title_key = f"notif_{event.kind}_title"
1058
+ body_key = f"notif_{event.kind}_body"
1059
+ content = content_cls.alloc().init()
1060
+ content.setTitle_(_t(self.language, title_key))
1061
+ content.setBody_(
1062
+ _t(
1063
+ self.language,
1064
+ body_key,
1065
+ tool=menubar_notify.notification_tool(event.channel),
1066
+ # row.title carries the window-aware label (e.g. Codex free
1067
+ # plan shows "Monthly"); fall back to the slot's scope text.
1068
+ scope=row.title
1069
+ or menubar_notify.notification_scope(self.language, event.channel),
1070
+ pct=_format_percent(row.percent or event.threshold or 0.0),
1071
+ reset=row.reset_text,
1072
+ )
1073
+ )
1074
+ content.setSound_(sound_cls.defaultSound())
1075
+ request = request_cls.requestWithIdentifier_content_trigger_(
1076
+ f"usage.{event.channel}.{event.kind}.{int(time.time() * 1000)}",
1077
+ content,
1078
+ None,
1079
+ )
1080
+ center.addNotificationRequest_withCompletionHandler_(request, lambda error: None)
1081
+ except Exception:
1082
+ if os.environ.get("USAGE_DEBUG") == "1":
1083
+ logger.warning("send quota notification failed", exc_info=True)
1084
+
1085
+ def _inject_web_language(self, language: str) -> None:
1086
+ content_view = self.popover_controller.currentContentView()
1087
+ if not hasattr(content_view, "evaluateJavaScript_completionHandler_"):
1088
+ return
1089
+ content_view.evaluateJavaScript_completionHandler_(
1090
+ f"window.usageSetLanguage && window.usageSetLanguage({json.dumps(language)})",
1091
+ None,
1092
+ )
1093
+
1094
+ def panelDidFirstPaint_(self, view: Any) -> None:
1095
+ self.popover_controller.panelDidFirstPaint_(view)
1096
+
1097
+ def _install_hook_in_background(self) -> None:
1098
+ menubar_actions.install_hook_in_background(self)
1099
+
1100
+ def _finishHookInstall_(self, result: dict[str, Any]) -> None:
1101
+ alert = _make_alert()
1102
+ if result["success"]:
1103
+ alert.setMessageText_(_t(self.language, "hook_installed_restart"))
1104
+ else:
1105
+ alert.setMessageText_(_t(self.language, "hook_install_failed"))
1106
+ alert.setInformativeText_(
1107
+ result["message"] or _t(self.language, "hook_install_failed_default")
1108
+ )
1109
+ alert.runModal()
1110
+ self._refresh()
1111
+
1112
+ def _toggle_statusline_in_background(self) -> None:
1113
+ self._statusline_action_in_background("toggle")
1114
+
1115
+ def _statusline_action_in_background(self, action: str) -> None:
1116
+ menubar_actions.statusline_action_in_background(self, action)
1117
+
1118
+ def _finishStatuslineAction_(self, result: dict[str, Any]) -> None:
1119
+ self._refresh()
1120
+ self._refresh_statusline_state()
1121
+ if result.get("ok", True):
1122
+ return
1123
+ alert = _make_alert()
1124
+ alert.setMessageText_(_t(self.language, "statusline_action_failed"))
1125
+ alert.setInformativeText_(str(result.get("output") or result.get("action") or ""))
1126
+ alert.runModal()
1127
+
1128
+ def _refresh_statusline_state(self) -> None:
1129
+ self.latest_state.statusline = _statusline_payload(self.language)
1130
+ self.popover_controller.setState_(self.latest_state)
1131
+
1132
+ def _analyze_usage_in_background(self, period: str) -> None:
1133
+ menubar_actions.analyze_usage_in_background(self, period)
1134
+
1135
+ def _finishAnalyzeUsage_(self, result: dict[str, Any]) -> None:
1136
+ if result["success"]:
1137
+ return
1138
+ alert = _make_alert()
1139
+ alert.setMessageText_(_t(self.language, "analysis_failed"))
1140
+ alert.setInformativeText_(str(result["message"]))
1141
+ alert.runModal()
1142
+
1143
+ async def _fetch(self) -> PollOutcome:
1144
+ return cast(PollOutcome, await self._usage_client.fetch_once())
1145
+
1146
+ def _statusline_setup_available(self) -> bool:
1147
+ try:
1148
+ import setup_hook
1149
+
1150
+ return setup_hook.CLAUDE_SETTINGS.parent.exists() or setup_hook.CODEX_CONFIG.exists()
1151
+ except Exception:
1152
+ return False
1153
+
1154
+ def _history_sources_fingerprint(self) -> tuple[tuple[str, int, float], ...]:
1155
+ return menubar_state.app_history_sources_fingerprint(self)
1156
+
1157
+ def _history_source_scan(self) -> menubar_state.HistorySourceScan:
1158
+ return menubar_state.app_history_source_scan(self)
1159
+
1160
+ def _load_history_entries(
1161
+ self,
1162
+ *,
1163
+ scan: menubar_state.HistorySourceScan | None = None,
1164
+ ) -> list[UsageEntry]:
1165
+ return menubar_state.app_load_history_entries(self, scan=scan)
1166
+
1167
+ def _project_rows(
1168
+ self,
1169
+ hours_back: int = 24,
1170
+ entries: list[UsageEntry] | None = None,
1171
+ ) -> list[tuple[str, int, float | None]]:
1172
+ return menubar_state.app_project_rows(self, hours_back=hours_back, entries=entries)
1173
+
1174
+ def run_app(mock: bool = False, interval: int = 60) -> None:
1175
+ global _APP_DELEGATE
1176
+ app = NSApplication.sharedApplication()
1177
+ _APP_DELEGATE = AppDelegate.alloc().initWithMock_interval_(mock, interval)
1178
+ app.setDelegate_(_APP_DELEGATE)
1179
+ app.run()
1180
+
1181
+
1182
+ def _generate_analysis_report(period: str = "month", language: str | None = None) -> str:
1183
+ from adapters.registry import detect_agents
1184
+ from analyzer.reporter import build_report_data
1185
+ from ui.html_report import save_and_open
1186
+
1187
+ agents = detect_agents()
1188
+ data = build_report_data(agents, period)
1189
+ return save_and_open(data, language=language)
1190
+
1191
+
1192
+ def _analysis_period_from_project_range(project_range: str) -> str:
1193
+ if project_range == "1d":
1194
+ return "today"
1195
+ if project_range == "7d":
1196
+ return "last7"
1197
+ if project_range == "30d":
1198
+ return "last30"
1199
+ if project_range == "all":
1200
+ return "all"
1201
+ return "month"