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
panels/web_panel.py
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
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,import-not-found,misc"
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import threading
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
import objc
|
|
17
|
+
from AppKit import NSColor, NSFont, NSMakeRect, NSTextField, NSView
|
|
18
|
+
from Foundation import NSObject
|
|
19
|
+
from Quartz import CGColorCreateGenericRGB
|
|
20
|
+
|
|
21
|
+
import talent_market_bridge
|
|
22
|
+
from menubar_prefs import (
|
|
23
|
+
_panel_flavor,
|
|
24
|
+
_save_panel_flavor,
|
|
25
|
+
_save_quota_card_order,
|
|
26
|
+
_valid_quota_card_order,
|
|
27
|
+
)
|
|
28
|
+
from panels.dynamic_height import inject_content_height_script
|
|
29
|
+
from panels.payload import (
|
|
30
|
+
_data_uri,
|
|
31
|
+
_load_i18n_bundle,
|
|
32
|
+
_load_panel_html,
|
|
33
|
+
_new_state_payload,
|
|
34
|
+
_row_payload,
|
|
35
|
+
_state_payload,
|
|
36
|
+
)
|
|
37
|
+
from panels.window_drag import inject_window_drag_script
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"_data_uri",
|
|
41
|
+
"_load_i18n_bundle",
|
|
42
|
+
"_load_panel_html",
|
|
43
|
+
"_new_state_payload",
|
|
44
|
+
"_row_payload",
|
|
45
|
+
"_state_payload",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
from WebKit import WKUserContentController, WKWebView, WKWebViewConfiguration
|
|
50
|
+
except ModuleNotFoundError:
|
|
51
|
+
with objc.autorelease_pool():
|
|
52
|
+
objc.loadBundle(
|
|
53
|
+
"WebKit",
|
|
54
|
+
globals(),
|
|
55
|
+
bundle_path="/System/Library/Frameworks/WebKit.framework",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# py2app can import WebKit without all wrapper metadata. Register the block
|
|
59
|
+
# signature unconditionally; PyObjC metadata registration is idempotent.
|
|
60
|
+
objc.registerMetaDataForSelector(
|
|
61
|
+
b"WKWebView",
|
|
62
|
+
b"evaluateJavaScript:completionHandler:",
|
|
63
|
+
{
|
|
64
|
+
"arguments": {
|
|
65
|
+
3: {
|
|
66
|
+
"callable": {
|
|
67
|
+
"retval": {"type": b"v"},
|
|
68
|
+
"arguments": {
|
|
69
|
+
0: {"type": b"^v"},
|
|
70
|
+
1: {"type": b"@"},
|
|
71
|
+
2: {"type": b"@"},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
logger = logging.getLogger(__name__)
|
|
80
|
+
|
|
81
|
+
if TYPE_CHECKING:
|
|
82
|
+
from menubar import PopoverState
|
|
83
|
+
|
|
84
|
+
PANEL_WIDTH = 364.0
|
|
85
|
+
PANEL_HEIGHT = 812.0
|
|
86
|
+
MAX_INJECTION_RELOADS = 2
|
|
87
|
+
NAVIGATION_MENU_IDENTIFIER_PARTS = (
|
|
88
|
+
"WKMenuItemIdentifierReload",
|
|
89
|
+
"WKMenuItemIdentifierGoBack",
|
|
90
|
+
"WKMenuItemIdentifierGoForward",
|
|
91
|
+
"WKMenuItemIdentifierOpenLink",
|
|
92
|
+
"WKMenuItemIdentifierOpenImage",
|
|
93
|
+
"WKMenuItemIdentifierOpenFrame",
|
|
94
|
+
"WKMenuItemIdentifierDownload",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _reload_web_panel(view: Any) -> None:
|
|
99
|
+
view._ready = False
|
|
100
|
+
view._first_paint_done = False
|
|
101
|
+
view._last_injected_payload = None
|
|
102
|
+
view._last_injected_state = None
|
|
103
|
+
if getattr(view, "_last_payload", None) is not None:
|
|
104
|
+
view._pending = view._last_payload
|
|
105
|
+
html = getattr(view, "_html", None)
|
|
106
|
+
if html is None:
|
|
107
|
+
view.reload()
|
|
108
|
+
return
|
|
109
|
+
view.loadHTMLString_baseURL_(html, None)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _is_navigation_menu_item(item: Any) -> bool:
|
|
113
|
+
item_identifier = getattr(item, "itemIdentifier", None)
|
|
114
|
+
if not callable(item_identifier):
|
|
115
|
+
return False
|
|
116
|
+
identifier = item_identifier()
|
|
117
|
+
if identifier is None:
|
|
118
|
+
return False
|
|
119
|
+
identifier_text = str(identifier)
|
|
120
|
+
return any(part in identifier_text for part in NAVIGATION_MENU_IDENTIFIER_PARTS)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _remove_navigation_menu_items(menu: Any) -> None:
|
|
124
|
+
for item in list(menu.itemArray()):
|
|
125
|
+
if _is_navigation_menu_item(item):
|
|
126
|
+
menu.removeItem_(item)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _handle_injection_error(view: Any, payload: dict[str, object], error: Any) -> None:
|
|
130
|
+
if error is None:
|
|
131
|
+
view._injection_retry_payload = None
|
|
132
|
+
view._injection_reload_count = 0
|
|
133
|
+
return
|
|
134
|
+
if os.environ.get("USAGE_DEBUG") == "1":
|
|
135
|
+
logger.warning("panel state injection failed: %s", error)
|
|
136
|
+
if getattr(view, "_injection_retry_payload", None) is not payload:
|
|
137
|
+
view._injection_retry_payload = payload
|
|
138
|
+
view._injection_reload_count = 0
|
|
139
|
+
if getattr(view, "_injection_reload_count", 0) >= MAX_INJECTION_RELOADS:
|
|
140
|
+
if os.environ.get("USAGE_DEBUG") == "1":
|
|
141
|
+
logger.warning("panel state injection reload limit reached")
|
|
142
|
+
view._pending = None
|
|
143
|
+
return
|
|
144
|
+
view._injection_reload_count += 1
|
|
145
|
+
view._pending = payload
|
|
146
|
+
_reload_web_panel(view)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class UsageScriptBridge(NSObject):
|
|
150
|
+
delegate = objc.ivar()
|
|
151
|
+
web_view = objc.ivar()
|
|
152
|
+
|
|
153
|
+
def initWithDelegate_webView_(self, delegate: Any, web_view: Any) -> UsageScriptBridge:
|
|
154
|
+
self = objc.super(UsageScriptBridge, self).init()
|
|
155
|
+
if self is None:
|
|
156
|
+
return None
|
|
157
|
+
self.delegate = delegate
|
|
158
|
+
self.web_view = web_view
|
|
159
|
+
return self
|
|
160
|
+
|
|
161
|
+
def userContentController_didReceiveScriptMessage_(self, controller: Any, message: Any) -> None:
|
|
162
|
+
action = str(message.body())
|
|
163
|
+
# New parametric actions arrive as a JSON string (e.g.
|
|
164
|
+
# {"action":"install_role","roleId":"contract-review"}). Plain fixed-string
|
|
165
|
+
# actions ("refresh", "switch", ...) never start with "{", so they skip
|
|
166
|
+
# this branch unchanged.
|
|
167
|
+
if action.startswith("{"):
|
|
168
|
+
try:
|
|
169
|
+
parsed = json.loads(action)
|
|
170
|
+
except ValueError:
|
|
171
|
+
parsed = None
|
|
172
|
+
if isinstance(parsed, dict) and "action" in parsed:
|
|
173
|
+
if parsed["action"] == "content_height":
|
|
174
|
+
self.delegate.panelContentHeight_forView_(
|
|
175
|
+
parsed.get("height"), self.web_view
|
|
176
|
+
)
|
|
177
|
+
return
|
|
178
|
+
if parsed["action"] == "begin_window_drag":
|
|
179
|
+
self.delegate.panelBeginWindowDrag_(self.web_view)
|
|
180
|
+
return
|
|
181
|
+
if parsed["action"] == "set_card_order":
|
|
182
|
+
order = _valid_quota_card_order(parsed.get("order"))
|
|
183
|
+
if order is not None:
|
|
184
|
+
_save_quota_card_order(list(order))
|
|
185
|
+
# Keep the in-memory state in sync so re-injections
|
|
186
|
+
# (popover reopen, panel switch) before the next poll
|
|
187
|
+
# don't snap the cards back to the stale order.
|
|
188
|
+
state = getattr(self.delegate, "latest_state", None)
|
|
189
|
+
if state is not None:
|
|
190
|
+
state.card_order = order
|
|
191
|
+
return
|
|
192
|
+
if parsed["action"] == "set_panel_flavor":
|
|
193
|
+
_save_panel_flavor(parsed.get("flavor"))
|
|
194
|
+
return
|
|
195
|
+
self._dispatch_talent_action(parsed)
|
|
196
|
+
return
|
|
197
|
+
if action == "refresh":
|
|
198
|
+
self.delegate.refreshNow_(None)
|
|
199
|
+
elif action == "quit":
|
|
200
|
+
self.delegate.quitApp_(None)
|
|
201
|
+
elif action == "install":
|
|
202
|
+
self.delegate.installHook_(None)
|
|
203
|
+
elif action == "switch":
|
|
204
|
+
self.delegate.switchPanel_(self.web_view)
|
|
205
|
+
elif action == "analyze":
|
|
206
|
+
self.web_view.evaluateJavaScript_completionHandler_(
|
|
207
|
+
"typeof projectRange === 'string' ? projectRange : '30d'",
|
|
208
|
+
lambda value, error: self.delegate.analyzeUsage_(
|
|
209
|
+
value if error is None else "30d"
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
elif action in {"toggle_statusline", "toggle-statusline"}:
|
|
213
|
+
self.delegate.toggleStatusline_(None)
|
|
214
|
+
elif action == "install_statusline":
|
|
215
|
+
self.delegate.installStatusline_(None)
|
|
216
|
+
elif action == "uninstall_statusline":
|
|
217
|
+
self.delegate.uninstallStatusline_(None)
|
|
218
|
+
|
|
219
|
+
def _dispatch_talent_action(self, payload: dict[str, object]) -> None:
|
|
220
|
+
action = str(payload.get("action", ""))
|
|
221
|
+
role_id = str(payload.get("roleId") or "")
|
|
222
|
+
if action == "set_folder":
|
|
223
|
+
path = talent_market_bridge.pick_folder()
|
|
224
|
+
if not path:
|
|
225
|
+
return
|
|
226
|
+
self._run_talent_then_refresh(lambda: talent_market_bridge.set_folder(role_id, path))
|
|
227
|
+
return
|
|
228
|
+
if action == "launch_role":
|
|
229
|
+
task_prompt_obj = payload.get("taskPrompt")
|
|
230
|
+
task_prompt = str(task_prompt_obj) if task_prompt_obj else None
|
|
231
|
+
self._run_talent_then_refresh(
|
|
232
|
+
lambda: talent_market_bridge.launch_role(role_id, task_prompt)
|
|
233
|
+
)
|
|
234
|
+
return
|
|
235
|
+
if action == "install_role":
|
|
236
|
+
self._run_talent_then_refresh(lambda: talent_market_bridge.install_role(role_id))
|
|
237
|
+
return
|
|
238
|
+
if action == "restore_role":
|
|
239
|
+
self._run_talent_then_refresh(lambda: talent_market_bridge.restore_role(role_id))
|
|
240
|
+
return
|
|
241
|
+
if action == "ignore_drift":
|
|
242
|
+
self._run_talent_then_refresh(lambda: talent_market_bridge.ignore_drift(role_id))
|
|
243
|
+
return
|
|
244
|
+
if action == "talent_refresh":
|
|
245
|
+
self.delegate.refreshNow_(None)
|
|
246
|
+
return
|
|
247
|
+
|
|
248
|
+
def _run_talent_then_refresh(self, fn: Any) -> None:
|
|
249
|
+
delegate = self.delegate
|
|
250
|
+
|
|
251
|
+
def _work() -> None:
|
|
252
|
+
try:
|
|
253
|
+
fn()
|
|
254
|
+
except Exception:
|
|
255
|
+
if os.environ.get("USAGE_DEBUG") == "1":
|
|
256
|
+
logger.warning("talent market action failed", exc_info=True)
|
|
257
|
+
if delegate is not None:
|
|
258
|
+
delegate.performSelectorOnMainThread_withObject_waitUntilDone_(
|
|
259
|
+
"refreshNow:", None, False
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
threading.Thread(target=_work, daemon=True).start()
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class WebPanelView(WKWebView):
|
|
266
|
+
delegate_ref = objc.ivar()
|
|
267
|
+
bridge = objc.ivar()
|
|
268
|
+
user_content_controller = objc.ivar()
|
|
269
|
+
_ready = objc.ivar()
|
|
270
|
+
_pending = objc.ivar()
|
|
271
|
+
_last_payload = objc.ivar()
|
|
272
|
+
_last_injected_payload = objc.ivar()
|
|
273
|
+
_last_injected_state = objc.ivar()
|
|
274
|
+
_injection_retry_payload = objc.ivar()
|
|
275
|
+
_injection_reload_count = objc.ivar()
|
|
276
|
+
_html = objc.ivar()
|
|
277
|
+
_first_paint_done = objc.ivar()
|
|
278
|
+
|
|
279
|
+
def initWithFrame_configuration_delegate_(
|
|
280
|
+
self,
|
|
281
|
+
frame: Any,
|
|
282
|
+
configuration: Any,
|
|
283
|
+
delegate: Any,
|
|
284
|
+
) -> WebPanelView:
|
|
285
|
+
self = objc.super(WebPanelView, self).initWithFrame_configuration_(frame, configuration)
|
|
286
|
+
if self is None:
|
|
287
|
+
return None
|
|
288
|
+
self.delegate_ref = delegate
|
|
289
|
+
self.bridge = None
|
|
290
|
+
self.user_content_controller = configuration.userContentController()
|
|
291
|
+
self._ready = False
|
|
292
|
+
self._pending = None
|
|
293
|
+
self._last_payload = None
|
|
294
|
+
self._last_injected_payload = None
|
|
295
|
+
self._last_injected_state = None
|
|
296
|
+
self._injection_retry_payload = None
|
|
297
|
+
self._injection_reload_count = 0
|
|
298
|
+
self._html = None
|
|
299
|
+
self._first_paint_done = False
|
|
300
|
+
self.setNavigationDelegate_(self)
|
|
301
|
+
self.setValue_forKey_(False, "drawsBackground")
|
|
302
|
+
self.setWantsLayer_(True)
|
|
303
|
+
layer = self.layer()
|
|
304
|
+
if layer is not None:
|
|
305
|
+
layer.setBackgroundColor_(
|
|
306
|
+
CGColorCreateGenericRGB(10 / 255, 15 / 255, 20 / 255, 1.0)
|
|
307
|
+
)
|
|
308
|
+
layer.setMasksToBounds_(True)
|
|
309
|
+
return self
|
|
310
|
+
|
|
311
|
+
def webView_didFinishNavigation_(self, web_view: Any, navigation: Any) -> None:
|
|
312
|
+
self._ready = True
|
|
313
|
+
if self._pending is not None:
|
|
314
|
+
payload = self._pending
|
|
315
|
+
self._pending = None
|
|
316
|
+
self.injectState_(payload)
|
|
317
|
+
|
|
318
|
+
def webView_didFailNavigation_withError_(
|
|
319
|
+
self, web_view: Any, navigation: Any, error: Any
|
|
320
|
+
) -> None:
|
|
321
|
+
if os.environ.get("USAGE_DEBUG") == "1":
|
|
322
|
+
logger.warning("panel navigation failed: %s", error)
|
|
323
|
+
|
|
324
|
+
def webView_didFailProvisionalNavigation_withError_(
|
|
325
|
+
self, web_view: Any, navigation: Any, error: Any
|
|
326
|
+
) -> None:
|
|
327
|
+
if os.environ.get("USAGE_DEBUG") == "1":
|
|
328
|
+
logger.warning("panel provisional navigation failed: %s", error)
|
|
329
|
+
|
|
330
|
+
def webViewWebContentProcessDidTerminate_(self, web_view: Any) -> None:
|
|
331
|
+
if os.environ.get("USAGE_DEBUG") == "1":
|
|
332
|
+
logger.warning("panel web content process terminated; reloading")
|
|
333
|
+
_reload_web_panel(self)
|
|
334
|
+
|
|
335
|
+
def willOpenMenu_withEvent_(self, menu: Any, event: Any) -> None:
|
|
336
|
+
_remove_navigation_menu_items(menu)
|
|
337
|
+
|
|
338
|
+
def renderTimeoutElapsed_(self, _arg: Any) -> None:
|
|
339
|
+
# If the HTML never finished rendering, the popover shows only the dark
|
|
340
|
+
# backing layer (a "grey window"). Surface that in the debug log so a
|
|
341
|
+
# reporter's USAGE_DEBUG output points straight at it instead of leaving
|
|
342
|
+
# us guessing.
|
|
343
|
+
if not self._ready and os.environ.get("USAGE_DEBUG") == "1":
|
|
344
|
+
logger.warning("panel HTML did not finish rendering within timeout")
|
|
345
|
+
|
|
346
|
+
def setBridge_(self, bridge: Any) -> None:
|
|
347
|
+
self.bridge = bridge
|
|
348
|
+
|
|
349
|
+
def injectState_(self, payload: dict[str, object]) -> None:
|
|
350
|
+
self._last_payload = payload
|
|
351
|
+
if not self._ready:
|
|
352
|
+
self._pending = payload
|
|
353
|
+
return
|
|
354
|
+
encoded = _new_state_payload(self, payload)
|
|
355
|
+
if encoded is None:
|
|
356
|
+
return
|
|
357
|
+
|
|
358
|
+
def _completed(_value: Any, error: Any) -> None:
|
|
359
|
+
_handle_injection_error(self, payload, error)
|
|
360
|
+
if error is None and not self._first_paint_done:
|
|
361
|
+
self._first_paint_done = True
|
|
362
|
+
delegate = self.delegate_ref
|
|
363
|
+
if delegate is not None and hasattr(delegate, "panelDidFirstPaint_"):
|
|
364
|
+
delegate.panelDidFirstPaint_(self)
|
|
365
|
+
|
|
366
|
+
self.evaluateJavaScript_completionHandler_(
|
|
367
|
+
f"window.usageApplyState({encoded})",
|
|
368
|
+
_completed,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
def teardown(self) -> None:
|
|
372
|
+
controller = self.user_content_controller
|
|
373
|
+
if controller is not None:
|
|
374
|
+
controller.removeScriptMessageHandlerForName_("usage")
|
|
375
|
+
self.setNavigationDelegate_(None)
|
|
376
|
+
self.bridge = None
|
|
377
|
+
self.delegate_ref = None
|
|
378
|
+
self.user_content_controller = None
|
|
379
|
+
self._last_payload = None
|
|
380
|
+
self._last_injected_payload = None
|
|
381
|
+
self._last_injected_state = None
|
|
382
|
+
self._pending = None
|
|
383
|
+
self._html = None
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
class ErrorPanelView(NSView):
|
|
387
|
+
# Native fallback shown when the WKWebView panel can't be built or loaded,
|
|
388
|
+
# so the popover never degrades to a silent grey window. Exposes the same
|
|
389
|
+
# injectState_/teardown surface as WebPanelView so the controller can drive
|
|
390
|
+
# it without special-casing.
|
|
391
|
+
def initWithFrame_message_(self, frame: Any, message: str) -> ErrorPanelView:
|
|
392
|
+
self = objc.super(ErrorPanelView, self).initWithFrame_(frame)
|
|
393
|
+
if self is None:
|
|
394
|
+
return None
|
|
395
|
+
self.setWantsLayer_(True)
|
|
396
|
+
layer = self.layer()
|
|
397
|
+
if layer is not None:
|
|
398
|
+
layer.setBackgroundColor_(CGColorCreateGenericRGB(10 / 255, 15 / 255, 20 / 255, 1.0))
|
|
399
|
+
inset = 24.0
|
|
400
|
+
label = NSTextField.alloc().initWithFrame_(
|
|
401
|
+
NSMakeRect(inset, inset, frame.size.width - 2 * inset, frame.size.height - 2 * inset)
|
|
402
|
+
)
|
|
403
|
+
label.setStringValue_(message)
|
|
404
|
+
label.setEditable_(False)
|
|
405
|
+
label.setBezeled_(False)
|
|
406
|
+
label.setDrawsBackground_(False)
|
|
407
|
+
label.setSelectable_(True)
|
|
408
|
+
label.setTextColor_(NSColor.whiteColor())
|
|
409
|
+
label.setFont_(NSFont.systemFontOfSize_(13.0))
|
|
410
|
+
label.cell().setWraps_(True)
|
|
411
|
+
self.addSubview_(label)
|
|
412
|
+
return self
|
|
413
|
+
|
|
414
|
+
def injectState_(self, payload: dict[str, object]) -> None:
|
|
415
|
+
pass
|
|
416
|
+
|
|
417
|
+
def teardown(self) -> None:
|
|
418
|
+
pass
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
class HTMLPanel:
|
|
422
|
+
id: str
|
|
423
|
+
i18n_key: str
|
|
424
|
+
html_filename: str
|
|
425
|
+
width: float
|
|
426
|
+
height: float
|
|
427
|
+
claude_card_height: float
|
|
428
|
+
codex_card_height: float
|
|
429
|
+
codex_row_height: float
|
|
430
|
+
agy_card_height: float
|
|
431
|
+
status_wrap_extra_height: float
|
|
432
|
+
service_alert_height: float
|
|
433
|
+
dynamic_height: bool
|
|
434
|
+
|
|
435
|
+
def __init__(
|
|
436
|
+
self,
|
|
437
|
+
panel_id: str,
|
|
438
|
+
i18n_key: str,
|
|
439
|
+
html_filename: str,
|
|
440
|
+
width: float = PANEL_WIDTH,
|
|
441
|
+
height: float = PANEL_HEIGHT,
|
|
442
|
+
*,
|
|
443
|
+
claude_card_height: float,
|
|
444
|
+
codex_card_height: float,
|
|
445
|
+
codex_row_height: float = 64.0,
|
|
446
|
+
agy_card_height: float = 0.0,
|
|
447
|
+
status_wrap_extra_height: float = 0.0,
|
|
448
|
+
service_alert_height: float = 0.0,
|
|
449
|
+
dynamic_height: bool = True,
|
|
450
|
+
) -> None:
|
|
451
|
+
self.id = panel_id
|
|
452
|
+
self.i18n_key = i18n_key
|
|
453
|
+
self.html_filename = html_filename
|
|
454
|
+
self.width = width
|
|
455
|
+
self.height = height
|
|
456
|
+
self.claude_card_height = claude_card_height
|
|
457
|
+
self.codex_card_height = codex_card_height
|
|
458
|
+
self.codex_row_height = codex_row_height
|
|
459
|
+
self.agy_card_height = agy_card_height
|
|
460
|
+
self.status_wrap_extra_height = status_wrap_extra_height
|
|
461
|
+
self.service_alert_height = service_alert_height
|
|
462
|
+
self.dynamic_height = dynamic_height
|
|
463
|
+
self._content_height_reports_available = False
|
|
464
|
+
|
|
465
|
+
def build_view(self, delegate: Any) -> NSView:
|
|
466
|
+
try:
|
|
467
|
+
if WKUserContentController is None or WKWebViewConfiguration is None:
|
|
468
|
+
raise RuntimeError("pyobjc-framework-WebKit is unavailable")
|
|
469
|
+
html = _load_panel_html(self.html_filename)
|
|
470
|
+
html = html.replace("{{PANEL_FLAVOR}}", _panel_flavor())
|
|
471
|
+
if self.dynamic_height:
|
|
472
|
+
html = inject_content_height_script(html)
|
|
473
|
+
html = inject_window_drag_script(html)
|
|
474
|
+
configuration = WKWebViewConfiguration.alloc().init()
|
|
475
|
+
controller = WKUserContentController.alloc().init()
|
|
476
|
+
configuration.setUserContentController_(controller)
|
|
477
|
+
web_view = WebPanelView.alloc().initWithFrame_configuration_delegate_(
|
|
478
|
+
NSMakeRect(0, 0, self.width, self.height),
|
|
479
|
+
configuration,
|
|
480
|
+
delegate,
|
|
481
|
+
)
|
|
482
|
+
bridge = UsageScriptBridge.alloc().initWithDelegate_webView_(delegate, web_view)
|
|
483
|
+
web_view.setBridge_(bridge)
|
|
484
|
+
controller.addScriptMessageHandler_name_(bridge, "usage")
|
|
485
|
+
web_view._html = html
|
|
486
|
+
web_view.loadHTMLString_baseURL_(html, None)
|
|
487
|
+
web_view.performSelector_withObject_afterDelay_("renderTimeoutElapsed:", None, 4.0)
|
|
488
|
+
self._content_height_reports_available = self.dynamic_height
|
|
489
|
+
return web_view
|
|
490
|
+
except Exception as exc:
|
|
491
|
+
self._content_height_reports_available = False
|
|
492
|
+
if os.environ.get("USAGE_DEBUG") == "1":
|
|
493
|
+
logger.warning("panel build failed", exc_info=True)
|
|
494
|
+
return self._error_view(delegate, exc)
|
|
495
|
+
|
|
496
|
+
def _error_view(self, delegate: Any, exc: BaseException) -> NSView:
|
|
497
|
+
language = getattr(delegate, "language", "en")
|
|
498
|
+
bundle = _load_i18n_bundle()
|
|
499
|
+
prompt = bundle.get(language, bundle.get("en", {})).get(
|
|
500
|
+
"panel_load_error", "Panel failed to load. Please report this:"
|
|
501
|
+
)
|
|
502
|
+
detail = f"{type(exc).__name__}: {exc}"
|
|
503
|
+
message = f"{prompt}\n\n{detail}\n\nhttps://github.com/aqua5230/usage/issues"
|
|
504
|
+
return ErrorPanelView.alloc().initWithFrame_message_(
|
|
505
|
+
NSMakeRect(0, 0, self.width, self.height),
|
|
506
|
+
message,
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
def apply_state(self, view: NSView, state: PopoverState) -> None:
|
|
510
|
+
view.injectState_(_state_payload(state))
|
|
511
|
+
|
|
512
|
+
def preferred_size(self) -> tuple[float, float]:
|
|
513
|
+
return (self.width, self.height)
|
panels/window_drag.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
|
|
3
|
+
#
|
|
4
|
+
# Part of "usage". Free software licensed under the GNU Affero General Public
|
|
5
|
+
# License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
WINDOW_DRAG_SCRIPT = """
|
|
10
|
+
<script>
|
|
11
|
+
(function() {
|
|
12
|
+
var interactiveSelector = [
|
|
13
|
+
"button",
|
|
14
|
+
"a",
|
|
15
|
+
"input",
|
|
16
|
+
"textarea",
|
|
17
|
+
"select",
|
|
18
|
+
"option",
|
|
19
|
+
"label",
|
|
20
|
+
"[data-action]",
|
|
21
|
+
"[contenteditable]",
|
|
22
|
+
"[role='button']",
|
|
23
|
+
".tm-scroll",
|
|
24
|
+
"[data-window-no-drag]"
|
|
25
|
+
].join(",");
|
|
26
|
+
|
|
27
|
+
function isScrollable(element) {
|
|
28
|
+
for (var current = element; current && current !== document.body;
|
|
29
|
+
current = current.parentElement) {
|
|
30
|
+
var style = window.getComputedStyle(current);
|
|
31
|
+
var overflowY = style.overflowY;
|
|
32
|
+
var overflowX = style.overflowX;
|
|
33
|
+
if ((overflowY === "auto" || overflowY === "scroll")
|
|
34
|
+
&& current.scrollHeight > current.clientHeight) return true;
|
|
35
|
+
if ((overflowX === "auto" || overflowX === "scroll")
|
|
36
|
+
&& current.scrollWidth > current.clientWidth) return true;
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
document.addEventListener("mousedown", function(event) {
|
|
42
|
+
if (event.button !== 0) return;
|
|
43
|
+
if (event.target.closest(interactiveSelector) || isScrollable(event.target)) return;
|
|
44
|
+
var bridge = window.webkit && window.webkit.messageHandlers
|
|
45
|
+
&& window.webkit.messageHandlers.usage;
|
|
46
|
+
if (bridge && typeof bridge.postMessage === "function") {
|
|
47
|
+
bridge.postMessage(JSON.stringify({ action: "begin_window_drag" }));
|
|
48
|
+
}
|
|
49
|
+
}, true);
|
|
50
|
+
})();
|
|
51
|
+
</script>
|
|
52
|
+
""".strip()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def inject_window_drag_script(html: str) -> str:
|
|
56
|
+
return html.replace("</body>", f"{WINDOW_DRAG_SCRIPT}\n</body>", 1)
|
prefs.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
|
|
3
|
+
#
|
|
4
|
+
# Part of "usage". Free software licensed under the GNU Affero General Public
|
|
5
|
+
# License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import tempfile
|
|
12
|
+
from contextlib import suppress
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
PREFERENCES_FILE = Path(os.path.expanduser("~/.claude/usage-preferences.json"))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _load_preferences(path: Path | None = None) -> dict[str, Any]:
|
|
20
|
+
preferences_file = PREFERENCES_FILE if path is None else path
|
|
21
|
+
if not preferences_file.exists():
|
|
22
|
+
return {}
|
|
23
|
+
try:
|
|
24
|
+
data = json.loads(preferences_file.read_text(encoding="utf-8"))
|
|
25
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
26
|
+
return {}
|
|
27
|
+
return data if isinstance(data, dict) else {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _save_preferences(data: dict[str, Any], path: Path | None = None) -> None:
|
|
31
|
+
preferences_file = PREFERENCES_FILE if path is None else path
|
|
32
|
+
preferences_file.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
payload = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
|
34
|
+
tmp_path: str | None = None
|
|
35
|
+
try:
|
|
36
|
+
fd, tmp_path = tempfile.mkstemp(dir=preferences_file.parent, suffix=".tmp")
|
|
37
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
38
|
+
f.write(payload)
|
|
39
|
+
os.replace(tmp_path, preferences_file)
|
|
40
|
+
tmp_path = None
|
|
41
|
+
finally:
|
|
42
|
+
if tmp_path and os.path.exists(tmp_path):
|
|
43
|
+
with suppress(OSError):
|
|
44
|
+
os.unlink(tmp_path)
|