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_state.py ADDED
@@ -0,0 +1,1200 @@
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 threading
12
+ import time
13
+ from dataclasses import dataclass
14
+ from datetime import UTC, datetime, timedelta
15
+ from datetime import time as datetime_time
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING, Any, Protocol, TypedDict, cast
18
+
19
+ import codex_loader
20
+ from burn_rate import WARNING_PERCENT_FLOOR, BurnRateTracker
21
+ from codex_paths import codex_home
22
+ from history_loader import CLAUDE_PROJECTS_DIR, UsageEntry, load_entries
23
+ from i18n import _t
24
+ from menubar_prefs import _hide_claude_enabled, _hide_codex_enabled, _quota_card_order
25
+ from pricing import calculate_cost
26
+ from service_status import ServiceStatus
27
+ from statusline_settings import _statusline_enabled
28
+ from time_utils import parse_iso8601_utc_or_raise
29
+ from usage_client import PollOutcome, PollState
30
+ from usage_rate import GROUP_NAMES
31
+
32
+ if TYPE_CHECKING:
33
+ from panels.base import Panel as UsagePanel
34
+
35
+ FILE_EVENT_REFRESH_MIN_INTERVAL_S = 30.0
36
+ HISTORY_FULL_SCAN_INTERVAL_S = 15 * 60.0
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class FileEventRefreshDecision:
41
+ refresh_now: bool
42
+ trailing_delay: float | None
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class CritterAnimationTick:
47
+ advance: tuple[bool, bool, bool]
48
+ timer_interval: float
49
+
50
+
51
+ def critter_animation_tick(
52
+ now: float,
53
+ intervals: tuple[float, float, float],
54
+ last_advanced_at: tuple[float, float, float],
55
+ ) -> CritterAnimationTick:
56
+ """Choose which critters advance on a shared animation timer tick."""
57
+ active_intervals = tuple(interval for interval in intervals if interval > 0)
58
+ timer_interval = min(active_intervals, default=0.0)
59
+ advance = tuple(
60
+ interval > 0 and now - previous + 1e-9 >= interval
61
+ for interval, previous in zip(intervals, last_advanced_at, strict=True)
62
+ )
63
+ return CritterAnimationTick(
64
+ advance=(advance[0], advance[1], advance[2]),
65
+ timer_interval=timer_interval,
66
+ )
67
+
68
+
69
+ def file_event_refresh_decision(
70
+ now: float,
71
+ last_refresh_started_at: float | None,
72
+ trailing_scheduled: bool,
73
+ *,
74
+ min_interval: float = FILE_EVENT_REFRESH_MIN_INTERVAL_S,
75
+ ) -> FileEventRefreshDecision:
76
+ """Decide whether a file event refreshes now or joins one trailing refresh."""
77
+ if last_refresh_started_at is None or now - last_refresh_started_at >= min_interval:
78
+ return FileEventRefreshDecision(refresh_now=True, trailing_delay=None)
79
+ if trailing_scheduled:
80
+ return FileEventRefreshDecision(refresh_now=False, trailing_delay=None)
81
+ return FileEventRefreshDecision(
82
+ refresh_now=False,
83
+ trailing_delay=max(0.0, min_interval - (now - last_refresh_started_at)),
84
+ )
85
+
86
+ logger = logging.getLogger(__name__)
87
+
88
+ CLAUDE_COLOR = (244 / 255, 145 / 255, 100 / 255)
89
+ CODEX_COLOR = (88 / 255, 214 / 255, 230 / 255)
90
+ AGY_COLOR = (107 / 255, 154 / 255, 1.0)
91
+ WARN_COLOR = (255 / 255, 196 / 255, 57 / 255)
92
+ DANGER_COLOR = (255 / 255, 69 / 255, 58 / 255)
93
+ WEEKLY_FORECAST_WINDOW_SECONDS = 30 * 60
94
+ WEEKLY_FORECAST_MIN_SPAN_SECONDS = 30 * 60
95
+ BUTTON_HEIGHT = 32.0
96
+ INSTALL_BUTTON_EXTRA_HEIGHT = BUTTON_HEIGHT + 10.0
97
+ SERVICE_ALERT_GAP = 4.0
98
+
99
+
100
+ def _bar_color(pct: float, brand: tuple[float, float, float]) -> tuple[float, float, float]:
101
+ if pct >= 80:
102
+ return DANGER_COLOR
103
+ if pct >= 50:
104
+ return WARN_COLOR
105
+ return brand
106
+
107
+
108
+ @dataclass(slots=True)
109
+ class QuotaRowState:
110
+ title: str
111
+ percent: float | None
112
+ percent_text: str
113
+ reset_text: str
114
+ color: tuple[float, float, float]
115
+ warning: bool = False
116
+ available: bool = True
117
+
118
+
119
+ class CodexStaleState(TypedDict):
120
+ ageText: str
121
+
122
+
123
+ class AgyStaleState(TypedDict):
124
+ ageText: str
125
+
126
+
127
+ class HistoryLoadErrorState(TypedDict):
128
+ reasonText: str
129
+
130
+
131
+ class CodexCreditsState(TypedDict):
132
+ balance: str | None
133
+ unlimited: bool
134
+
135
+
136
+ @dataclass(slots=True)
137
+ class PopoverState:
138
+ language: str
139
+ claude_session: QuotaRowState
140
+ claude_weekly: QuotaRowState
141
+ codex_session: QuotaRowState
142
+ codex_weekly: QuotaRowState
143
+ agy_session: QuotaRowState
144
+ agy_weekly: QuotaRowState
145
+ agy_group_name: str
146
+ projects: list[tuple[str, int, float | None]]
147
+ projects_yesterday: list[tuple[str, int, float | None]]
148
+ projects_7d: list[tuple[str, int, float | None]]
149
+ projects_30d: list[tuple[str, int, float | None]]
150
+ projects_all: list[tuple[str, int, float | None]]
151
+ rate_text: str
152
+ status_text: str
153
+ today_text: str
154
+ yesterday_text: str
155
+ statusline: dict[str, object]
156
+ service_alerts: tuple[str, ...] = ()
157
+ show_install_button: bool = False
158
+ status_long: bool = False
159
+ hide_claude: bool = False
160
+ hide_codex: bool = False
161
+ hide_agy: bool = True
162
+ codex_stale: CodexStaleState | None = None
163
+ codex_credits: CodexCreditsState | None = None
164
+ agy_stale: AgyStaleState | None = None
165
+ card_order: tuple[str, ...] = ("claude", "codex", "agy")
166
+ history_error: HistoryLoadErrorState | None = None
167
+ # Talent-market panel payload (None for non-talent panels). Fetched from the
168
+ # external instate-cli by talent_market_bridge, only when the active panel
169
+ # is "talent_market", so classic/matrix users never spawn that subprocess.
170
+ talent: dict[str, object] | None = None
171
+
172
+
173
+ @dataclass(frozen=True, slots=True)
174
+ class HistorySourceScan:
175
+ fingerprint: tuple[tuple[str, int, float], ...]
176
+ claude_paths: tuple[Path, ...]
177
+ codex_paths: tuple[Path, ...]
178
+ codex_rate_limit_candidates: tuple[tuple[Path, float], ...] = ()
179
+
180
+
181
+ @dataclass(slots=True)
182
+ class HistorySourceIndex:
183
+ file_stats: dict[Path, tuple[int, int]]
184
+ last_full_scan_at: float
185
+
186
+
187
+ class HistorySourceTracker:
188
+ """Maintain the history source index without depending on PyObjC."""
189
+
190
+ def __init__(self, *, incremental_enabled: bool = False) -> None:
191
+ self._incremental_enabled = incremental_enabled
192
+ self._index: HistorySourceIndex | None = None
193
+ self._dirty_paths: set[Path] = set()
194
+ self._needs_full_scan = False
195
+ self._lock = threading.Lock()
196
+
197
+ def set_incremental_enabled(self, enabled: bool) -> None:
198
+ with self._lock:
199
+ self._incremental_enabled = enabled
200
+
201
+ def record_changes(
202
+ self,
203
+ paths: set[Path] | frozenset[Path],
204
+ *,
205
+ needs_full_scan: bool = False,
206
+ ) -> None:
207
+ with self._lock:
208
+ self._dirty_paths.update(paths)
209
+ self._needs_full_scan = self._needs_full_scan or needs_full_scan
210
+
211
+ def scan(self, *, now: float | None = None) -> HistorySourceScan:
212
+ current_time = time.monotonic() if now is None else now
213
+ with self._lock:
214
+ incremental_enabled = self._incremental_enabled
215
+ dirty_paths = set(self._dirty_paths)
216
+ needs_full_scan = self._needs_full_scan
217
+ self._dirty_paths.difference_update(dirty_paths)
218
+ if needs_full_scan:
219
+ self._needs_full_scan = False
220
+ index = self._index
221
+
222
+ if (
223
+ not incremental_enabled
224
+ or index is None
225
+ or needs_full_scan
226
+ or current_time - index.last_full_scan_at >= HISTORY_FULL_SCAN_INTERVAL_S
227
+ ):
228
+ index = _build_history_source_index(current_time)
229
+ else:
230
+ # The sqlite sources live outside the FSEvents-watched directories,
231
+ # so re-stat them every scan to keep their fingerprint fresh.
232
+ index = _update_history_source_index(
233
+ index,
234
+ dirty_paths | set(_history_file_sources()),
235
+ )
236
+
237
+ with self._lock:
238
+ self._index = index
239
+ return _history_scan_from_index(index)
240
+
241
+
242
+ def history_cache_needs_reload(
243
+ previous_fingerprint: tuple[tuple[str, int, float], ...] | None,
244
+ current_fingerprint: tuple[tuple[str, int, float], ...],
245
+ *,
246
+ has_cached_result: bool,
247
+ ) -> bool:
248
+ """Decide whether Windows history projections need to be rebuilt."""
249
+ return not has_cached_result or previous_fingerprint != current_fingerprint
250
+
251
+
252
+ def _jsonl_paths(root: Path) -> tuple[Path, ...]:
253
+ if not root.exists():
254
+ return ()
255
+ try:
256
+ return tuple(root.rglob("*.jsonl"))
257
+ except OSError:
258
+ return ()
259
+
260
+
261
+ def _history_directory_sources() -> tuple[Path, Path, Path]:
262
+ return (
263
+ CLAUDE_PROJECTS_DIR,
264
+ codex_loader.SESSIONS_DIR,
265
+ codex_loader.ARCHIVED_SESSIONS_DIR,
266
+ )
267
+
268
+
269
+ def _history_file_sources() -> tuple[Path, Path, Path, Path]:
270
+ codex_dir = codex_home()
271
+ return (
272
+ codex_loader.LOGS_DB,
273
+ codex_dir / "logs_2.sqlite-wal",
274
+ codex_loader.STATE_DB,
275
+ codex_dir / "state_5.sqlite-wal",
276
+ )
277
+
278
+
279
+ def _stat_index_entry(path: Path) -> tuple[int, int] | None:
280
+ try:
281
+ stat = path.stat()
282
+ except OSError:
283
+ return None
284
+ return (stat.st_mtime_ns, stat.st_size)
285
+
286
+
287
+ def _build_history_source_index(now: float) -> HistorySourceIndex:
288
+ file_stats: dict[Path, tuple[int, int]] = {}
289
+ for root in _history_directory_sources():
290
+ for path in _jsonl_paths(root):
291
+ entry = _stat_index_entry(path)
292
+ if entry is not None:
293
+ file_stats[path] = entry
294
+ for path in _history_file_sources():
295
+ entry = _stat_index_entry(path)
296
+ if entry is not None:
297
+ file_stats[path] = entry
298
+ return HistorySourceIndex(file_stats=file_stats, last_full_scan_at=now)
299
+
300
+
301
+ def _is_indexed_history_path(path: Path) -> bool:
302
+ if path in _history_file_sources():
303
+ return True
304
+ if path.suffix != ".jsonl":
305
+ return False
306
+ return any(path == root or root in path.parents for root in _history_directory_sources())
307
+
308
+
309
+ def _update_history_source_index(
310
+ index: HistorySourceIndex,
311
+ dirty_paths: set[Path],
312
+ ) -> HistorySourceIndex:
313
+ file_stats = dict(index.file_stats)
314
+ for path in dirty_paths:
315
+ if not _is_indexed_history_path(path):
316
+ continue
317
+ entry = _stat_index_entry(path)
318
+ if entry is None:
319
+ file_stats.pop(path, None)
320
+ else:
321
+ file_stats[path] = entry
322
+ return HistorySourceIndex(file_stats, index.last_full_scan_at)
323
+
324
+
325
+ def _source_fingerprint_from_index(
326
+ source: Path,
327
+ file_stats: dict[Path, tuple[int, int]],
328
+ ) -> tuple[str, int, float]:
329
+ if source in _history_file_sources():
330
+ entry = file_stats.get(source)
331
+ return (str(source), int(entry is not None), 0.0 if entry is None else entry[0] / 1e9)
332
+ mtimes = [
333
+ entry[0]
334
+ for path, entry in file_stats.items()
335
+ if path == source or source in path.parents
336
+ ]
337
+ return (str(source), len(mtimes), max(mtimes, default=0) / 1e9)
338
+
339
+
340
+ def _history_scan_from_index(index: HistorySourceIndex) -> HistorySourceScan:
341
+ directory_sources = _history_directory_sources()
342
+ file_sources = _history_file_sources()
343
+ source_fingerprint = tuple(
344
+ _source_fingerprint_from_index(source, index.file_stats)
345
+ for source in (*directory_sources, *file_sources)
346
+ )
347
+ file_fingerprint = tuple(
348
+ (str(path), entry[1], entry[0] / 1e9)
349
+ for path, entry in sorted(index.file_stats.items(), key=lambda item: str(item[0]))
350
+ )
351
+ fingerprint = source_fingerprint + file_fingerprint
352
+ claude_root, sessions_root, archived_root = directory_sources
353
+ claude_paths = tuple(
354
+ path
355
+ for path in index.file_stats
356
+ if path.suffix == ".jsonl" and (path == claude_root or claude_root in path.parents)
357
+ )
358
+ codex_paths = tuple(
359
+ path
360
+ for path in index.file_stats
361
+ if path.suffix == ".jsonl"
362
+ and any(path == root or root in path.parents for root in (sessions_root, archived_root))
363
+ )
364
+ codex_rate_limit_candidates = tuple(
365
+ (path, index.file_stats[path][0] / 1e9)
366
+ for path in codex_paths
367
+ if all(
368
+ not part.startswith(".")
369
+ for root in (sessions_root, archived_root)
370
+ if path == root or root in path.parents
371
+ for part in path.relative_to(root).parts
372
+ )
373
+ )
374
+ return HistorySourceScan(
375
+ fingerprint,
376
+ claude_paths,
377
+ codex_paths,
378
+ codex_rate_limit_candidates,
379
+ )
380
+
381
+
382
+ def history_source_scan() -> HistorySourceScan:
383
+ return _history_scan_from_index(_build_history_source_index(time.monotonic()))
384
+
385
+
386
+ class _HistoryApp(Protocol):
387
+ mock: bool
388
+ _history_source_tracker: Any
389
+ _history_entries_cache: list[UsageEntry] | None
390
+ _history_entries_cache_fingerprint: tuple[tuple[str, int, float], ...] | None
391
+ _history_load_error_key: str | None
392
+
393
+ def _history_source_scan(self) -> HistorySourceScan: ...
394
+
395
+
396
+ def app_history_sources_fingerprint(app: _HistoryApp) -> tuple[tuple[str, int, float], ...]:
397
+ return app._history_source_scan().fingerprint
398
+
399
+
400
+ def app_history_source_scan(app: _HistoryApp) -> HistorySourceScan:
401
+ return cast(HistorySourceScan, app._history_source_tracker.scan())
402
+
403
+
404
+ def app_load_history_entries(
405
+ app: _HistoryApp,
406
+ *,
407
+ scan: HistorySourceScan | None = None,
408
+ ) -> list[UsageEntry]:
409
+ if app.mock:
410
+ return []
411
+ if scan is None:
412
+ scan = app._history_source_scan()
413
+ fingerprint = scan.fingerprint
414
+ if (
415
+ app._history_entries_cache is not None
416
+ and app._history_entries_cache_fingerprint == fingerprint
417
+ ):
418
+ return list(app._history_entries_cache)
419
+
420
+ entries: list[UsageEntry] = []
421
+ error_key: str | None = None
422
+ try:
423
+ entries.extend(load_entries(hours_back=0, jsonl_paths=scan.claude_paths))
424
+ except Exception as exc:
425
+ if os.environ.get("USAGE_DEBUG") == "1":
426
+ logger.warning("Claude project usage load failed", exc_info=True)
427
+ error_key = _classify_history_load_error(exc)
428
+ try:
429
+ entries.extend(codex_loader.load_entries(hours_back=0, jsonl_paths=scan.codex_paths))
430
+ except Exception as exc:
431
+ if os.environ.get("USAGE_DEBUG") == "1":
432
+ logger.warning("Codex project usage load failed", exc_info=True)
433
+ error_key = _classify_history_load_error(exc)
434
+ app._history_load_error_key = error_key
435
+ app._history_entries_cache = list(entries)
436
+ app._history_entries_cache_fingerprint = fingerprint
437
+ return entries
438
+
439
+
440
+ def app_project_rows(
441
+ app: _HistoryApp,
442
+ hours_back: int = 24,
443
+ entries: list[UsageEntry] | None = None,
444
+ ) -> list[tuple[str, int, float | None]]:
445
+ if app.mock:
446
+ if hours_back <= 0:
447
+ return [
448
+ ("usage", 624_000_000, 361.00),
449
+ ("FinMind", 172_800_000, 100.24),
450
+ ("AI客服", 44_000_000, 26.40),
451
+ ]
452
+ if hours_back <= 24:
453
+ return [
454
+ ("usage", 11_200_000, 6.47),
455
+ ("FinMind", 3_100_000, 1.82),
456
+ ("AI客服", 800_000, 0.48),
457
+ ]
458
+ if hours_back <= 168:
459
+ return [
460
+ ("usage", 78_400_000, 45.20),
461
+ ("FinMind", 21_700_000, 12.74),
462
+ ("AI客服", 5_600_000, 3.36),
463
+ ]
464
+ return [
465
+ ("usage", 312_000_000, 180.50),
466
+ ("FinMind", 86_400_000, 50.12),
467
+ ("AI客服", 22_000_000, 13.20),
468
+ ]
469
+
470
+ if entries is None:
471
+ try:
472
+ resolved = load_entries(hours_back=hours_back)
473
+ except Exception:
474
+ if os.environ.get("USAGE_DEBUG") == "1":
475
+ logger.warning("project usage load failed", exc_info=True)
476
+ return []
477
+ else:
478
+ if hours_back == 24:
479
+ today = datetime.now().astimezone().date()
480
+ resolved = [e for e in entries if e.timestamp.astimezone().date() == today]
481
+ elif hours_back > 0:
482
+ cutoff = datetime.now(tz=UTC) - timedelta(hours=hours_back)
483
+ resolved = [e for e in entries if e.timestamp >= cutoff]
484
+ else:
485
+ resolved = entries
486
+ return project_rows(resolved)
487
+
488
+
489
+ def project_rows(entries: list[UsageEntry]) -> list[tuple[str, int, float | None]]:
490
+ aggregates: dict[str, list[float]] = {}
491
+ for entry in entries:
492
+ bucket = aggregates.setdefault(entry.project, [0.0, 0.0])
493
+ bucket[0] += entry.total_tokens
494
+ bucket[1] += calculate_cost(entry)
495
+
496
+ ranked = sorted(
497
+ aggregates.items(),
498
+ key=lambda item: (int(item[1][0]), item[0]),
499
+ reverse=True,
500
+ )
501
+ rows: list[tuple[str, int, float | None]] = []
502
+ for project, (tokens, cost) in ranked[:3]:
503
+ rows.append(
504
+ (
505
+ project,
506
+ int(tokens),
507
+ cost,
508
+ )
509
+ )
510
+ return rows
511
+
512
+
513
+ def project_rows_for_windows(
514
+ entries: list[UsageEntry],
515
+ *,
516
+ now: datetime | None = None,
517
+ ) -> tuple[
518
+ list[tuple[str, int, float | None]],
519
+ list[tuple[str, int, float | None]],
520
+ list[tuple[str, int, float | None]],
521
+ list[tuple[str, int, float | None]],
522
+ list[tuple[str, int, float | None]],
523
+ ]:
524
+ """Aggregate the five project windows in one pass over the history."""
525
+ current_time = datetime.now(UTC) if now is None else now
526
+ local_now = current_time.astimezone()
527
+ local_today = local_now.date()
528
+ local_tz = local_now.tzinfo
529
+ assert local_tz is not None
530
+ today_start = datetime.combine(local_today, datetime_time.min, tzinfo=local_tz).astimezone(
531
+ UTC
532
+ )
533
+ tomorrow_start = datetime.combine(
534
+ local_today + timedelta(days=1), datetime_time.min, tzinfo=local_tz
535
+ ).astimezone(UTC)
536
+ yesterday_start = datetime.combine(
537
+ local_today - timedelta(days=1), datetime_time.min, tzinfo=local_tz
538
+ ).astimezone(UTC)
539
+ cutoff_7d = current_time - timedelta(hours=168)
540
+ cutoff_30d = current_time - timedelta(hours=720)
541
+ aggregates_24h: dict[str, list[float]] = {}
542
+ aggregates_yesterday: dict[str, list[float]] = {}
543
+ aggregates_7d: dict[str, list[float]] = {}
544
+ aggregates_30d: dict[str, list[float]] = {}
545
+ aggregates_all: dict[str, list[float]] = {}
546
+
547
+ for entry in entries:
548
+ tokens = entry.total_tokens
549
+ cost = calculate_cost(entry)
550
+ _add_project_usage(aggregates_all, entry.project, tokens, cost)
551
+ if entry.timestamp >= cutoff_30d:
552
+ _add_project_usage(aggregates_30d, entry.project, tokens, cost)
553
+ if entry.timestamp >= cutoff_7d:
554
+ _add_project_usage(aggregates_7d, entry.project, tokens, cost)
555
+ if today_start <= entry.timestamp < tomorrow_start:
556
+ _add_project_usage(aggregates_24h, entry.project, tokens, cost)
557
+ elif yesterday_start <= entry.timestamp < today_start:
558
+ _add_project_usage(aggregates_yesterday, entry.project, tokens, cost)
559
+
560
+ return (
561
+ _rank_project_rows(aggregates_24h),
562
+ _rank_project_rows(aggregates_yesterday),
563
+ _rank_project_rows(aggregates_7d),
564
+ _rank_project_rows(aggregates_30d),
565
+ _rank_project_rows(aggregates_all),
566
+ )
567
+
568
+
569
+ def _add_project_usage(
570
+ aggregates: dict[str, list[float]], project: str, tokens: int, cost: float
571
+ ) -> None:
572
+ bucket = aggregates.setdefault(project, [0.0, 0.0])
573
+ bucket[0] += tokens
574
+ bucket[1] += cost
575
+
576
+
577
+ def _rank_project_rows(
578
+ aggregates: dict[str, list[float]],
579
+ ) -> list[tuple[str, int, float | None]]:
580
+ ranked = sorted(
581
+ aggregates.items(),
582
+ key=lambda item: (int(item[1][0]), item[0]),
583
+ reverse=True,
584
+ )
585
+ return [(project, int(tokens), cost) for project, (tokens, cost) in ranked[:3]]
586
+
587
+
588
+ def _group_name(group: int, language: str) -> str:
589
+ return _t(language, f"group_{GROUP_NAMES[group].lower()}")
590
+
591
+
592
+ def _status_message_value(outcome: PollOutcome, fallback_key: str, language: str) -> str:
593
+ if outcome.message == "awaiting_rate_limits":
594
+ return _t(language, "awaiting_rate_limits")
595
+ if outcome.message in {"hook_broken_not_installed", "hook_broken_restart"}:
596
+ return _t(language, outcome.message)
597
+ return outcome.message or _t(language, fallback_key)
598
+
599
+
600
+ def format_human_time(seconds: float, language: str = "en") -> str:
601
+ if seconds <= 0:
602
+ return _t(language, "duration_minutes", minutes=0)
603
+ days, remainder = divmod(int(seconds), 86400)
604
+ hours, remainder = divmod(remainder, 3600)
605
+ minutes, _ = divmod(remainder, 60)
606
+
607
+ if days > 0:
608
+ return _t(language, "duration_days", days=days, hours=hours)
609
+ if hours > 0:
610
+ return _t(language, "duration_hours", hours=hours, minutes=minutes)
611
+ return _t(language, "duration_minutes", minutes=minutes)
612
+
613
+
614
+ def codex_stale_state(updated_at: str, now: float, language: str) -> CodexStaleState | None:
615
+ if not updated_at:
616
+ return None
617
+ timestamp = parse_iso8601_utc_or_raise(updated_at)
618
+ age_seconds = now - timestamp.timestamp()
619
+ if age_seconds <= 900:
620
+ return None
621
+ if age_seconds < 3600:
622
+ minutes = max(1, int(age_seconds // 60))
623
+ return {"ageText": _t(language, "codex_stale_minutes", minutes=minutes)}
624
+ hours = max(1, int(age_seconds // 3600))
625
+ return {"ageText": _t(language, "codex_stale_hours", hours=hours)}
626
+
627
+
628
+ def history_load_error_state(
629
+ reason_key: str | None, language: str
630
+ ) -> HistoryLoadErrorState | None:
631
+ if reason_key is None:
632
+ return None
633
+ return {"reasonText": _t(language, reason_key)}
634
+
635
+
636
+ # Codex reports each quota slot's window length in minutes. Map it to a label so
637
+ # the row name follows the plan instead of being hard-coded: ~300m → Session,
638
+ # ~10080m → Weekly, ~43200m → Monthly (free plan). Thresholds are generous so
639
+ # minor drift in Codex's reported minutes still lands on the right label.
640
+ _CODEX_SESSION_MAX_MINUTES = 600.0 # ≤10h counts as the 5-hour session window
641
+ _CODEX_WEEKLY_MAX_MINUTES = 20160.0 # ≤14d counts as the weekly window
642
+
643
+
644
+ def _codex_window_label_key(window_minutes: float | None) -> str | None:
645
+ if window_minutes is None:
646
+ return None
647
+ if window_minutes <= _CODEX_SESSION_MAX_MINUTES:
648
+ return "session_label"
649
+ if window_minutes <= _CODEX_WEEKLY_MAX_MINUTES:
650
+ return "weekly_label"
651
+ return "monthly_label"
652
+
653
+
654
+ def _codex_window_title(
655
+ window_minutes: float | None,
656
+ slot_default_key: str,
657
+ language: str,
658
+ ) -> str:
659
+ # Fall back to the slot's historical label when Codex omits window_minutes,
660
+ # so older logs / header-only sources keep their previous behaviour.
661
+ key = _codex_window_label_key(window_minutes) or slot_default_key
662
+ return _t(language, key)
663
+
664
+
665
+ def codex_rows(
666
+ *,
667
+ mock: bool,
668
+ language: str,
669
+ burn_rate_trackers: dict[str, BurnRateTracker],
670
+ jsonl_candidates: tuple[tuple[Path, float], ...] | None = None,
671
+ ) -> tuple[
672
+ tuple[QuotaRowState, QuotaRowState],
673
+ float | None,
674
+ str,
675
+ CodexStaleState | None,
676
+ CodexCreditsState | None,
677
+ ]:
678
+ if mock:
679
+ now = time.time()
680
+ burn_rate_trackers["codex_session"].record(now, 12.0)
681
+ burn_rate_trackers["codex_weekly"].record(now, 28.0)
682
+ rows = (
683
+ _quota_row(
684
+ _t(language, "session_label"),
685
+ 12.0,
686
+ now + (4 * 3600) + (15 * 60),
687
+ now,
688
+ CODEX_COLOR,
689
+ language,
690
+ forecast_seconds=burn_rate_trackers["codex_session"].forecast_seconds(),
691
+ ),
692
+ _quota_row(
693
+ _t(language, "weekly_label"),
694
+ 28.0,
695
+ now + (4 * 86400),
696
+ now,
697
+ CODEX_COLOR,
698
+ language,
699
+ forecast_seconds=burn_rate_trackers["codex_weekly"].forecast_seconds(),
700
+ warning_max_seconds=24 * 3600,
701
+ ),
702
+ )
703
+ return rows, 12, "gpt-5", None, None
704
+
705
+ try:
706
+ rate_limits = (
707
+ codex_loader.load_rate_limits()
708
+ if jsonl_candidates is None
709
+ else codex_loader.load_rate_limits(jsonl_candidates=jsonl_candidates)
710
+ )
711
+ except Exception:
712
+ if os.environ.get("USAGE_DEBUG") == "1":
713
+ logger.warning("codex rate limits load failed", exc_info=True)
714
+ rate_limits = None
715
+
716
+ if rate_limits is None:
717
+ rows = (
718
+ _missing_row(_t(language, "session_label"), CODEX_COLOR, language),
719
+ _missing_row(_t(language, "weekly_label"), CODEX_COLOR, language),
720
+ )
721
+ return rows, None, "unknown", None, None
722
+ model = rate_limits.model or "unknown"
723
+
724
+ now = time.time()
725
+ try:
726
+ codex_stale = codex_stale_state(
727
+ rate_limits.updated_at,
728
+ now,
729
+ language,
730
+ )
731
+ except Exception:
732
+ codex_stale = None
733
+ codex_5h_pct = (
734
+ rate_limits.five_hour_pct
735
+ if rate_limits.five_hour_pct is not None
736
+ else rate_limits.seven_day_pct
737
+ )
738
+ if rate_limits.five_hour_pct is not None:
739
+ burn_rate_trackers["codex_session"].record(now, rate_limits.five_hour_pct)
740
+ if rate_limits.seven_day_pct is not None:
741
+ burn_rate_trackers["codex_weekly"].record(now, rate_limits.seven_day_pct)
742
+ session_absent = (
743
+ rate_limits.five_hour_pct is None and rate_limits.five_hour_window_minutes is None
744
+ )
745
+ session_title = (
746
+ ""
747
+ if session_absent
748
+ else _codex_window_title(
749
+ rate_limits.five_hour_window_minutes, "session_label", language
750
+ )
751
+ )
752
+ # A slot with neither usage nor a window is absent (the free plan has no
753
+ # weekly window) — leave its label blank rather than mislabel it "Weekly".
754
+ weekly_absent = (
755
+ rate_limits.seven_day_pct is None and rate_limits.seven_day_window_minutes is None
756
+ )
757
+ weekly_title = (
758
+ ""
759
+ if weekly_absent
760
+ else _codex_window_title(
761
+ rate_limits.seven_day_window_minutes, "weekly_label", language
762
+ )
763
+ )
764
+ rows = (
765
+ _quota_row(
766
+ session_title,
767
+ rate_limits.five_hour_pct,
768
+ rate_limits.five_hour_resets_at,
769
+ now,
770
+ CODEX_COLOR,
771
+ language,
772
+ forecast_seconds=burn_rate_trackers["codex_session"].forecast_seconds(),
773
+ ),
774
+ _quota_row(
775
+ weekly_title,
776
+ rate_limits.seven_day_pct,
777
+ rate_limits.seven_day_resets_at,
778
+ now,
779
+ CODEX_COLOR,
780
+ language,
781
+ forecast_seconds=burn_rate_trackers["codex_weekly"].forecast_seconds(
782
+ window_seconds=WEEKLY_FORECAST_WINDOW_SECONDS,
783
+ min_span_seconds=WEEKLY_FORECAST_MIN_SPAN_SECONDS,
784
+ ),
785
+ warning_max_seconds=24 * 3600,
786
+ ),
787
+ )
788
+ credits: CodexCreditsState | None = (
789
+ {
790
+ "balance": rate_limits.credit_balance,
791
+ "unlimited": rate_limits.credits_unlimited,
792
+ }
793
+ if rate_limits.has_credits
794
+ else None
795
+ )
796
+ return rows, codex_5h_pct, model, codex_stale, credits
797
+
798
+
799
+ def build_popover_state(
800
+ *,
801
+ outcome: PollOutcome,
802
+ codex_rows: tuple[QuotaRowState, QuotaRowState],
803
+ agy_rows: tuple[QuotaRowState, QuotaRowState],
804
+ agy_group_name: str,
805
+ projects: list[tuple[str, int, float | None]],
806
+ projects_yesterday: list[tuple[str, int, float | None]],
807
+ projects_7d: list[tuple[str, int, float | None]],
808
+ projects_30d: list[tuple[str, int, float | None]],
809
+ projects_all: list[tuple[str, int, float | None]],
810
+ language: str,
811
+ group: int,
812
+ burn_rate_trackers: dict[str, BurnRateTracker],
813
+ today_text: str,
814
+ yesterday_text: str,
815
+ statusline: dict[str, object],
816
+ show_install_button: bool,
817
+ hide_claude: bool,
818
+ hide_codex: bool,
819
+ hide_agy: bool,
820
+ codex_stale: CodexStaleState | None,
821
+ codex_credits: CodexCreditsState | None = None,
822
+ agy_stale: AgyStaleState | None,
823
+ card_order: tuple[str, ...] = ("claude", "codex", "agy"),
824
+ history_error: HistoryLoadErrorState | None = None,
825
+ service_statuses: tuple[ServiceStatus, ...] = (),
826
+ ) -> PopoverState:
827
+ now = time.time()
828
+ group_name = _group_name(group, language)
829
+ status_text = _t(
830
+ language,
831
+ "status_text",
832
+ value=_status_message_value(outcome, "status_loading", language),
833
+ )
834
+
835
+ if outcome.state == PollState.SUCCESS and outcome.snapshot is not None:
836
+ snapshot = outcome.snapshot
837
+ if snapshot.current_percent is not None:
838
+ burn_rate_trackers["claude_session"].record(
839
+ snapshot.polled_at,
840
+ float(snapshot.current_percent),
841
+ )
842
+ if snapshot.weekly_percent is not None:
843
+ burn_rate_trackers["claude_weekly"].record(
844
+ snapshot.polled_at,
845
+ float(snapshot.weekly_percent),
846
+ )
847
+ claude_session = _quota_row(
848
+ _t(language, "session_label"),
849
+ float(snapshot.current_percent) if snapshot.current_percent is not None else None,
850
+ snapshot.current_reset_at,
851
+ now,
852
+ CLAUDE_COLOR,
853
+ language,
854
+ forecast_seconds=burn_rate_trackers["claude_session"].forecast_seconds(),
855
+ )
856
+ claude_weekly = _quota_row(
857
+ _t(language, "weekly_label"),
858
+ float(snapshot.weekly_percent) if snapshot.weekly_percent is not None else None,
859
+ snapshot.weekly_reset_at,
860
+ now,
861
+ CLAUDE_COLOR,
862
+ language,
863
+ forecast_seconds=burn_rate_trackers["claude_weekly"].forecast_seconds(
864
+ window_seconds=WEEKLY_FORECAST_WINDOW_SECONDS,
865
+ min_span_seconds=WEEKLY_FORECAST_MIN_SPAN_SECONDS,
866
+ ),
867
+ warning_max_seconds=24 * 3600,
868
+ )
869
+ status_value = _status_message_value(outcome, "status_synced", language)
870
+ if snapshot.is_stale or snapshot.data_source != "hook":
871
+ status_value = _status_message_value(outcome, "data_stale_hint", language)
872
+ status_text = _t(
873
+ language,
874
+ "status_text",
875
+ value=status_value,
876
+ )
877
+ status_long = (
878
+ bool(outcome.message)
879
+ or snapshot.is_stale
880
+ or snapshot.data_source != "hook"
881
+ )
882
+ else:
883
+ claude_session = _missing_row(_t(language, "session_label"), CLAUDE_COLOR, language)
884
+ claude_weekly = _missing_row(_t(language, "weekly_label"), CLAUDE_COLOR, language)
885
+ if hide_claude:
886
+ status_value = _t(language, "status_synced")
887
+ else:
888
+ status_value = _status_message_value(outcome, "status_no_data", language)
889
+ status_text = _t(language, "status_text", value=status_value)
890
+ status_long = not hide_claude and bool(outcome.message)
891
+
892
+ service_alert_keys = {
893
+ "degraded_performance": "service_degraded",
894
+ "partial_outage": "service_partial_outage",
895
+ "major_outage": "service_major_outage",
896
+ }
897
+ hidden_services = {
898
+ "Claude": hide_claude,
899
+ "Codex": hide_codex,
900
+ }
901
+ service_alerts = tuple(
902
+ _t(
903
+ language,
904
+ "service_alert",
905
+ tool=service_status.service_name,
906
+ status=_t(language, service_alert_key),
907
+ )
908
+ for service_status in service_statuses
909
+ if service_status.is_abnormal
910
+ and not hidden_services.get(service_status.service_name, False)
911
+ and (service_alert_key := service_alert_keys.get(service_status.status)) is not None
912
+ )
913
+
914
+ return PopoverState(
915
+ language=language,
916
+ claude_session=claude_session,
917
+ claude_weekly=claude_weekly,
918
+ codex_session=codex_rows[0],
919
+ codex_weekly=codex_rows[1],
920
+ agy_session=agy_rows[0],
921
+ agy_weekly=agy_rows[1],
922
+ agy_group_name=agy_group_name,
923
+ projects=projects,
924
+ projects_yesterday=projects_yesterday,
925
+ projects_7d=projects_7d,
926
+ projects_30d=projects_30d,
927
+ projects_all=projects_all,
928
+ rate_text=_t(language, "rate_text", value=group_name),
929
+ status_text=status_text,
930
+ today_text=today_text,
931
+ yesterday_text=yesterday_text,
932
+ statusline=statusline,
933
+ service_alerts=service_alerts,
934
+ show_install_button=show_install_button,
935
+ status_long=status_long,
936
+ hide_claude=hide_claude,
937
+ hide_codex=hide_codex,
938
+ hide_agy=hide_agy,
939
+ codex_stale=codex_stale,
940
+ codex_credits=codex_credits,
941
+ agy_stale=agy_stale,
942
+ card_order=card_order,
943
+ history_error=history_error,
944
+ )
945
+
946
+
947
+ def _quota_row(
948
+ title: str,
949
+ pct: float | None,
950
+ resets_at: float | None,
951
+ now: float,
952
+ color: tuple[float, float, float],
953
+ language: str = "en",
954
+ forecast_seconds: float | None = None,
955
+ warning_max_seconds: float | None = None,
956
+ ) -> QuotaRowState:
957
+ if pct is None or resets_at is None:
958
+ return _missing_row(title, color, language)
959
+ pct = max(0.0, min(100.0, float(pct)))
960
+ time_to_reset = resets_at - now
961
+ if time_to_reset < 60:
962
+ reset_text = _t(language, "reset_imminent")
963
+ warning = False
964
+ else:
965
+ warning_seconds: float | None = None
966
+ if (
967
+ forecast_seconds is not None
968
+ and 0 < forecast_seconds < time_to_reset
969
+ and (warning_max_seconds is None or forecast_seconds < warning_max_seconds)
970
+ and pct >= WARNING_PERCENT_FLOOR
971
+ ):
972
+ warning_seconds = forecast_seconds
973
+ warning = warning_seconds is not None
974
+ if warning_seconds is not None:
975
+ reset_text = _t(
976
+ language,
977
+ "burn_warning",
978
+ empty=format_human_time(warning_seconds, language),
979
+ reset=format_human_time(time_to_reset, language),
980
+ )
981
+ else:
982
+ reset_text = _t(
983
+ language,
984
+ "reset_in",
985
+ time=format_human_time(time_to_reset, language),
986
+ )
987
+ return QuotaRowState(
988
+ title=title,
989
+ percent=pct,
990
+ percent_text=_t(language, "percent_used", value=_format_percent(pct)),
991
+ reset_text=reset_text,
992
+ color=_bar_color(pct, color),
993
+ warning=warning,
994
+ available=True,
995
+ )
996
+
997
+
998
+ def _missing_row(
999
+ title: str,
1000
+ color: tuple[float, float, float],
1001
+ language: str = "en",
1002
+ ) -> QuotaRowState:
1003
+ return QuotaRowState(
1004
+ title=title,
1005
+ percent=None,
1006
+ percent_text="--",
1007
+ reset_text=_t(language, "reset_placeholder"),
1008
+ color=color,
1009
+ available=False,
1010
+ )
1011
+
1012
+
1013
+ def _format_percent(value: float) -> str:
1014
+ if value.is_integer():
1015
+ return str(int(value))
1016
+ return f"{value:.1f}"
1017
+
1018
+
1019
+ def popover_dimensions(
1020
+ state: PopoverState, panel: UsagePanel | None = None
1021
+ ) -> tuple[float, float]:
1022
+ # Imported here, not at module scope: panels pulls in PyObjC, and this
1023
+ # module stays importable without the ObjC runtime so the projections
1024
+ # above remain unit-testable.
1025
+ import panels
1026
+
1027
+ active_panel = panel if panel is not None else panels.get_panel("classic")
1028
+ width, base_height = active_panel.preferred_size()
1029
+ claude_deduct = active_panel.claude_card_height if state.hide_claude else 0.0
1030
+ codex_deduct = active_panel.codex_card_height if state.hide_codex else 0.0
1031
+ codex_missing_rows = sum(
1032
+ not row.title for row in (state.codex_session, state.codex_weekly)
1033
+ )
1034
+ codex_row_deduct = (
1035
+ getattr(active_panel, "codex_row_height", 0.0) * codex_missing_rows
1036
+ if not state.hide_codex and active_panel.codex_card_height > 0
1037
+ else 0.0
1038
+ )
1039
+ agy_card_height = getattr(active_panel, "agy_card_height", 0.0)
1040
+ agy_deduct = agy_card_height if state.hide_agy else 0.0
1041
+ install_extra = INSTALL_BUTTON_EXTRA_HEIGHT if state.show_install_button else 0.0
1042
+ status_extra = (
1043
+ getattr(active_panel, "status_wrap_extra_height", 0.0)
1044
+ if state.status_long
1045
+ else 0.0
1046
+ )
1047
+ codex_credits_extra = (
1048
+ 24.0
1049
+ if (
1050
+ active_panel.codex_card_height > 0
1051
+ and state.codex_credits is not None
1052
+ and not state.hide_codex
1053
+ )
1054
+ else 0.0
1055
+ )
1056
+ service_alert_height = getattr(active_panel, "service_alert_height", 0.0)
1057
+ alert_count = len(state.service_alerts) if service_alert_height else 0
1058
+ service_alert_extra = service_alert_height * alert_count + SERVICE_ALERT_GAP * max(
1059
+ alert_count - 1, 0
1060
+ )
1061
+ height = (
1062
+ base_height
1063
+ + install_extra
1064
+ + status_extra
1065
+ + codex_credits_extra
1066
+ + service_alert_extra
1067
+ - claude_deduct
1068
+ - codex_deduct
1069
+ - codex_row_deduct
1070
+ - agy_deduct
1071
+ )
1072
+ return width, height
1073
+
1074
+
1075
+ def _classify_history_load_error(exc: Exception) -> str:
1076
+ if isinstance(exc, OSError):
1077
+ return "history_load_error_file"
1078
+ if isinstance(exc, (ValueError, KeyError, TypeError)):
1079
+ return "history_load_error_parse"
1080
+ return "history_load_error_unknown"
1081
+
1082
+
1083
+ def _empty_state(language: str = "en") -> PopoverState:
1084
+ import menubar_agy
1085
+
1086
+ return PopoverState(
1087
+ language=language,
1088
+ claude_session=_missing_row(_t(language, "session_label"), CLAUDE_COLOR, language),
1089
+ claude_weekly=_missing_row(_t(language, "weekly_label"), CLAUDE_COLOR, language),
1090
+ codex_session=_missing_row(_t(language, "session_label"), CODEX_COLOR, language),
1091
+ codex_weekly=_missing_row(_t(language, "weekly_label"), CODEX_COLOR, language),
1092
+ agy_session=_missing_row(_t(language, "session_label"), AGY_COLOR, language),
1093
+ agy_weekly=_missing_row(_t(language, "weekly_label"), AGY_COLOR, language),
1094
+ agy_group_name="",
1095
+ projects=[],
1096
+ projects_yesterday=[],
1097
+ projects_7d=[],
1098
+ projects_30d=[],
1099
+ projects_all=[],
1100
+ rate_text=_t(language, "rate_text", value="--"),
1101
+ status_text=_t(language, "status_text", value=_t(language, "status_loading")),
1102
+ today_text=_t(language, "today_text", cost="0.00", tokens="0"),
1103
+ yesterday_text=_t(language, "yesterday_text", cost="0.00", tokens="0"),
1104
+ statusline=_statusline_payload(language),
1105
+ service_alerts=(),
1106
+ show_install_button=False,
1107
+ hide_claude=_hide_claude_enabled(),
1108
+ hide_codex=_hide_codex_enabled(),
1109
+ # Keep the card mounted while the first background probe is running.
1110
+ # Otherwise startup shows the shorter layout until agy finishes, which
1111
+ # looks like the integration never loaded even though the child process
1112
+ # is already active.
1113
+ hide_agy=menubar_agy.find_agy() is None,
1114
+ card_order=_quota_card_order(),
1115
+ )
1116
+
1117
+
1118
+ def _error_state(message: str, mock: bool, language: str = "en") -> PopoverState:
1119
+ state = _empty_state(language)
1120
+ state.status_text = _t(
1121
+ language,
1122
+ "status_text",
1123
+ value=_t(language, "status_error", message=message),
1124
+ )
1125
+ state.today_text = _today_title(mock, language)
1126
+ state.yesterday_text = _yesterday_title(mock, language)
1127
+ state.show_install_button = False
1128
+ return state
1129
+
1130
+
1131
+ def _statusline_payload(language: str) -> dict[str, object]:
1132
+ return {
1133
+ "enabled": _statusline_enabled(),
1134
+ "enabledText": _t(language, "cli_enabled"),
1135
+ "disabledText": _t(language, "cli_disabled"),
1136
+ }
1137
+
1138
+
1139
+ def _today_title(
1140
+ mock: bool = False,
1141
+ language: str = "en",
1142
+ entries: list[UsageEntry] | None = None,
1143
+ ) -> str:
1144
+ if mock:
1145
+ return _t(language, "today_text", cost="45.20", tokens="50,193,442")
1146
+
1147
+ try:
1148
+ today = datetime.now().astimezone().date()
1149
+ total_tokens = 0
1150
+ total_cost = 0.0
1151
+
1152
+ all_entries = (
1153
+ entries
1154
+ if entries is not None
1155
+ else list(load_entries(hours_back=24)) + codex_loader.load_entries(hours_back=24)
1156
+ )
1157
+ for entry in all_entries:
1158
+ if entry.timestamp.astimezone().date() != today:
1159
+ continue
1160
+ total_tokens += entry.total_tokens
1161
+ total_cost += calculate_cost(entry)
1162
+ except Exception:
1163
+ if os.environ.get("USAGE_DEBUG") == "1":
1164
+ logger.warning("today totals load failed", exc_info=True)
1165
+ return _t(language, "today_text", cost="0.00", tokens="0")
1166
+
1167
+ return _t(language, "today_text", cost=f"{total_cost:.2f}", tokens=f"{total_tokens:,}")
1168
+
1169
+
1170
+ def _yesterday_title(
1171
+ mock: bool = False,
1172
+ language: str = "en",
1173
+ entries: list[UsageEntry] | None = None,
1174
+ ) -> str:
1175
+ if mock:
1176
+ return _t(language, "yesterday_text", cost="41.10", tokens="48,200,000")
1177
+
1178
+ try:
1179
+ yesterday = datetime.now().astimezone().date() - timedelta(days=1)
1180
+ all_entries = (
1181
+ entries
1182
+ if entries is not None
1183
+ else list(load_entries(hours_back=48)) + codex_loader.load_entries(hours_back=48)
1184
+ )
1185
+ selected = [
1186
+ entry for entry in all_entries if entry.timestamp.astimezone().date() == yesterday
1187
+ ]
1188
+ total_tokens = sum(entry.total_tokens for entry in selected)
1189
+ total_cost = sum(calculate_cost(entry) for entry in selected)
1190
+ except Exception:
1191
+ if os.environ.get("USAGE_DEBUG") == "1":
1192
+ logger.warning("yesterday totals load failed", exc_info=True)
1193
+ return _t(language, "yesterday_text", cost="0.00", tokens="0")
1194
+
1195
+ return _t(
1196
+ language,
1197
+ "yesterday_text",
1198
+ cost=f"{total_cost:.2f}",
1199
+ tokens=f"{total_tokens:,}",
1200
+ )