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_actions.py ADDED
@@ -0,0 +1,204 @@
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 workers behind the menu's hook, statusline and report actions."""
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import io
13
+ import logging
14
+ import os
15
+ from typing import Any, Protocol
16
+
17
+ import session_hooks
18
+ import setup_hook
19
+ from i18n import _t
20
+ from menubar_chrome import _make_alert
21
+ from statusline_settings import (
22
+ _disable_statusline_settings,
23
+ _enable_statusline_settings,
24
+ _set_forwarder_mode_prompt_dismissed,
25
+ _toggle_statusline_settings,
26
+ )
27
+ from usage_lang import detect_lang
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ class _ActionApp(Protocol):
33
+ language: str
34
+
35
+ def performSelectorOnMainThread_withObject_waitUntilDone_(
36
+ self, selector: str, obj: Any, wait: bool
37
+ ) -> None: ...
38
+
39
+
40
+ def toggle_session_resume_in_background(app: _ActionApp) -> None:
41
+ import session_hooks
42
+
43
+ output = io.StringIO()
44
+ ok = True
45
+ enabled = False
46
+ try:
47
+ with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output):
48
+ if session_hooks.is_resume_enabled():
49
+ session_hooks.disable_session_resume()
50
+ else:
51
+ ok = session_hooks.enable_session_resume() == 0
52
+ enabled = ok
53
+ except SystemExit as exc:
54
+ if exc.code:
55
+ ok = False
56
+ print(exc.code, file=output)
57
+ except Exception as exc:
58
+ ok = False
59
+ print(f"{type(exc).__name__}: {exc}", file=output)
60
+
61
+ app.performSelectorOnMainThread_withObject_waitUntilDone_(
62
+ "_finishSessionResume:",
63
+ {"ok": ok, "enabled": enabled, "output": output.getvalue().strip()},
64
+ False,
65
+ )
66
+
67
+
68
+ def toggle_terse_mode_in_background(app: _ActionApp) -> None:
69
+ import session_hooks
70
+
71
+ output = io.StringIO()
72
+ ok = True
73
+ enabled = False
74
+ try:
75
+ with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output):
76
+ if session_hooks.is_terse_mode_enabled():
77
+ session_hooks.disable_terse_mode()
78
+ else:
79
+ ok = session_hooks.enable_terse_mode() == 0
80
+ enabled = ok
81
+ except SystemExit as exc:
82
+ if exc.code:
83
+ ok = False
84
+ print(exc.code, file=output)
85
+ except Exception as exc:
86
+ ok = False
87
+ print(f"{type(exc).__name__}: {exc}", file=output)
88
+
89
+ app.performSelectorOnMainThread_withObject_waitUntilDone_(
90
+ "_finishTerseMode:",
91
+ {"ok": ok, "enabled": enabled, "output": output.getvalue().strip()},
92
+ False,
93
+ )
94
+
95
+
96
+ def install_hook_in_background(app: _ActionApp) -> None:
97
+ output = io.StringIO()
98
+ exit_code = 1
99
+ try:
100
+ with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output):
101
+ import session_hooks
102
+ import setup_hook
103
+
104
+ exit_code = setup_hook.setup()
105
+ if exit_code == 0:
106
+ session_hooks._migrate_bundled_python_commands_if_needed()
107
+ except SystemExit as exc:
108
+ exit_code = exc.code if isinstance(exc.code, int) else 1
109
+ if exc.code:
110
+ print(exc.code, file=output)
111
+ except Exception as exc:
112
+ print(f"{type(exc).__name__}: {exc}", file=output)
113
+
114
+ result = {
115
+ "success": exit_code == 0,
116
+ "message": output.getvalue().strip(),
117
+ }
118
+ app.performSelectorOnMainThread_withObject_waitUntilDone_(
119
+ "_finishHookInstall:",
120
+ result,
121
+ False,
122
+ )
123
+
124
+
125
+ def statusline_action_in_background(app: _ActionApp, action: str) -> None:
126
+ output = io.StringIO()
127
+ ok = True
128
+ try:
129
+ with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output):
130
+ if action == "toggle":
131
+ _toggle_statusline_settings()
132
+ elif action == "uninstall":
133
+ _disable_statusline_settings()
134
+ else:
135
+ _enable_statusline_settings()
136
+ except SystemExit as exc:
137
+ if exc.code:
138
+ ok = False
139
+ print(exc.code, file=output)
140
+ except Exception as exc:
141
+ ok = False
142
+ print(f"{type(exc).__name__}: {exc}", file=output)
143
+
144
+ app.performSelectorOnMainThread_withObject_waitUntilDone_(
145
+ "_finishStatuslineAction:",
146
+ {"ok": ok, "action": action, "output": output.getvalue().strip()},
147
+ False,
148
+ )
149
+
150
+
151
+ def analyze_usage_in_background(app: _ActionApp, period: str) -> None:
152
+ from menubar import _generate_analysis_report
153
+
154
+ result: dict[str, str | bool]
155
+ try:
156
+ saved = _generate_analysis_report(period=period, language=app.language)
157
+ result = {"success": True, "message": saved}
158
+ except Exception as exc:
159
+ if os.environ.get("USAGE_DEBUG") == "1":
160
+ logger.warning("analysis report failed", exc_info=True)
161
+ result = {"success": False, "message": f"{type(exc).__name__}: {exc}"}
162
+ app.performSelectorOnMainThread_withObject_waitUntilDone_(
163
+ "_finishAnalyzeUsage:",
164
+ result,
165
+ False,
166
+ )
167
+
168
+
169
+ def show_forwarder_mode_prompt_if_needed(language: str | None = None) -> None:
170
+ try:
171
+ settings = setup_hook._load_settings()
172
+ usage_settings = settings.get(setup_hook.BACKUP_KEY)
173
+ dismissed = (
174
+ isinstance(usage_settings, dict)
175
+ and usage_settings.get("forwarderModePromptDismissed") is True
176
+ )
177
+ if dismissed or setup_hook._detect_current_state(settings) != "external":
178
+ return
179
+ except Exception:
180
+ if os.environ.get("USAGE_DEBUG") == "1":
181
+ logger.warning("forwarder prompt check failed", exc_info=True)
182
+ return
183
+
184
+ lang = language or detect_lang()
185
+ alert = _make_alert()
186
+ alert.setMessageText_(_t(lang, "alert_forwarder_title"))
187
+ alert.setInformativeText_(_t(lang, "alert_forwarder_body"))
188
+ alert.addButtonWithTitle_(_t(lang, "alert_forwarder_enable"))
189
+ alert.addButtonWithTitle_(_t(lang, "alert_forwarder_keep"))
190
+ result = int(alert.runModal())
191
+
192
+ try:
193
+ if result == 1000:
194
+ setup_hook.setup(force_forwarder=True)
195
+ session_hooks._migrate_bundled_python_commands_if_needed()
196
+ except Exception:
197
+ if os.environ.get("USAGE_DEBUG") == "1":
198
+ logger.warning("forwarder setup failed", exc_info=True)
199
+ finally:
200
+ try:
201
+ _set_forwarder_mode_prompt_dismissed()
202
+ except Exception:
203
+ if os.environ.get("USAGE_DEBUG") == "1":
204
+ logger.warning("forwarder prompt dismissal failed", exc_info=True)
menubar_agy.py ADDED
@@ -0,0 +1,193 @@
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 Antigravity quota projection for the menu-bar panel."""
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from dataclasses import dataclass
13
+ from typing import cast
14
+
15
+ from agy_quota_probe import (
16
+ AgyQuotaGroup,
17
+ AgyQuotaResult,
18
+ AgyQuotaWindow,
19
+ load_quota,
20
+ )
21
+ from agy_quota_probe import (
22
+ find_agy as find_agy,
23
+ )
24
+ from i18n import _t
25
+ from menubar_state import (
26
+ AGY_COLOR,
27
+ AgyStaleState,
28
+ QuotaRowState,
29
+ _bar_color,
30
+ _format_percent,
31
+ format_human_time,
32
+ )
33
+ from time_utils import parse_iso8601_utc_or_raise
34
+
35
+ AGY_STALE_SECONDS = 20 * 60
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class AgyQuotaProjection:
40
+ """Panel-ready data for Antigravity's Gemini group by default."""
41
+
42
+ group_name: str
43
+ session: QuotaRowState
44
+ weekly: QuotaRowState
45
+ stale: AgyStaleState | None
46
+ five_hour: AgyQuotaWindow | None
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class AgyRefreshResult:
51
+ """One background probe/load outcome, including card visibility."""
52
+
53
+ projection: AgyQuotaProjection | None
54
+ hide_agy: bool
55
+
56
+
57
+ def project_quota(
58
+ quota: AgyQuotaResult | None,
59
+ language: str,
60
+ now: float | None = None,
61
+ ) -> AgyQuotaProjection | None:
62
+ """Select and convert the Gemini quota group without I/O when available."""
63
+ if quota is None or not quota.groups:
64
+ return None
65
+ selected = next(
66
+ (group for group in quota.groups if "gemini" in group.name.lower()),
67
+ min(quota.groups, key=_group_remaining_percent),
68
+ )
69
+ current_time = time.time() if now is None else now
70
+ age_minutes = _cache_age_minutes(quota.fetched_at, current_time)
71
+ return AgyQuotaProjection(
72
+ group_name=selected.name,
73
+ session=_window_row(
74
+ _t(language, "session_label"),
75
+ selected.five_hour,
76
+ language,
77
+ age_minutes,
78
+ ),
79
+ weekly=_window_row(
80
+ _t(language, "weekly_label"),
81
+ selected.weekly,
82
+ language,
83
+ age_minutes,
84
+ ),
85
+ stale=_stale_state(quota.fetched_at, current_time, language),
86
+ five_hour=selected.five_hour,
87
+ )
88
+
89
+
90
+ def load_refresh_result(language: str) -> AgyRefreshResult:
91
+ """Load/probe quota for a worker thread; never call this on the main thread."""
92
+ if find_agy() is None:
93
+ return AgyRefreshResult(projection=None, hide_agy=True)
94
+ try:
95
+ projection = project_quota(load_quota(), language)
96
+ except Exception:
97
+ projection = None
98
+ return AgyRefreshResult(projection=projection, hide_agy=projection is None)
99
+
100
+
101
+ def fallback_projection(language: str) -> AgyQuotaProjection:
102
+ """Return inert rows while the card is hidden after an unavailable probe."""
103
+ return AgyQuotaProjection(
104
+ group_name="",
105
+ session=QuotaRowState(
106
+ title=_t(language, "session_label"),
107
+ percent=None,
108
+ percent_text="--",
109
+ reset_text=_t(language, "reset_placeholder"),
110
+ color=AGY_COLOR,
111
+ available=False,
112
+ ),
113
+ weekly=QuotaRowState(
114
+ title=_t(language, "weekly_label"),
115
+ percent=None,
116
+ percent_text="--",
117
+ reset_text=_t(language, "reset_placeholder"),
118
+ color=AGY_COLOR,
119
+ available=False,
120
+ ),
121
+ stale=None,
122
+ five_hour=None,
123
+ )
124
+
125
+
126
+ def _group_remaining_percent(group: AgyQuotaGroup) -> float:
127
+ return min(
128
+ _remaining_percent(group.five_hour),
129
+ _remaining_percent(group.weekly),
130
+ )
131
+
132
+
133
+ def _remaining_percent(window: AgyQuotaWindow) -> float:
134
+ return max(0.0, min(100.0, float(window.remaining_percent)))
135
+
136
+
137
+ def _cache_age_minutes(fetched_at: str, now: float) -> int:
138
+ """Whole minutes since the cached snapshot was taken (never negative)."""
139
+ try:
140
+ age_seconds = now - parse_iso8601_utc_or_raise(fetched_at).timestamp()
141
+ except (TypeError, ValueError):
142
+ return 0
143
+ return max(0, int(age_seconds // 60))
144
+
145
+
146
+ def _window_row(
147
+ title: str, window: AgyQuotaWindow, language: str, age_minutes: int = 0
148
+ ) -> QuotaRowState:
149
+ remaining = _remaining_percent(window)
150
+ used = 100.0 - remaining
151
+ if remaining == 100.0:
152
+ reset_text = _t(language, "agy_quota_full")
153
+ elif window.resets_in_minutes is None:
154
+ reset_text = _t(language, "reset_placeholder")
155
+ else:
156
+ minutes_left = max(1, window.resets_in_minutes - max(0, age_minutes))
157
+ reset_text = _t(
158
+ language,
159
+ "reset_in",
160
+ time=format_human_time(minutes_left * 60, language),
161
+ )
162
+ return QuotaRowState(
163
+ title=title,
164
+ percent=used,
165
+ percent_text=_t(language, "percent_used", value=_format_percent(used)),
166
+ reset_text=reset_text,
167
+ color=_bar_color(used, AGY_COLOR),
168
+ available=True,
169
+ )
170
+
171
+
172
+ def _stale_state(fetched_at: str, now: float, language: str) -> AgyStaleState | None:
173
+ try:
174
+ age_seconds = now - parse_iso8601_utc_or_raise(fetched_at).timestamp()
175
+ except (TypeError, ValueError):
176
+ return None
177
+ if age_seconds <= AGY_STALE_SECONDS:
178
+ return None
179
+ if age_seconds < 3600:
180
+ return cast(
181
+ AgyStaleState,
182
+ {
183
+ "ageText": _t(
184
+ language,
185
+ "agy_stale_minutes",
186
+ minutes=max(1, int(age_seconds // 60)),
187
+ )
188
+ },
189
+ )
190
+ return cast(
191
+ AgyStaleState,
192
+ {"ageText": _t(language, "agy_stale_hours", hours=max(1, int(age_seconds // 3600)))}
193
+ )
menubar_chrome.py ADDED
@@ -0,0 +1,156 @@
1
+ # mypy: disable-error-code="import-untyped,misc"
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import os
6
+ from typing import Any
7
+
8
+ from AppKit import NSAlert, NSAttributedString, NSImage, NSMakeRect, NSMakeSize, NSTextAttachment
9
+
10
+ from panels.base import resolve_resource
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ _ALERT_ICON: Any = None
15
+ _ALERT_ICON_LOADED = False
16
+ _CLAUDE_MENUBAR_ICON: Any = None
17
+ _CLAUDE_MENUBAR_ICON_LOADED = False
18
+ _CODEX_MENUBAR_ICON: Any = None
19
+ _CODEX_MENUBAR_ICON_LOADED = False
20
+ _AGY_MENUBAR_ICON: Any = None
21
+ _AGY_MENUBAR_ICON_LOADED = False
22
+ _CRITTER_IMAGE_CACHE: dict[str, Any] = {}
23
+ _CRITTER_ATTACHMENT_STRING_CACHE: dict[str, Any] = {}
24
+
25
+
26
+ class _NoopAlert:
27
+ def setIcon_(self, icon: Any) -> None:
28
+ return
29
+
30
+ def setMessageText_(self, text: str) -> None:
31
+ return
32
+
33
+ def setInformativeText_(self, text: str) -> None:
34
+ return
35
+
36
+ def addButtonWithTitle_(self, title: str) -> None:
37
+ return
38
+
39
+ def runModal(self) -> int:
40
+ return 0
41
+
42
+
43
+ def _alert_icon() -> Any:
44
+ # NSAlert defaults to the application icon, which from source (and for an
45
+ # accessory app with no Dock presence) is py2app's / Python's rocket. Setting
46
+ # NSApp.applicationIconImage does not propagate to NSAlert, so each alert must
47
+ # set the branded icon explicitly. Loaded once and cached.
48
+ global _ALERT_ICON, _ALERT_ICON_LOADED
49
+ if not _ALERT_ICON_LOADED:
50
+ _ALERT_ICON_LOADED = True
51
+ try:
52
+ _ALERT_ICON = NSImage.alloc().initWithContentsOfFile_(resolve_resource("usage.icns"))
53
+ except Exception:
54
+ _ALERT_ICON = None
55
+ if os.environ.get("USAGE_DEBUG") == "1":
56
+ logger.warning("load alert icon failed", exc_info=True)
57
+ return _ALERT_ICON
58
+
59
+
60
+ def _load_menubar_color_icon(filename: str) -> Any:
61
+ image = NSImage.alloc().initWithContentsOfFile_(resolve_resource(filename))
62
+ if image is not None:
63
+ image.setTemplate_(False)
64
+ image.setSize_(NSMakeSize(14, 14))
65
+ return image
66
+
67
+
68
+ def _claude_menubar_icon() -> Any:
69
+ global _CLAUDE_MENUBAR_ICON, _CLAUDE_MENUBAR_ICON_LOADED
70
+ if not _CLAUDE_MENUBAR_ICON_LOADED:
71
+ _CLAUDE_MENUBAR_ICON_LOADED = True
72
+ try:
73
+ _CLAUDE_MENUBAR_ICON = _load_menubar_color_icon("claude_color_menubar.png")
74
+ except Exception:
75
+ _CLAUDE_MENUBAR_ICON = None
76
+ if os.environ.get("USAGE_DEBUG") == "1":
77
+ logger.warning("load Claude menubar icon failed", exc_info=True)
78
+ return _CLAUDE_MENUBAR_ICON
79
+
80
+
81
+ def _codex_menubar_icon() -> Any:
82
+ global _CODEX_MENUBAR_ICON, _CODEX_MENUBAR_ICON_LOADED
83
+ if not _CODEX_MENUBAR_ICON_LOADED:
84
+ _CODEX_MENUBAR_ICON_LOADED = True
85
+ try:
86
+ _CODEX_MENUBAR_ICON = _load_menubar_color_icon("codex_color_menubar.png")
87
+ except Exception:
88
+ _CODEX_MENUBAR_ICON = None
89
+ if os.environ.get("USAGE_DEBUG") == "1":
90
+ logger.warning("load Codex menubar icon failed", exc_info=True)
91
+ return _CODEX_MENUBAR_ICON
92
+
93
+
94
+ def _agy_menubar_icon() -> Any:
95
+ global _AGY_MENUBAR_ICON, _AGY_MENUBAR_ICON_LOADED
96
+ if not _AGY_MENUBAR_ICON_LOADED:
97
+ _AGY_MENUBAR_ICON_LOADED = True
98
+ try:
99
+ _AGY_MENUBAR_ICON = _load_menubar_color_icon("agy_color_menubar.png")
100
+ except Exception:
101
+ _AGY_MENUBAR_ICON = None
102
+ if os.environ.get("USAGE_DEBUG") == "1":
103
+ logger.warning("load Antigravity menubar icon failed", exc_info=True)
104
+ return _AGY_MENUBAR_ICON
105
+
106
+
107
+ def _menubar_icon_attachment_string(image: Any) -> Any:
108
+ attachment = NSTextAttachment.alloc().init()
109
+ attachment.setImage_(image)
110
+ attachment.setBounds_(NSMakeRect(0, -2.5, 14, 14))
111
+ return NSAttributedString.attributedStringWithAttachment_(attachment)
112
+
113
+
114
+ def _critter_icon_attachment_string(path: str, image: Any) -> Any:
115
+ cached = _CRITTER_ATTACHMENT_STRING_CACHE.get(path)
116
+ if cached is not None:
117
+ return cached
118
+ attachment = NSTextAttachment.alloc().init()
119
+ attachment.setImage_(image)
120
+ attachment.setBounds_(NSMakeRect(0, -4.0, 18, 18))
121
+ attributed = NSAttributedString.attributedStringWithAttachment_(attachment)
122
+ _CRITTER_ATTACHMENT_STRING_CACHE[path] = attributed
123
+ return attributed
124
+
125
+
126
+ def _critter_frame_image(path: str) -> Any:
127
+ cached = _CRITTER_IMAGE_CACHE.get(path)
128
+ if cached is not None:
129
+ return cached
130
+ image = NSImage.alloc().initWithContentsOfFile_(resolve_resource(path))
131
+ if image is not None:
132
+ image.setTemplate_(True)
133
+ image.setSize_(NSMakeSize(18, 18))
134
+ _CRITTER_IMAGE_CACHE[path] = image
135
+ return image
136
+
137
+
138
+ def _make_alert() -> Any:
139
+ try:
140
+ alert = NSAlert.alloc().init()
141
+ except Exception:
142
+ if os.environ.get("USAGE_DEBUG") == "1":
143
+ logger.warning("create alert failed", exc_info=True)
144
+ return _NoopAlert()
145
+ if alert is None:
146
+ if os.environ.get("USAGE_DEBUG") == "1":
147
+ logger.warning("create alert returned None")
148
+ return _NoopAlert()
149
+ icon = _alert_icon()
150
+ if icon is not None:
151
+ try:
152
+ alert.setIcon_(icon)
153
+ except Exception:
154
+ if os.environ.get("USAGE_DEBUG") == "1":
155
+ logger.warning("set alert icon failed", exc_info=True)
156
+ return alert