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
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)