usage-cli 0.29.32__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. adapters/__init__.py +5 -0
  2. adapters/agy.py +68 -0
  3. adapters/claude.py +215 -0
  4. adapters/codex.py +209 -0
  5. adapters/rate_limits.py +76 -0
  6. adapters/registry.py +17 -0
  7. adapters/types.py +139 -0
  8. agy_disk_cache.py +135 -0
  9. agy_loader.py +416 -0
  10. agy_quota_probe.py +748 -0
  11. agy_window_keeper.py +185 -0
  12. analyzer/__init__.py +5 -0
  13. analyzer/aggregator.py +139 -0
  14. analyzer/blocks.py +80 -0
  15. analyzer/diagnoser.py +638 -0
  16. analyzer/insights.py +277 -0
  17. analyzer/persona_loader.py +199 -0
  18. analyzer/reporter.py +989 -0
  19. analyzer/subscription.py +108 -0
  20. burn_rate.py +75 -0
  21. cache_quarantine.py +50 -0
  22. codex_disk_cache.py +227 -0
  23. codex_events.py +136 -0
  24. codex_fork_replay.py +111 -0
  25. codex_loader.py +1426 -0
  26. codex_paths.py +20 -0
  27. critter_frames.py +26 -0
  28. discussion_bridge.py +1196 -0
  29. discussion_cli.py +844 -0
  30. discussion_session.py +622 -0
  31. discussion_usage.py +13 -0
  32. discussion_window.py +955 -0
  33. disk_cache_common.py +132 -0
  34. disk_cache_lifecycle.py +39 -0
  35. doctor.py +452 -0
  36. fsevents_watch.py +207 -0
  37. history_disk_cache.py +110 -0
  38. history_loader.py +416 -0
  39. i18n.py +88 -0
  40. jsonl_limits.py +17 -0
  41. jsonl_utils.py +40 -0
  42. login_item.py +154 -0
  43. main.py +387 -0
  44. menubar.py +1201 -0
  45. menubar_actions.py +204 -0
  46. menubar_agy.py +193 -0
  47. menubar_chrome.py +156 -0
  48. menubar_menu.py +169 -0
  49. menubar_notify.py +102 -0
  50. menubar_popover.py +233 -0
  51. menubar_prefs.py +118 -0
  52. menubar_refresh.py +285 -0
  53. menubar_state.py +1200 -0
  54. menubar_title.py +157 -0
  55. menubar_update.py +123 -0
  56. panel_window.py +78 -0
  57. panel_window_state.py +159 -0
  58. panels/__init__.py +186 -0
  59. panels/base.py +83 -0
  60. panels/dynamic_height.py +140 -0
  61. panels/payload.py +178 -0
  62. panels/web_panel.py +513 -0
  63. panels/window_drag.py +56 -0
  64. prefs.py +44 -0
  65. pricing.py +452 -0
  66. project_resolver.py +112 -0
  67. service_status.py +383 -0
  68. session_hooks.py +1154 -0
  69. setup_app.py +171 -0
  70. setup_hook.py +1011 -0
  71. statusline_settings.py +160 -0
  72. talent_market_bridge.py +243 -0
  73. time_utils.py +24 -0
  74. tui.py +288 -0
  75. tui_sprite.py +206 -0
  76. ui/__init__.py +5 -0
  77. ui/html_report.py +923 -0
  78. ui/report_scripts.py +251 -0
  79. ui/report_styles.py +370 -0
  80. ui/tables.py +888 -0
  81. update_checker.py +156 -0
  82. update_gate.py +66 -0
  83. update_release_notes.py +49 -0
  84. usage_cli-0.29.32.data/data/share/usage/i18n.json +2427 -0
  85. usage_cli-0.29.32.dist-info/METADATA +223 -0
  86. usage_cli-0.29.32.dist-info/RECORD +109 -0
  87. usage_cli-0.29.32.dist-info/WHEEL +5 -0
  88. usage_cli-0.29.32.dist-info/entry_points.txt +3 -0
  89. usage_cli-0.29.32.dist-info/licenses/LICENSE +663 -0
  90. usage_cli-0.29.32.dist-info/top_level.txt +80 -0
  91. usage_cli.py +827 -0
  92. usage_client.py +487 -0
  93. usage_diagnosis_snapshot.py +143 -0
  94. usage_dir_sweeper.py +100 -0
  95. usage_lang.py +79 -0
  96. usage_logging.py +75 -0
  97. usage_notifications.py +96 -0
  98. usage_rate.py +97 -0
  99. usage_session_resume.py +913 -0
  100. usage_statusline.py +810 -0
  101. usage_statusline_agy.py +397 -0
  102. usage_statusline_forwarder.py +88 -0
  103. usage_terse_mode.py +223 -0
  104. usage_terse_reminder.py +151 -0
  105. win_login_item.py +53 -0
  106. window_keeper.py +264 -0
  107. windows_watch.py +443 -0
  108. wintray.py +2014 -0
  109. wintray_menu.py +136 -0
menubar_title.py ADDED
@@ -0,0 +1,157 @@
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
+ """Menu bar title rendering."""
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Protocol
12
+
13
+ from AppKit import NSAttributedString, NSFont, NSFontAttributeName, NSMutableAttributedString
14
+
15
+ import critter_frames
16
+ from menubar_chrome import (
17
+ _agy_menubar_icon,
18
+ _claude_menubar_icon,
19
+ _codex_menubar_icon,
20
+ _critter_frame_image,
21
+ _critter_icon_attachment_string,
22
+ _menubar_icon_attachment_string,
23
+ )
24
+ from menubar_state import PopoverState, _format_percent
25
+
26
+
27
+ class _TitleApp(Protocol):
28
+ _menubar_text_cache: dict[str, Any]
29
+ critter_frame: int
30
+ dragon_frame: int
31
+ lion_frame: int
32
+ codex_5h_pct: float | None
33
+ _last_button_title_key: tuple[str, int | None, int | None, int | None] | None
34
+ _last_plain_title_key: tuple[str] | None
35
+ status_item: Any
36
+
37
+
38
+ def _menubar_text_string(app: _TitleApp, text: str) -> Any:
39
+ cached = app._menubar_text_cache.get(text)
40
+ if cached is not None:
41
+ return cached
42
+ attributed = NSAttributedString.alloc().initWithString_attributes_(
43
+ text,
44
+ {NSFontAttributeName: NSFont.menuBarFontOfSize_(0)},
45
+ )
46
+ app._menubar_text_cache[text] = attributed
47
+ return attributed
48
+
49
+
50
+ def _menubar_attributed_title(app: _TitleApp, state: PopoverState) -> Any:
51
+ title = NSMutableAttributedString.alloc().init()
52
+ phoenix_frame = int(app.critter_frame) % len(critter_frames.PHOENIX_FRAMES)
53
+ dragon_frame = int(app.dragon_frame) % len(critter_frames.DRAGON_FRAMES)
54
+ lion_frame = int(app.lion_frame) % len(critter_frames.LION_FRAMES)
55
+ if not state.hide_claude:
56
+ claude_percent = (
57
+ "--"
58
+ if state.claude_session.percent is None
59
+ else f"{_format_percent(state.claude_session.percent)}%"
60
+ )
61
+ title.appendAttributedString_(_menubar_icon_attachment_string(_claude_menubar_icon()))
62
+ title.appendAttributedString_(_menubar_text_string(app, f" {claude_percent}"))
63
+ phoenix_path = critter_frames.PHOENIX_FRAMES[phoenix_frame]
64
+ phoenix = _critter_frame_image(phoenix_path)
65
+ if phoenix is not None:
66
+ title.appendAttributedString_(_menubar_text_string(app, " "))
67
+ title.appendAttributedString_(
68
+ _critter_icon_attachment_string(phoenix_path, phoenix)
69
+ )
70
+ if not state.hide_codex and (app.codex_5h_pct is not None or state.hide_claude):
71
+ codex_percent = (
72
+ "--"
73
+ if app.codex_5h_pct is None
74
+ else f"{_format_percent(float(app.codex_5h_pct))}%"
75
+ )
76
+ if not state.hide_claude:
77
+ title.appendAttributedString_(_menubar_text_string(app, " · "))
78
+ title.appendAttributedString_(_menubar_icon_attachment_string(_codex_menubar_icon()))
79
+ title.appendAttributedString_(_menubar_text_string(app, f" {codex_percent}"))
80
+ dragon_path = critter_frames.DRAGON_FRAMES[dragon_frame]
81
+ dragon = _critter_frame_image(dragon_path)
82
+ if dragon is not None:
83
+ title.appendAttributedString_(_menubar_text_string(app, " "))
84
+ title.appendAttributedString_(
85
+ _critter_icon_attachment_string(dragon_path, dragon)
86
+ )
87
+ agy_session_percent = state.agy_session.percent
88
+ agy_visible = not state.hide_agy and agy_session_percent is not None
89
+ if agy_visible:
90
+ assert agy_session_percent is not None
91
+ agy_percent = f"{_format_percent(agy_session_percent)}%"
92
+ if title.length() > 0:
93
+ title.appendAttributedString_(_menubar_text_string(app, " · "))
94
+ title.appendAttributedString_(_menubar_icon_attachment_string(_agy_menubar_icon()))
95
+ title.appendAttributedString_(_menubar_text_string(app, f" {agy_percent}"))
96
+ lion_path = critter_frames.LION_FRAMES[lion_frame]
97
+ lion = _critter_frame_image(lion_path)
98
+ if lion is not None:
99
+ title.appendAttributedString_(_menubar_text_string(app, " "))
100
+ title.appendAttributedString_(
101
+ _critter_icon_attachment_string(lion_path, lion)
102
+ )
103
+ if title.length() == 0:
104
+ # Both providers hidden: keep a recognizable, clickable status item.
105
+ title.appendAttributedString_(_menubar_icon_attachment_string(_claude_menubar_icon()))
106
+ return title
107
+
108
+
109
+ def _set_button_title(app: _TitleApp, state: PopoverState) -> None:
110
+ title = _compose_title(app, state)
111
+ phoenix_frame = int(app.critter_frame) if not state.hide_claude else None
112
+ dragon_visible = not state.hide_codex and (
113
+ app.codex_5h_pct is not None or state.hide_claude
114
+ )
115
+ dragon_frame = int(app.dragon_frame) if dragon_visible else None
116
+ lion_visible = not state.hide_agy and state.agy_session.percent is not None
117
+ lion_frame = int(app.lion_frame) if lion_visible else None
118
+ title_key = (title, phoenix_frame, dragon_frame, lion_frame)
119
+ if app._last_button_title_key == title_key:
120
+ return
121
+
122
+ button = app.status_item.button()
123
+ # setTitle: forces a full NSStatusItem relayout (_adjustLength) that
124
+ # cascades into the popover's frame/corner-mask recompute even while
125
+ # it's hidden. attributedTitle is what's actually rendered, so during
126
+ # critter/dragon/lion animation (their frame values changing every
127
+ # 0.10-0.18s) only push the plain title when its own text changed —
128
+ # not on every sprite frame — to avoid paying that relayout per frame.
129
+ plain_key = (title,)
130
+ if app._last_plain_title_key != plain_key:
131
+ button.setTitle_(title)
132
+ app._last_plain_title_key = plain_key
133
+ button.setAttributedTitle_(_menubar_attributed_title(app, state))
134
+ app._last_button_title_key = title_key
135
+
136
+
137
+ def _compose_title(app: _TitleApp, state: PopoverState) -> str:
138
+ parts: list[str] = []
139
+ if not state.hide_claude:
140
+ claude = (
141
+ "--"
142
+ if state.claude_session.percent is None
143
+ else f"{_format_percent(state.claude_session.percent)}%"
144
+ )
145
+ parts.append(f"🐾 {claude}")
146
+ if not state.hide_codex and (app.codex_5h_pct is not None or state.hide_claude):
147
+ codex = (
148
+ "--"
149
+ if app.codex_5h_pct is None
150
+ else f"{_format_percent(float(app.codex_5h_pct))}%"
151
+ )
152
+ parts.append(f"📜 {codex}")
153
+ if not state.hide_agy and state.agy_session.percent is not None:
154
+ agy = f"{_format_percent(state.agy_session.percent)}%"
155
+ parts.append(f"🦁 {agy}")
156
+ # Both providers hidden: keep a recognizable, clickable status item.
157
+ return " · ".join(parts) if parts else "🐾"
menubar_update.py ADDED
@@ -0,0 +1,123 @@
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
+ """Background-safe release-check scheduling and update-cache maintenance."""
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import os
13
+ from typing import Any, Protocol
14
+
15
+ import update_checker
16
+ import update_gate
17
+ import usage_diagnosis_snapshot
18
+ from menubar_prefs import _auto_update_check_enabled
19
+ from prefs import _load_preferences, _save_preferences
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class _UpdateApp(Protocol):
25
+ def performSelectorOnMainThread_withObject_waitUntilDone_(
26
+ self, selector: str, obj: Any, wait: bool
27
+ ) -> None: ...
28
+
29
+ def _check_update_in_background(
30
+ self,
31
+ *,
32
+ manual: bool,
33
+ ignore_cooldown: bool,
34
+ ignore_skipped: bool,
35
+ ) -> None: ...
36
+
37
+
38
+ def clear_stale_update_cache() -> None:
39
+ from menubar import _current_version
40
+
41
+ try:
42
+ current_version = _current_version()
43
+ prefs = _load_preferences()
44
+ updated_cache = update_gate.stale_cache_reset(prefs, current_version)
45
+ if updated_cache is not None:
46
+ prefs["last_update_check"] = updated_cache
47
+ _save_preferences(prefs)
48
+ except Exception:
49
+ pass
50
+
51
+
52
+ def maybe_check_update_in_background(app: _UpdateApp) -> None:
53
+ usage_diagnosis_snapshot.maybe_schedule_refresh()
54
+ app._check_update_in_background(
55
+ manual=False,
56
+ ignore_cooldown=False,
57
+ ignore_skipped=False,
58
+ )
59
+
60
+
61
+ def check_update_in_background(
62
+ app: _UpdateApp,
63
+ *,
64
+ manual: bool,
65
+ ignore_cooldown: bool,
66
+ ignore_skipped: bool,
67
+ ) -> None:
68
+ from menubar import _current_version
69
+
70
+ prefs = _load_preferences()
71
+ if not manual and not _auto_update_check_enabled(prefs):
72
+ return
73
+
74
+ if not manual and not update_gate.auto_check_is_due(prefs):
75
+ return
76
+
77
+ if not ignore_cooldown and update_gate.dismissed_recently(prefs):
78
+ return
79
+
80
+ try:
81
+ current_version = _current_version()
82
+ check_result = update_checker.check_latest_release_result(current_version)
83
+ except Exception:
84
+ if os.environ.get("USAGE_DEBUG") == "1":
85
+ logger.warning("update check failed", exc_info=True)
86
+ if manual:
87
+ app.performSelectorOnMainThread_withObject_waitUntilDone_(
88
+ "_showUpdateCheckFailed:",
89
+ None,
90
+ False,
91
+ )
92
+ return
93
+
94
+ if check_result.failed:
95
+ if manual:
96
+ app.performSelectorOnMainThread_withObject_waitUntilDone_(
97
+ "_showUpdateCheckFailed:",
98
+ None,
99
+ False,
100
+ )
101
+ return
102
+
103
+ release = check_result.release
104
+ prefs["last_update_check"] = update_gate.build_check_cache_entry(current_version, release)
105
+ _save_preferences(prefs)
106
+
107
+ if release is None:
108
+ if manual:
109
+ app.performSelectorOnMainThread_withObject_waitUntilDone_(
110
+ "_showNoUpdateAvailable:",
111
+ None,
112
+ False,
113
+ )
114
+ return
115
+
116
+ if not ignore_skipped and prefs.get("update_skipped_version") == release.version:
117
+ return
118
+
119
+ app.performSelectorOnMainThread_withObject_waitUntilDone_(
120
+ "_showUpdateAlert:",
121
+ release,
122
+ False,
123
+ )
panel_window.py ADDED
@@ -0,0 +1,78 @@
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
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ import objc
13
+ from AppKit import (
14
+ NSBackingStoreBuffered,
15
+ NSColor,
16
+ NSFloatingWindowLevel,
17
+ NSMakePoint,
18
+ NSPanel,
19
+ NSWindowCollectionBehaviorCanJoinAllSpaces,
20
+ NSWindowCollectionBehaviorFullScreenAuxiliary,
21
+ NSWindowStyleMaskBorderless,
22
+ NSWindowStyleMaskNonactivatingPanel,
23
+ )
24
+
25
+ PANEL_CORNER_RADIUS = 12.0
26
+
27
+
28
+ class PanelWindow(NSPanel):
29
+ def initWithContentRect_(self, content_rect: Any) -> PanelWindow:
30
+ style_mask = int(NSWindowStyleMaskBorderless) | int(
31
+ NSWindowStyleMaskNonactivatingPanel
32
+ )
33
+ self = objc.super(PanelWindow, self).initWithContentRect_styleMask_backing_defer_(
34
+ content_rect,
35
+ style_mask,
36
+ NSBackingStoreBuffered,
37
+ False,
38
+ )
39
+ if self is None:
40
+ return None
41
+ self.setOpaque_(False)
42
+ self.setBackgroundColor_(NSColor.clearColor())
43
+ self.setHasShadow_(True)
44
+ self.setLevel_(NSFloatingWindowLevel)
45
+ self.setMovableByWindowBackground_(True)
46
+ self.setHidesOnDeactivate_(False)
47
+ self.setReleasedWhenClosed_(False)
48
+ behavior = int(NSWindowCollectionBehaviorCanJoinAllSpaces) | int(
49
+ NSWindowCollectionBehaviorFullScreenAuxiliary
50
+ )
51
+ self.setCollectionBehavior_(behavior)
52
+ return self
53
+
54
+ def canBecomeKeyWindow(self) -> bool:
55
+ return True
56
+
57
+ def cancelOperation_(self, sender: Any) -> None:
58
+ self.close()
59
+
60
+ def setRoundedContentView_(self, view: Any) -> None:
61
+ view.setWantsLayer_(True)
62
+ layer = view.layer()
63
+ if layer is not None:
64
+ layer.setCornerRadius_(PANEL_CORNER_RADIUS)
65
+ layer.setMasksToBounds_(True)
66
+ self.setContentView_(view)
67
+
68
+ def setContentSizeKeepingTopLeft_(self, size: Any) -> None:
69
+ frame = self.frame()
70
+ top = float(frame.origin.y) + float(frame.size.height)
71
+ self.setContentSize_(size)
72
+ resized_frame = self.frame()
73
+ self.setFrameOrigin_(
74
+ NSMakePoint(
75
+ float(resized_frame.origin.x),
76
+ top - float(resized_frame.size.height),
77
+ )
78
+ )
panel_window_state.py ADDED
@@ -0,0 +1,159 @@
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 math
10
+ import sys
11
+ from collections.abc import Mapping, Sequence
12
+ from typing import Any
13
+
14
+ if sys.platform == "darwin":
15
+ from Foundation import NSUserDefaults
16
+ else:
17
+ NSUserDefaults = None
18
+
19
+ PANEL_WINDOW_ORIGIN_DEFAULTS_KEY = "usage.panelWindowOrigin"
20
+ PANEL_WINDOW_TOP_LEFT_DEFAULTS_KEY = "usage.panelWindowTopLeft"
21
+ PANEL_CONTENT_HEIGHTS_DEFAULTS_KEY = "usage.panelContentHeights"
22
+
23
+ Origin = tuple[float, float]
24
+ Size = tuple[float, float]
25
+ VisibleFrame = tuple[float, float, float, float]
26
+
27
+
28
+ def clamp_origin_to_visible_frames(
29
+ origin: Origin,
30
+ size: Size,
31
+ visible_frames: Sequence[VisibleFrame],
32
+ ) -> Origin:
33
+ if not visible_frames:
34
+ return origin
35
+
36
+ width = max(0.0, float(size[0]))
37
+ height = max(0.0, float(size[1]))
38
+ candidates: list[Origin] = []
39
+ for frame_x, frame_y, frame_width, frame_height in visible_frames:
40
+ maximum_x = float(frame_x) + max(0.0, float(frame_width) - width)
41
+ maximum_y = float(frame_y) + max(0.0, float(frame_height) - height)
42
+ candidate_x = min(max(float(origin[0]), float(frame_x)), maximum_x)
43
+ candidate_y = min(max(float(origin[1]), float(frame_y)), maximum_y)
44
+ candidates.append((candidate_x, candidate_y))
45
+
46
+ return min(
47
+ candidates,
48
+ key=lambda candidate: (candidate[0] - origin[0]) ** 2
49
+ + (candidate[1] - origin[1]) ** 2,
50
+ )
51
+
52
+
53
+ def load_panel_window_origin(defaults: Any | None = None) -> Origin | None:
54
+ store = defaults
55
+ if store is None:
56
+ assert NSUserDefaults is not None
57
+ store = NSUserDefaults.standardUserDefaults()
58
+ value = store.arrayForKey_(PANEL_WINDOW_ORIGIN_DEFAULTS_KEY)
59
+ if not isinstance(value, Sequence) or isinstance(value, str | bytes) or len(value) != 2:
60
+ return None
61
+ x, y = value
62
+ if (
63
+ isinstance(x, bool)
64
+ or isinstance(y, bool)
65
+ or not isinstance(x, int | float)
66
+ or not isinstance(y, int | float)
67
+ or not math.isfinite(float(x))
68
+ or not math.isfinite(float(y))
69
+ ):
70
+ return None
71
+ return (float(x), float(y))
72
+
73
+
74
+ def save_panel_window_top_left(
75
+ top_left: Origin,
76
+ defaults: Any | None = None,
77
+ ) -> None:
78
+ store = defaults
79
+ if store is None:
80
+ assert NSUserDefaults is not None
81
+ store = NSUserDefaults.standardUserDefaults()
82
+ store.setObject_forKey_(
83
+ [float(top_left[0]), float(top_left[1])],
84
+ PANEL_WINDOW_TOP_LEFT_DEFAULTS_KEY,
85
+ )
86
+
87
+
88
+ def load_panel_window_top_left(defaults: Any | None = None) -> Origin | None:
89
+ store = defaults
90
+ if store is None:
91
+ assert NSUserDefaults is not None
92
+ store = NSUserDefaults.standardUserDefaults()
93
+ value = store.arrayForKey_(PANEL_WINDOW_TOP_LEFT_DEFAULTS_KEY)
94
+ if not isinstance(value, Sequence) or isinstance(value, str | bytes) or len(value) != 2:
95
+ return None
96
+ x, y = value
97
+ if (
98
+ isinstance(x, bool)
99
+ or isinstance(y, bool)
100
+ or not isinstance(x, int | float)
101
+ or not isinstance(y, int | float)
102
+ or not math.isfinite(float(x))
103
+ or not math.isfinite(float(y))
104
+ ):
105
+ return None
106
+ return (float(x), float(y))
107
+
108
+
109
+ def save_panel_content_height(
110
+ panel_id: str,
111
+ height: float,
112
+ defaults: Any | None = None,
113
+ ) -> None:
114
+ store = defaults
115
+ if store is None:
116
+ assert NSUserDefaults is not None
117
+ store = NSUserDefaults.standardUserDefaults()
118
+ value = store.objectForKey_(PANEL_CONTENT_HEIGHTS_DEFAULTS_KEY)
119
+ heights = dict(value) if isinstance(value, Mapping) else {}
120
+ heights[panel_id] = float(height)
121
+ store.setObject_forKey_(heights, PANEL_CONTENT_HEIGHTS_DEFAULTS_KEY)
122
+
123
+
124
+ def load_panel_content_height(
125
+ panel_id: str,
126
+ defaults: Any | None = None,
127
+ ) -> float | None:
128
+ store = defaults
129
+ if store is None:
130
+ assert NSUserDefaults is not None
131
+ store = NSUserDefaults.standardUserDefaults()
132
+ heights = store.objectForKey_(PANEL_CONTENT_HEIGHTS_DEFAULTS_KEY)
133
+ if not isinstance(heights, Mapping):
134
+ return None
135
+ value = heights.get(panel_id)
136
+ if (
137
+ isinstance(value, bool)
138
+ or not isinstance(value, int | float)
139
+ or not math.isfinite(float(value))
140
+ or value <= 0
141
+ ):
142
+ return None
143
+ return float(value)
144
+
145
+
146
+ def resolve_panel_size(
147
+ state: Any,
148
+ panel: Any | None,
149
+ defaults: Any | None = None,
150
+ ) -> Size:
151
+ import menubar_state
152
+
153
+ width, height = menubar_state.popover_dimensions(state, panel)
154
+ measurement_available = getattr(panel, "_content_height_reports_available", True)
155
+ if measurement_available and panel is not None and (
156
+ saved_height := load_panel_content_height(panel.id, defaults)
157
+ ):
158
+ height = saved_height
159
+ return (width, height)
panels/__init__.py ADDED
@@ -0,0 +1,186 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
3
+ #
4
+ # Part of "usage". Free software licensed under the GNU Affero General Public
5
+ # License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
6
+
7
+ from __future__ import annotations
8
+
9
+ from functools import cache
10
+
11
+ from panels.base import Panel
12
+
13
+
14
+ @cache
15
+ def all_panels() -> tuple[Panel, ...]:
16
+ # web_panel requires PyObjC at import time (objc/AppKit/WebKit), which only
17
+ # exists on macOS. Import it lazily so importing the panels package (e.g.
18
+ # wintray -> panels.payload on Windows) never pulls it in.
19
+ from panels.web_panel import HTMLPanel
20
+
21
+ # claude_card_height mirrors codex_card_height: the two cards share the same
22
+ # structure (header + two quota rows) and measure equal in headless renders.
23
+ return (
24
+ HTMLPanel(
25
+ "classic",
26
+ "panel_default_name",
27
+ "classic.html",
28
+ height=1004.0,
29
+ claude_card_height=192.0,
30
+ codex_card_height=192.0,
31
+ agy_card_height=192.0,
32
+ status_wrap_extra_height=30.0,
33
+ service_alert_height=32.0,
34
+ ),
35
+ HTMLPanel(
36
+ "matrix",
37
+ "panel_matrix",
38
+ "matrix.html",
39
+ height=1046.0,
40
+ claude_card_height=200.0,
41
+ codex_card_height=200.0,
42
+ agy_card_height=200.0,
43
+ status_wrap_extra_height=32.0,
44
+ service_alert_height=32.0,
45
+ ),
46
+ HTMLPanel(
47
+ "win95",
48
+ "panel_win95",
49
+ "win95.html",
50
+ height=1055.0,
51
+ claude_card_height=210.0,
52
+ codex_card_height=209.0,
53
+ agy_card_height=209.0,
54
+ status_wrap_extra_height=32.0,
55
+ service_alert_height=32.0,
56
+ ),
57
+ HTMLPanel(
58
+ "newspaper",
59
+ "panel_newspaper",
60
+ "newspaper.html",
61
+ height=1051.0,
62
+ claude_card_height=205.0,
63
+ codex_card_height=203.0,
64
+ agy_card_height=203.0,
65
+ status_wrap_extra_height=30.0,
66
+ service_alert_height=32.0,
67
+ ),
68
+ HTMLPanel(
69
+ "cloud_observation",
70
+ "panel_cloud_observation",
71
+ "cloud_observation.html",
72
+ height=1006.0,
73
+ claude_card_height=211.0,
74
+ codex_card_height=211.0,
75
+ agy_card_height=211.0,
76
+ service_alert_height=32.0,
77
+ ),
78
+ HTMLPanel(
79
+ "aquarium",
80
+ "panel_aquarium",
81
+ "aquarium.html",
82
+ height=1006.0,
83
+ claude_card_height=211.0,
84
+ codex_card_height=211.0,
85
+ agy_card_height=211.0,
86
+ service_alert_height=32.0,
87
+ ),
88
+ HTMLPanel(
89
+ "prism_arcade",
90
+ "panel_prism_arcade",
91
+ "prism_arcade.html",
92
+ height=1006.0,
93
+ claude_card_height=211.0,
94
+ codex_card_height=211.0,
95
+ agy_card_height=211.0,
96
+ service_alert_height=32.0,
97
+ ),
98
+ # Reuse classic's measured values because the DOM structure is identical;
99
+ # remeasure if a future render shows clipping.
100
+ HTMLPanel(
101
+ "stained_glass",
102
+ "panel_stained_glass",
103
+ "stained_glass.html",
104
+ height=1004.0,
105
+ claude_card_height=192.0,
106
+ codex_card_height=192.0,
107
+ agy_card_height=192.0,
108
+ status_wrap_extra_height=30.0,
109
+ service_alert_height=32.0,
110
+ ),
111
+ # Reuse classic's measured values because the DOM structure is identical;
112
+ # remeasure if a future render shows clipping.
113
+ HTMLPanel(
114
+ "origami",
115
+ "panel_origami",
116
+ "origami.html",
117
+ height=1004.0,
118
+ claude_card_height=192.0,
119
+ codex_card_height=192.0,
120
+ agy_card_height=192.0,
121
+ status_wrap_extra_height=30.0,
122
+ service_alert_height=32.0,
123
+ ),
124
+ HTMLPanel(
125
+ "black_hole",
126
+ "panel_black_hole",
127
+ "black_hole.html",
128
+ height=1006.0,
129
+ claude_card_height=211.0,
130
+ codex_card_height=211.0,
131
+ agy_card_height=211.0,
132
+ service_alert_height=32.0,
133
+ ),
134
+ HTMLPanel(
135
+ "lepidoptera",
136
+ "panel_lepidoptera",
137
+ "lepidoptera.html",
138
+ height=1046.0,
139
+ claude_card_height=208.0,
140
+ codex_card_height=208.0,
141
+ agy_card_height=208.0,
142
+ status_wrap_extra_height=32.0,
143
+ service_alert_height=32.0,
144
+ ),
145
+ HTMLPanel(
146
+ "world_cup",
147
+ "panel_world_cup",
148
+ "world_cup.html",
149
+ claude_card_height=0.0,
150
+ codex_card_height=0.0,
151
+ service_alert_height=32.0,
152
+ ),
153
+ HTMLPanel(
154
+ "talent_market",
155
+ "panel_talent_market",
156
+ "talent_market.html",
157
+ height=812.0,
158
+ claude_card_height=0.0,
159
+ codex_card_height=0.0,
160
+ dynamic_height=False,
161
+ ),
162
+ # 1038 = classic's 1004 + roughly 34px for the flavor swatches.
163
+ # This is estimated, not measured; verify clipping visually after packaging.
164
+ HTMLPanel(
165
+ "catppuccin",
166
+ "panel_catppuccin",
167
+ "catppuccin.html",
168
+ height=1038.0,
169
+ claude_card_height=192.0,
170
+ codex_card_height=192.0,
171
+ agy_card_height=192.0,
172
+ status_wrap_extra_height=30.0,
173
+ service_alert_height=32.0,
174
+ ),
175
+ )
176
+
177
+
178
+ def panel_ids() -> tuple[str, ...]:
179
+ return tuple(panel.id for panel in all_panels())
180
+
181
+
182
+ def get_panel(panel_id: str) -> Panel:
183
+ for panel in all_panels():
184
+ if panel.id == panel_id:
185
+ return panel
186
+ return all_panels()[0]