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_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
|
+
)
|