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.
- adapters/__init__.py +5 -0
- adapters/agy.py +68 -0
- adapters/claude.py +215 -0
- adapters/codex.py +209 -0
- adapters/rate_limits.py +76 -0
- adapters/registry.py +17 -0
- adapters/types.py +139 -0
- agy_disk_cache.py +135 -0
- agy_loader.py +416 -0
- agy_quota_probe.py +748 -0
- agy_window_keeper.py +185 -0
- analyzer/__init__.py +5 -0
- analyzer/aggregator.py +139 -0
- analyzer/blocks.py +80 -0
- analyzer/diagnoser.py +638 -0
- analyzer/insights.py +277 -0
- analyzer/persona_loader.py +199 -0
- analyzer/reporter.py +989 -0
- analyzer/subscription.py +108 -0
- burn_rate.py +75 -0
- cache_quarantine.py +50 -0
- codex_disk_cache.py +227 -0
- codex_events.py +136 -0
- codex_fork_replay.py +111 -0
- codex_loader.py +1426 -0
- codex_paths.py +20 -0
- critter_frames.py +26 -0
- discussion_bridge.py +1196 -0
- discussion_cli.py +844 -0
- discussion_session.py +622 -0
- discussion_usage.py +13 -0
- discussion_window.py +955 -0
- disk_cache_common.py +132 -0
- disk_cache_lifecycle.py +39 -0
- doctor.py +452 -0
- fsevents_watch.py +207 -0
- history_disk_cache.py +110 -0
- history_loader.py +416 -0
- i18n.py +88 -0
- jsonl_limits.py +17 -0
- jsonl_utils.py +40 -0
- login_item.py +154 -0
- main.py +387 -0
- menubar.py +1201 -0
- menubar_actions.py +204 -0
- menubar_agy.py +193 -0
- menubar_chrome.py +156 -0
- menubar_menu.py +169 -0
- menubar_notify.py +102 -0
- menubar_popover.py +233 -0
- menubar_prefs.py +118 -0
- menubar_refresh.py +285 -0
- menubar_state.py +1200 -0
- menubar_title.py +157 -0
- menubar_update.py +123 -0
- panel_window.py +78 -0
- panel_window_state.py +159 -0
- panels/__init__.py +186 -0
- panels/base.py +83 -0
- panels/dynamic_height.py +140 -0
- panels/payload.py +178 -0
- panels/web_panel.py +513 -0
- panels/window_drag.py +56 -0
- prefs.py +44 -0
- pricing.py +452 -0
- project_resolver.py +112 -0
- service_status.py +383 -0
- session_hooks.py +1154 -0
- setup_app.py +171 -0
- setup_hook.py +1011 -0
- statusline_settings.py +160 -0
- talent_market_bridge.py +243 -0
- time_utils.py +24 -0
- tui.py +288 -0
- tui_sprite.py +206 -0
- ui/__init__.py +5 -0
- ui/html_report.py +923 -0
- ui/report_scripts.py +251 -0
- ui/report_styles.py +370 -0
- ui/tables.py +888 -0
- update_checker.py +156 -0
- update_gate.py +66 -0
- update_release_notes.py +49 -0
- usage_cli-0.29.32.data/data/share/usage/i18n.json +2427 -0
- usage_cli-0.29.32.dist-info/METADATA +223 -0
- usage_cli-0.29.32.dist-info/RECORD +109 -0
- usage_cli-0.29.32.dist-info/WHEEL +5 -0
- usage_cli-0.29.32.dist-info/entry_points.txt +3 -0
- usage_cli-0.29.32.dist-info/licenses/LICENSE +663 -0
- usage_cli-0.29.32.dist-info/top_level.txt +80 -0
- usage_cli.py +827 -0
- usage_client.py +487 -0
- usage_diagnosis_snapshot.py +143 -0
- usage_dir_sweeper.py +100 -0
- usage_lang.py +79 -0
- usage_logging.py +75 -0
- usage_notifications.py +96 -0
- usage_rate.py +97 -0
- usage_session_resume.py +913 -0
- usage_statusline.py +810 -0
- usage_statusline_agy.py +397 -0
- usage_statusline_forwarder.py +88 -0
- usage_terse_mode.py +223 -0
- usage_terse_reminder.py +151 -0
- win_login_item.py +53 -0
- window_keeper.py +264 -0
- windows_watch.py +443 -0
- wintray.py +2014 -0
- 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"
|