Skip to content

Commit ae7a313

Browse files
aqua5230claude
andcommitted
refactor(menubar): extract popover state into menubar_state.py
Move PopoverState, _group_name and _status_message_value out of menubar.py into a new menubar_state.py, and add build_popover_state(...) that takes statusline / show_install_button / hide_codex / today_text as parameters. menubar.py drops ~387 lines of state-shaping logic. Add golden-fixture test (tests/test_codex_consistency.py) locking token totals parity between codex_loader and adapters.codex, plus unit tests for the new state builder. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 30ab89b commit ae7a313

8 files changed

Lines changed: 592 additions & 387 deletions

File tree

menubar.py

Lines changed: 81 additions & 344 deletions
Large diffs are not rendered by default.

menubar_state.py

Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
import os
5+
import time
6+
from dataclasses import dataclass
7+
from datetime import UTC, datetime
8+
from typing import TypedDict
9+
10+
import codex_loader
11+
from burn_rate import WARNING_PERCENT_FLOOR, BurnRateTracker
12+
from i18n import _t
13+
from usage_client import PollOutcome, PollState
14+
from usage_rate import GROUP_NAMES
15+
16+
logger = logging.getLogger(__name__)
17+
18+
CLAUDE_COLOR = (244 / 255, 145 / 255, 100 / 255)
19+
CODEX_COLOR = (88 / 255, 214 / 255, 230 / 255)
20+
WARN_COLOR = (255 / 255, 196 / 255, 57 / 255)
21+
DANGER_COLOR = (255 / 255, 69 / 255, 58 / 255)
22+
WEEKLY_FORECAST_WINDOW_SECONDS = 30 * 60
23+
WEEKLY_FORECAST_MIN_SPAN_SECONDS = 30 * 60
24+
25+
26+
def _bar_color(pct: float, brand: tuple[float, float, float]) -> tuple[float, float, float]:
27+
if pct >= 80:
28+
return DANGER_COLOR
29+
if pct >= 50:
30+
return WARN_COLOR
31+
return brand
32+
33+
34+
@dataclass(slots=True)
35+
class QuotaRowState:
36+
title: str
37+
percent: float | None
38+
percent_text: str
39+
reset_text: str
40+
color: tuple[float, float, float]
41+
warning: bool = False
42+
available: bool = True
43+
44+
45+
class CodexStaleState(TypedDict):
46+
ageText: str
47+
48+
49+
@dataclass(slots=True)
50+
class PopoverState:
51+
language: str
52+
claude_session: QuotaRowState
53+
claude_weekly: QuotaRowState
54+
codex_session: QuotaRowState
55+
codex_weekly: QuotaRowState
56+
projects: list[tuple[str, int, float | None]]
57+
projects_7d: list[tuple[str, int, float | None]]
58+
projects_30d: list[tuple[str, int, float | None]]
59+
projects_all: list[tuple[str, int, float | None]]
60+
rate_text: str
61+
status_text: str
62+
today_text: str
63+
statusline: dict[str, object]
64+
show_install_button: bool = False
65+
hide_codex: bool = False
66+
codex_stale: CodexStaleState | None = None
67+
68+
69+
def _group_name(group: int, language: str) -> str:
70+
return _t(language, f"group_{GROUP_NAMES[group].lower()}")
71+
72+
73+
def _status_message_value(outcome: PollOutcome, fallback_key: str, language: str) -> str:
74+
if outcome.message == "awaiting_rate_limits":
75+
return _t(language, "awaiting_rate_limits")
76+
return outcome.message or _t(language, fallback_key)
77+
78+
79+
def format_human_time(seconds: float, language: str = "en") -> str:
80+
if seconds <= 0:
81+
return _t(language, "duration_minutes", minutes=0)
82+
days, remainder = divmod(int(seconds), 86400)
83+
hours, remainder = divmod(remainder, 3600)
84+
minutes, _ = divmod(remainder, 60)
85+
86+
if days > 0:
87+
return _t(language, "duration_days", days=days, hours=hours)
88+
if hours > 0:
89+
return _t(language, "duration_hours", hours=hours, minutes=minutes)
90+
return _t(language, "duration_minutes", minutes=minutes)
91+
92+
93+
def codex_stale_state(updated_at: str, now: float, language: str) -> CodexStaleState | None:
94+
if not updated_at:
95+
return None
96+
timestamp = datetime.fromisoformat(updated_at.replace("Z", "+00:00"))
97+
if timestamp.tzinfo is None:
98+
timestamp = timestamp.replace(tzinfo=UTC)
99+
else:
100+
timestamp = timestamp.astimezone(UTC)
101+
age_seconds = now - timestamp.timestamp()
102+
if age_seconds <= 900:
103+
return None
104+
if age_seconds < 3600:
105+
minutes = max(1, int(age_seconds // 60))
106+
return {"ageText": _t(language, "codex_stale_minutes", minutes=minutes)}
107+
hours = max(1, int(age_seconds // 3600))
108+
return {"ageText": _t(language, "codex_stale_hours", hours=hours)}
109+
110+
111+
def codex_rows(
112+
*,
113+
mock: bool,
114+
language: str,
115+
burn_rate_trackers: dict[str, BurnRateTracker],
116+
) -> tuple[tuple[QuotaRowState, QuotaRowState], int | None, str, CodexStaleState | None]:
117+
if mock:
118+
now = time.time()
119+
burn_rate_trackers["codex_session"].record(now, 12.0)
120+
burn_rate_trackers["codex_weekly"].record(now, 28.0)
121+
rows = (
122+
_quota_row(
123+
"Session",
124+
12.0,
125+
now + (4 * 3600) + (15 * 60),
126+
now,
127+
CODEX_COLOR,
128+
language,
129+
forecast_seconds=burn_rate_trackers["codex_session"].forecast_seconds(),
130+
),
131+
_quota_row(
132+
"Weekly",
133+
28.0,
134+
now + (4 * 86400),
135+
now,
136+
CODEX_COLOR,
137+
language,
138+
forecast_seconds=burn_rate_trackers["codex_weekly"].forecast_seconds(),
139+
warning_max_seconds=24 * 3600,
140+
),
141+
)
142+
return rows, 12, "gpt-5", None
143+
144+
try:
145+
rate_limits = codex_loader.load_rate_limits()
146+
except Exception:
147+
if os.environ.get("USAGE_DEBUG") == "1":
148+
logger.warning("codex rate limits load failed", exc_info=True)
149+
rate_limits = None
150+
151+
if rate_limits is None:
152+
rows = (
153+
_missing_row("Session", CODEX_COLOR, language),
154+
_missing_row("Weekly", CODEX_COLOR, language),
155+
)
156+
return rows, None, "unknown", None
157+
model = rate_limits.model or "unknown"
158+
159+
now = time.time()
160+
try:
161+
codex_stale = codex_stale_state(
162+
rate_limits.updated_at,
163+
now,
164+
language,
165+
)
166+
except Exception:
167+
codex_stale = None
168+
codex_5h_pct = (
169+
round(rate_limits.five_hour_pct) if rate_limits.five_hour_pct is not None else None
170+
)
171+
if rate_limits.five_hour_pct is not None:
172+
burn_rate_trackers["codex_session"].record(now, rate_limits.five_hour_pct)
173+
if rate_limits.seven_day_pct is not None:
174+
burn_rate_trackers["codex_weekly"].record(now, rate_limits.seven_day_pct)
175+
rows = (
176+
_quota_row(
177+
"Session",
178+
rate_limits.five_hour_pct,
179+
rate_limits.five_hour_resets_at,
180+
now,
181+
CODEX_COLOR,
182+
language,
183+
forecast_seconds=burn_rate_trackers["codex_session"].forecast_seconds(),
184+
),
185+
_quota_row(
186+
"Weekly",
187+
rate_limits.seven_day_pct,
188+
rate_limits.seven_day_resets_at,
189+
now,
190+
CODEX_COLOR,
191+
language,
192+
forecast_seconds=burn_rate_trackers["codex_weekly"].forecast_seconds(
193+
window_seconds=WEEKLY_FORECAST_WINDOW_SECONDS,
194+
min_span_seconds=WEEKLY_FORECAST_MIN_SPAN_SECONDS,
195+
),
196+
warning_max_seconds=24 * 3600,
197+
),
198+
)
199+
return rows, codex_5h_pct, model, codex_stale
200+
201+
202+
def build_popover_state(
203+
*,
204+
outcome: PollOutcome,
205+
codex_rows: tuple[QuotaRowState, QuotaRowState],
206+
projects: list[tuple[str, int, float | None]],
207+
projects_7d: list[tuple[str, int, float | None]],
208+
projects_30d: list[tuple[str, int, float | None]],
209+
projects_all: list[tuple[str, int, float | None]],
210+
language: str,
211+
group: int,
212+
burn_rate_trackers: dict[str, BurnRateTracker],
213+
today_text: str,
214+
statusline: dict[str, object],
215+
show_install_button: bool,
216+
hide_codex: bool,
217+
codex_stale: CodexStaleState | None,
218+
) -> PopoverState:
219+
now = time.time()
220+
group_name = _group_name(group, language)
221+
status_text = _t(
222+
language,
223+
"status_text",
224+
value=_status_message_value(outcome, "status_loading", language),
225+
)
226+
227+
if outcome.state == PollState.SUCCESS and outcome.snapshot is not None:
228+
snapshot = outcome.snapshot
229+
if snapshot.current_percent is not None:
230+
burn_rate_trackers["claude_session"].record(
231+
snapshot.polled_at,
232+
float(snapshot.current_percent),
233+
)
234+
if snapshot.weekly_percent is not None:
235+
burn_rate_trackers["claude_weekly"].record(
236+
snapshot.polled_at,
237+
float(snapshot.weekly_percent),
238+
)
239+
claude_session = _quota_row(
240+
"Session",
241+
float(snapshot.current_percent) if snapshot.current_percent is not None else None,
242+
snapshot.current_reset_at,
243+
now,
244+
CLAUDE_COLOR,
245+
language,
246+
forecast_seconds=burn_rate_trackers["claude_session"].forecast_seconds(),
247+
)
248+
claude_weekly = _quota_row(
249+
"Weekly",
250+
float(snapshot.weekly_percent) if snapshot.weekly_percent is not None else None,
251+
snapshot.weekly_reset_at,
252+
now,
253+
CLAUDE_COLOR,
254+
language,
255+
forecast_seconds=burn_rate_trackers["claude_weekly"].forecast_seconds(
256+
window_seconds=WEEKLY_FORECAST_WINDOW_SECONDS,
257+
min_span_seconds=WEEKLY_FORECAST_MIN_SPAN_SECONDS,
258+
),
259+
warning_max_seconds=24 * 3600,
260+
)
261+
status_value = outcome.message or _t(language, "status_synced")
262+
if snapshot.is_stale or snapshot.data_source != "hook":
263+
status_value = _t(language, "data_stale_hint")
264+
status_text = _t(
265+
language,
266+
"status_text",
267+
value=status_value,
268+
)
269+
else:
270+
claude_session = _missing_row("Session", CLAUDE_COLOR, language)
271+
claude_weekly = _missing_row("Weekly", CLAUDE_COLOR, language)
272+
status_text = _t(
273+
language,
274+
"status_text",
275+
value=_status_message_value(outcome, "status_no_data", language),
276+
)
277+
278+
return PopoverState(
279+
language=language,
280+
claude_session=claude_session,
281+
claude_weekly=claude_weekly,
282+
codex_session=codex_rows[0],
283+
codex_weekly=codex_rows[1],
284+
projects=projects,
285+
projects_7d=projects_7d,
286+
projects_30d=projects_30d,
287+
projects_all=projects_all,
288+
rate_text=_t(language, "rate_text", value=group_name),
289+
status_text=status_text,
290+
today_text=today_text,
291+
statusline=statusline,
292+
show_install_button=show_install_button,
293+
hide_codex=hide_codex,
294+
codex_stale=codex_stale,
295+
)
296+
297+
298+
def _quota_row(
299+
title: str,
300+
pct: float | None,
301+
resets_at: float | None,
302+
now: float,
303+
color: tuple[float, float, float],
304+
language: str = "en",
305+
forecast_seconds: float | None = None,
306+
warning_max_seconds: float | None = None,
307+
) -> QuotaRowState:
308+
if pct is None or resets_at is None:
309+
return _missing_row(title, color, language)
310+
pct = max(0.0, min(100.0, float(pct)))
311+
time_to_reset = resets_at - now
312+
warning_seconds: float | None = None
313+
if (
314+
forecast_seconds is not None
315+
and 0 < forecast_seconds < time_to_reset
316+
and (warning_max_seconds is None or forecast_seconds < warning_max_seconds)
317+
and pct >= WARNING_PERCENT_FLOOR
318+
):
319+
warning_seconds = forecast_seconds
320+
warning = warning_seconds is not None
321+
if warning_seconds is not None:
322+
reset_text = _t(
323+
language,
324+
"burn_warning",
325+
empty=format_human_time(warning_seconds, language),
326+
reset=format_human_time(time_to_reset, language),
327+
)
328+
else:
329+
reset_text = _t(language, "reset_in", time=format_human_time(time_to_reset, language))
330+
return QuotaRowState(
331+
title=title,
332+
percent=pct,
333+
percent_text=_t(language, "percent_used", value=_format_percent(pct)),
334+
reset_text=reset_text,
335+
color=_bar_color(pct, color),
336+
warning=warning,
337+
available=True,
338+
)
339+
340+
341+
def _missing_row(
342+
title: str,
343+
color: tuple[float, float, float],
344+
language: str = "en",
345+
) -> QuotaRowState:
346+
return QuotaRowState(
347+
title=title,
348+
percent=None,
349+
percent_text="--",
350+
reset_text=_t(language, "reset_placeholder"),
351+
color=color,
352+
available=False,
353+
)
354+
355+
356+
def _format_percent(value: float) -> str:
357+
if value.is_integer():
358+
return str(int(value))
359+
return f"{value:.1f}"

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ build-backend = "setuptools.build_meta"
2121
py-modules = [
2222
"main",
2323
"menubar",
24+
"menubar_state",
2425
"tui",
2526
"pricing",
2627
"history_loader",
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{"type":"session_meta","payload":{"id":"golden-codex-session","timestamp":"2026-06-01T04:00:00Z","cwd":"/Users/example/Developer/usage"}}
2+
{"type":"event_msg","timestamp":"2026-06-01T04:01:00Z","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":120,"cached_input_tokens":20,"output_tokens":30,"reasoning_output_tokens":5}}}}
3+
{"type":"event_msg","timestamp":"2026-06-01T04:03:00Z","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":200,"cached_input_tokens":50,"output_tokens":75,"reasoning_output_tokens":15}}}}
4+
{"type":"event_msg","timestamp":"2026-06-01T04:06:00Z","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":320,"cached_input_tokens":80,"output_tokens":120,"reasoning_output_tokens":20}}}}
5+
{"type":"event_msg","timestamp":"2026-06-01T04:10:00Z","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":450,"cached_input_tokens":100,"output_tokens":160,"reasoning_output_tokens":30}}}}

0 commit comments

Comments
 (0)