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
login_item.py ADDED
@@ -0,0 +1,154 @@
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 logging
10
+ import os
11
+ import plistlib
12
+ import subprocess
13
+ import sys
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from Foundation import NSBundle
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ LABEL = "com.lollapalooza.usage"
22
+ PLIST_PATH = Path.home() / "Library" / "LaunchAgents" / f"{LABEL}.plist"
23
+ _LOG_DIR = Path.home() / "Library" / "Logs" / "usage"
24
+
25
+
26
+ def build_plist(program_args: list[str], working_dir: str | None) -> str:
27
+ log_dir = str(_LOG_DIR)
28
+ payload: dict[str, object] = {
29
+ "Label": LABEL,
30
+ "RunAtLoad": True,
31
+ "ProgramArguments": program_args,
32
+ "StandardOutPath": f"{log_dir}/usage.log",
33
+ "StandardErrorPath": f"{log_dir}/usage.err.log",
34
+ "ThrottleInterval": 15,
35
+ "StartDelay": 10,
36
+ "ProcessType": "Interactive",
37
+ }
38
+ if working_dir is None:
39
+ # .app launchd entry uses `open <bundle>`; KeepAlive would re-open endlessly.
40
+ pass
41
+ else:
42
+ payload["KeepAlive"] = {"SuccessfulExit": False}
43
+ payload["WorkingDirectory"] = working_dir
44
+ return plistlib.dumps(payload, fmt=plistlib.FMT_XML, sort_keys=False).decode("utf-8")
45
+
46
+
47
+ def _bundle_value(bundle: Any, attr_name: str) -> str | None:
48
+ attr = getattr(bundle, attr_name, None)
49
+ value = attr() if callable(attr) else attr
50
+ return str(value) if value is not None else None
51
+
52
+
53
+ def _program_context() -> tuple[list[str], str | None]:
54
+ bundle = NSBundle.mainBundle()
55
+ if _bundle_value(bundle, "bundleIdentifier") == LABEL:
56
+ bundle_path = _bundle_value(bundle, "bundlePath")
57
+ if bundle_path:
58
+ return ["/usr/bin/open", bundle_path], None
59
+ project_dir = Path(__file__).resolve().parent
60
+ main_py = project_dir / "main.py"
61
+ return [sys.executable, str(main_py)], str(project_dir)
62
+
63
+
64
+ def is_enabled() -> bool:
65
+ return PLIST_PATH.exists()
66
+
67
+
68
+ def _plist_text() -> str:
69
+ program_args, working_dir = _program_context()
70
+ return build_plist(program_args, working_dir)
71
+
72
+
73
+ def _ensure_parent_dirs() -> None:
74
+ PLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
75
+ _LOG_DIR.mkdir(parents=True, exist_ok=True)
76
+
77
+
78
+ def _write_plist(contents: str) -> None:
79
+ PLIST_PATH.write_text(contents, encoding="utf-8")
80
+
81
+
82
+ def _launchctl_domain_target() -> str:
83
+ # login_item is only imported on macOS; the guard also keeps mypy's win32
84
+ # run (which has no os.getuid) off the POSIX call.
85
+ if sys.platform == "win32":
86
+ raise RuntimeError("login_item is macOS-only")
87
+ return f"gui/{os.getuid()}"
88
+
89
+
90
+ def _stderr_summary(stderr: str) -> str:
91
+ return stderr.strip().replace("\n", " ")[:300]
92
+
93
+
94
+ def _log_launchctl_error(action: str, message: str) -> None:
95
+ logger.warning("launchctl %s failed: %s", action, message)
96
+
97
+
98
+ def _launchctl_bootstrap() -> None:
99
+ cmd = ["launchctl", "bootstrap", _launchctl_domain_target(), str(PLIST_PATH)]
100
+ try:
101
+ result = subprocess.run(
102
+ cmd,
103
+ capture_output=True,
104
+ text=True,
105
+ check=False,
106
+ timeout=5,
107
+ )
108
+ except FileNotFoundError as exc:
109
+ _log_launchctl_error("bootstrap", str(exc))
110
+ return
111
+ except subprocess.TimeoutExpired as exc:
112
+ _log_launchctl_error("bootstrap", f"timed out after {exc.timeout} seconds")
113
+ return
114
+
115
+ if result.returncode not in (0, 17):
116
+ stderr = _stderr_summary(result.stderr)
117
+ _log_launchctl_error("bootstrap", f"returncode={result.returncode} stderr={stderr!r}")
118
+
119
+
120
+ def enable() -> None:
121
+ _ensure_parent_dirs()
122
+ _write_plist(_plist_text())
123
+ _launchctl_bootstrap()
124
+
125
+
126
+ def _remove_plist() -> None:
127
+ PLIST_PATH.unlink(missing_ok=True)
128
+
129
+
130
+ def _launchctl_bootout() -> None:
131
+ cmd = ["launchctl", "bootout", f"{_launchctl_domain_target()}/{LABEL}"]
132
+ try:
133
+ result = subprocess.run(
134
+ cmd,
135
+ capture_output=True,
136
+ text=True,
137
+ check=False,
138
+ timeout=5,
139
+ )
140
+ except FileNotFoundError as exc:
141
+ _log_launchctl_error("bootout", str(exc))
142
+ return
143
+ except subprocess.TimeoutExpired as exc:
144
+ _log_launchctl_error("bootout", f"timed out after {exc.timeout} seconds")
145
+ return
146
+
147
+ if result.returncode not in (0, 113):
148
+ stderr = _stderr_summary(result.stderr)
149
+ _log_launchctl_error("bootout", f"returncode={result.returncode} stderr={stderr!r}")
150
+
151
+
152
+ def disable() -> None:
153
+ _launchctl_bootout()
154
+ _remove_plist()
main.py ADDED
@@ -0,0 +1,387 @@
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 argparse
10
+ import asyncio
11
+ import importlib
12
+ import json
13
+ import logging
14
+ import os
15
+ import sys
16
+ import time
17
+ from contextlib import suppress
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ # Inside usage.app this file sits in Contents/Resources/ next to py2app's
22
+ # __boot__.py, while the modules it imports live in lib/python313.zip. Running
23
+ # it with an outside interpreter therefore fails on the first local import with
24
+ # a confusing ImportError (a PyPI package named `i18n` even shadows ours). Bail
25
+ # out with instructions instead. py2app's own launcher sets RESOURCEPATH. (#92)
26
+ if not os.environ.get("RESOURCEPATH") and (Path(__file__).parent / "__boot__.py").exists():
27
+ sys.exit(
28
+ "This main.py is part of the usage.app bundle and cannot be run directly.\n"
29
+ 'To install the status line, open usage from the menu bar and click "Set Up Status Line".\n'
30
+ "To run from source instead: https://github.com/aqua5230/usage"
31
+ )
32
+
33
+ import prefs
34
+ from i18n import packaged_resource_path
35
+ from i18n import t as _t
36
+ from prefs import PREFERENCES_FILE as PREFERENCES_FILE
37
+ from usage_client import ClaudeUsageClient, PollOutcome, PollState
38
+ from usage_lang import detect_lang
39
+ from usage_rate import UsageRateTracker
40
+
41
+ SPRITE_INTERVAL_S = [2.0, 0.8, 0.4, 0.15] # idle/normal/active/heavy
42
+ IMPORT_RETRY_ATTEMPTS = 6
43
+ IMPORT_RETRY_DELAY_S = 3.0
44
+ REPAIR_DISMISS_SECONDS = 24 * 3600
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+
49
+ def _load_preferences() -> dict[str, Any]:
50
+ return prefs._load_preferences(PREFERENCES_FILE)
51
+
52
+
53
+ def _save_preferences(data: dict[str, Any]) -> None:
54
+ prefs._save_preferences(data, PREFERENCES_FILE)
55
+
56
+
57
+ def _load_rich() -> tuple[type[Any], type[Any]]:
58
+ rich_console = _import_module_with_oserror_retry("rich.console")
59
+ rich_live = _import_module_with_oserror_retry("rich.live")
60
+ return rich_console.Console, rich_live.Live
61
+
62
+
63
+ def _import_module_with_oserror_retry(name: str) -> Any:
64
+ """Retry imports that can transiently fail under launchd with Errno 11."""
65
+ for attempt in range(IMPORT_RETRY_ATTEMPTS):
66
+ try:
67
+ return importlib.import_module(name)
68
+ except OSError:
69
+ if attempt >= IMPORT_RETRY_ATTEMPTS - 1:
70
+ raise
71
+ logger.warning("import failed for %s, retrying", name, exc_info=True)
72
+ time.sleep(IMPORT_RETRY_DELAY_S)
73
+ raise RuntimeError("unreachable")
74
+
75
+
76
+ def _setup_logging() -> None:
77
+ import usage_logging
78
+
79
+ usage_logging.setup_logging()
80
+
81
+
82
+ def _i18n_text(language: str, key: str) -> str:
83
+ try:
84
+ i18n_path = packaged_resource_path(
85
+ "i18n.json", Path(__file__).with_name("i18n.json")
86
+ )
87
+ data = json.loads(i18n_path.read_text(encoding="utf-8"))
88
+ table = data.get(language) or data.get("en") or {}
89
+ return str(table.get(key) or data.get("en", {}).get(key) or key)
90
+ except (OSError, json.JSONDecodeError, AttributeError):
91
+ return key
92
+
93
+
94
+ def _health_language() -> str:
95
+ return detect_lang()
96
+
97
+
98
+ def _save_user_preference(key: str) -> None:
99
+ prefs = _load_preferences()
100
+ if key == "repair_dismissed_at":
101
+ prefs[key] = time.time()
102
+ else:
103
+ prefs[key] = True
104
+ _save_preferences(prefs)
105
+
106
+
107
+ def _user_dismissed_repair_today() -> bool:
108
+ prefs = _load_preferences()
109
+ if prefs.get("no_auto_repair") is True:
110
+ return True
111
+ dismissed_at = prefs.get("repair_dismissed_at")
112
+ if isinstance(dismissed_at, int | float):
113
+ return (time.time() - float(dismissed_at)) < REPAIR_DISMISS_SECONDS
114
+ return False
115
+
116
+
117
+ def _is_our_hook_in_settings() -> bool:
118
+ try:
119
+ import setup_hook
120
+
121
+ return setup_hook._detect_current_state() in {"us-direct", "us-forwarder"}
122
+ except Exception:
123
+ if os.environ.get("USAGE_DEBUG") == "1":
124
+ logger.warning("hook health check failed", exc_info=True)
125
+ return False
126
+
127
+
128
+ def _is_first_run() -> bool:
129
+ try:
130
+ import setup_hook
131
+ import usage_client
132
+
133
+ return not setup_hook.HOOK_TARGET.exists() and not Path(usage_client.STATUS_FILE).exists()
134
+ except Exception:
135
+ return True
136
+
137
+
138
+ def _show_repair_dialog() -> str:
139
+ language = _health_language()
140
+ title = _i18n_text(language, "repair_dialog_title")
141
+ message = _i18n_text(language, "repair_dialog_message")
142
+ repair = _i18n_text(language, "repair_dialog_repair")
143
+ skip = _i18n_text(language, "repair_dialog_skip")
144
+ never = _i18n_text(language, "repair_dialog_never")
145
+
146
+ try:
147
+ appkit = importlib.import_module("AppKit")
148
+ alert = appkit.NSAlert.alloc().init()
149
+ alert.setMessageText_(title)
150
+ alert.setInformativeText_(message)
151
+ alert.addButtonWithTitle_(repair)
152
+ alert.addButtonWithTitle_(skip)
153
+ alert.addButtonWithTitle_(never)
154
+ result = int(alert.runModal())
155
+ except Exception:
156
+ print(f"{title}: {message}")
157
+ return "skip"
158
+
159
+ if result == 1000:
160
+ return "repair"
161
+ if result == 1002:
162
+ return "never"
163
+ return "skip"
164
+
165
+
166
+ def health_check() -> None:
167
+ if _is_our_hook_in_settings():
168
+ return
169
+ if _is_first_run():
170
+ return
171
+ if _user_dismissed_repair_today():
172
+ return
173
+
174
+ choice = _show_repair_dialog()
175
+ if choice == "repair":
176
+ import session_hooks
177
+ import setup_hook
178
+
179
+ setup_hook.setup(force_forwarder=True)
180
+ session_hooks._migrate_bundled_python_commands_if_needed()
181
+ elif choice == "never":
182
+ _save_user_preference("no_auto_repair")
183
+ else:
184
+ _save_user_preference("repair_dismissed_at")
185
+
186
+
187
+ def _self_heal() -> None:
188
+ try:
189
+ import session_hooks
190
+
191
+ session_hooks.self_heal()
192
+ except Exception:
193
+ if os.environ.get("USAGE_DEBUG") == "1":
194
+ logger.warning("self-heal failed", exc_info=True)
195
+
196
+ try:
197
+ from usage_dir_sweeper import sweep_stale_temp_files
198
+
199
+ sweep_stale_temp_files()
200
+ except Exception:
201
+ if os.environ.get("USAGE_DEBUG") == "1":
202
+ logger.warning("stale temp file sweep failed", exc_info=True)
203
+
204
+
205
+ def parse_args() -> argparse.Namespace:
206
+ parser = argparse.ArgumentParser(description="顯示 Claude Code 用量的工具")
207
+ parser.add_argument("--mock", action="store_true", help="使用假資料預覽介面")
208
+ parser.add_argument(
209
+ "--interval",
210
+ type=int,
211
+ default=60,
212
+ help="輪詢秒數,預設 60,最小 30",
213
+ )
214
+ parser.add_argument(
215
+ "--tui",
216
+ action="store_true",
217
+ help="使用舊版終端機 TUI 介面",
218
+ )
219
+ parser.add_argument(
220
+ "--force-group",
221
+ type=int,
222
+ choices=[0, 1, 2, 3],
223
+ default=None,
224
+ help="強制使用某速率組(測試用,僅 TUI 模式有效),0=Idle 1=Normal 2=Active 3=Heavy",
225
+ )
226
+ parser.add_argument(
227
+ "--setup",
228
+ action="store_true",
229
+ help="安裝 statusLine hook 到 Claude Code(首次使用必跑)",
230
+ )
231
+ parser.add_argument(
232
+ "--unsetup",
233
+ action="store_true",
234
+ help="從 Claude Code 移除 statusLine hook 並還原原設定",
235
+ )
236
+ parser.add_argument(
237
+ "--doctor",
238
+ action="store_true",
239
+ help=argparse.SUPPRESS,
240
+ )
241
+ parser.add_argument(
242
+ "--json",
243
+ action="store_true",
244
+ help=argparse.SUPPRESS,
245
+ )
246
+ args = parser.parse_args()
247
+ args.interval = max(30, args.interval)
248
+ return args
249
+
250
+
251
+ async def poll_usage(
252
+ client: ClaudeUsageClient,
253
+ state: Any,
254
+ stop_event: asyncio.Event,
255
+ ) -> None:
256
+ while not stop_event.is_set():
257
+ try:
258
+ await asyncio.wait_for(stop_event.wait(), timeout=client.interval_seconds)
259
+ return
260
+ except TimeoutError:
261
+ pass
262
+
263
+ state.poll_state = PollState.LOADING
264
+ outcome = await client.fetch_once()
265
+ _apply_outcome(state, outcome)
266
+
267
+
268
+ def _apply_outcome(state: Any, outcome: PollOutcome) -> None:
269
+ state.poll_state = outcome.state
270
+ if outcome.snapshot is not None:
271
+ state.snapshot = outcome.snapshot
272
+ if outcome.message:
273
+ state.message = outcome.message
274
+ if outcome.state == PollState.SUCCESS:
275
+ state.fatal_message = None
276
+
277
+
278
+ async def run_tui(mock: bool, interval: int, force_group: int | None = None) -> None:
279
+ tui = _import_module_with_oserror_retry("tui")
280
+ Console, Live = _load_rich()
281
+ console = Console()
282
+ state = tui.AppViewState()
283
+ tracker = UsageRateTracker(forced_group=force_group, mock=mock)
284
+ stop_event = asyncio.Event()
285
+ client = ClaudeUsageClient(interval_seconds=interval, mock=mock)
286
+
287
+ try:
288
+ first_outcome = await client.fetch_once()
289
+ _apply_outcome(state, first_outcome)
290
+
291
+ poll_task = asyncio.create_task(poll_usage(client, state, stop_event))
292
+
293
+ with Live(
294
+ tui.render_screen(state, 0),
295
+ console=console,
296
+ screen=True,
297
+ refresh_per_second=10,
298
+ transient=False,
299
+ ) as live:
300
+ start_time = time.monotonic()
301
+ while not stop_event.is_set():
302
+ now = time.monotonic()
303
+
304
+ effective_group = tracker.group()
305
+ state.rate_group = effective_group
306
+
307
+ interval_s = SPRITE_INTERVAL_S[effective_group]
308
+ frame_index = int((now - start_time) / interval_s) % 4
309
+
310
+ live.update(tui.render_screen(state, frame_index), refresh=True)
311
+ await asyncio.sleep(0.1)
312
+
313
+ await poll_task
314
+ finally:
315
+ stop_event.set()
316
+ await client.aclose()
317
+
318
+
319
+ def main() -> None:
320
+ _setup_logging()
321
+ args = parse_args()
322
+ if args.doctor:
323
+ import doctor
324
+
325
+ report = doctor.collect()
326
+ output = doctor.render_json(report) if args.json else doctor.render(report)
327
+ print(output, end="")
328
+ raise SystemExit(doctor.exit_code(report))
329
+ if args.setup:
330
+ import session_hooks
331
+ from setup_hook import setup
332
+
333
+ exit_code = setup()
334
+ if exit_code == 0:
335
+ session_hooks._migrate_bundled_python_commands_if_needed()
336
+ raise SystemExit(exit_code)
337
+ if args.unsetup:
338
+ from session_hooks import disable_session_resume, disable_terse_mode
339
+ from setup_hook import unsetup
340
+
341
+ disable_session_resume()
342
+ disable_terse_mode()
343
+ raise SystemExit(unsetup())
344
+ _self_heal()
345
+ if args.tui:
346
+ with suppress(KeyboardInterrupt):
347
+ asyncio.run(
348
+ run_tui(mock=args.mock, interval=args.interval, force_group=args.force_group)
349
+ )
350
+ elif sys.platform == "darwin":
351
+ menubar = _import_module_with_oserror_retry("menubar")
352
+ menubar.show_forwarder_mode_prompt_if_needed()
353
+ menubar.run_app(mock=args.mock, interval=args.interval)
354
+ elif sys.platform == "win32":
355
+ try:
356
+ wintray = importlib.import_module("wintray")
357
+ except ModuleNotFoundError as exc:
358
+ # Any missing module — wintray itself, an optional GUI package, or a
359
+ # transitive import — must degrade to the TUI; a bare raise would exit
360
+ # a --windowed build with no visible output at all.
361
+ print(f"{_t('wintray_unavailable')} [{exc.name}]")
362
+ with suppress(KeyboardInterrupt):
363
+ asyncio.run(
364
+ run_tui(mock=args.mock, interval=args.interval, force_group=args.force_group)
365
+ )
366
+ else:
367
+ try:
368
+ wintray.run_app(mock=args.mock, interval=args.interval)
369
+ except ModuleNotFoundError as exc:
370
+ print(f"{_t('wintray_unavailable')} [{exc.name}]")
371
+ with suppress(KeyboardInterrupt):
372
+ asyncio.run(
373
+ run_tui(
374
+ mock=args.mock,
375
+ interval=args.interval,
376
+ force_group=args.force_group,
377
+ )
378
+ )
379
+ else:
380
+ with suppress(KeyboardInterrupt):
381
+ asyncio.run(
382
+ run_tui(mock=args.mock, interval=args.interval, force_group=args.force_group)
383
+ )
384
+
385
+
386
+ if __name__ == "__main__":
387
+ main()