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