Skip to content

Commit 320254d

Browse files
aqua5230claude
andcommitted
feat(panel): show staleness hint on Codex card when local data is old
Codex has no live status-line hook (unlike Claude Code), so its rate-limit numbers come from session logs it only writes intermittently and can lag the live account. When the local snapshot is older than 15 minutes, the classic panel's Codex card now shows an "about N minutes ago" tag plus an info tooltip explaining why (and that staying offline avoids burning tokens). No network/API. - menubar.py: compute codex staleness from rate_limits.updated_at into PopoverState - panels/web_panel.py: pass codex.stale into the web payload - assets/panels/classic.html: hidden tag + hover tooltip, dynamic text via textContent - i18n.json: codex_stale_minutes/hours/tooltip across all 5 languages Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b757c4f commit 320254d

5 files changed

Lines changed: 179 additions & 6 deletions

File tree

assets/panels/classic.html

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,11 @@
9595
padding: 13px 15px 12px;
9696
}
9797

98+
.card[data-card="codex"] {
99+
overflow: visible;
100+
z-index: 2;
101+
}
102+
98103
.card::before {
99104
content: "";
100105
position: absolute;
@@ -148,6 +153,58 @@
148153
font-weight: 780;
149154
}
150155

156+
.codex-stale {
157+
display: inline-flex;
158+
align-items: center;
159+
gap: 5px;
160+
flex: none;
161+
min-width: 0;
162+
color: var(--warn);
163+
font-size: 11px;
164+
line-height: 1;
165+
font-weight: 680;
166+
white-space: nowrap;
167+
}
168+
169+
.codex-stale[hidden] {
170+
display: none;
171+
}
172+
173+
.codex-stale-info {
174+
position: relative;
175+
display: inline-flex;
176+
align-items: center;
177+
justify-content: center;
178+
width: 14px;
179+
height: 14px;
180+
color: var(--muted);
181+
font-size: 12px;
182+
line-height: 1;
183+
}
184+
185+
.codex-stale-tooltip {
186+
position: absolute;
187+
z-index: 20;
188+
top: calc(100% + 8px);
189+
right: 0;
190+
display: none;
191+
width: max-content;
192+
max-width: 260px;
193+
padding: 8px 10px;
194+
border-radius: 7px;
195+
background: rgba(10, 14, 20, 0.96);
196+
color: rgba(255, 255, 255, 0.94);
197+
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28);
198+
font-size: 11px;
199+
line-height: 1.45;
200+
font-weight: 500;
201+
white-space: normal;
202+
}
203+
204+
.codex-stale-info:hover .codex-stale-tooltip {
205+
display: block;
206+
}
207+
151208
.card[data-card="projects"] .brand {
152209
display: grid;
153210
grid-template-columns: 30px minmax(0, 1fr);
@@ -478,6 +535,13 @@ <h1 data-i18n="claude_name">Claude Code</h1>
478535
<header class="brand">
479536
<img src="{{CODEX_ICON}}" alt="">
480537
<h1 data-i18n="codex_name">Codex</h1>
538+
<div class="codex-stale" data-codex-stale hidden>
539+
<span aria-hidden="true"></span>
540+
<span data-codex-stale-age></span>
541+
<span class="codex-stale-info" aria-hidden="true">
542+
<span class="codex-stale-tooltip" data-codex-stale-tooltip></span>
543+
</span>
544+
</div>
481545
</header>
482546
<div class="row" data-row="session"></div>
483547
<div class="row" data-row="weekly"></div>
@@ -614,6 +678,22 @@ <h1 data-i18n="projects_title">Project Usage</h1>
614678
renderRow(name, "weekly", rows && rows.weekly);
615679
}
616680

681+
function renderCodexStale(stale) {
682+
const staleEl = document.querySelector("[data-codex-stale]");
683+
const ageEl = document.querySelector("[data-codex-stale-age]");
684+
const tooltipEl = document.querySelector("[data-codex-stale-tooltip]");
685+
if (!staleEl || !ageEl || !tooltipEl) return;
686+
if (stale && stale.ageText) {
687+
ageEl.textContent = stale.ageText;
688+
tooltipEl.textContent = t("codex_stale_tooltip");
689+
staleEl.hidden = false;
690+
return;
691+
}
692+
ageEl.textContent = "";
693+
tooltipEl.textContent = "";
694+
staleEl.hidden = true;
695+
}
696+
617697
function renderProjects(projects) {
618698
const list = document.querySelector('[data-project-list]');
619699
if (!list) return;
@@ -676,6 +756,7 @@ <h1 data-i18n="projects_title">Project Usage</h1>
676756
applyStaticText();
677757
applyCard("claude", state.claude);
678758
applyCard("codex", state.codex);
759+
renderCodexStale(state.codex && state.codex.stale);
679760
latestState = state;
680761
renderProjects(
681762
projectRange === "1d" ? state.projects

i18n.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@
5757
"group_heavy": "高負載",
5858
"claude_name": "Claude Code",
5959
"codex_name": "Codex",
60+
"codex_stale_minutes": "約 {minutes} 分鐘前",
61+
"codex_stale_hours": "約 {hours} 小時前",
62+
"codex_stale_tooltip": "Claude 有即時回報所以很準;Codex 沒有,usage 只能讀它本機偶爾留下的紀錄,可能比實際略舊。我們刻意不連網去補(連網會多燒你的 token)——繼續在終端機跑 Codex 就會自動更新。",
6063
"projects_title": "專案用量",
6164
"project_range_1d": "今日",
6265
"project_range_7d": "7 日",
@@ -336,6 +339,9 @@
336339
"group_heavy": "Heavy",
337340
"claude_name": "Claude Code",
338341
"codex_name": "Codex",
342+
"codex_stale_minutes": "about {minutes} minutes ago",
343+
"codex_stale_hours": "about {hours} hours ago",
344+
"codex_stale_tooltip": "Claude is accurate because it reports in real time; Codex does not, so usage can only read the local records it leaves occasionally, which may be slightly older than reality. We intentionally do not go online to fill the gap (that would burn more of your tokens) — keep running Codex in the terminal and it will update automatically.",
339345
"projects_title": "Project Usage",
340346
"project_range_1d": "Today",
341347
"project_range_7d": "7 Days",
@@ -615,6 +621,9 @@
615621
"group_heavy": "高负载",
616622
"claude_name": "Claude Code",
617623
"codex_name": "Codex",
624+
"codex_stale_minutes": "约 {minutes} 分钟前",
625+
"codex_stale_hours": "约 {hours} 小时前",
626+
"codex_stale_tooltip": "Claude 有实时回报所以很准;Codex 没有,usage 只能读取它本机偶尔留下的记录,可能比实际略旧。我们刻意不联网去补(联网会多消耗你的 token)——继续在终端机里运行 Codex 就会自动更新。",
618627
"projects_title": "项目用量",
619628
"project_range_1d": "今日",
620629
"project_range_7d": "7 日",
@@ -894,6 +903,9 @@
894903
"group_heavy": "高負荷",
895904
"claude_name": "Claude Code",
896905
"codex_name": "Codex",
906+
"codex_stale_minutes": "約 {minutes} 分前",
907+
"codex_stale_hours": "約 {hours} 時間前",
908+
"codex_stale_tooltip": "Claude はリアルタイムに報告するので正確です。Codex にはそれがないため、usage は Codex がローカルに時々残す記録だけを読み取り、実際より少し古い場合があります。補うために意図的にネット接続はしません(接続するとあなたの token を余分に消費します)——ターミナルで Codex を使い続けると自動的に更新されます。",
897909
"projects_title": "プロジェクト使用量",
898910
"project_range_1d": "今日",
899911
"project_range_7d": "7日間",
@@ -1173,6 +1185,9 @@
11731185
"group_heavy": "고부하",
11741186
"claude_name": "Claude Code",
11751187
"codex_name": "Codex",
1188+
"codex_stale_minutes": "약 {minutes}분 전",
1189+
"codex_stale_hours": "약 {hours}시간 전",
1190+
"codex_stale_tooltip": "Claude는 실시간으로 보고하므로 정확합니다. Codex는 그렇지 않아 usage가 Codex가 로컬에 가끔 남기는 기록만 읽을 수 있고, 실제보다 조금 오래된 값일 수 있습니다. 우리는 이를 보완하려고 일부러 온라인 연결을 하지 않습니다(연결하면 당신의 token을 더 쓰게 됩니다)——터미널에서 Codex를 계속 실행하면 자동으로 업데이트됩니다.",
11761191
"projects_title": "프로젝트 사용량",
11771192
"project_range_1d": "오늘",
11781193
"project_range_7d": "7일",

menubar.py

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from datetime import UTC, datetime, timedelta
2020
from importlib import metadata
2121
from pathlib import Path
22-
from typing import Any
22+
from typing import Any, TypedDict
2323

2424
import objc
2525
from AppKit import (
@@ -298,6 +298,10 @@ class QuotaRowState:
298298
available: bool = True
299299

300300

301+
class CodexStaleState(TypedDict):
302+
ageText: str
303+
304+
301305
@dataclass(slots=True)
302306
class PopoverState:
303307
language: str
@@ -315,6 +319,7 @@ class PopoverState:
315319
statusline: dict[str, object]
316320
show_install_button: bool = False
317321
hide_codex: bool = False
322+
codex_stale: CodexStaleState | None = None
318323

319324

320325
def format_human_time(seconds: float, language: str = "en") -> str:
@@ -783,7 +788,7 @@ def _refresh(self, queue_if_busy: bool = False) -> None:
783788
def _refresh_in_background(self) -> None:
784789
try:
785790
outcome = asyncio.run(self._fetch())
786-
codex_rows, codex_5h_pct, codex_model = self._codex_rows()
791+
codex_rows, codex_5h_pct, codex_model, codex_stale = self._codex_rows()
787792
all_entries = self._load_history_entries()
788793
project_rows = self._project_rows(hours_back=24, entries=all_entries)
789794
project_rows_7d = self._project_rows(hours_back=168, entries=all_entries)
@@ -798,6 +803,7 @@ def _refresh_in_background(self) -> None:
798803
project_rows_all,
799804
history_entries=all_entries,
800805
codex_model=codex_model,
806+
codex_stale=codex_stale,
801807
)
802808
except Exception as exc:
803809
if os.environ.get("USAGE_DEBUG") == "1":
@@ -975,6 +981,7 @@ def _state_from_outcome(
975981
project_rows_all: list[tuple[str, int, float | None]],
976982
history_entries: list[UsageEntry] | None = None,
977983
codex_model: str = "unknown",
984+
codex_stale: CodexStaleState | None = None,
978985
) -> PopoverState:
979986
now = time.time()
980987
today_text = _today_title(self.mock, self.language, entries=history_entries)
@@ -1054,9 +1061,12 @@ def _state_from_outcome(
10541061
outcome.state == PollState.TOKEN_ERROR and self._statusline_setup_available()
10551062
),
10561063
hide_codex=_hide_codex_enabled(),
1064+
codex_stale=codex_stale,
10571065
)
10581066

1059-
def _codex_rows(self) -> tuple[tuple[QuotaRowState, QuotaRowState], int | None, str]:
1067+
def _codex_rows(
1068+
self,
1069+
) -> tuple[tuple[QuotaRowState, QuotaRowState], int | None, str, CodexStaleState | None]:
10601070
if self.mock:
10611071
now = time.time()
10621072
self.burn_rate_trackers["codex_session"].record(now, 12.0)
@@ -1082,7 +1092,7 @@ def _codex_rows(self) -> tuple[tuple[QuotaRowState, QuotaRowState], int | None,
10821092
warning_max_seconds=24 * 3600,
10831093
),
10841094
)
1085-
return rows, 12, "gpt-5"
1095+
return rows, 12, "gpt-5", None
10861096

10871097
try:
10881098
rate_limits = codex_loader.load_rate_limits()
@@ -1096,10 +1106,14 @@ def _codex_rows(self) -> tuple[tuple[QuotaRowState, QuotaRowState], int | None,
10961106
_missing_row("Session", CODEX_COLOR, self.language),
10971107
_missing_row("Weekly", CODEX_COLOR, self.language),
10981108
)
1099-
return rows, None, "unknown"
1109+
return rows, None, "unknown", None
11001110
model = rate_limits.model or "unknown"
11011111

11021112
now = time.time()
1113+
try:
1114+
codex_stale = self._codex_stale_state(rate_limits.updated_at, now)
1115+
except Exception:
1116+
codex_stale = None
11031117
codex_5h_pct = (
11041118
round(rate_limits.five_hour_pct) if rate_limits.five_hour_pct is not None else None
11051119
)
@@ -1131,7 +1145,24 @@ def _codex_rows(self) -> tuple[tuple[QuotaRowState, QuotaRowState], int | None,
11311145
warning_max_seconds=24 * 3600,
11321146
),
11331147
)
1134-
return rows, codex_5h_pct, model
1148+
return rows, codex_5h_pct, model, codex_stale
1149+
1150+
def _codex_stale_state(self, updated_at: str, now: float) -> CodexStaleState | None:
1151+
if not updated_at:
1152+
return None
1153+
timestamp = datetime.fromisoformat(updated_at.replace("Z", "+00:00"))
1154+
if timestamp.tzinfo is None:
1155+
timestamp = timestamp.replace(tzinfo=UTC)
1156+
else:
1157+
timestamp = timestamp.astimezone(UTC)
1158+
age_seconds = now - timestamp.timestamp()
1159+
if age_seconds <= 900:
1160+
return None
1161+
if age_seconds < 3600:
1162+
minutes = max(1, int(age_seconds // 60))
1163+
return {"ageText": _t(self.language, "codex_stale_minutes", minutes=minutes)}
1164+
hours = max(1, int(age_seconds // 3600))
1165+
return {"ageText": _t(self.language, "codex_stale_hours", hours=hours)}
11351166

11361167
def _history_sources_fingerprint(self) -> tuple[tuple[str, int, float], ...]:
11371168
sources = (

panels/web_panel.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,7 @@ def _state_payload(state: PopoverState) -> dict[str, object]:
271271
"codex": {
272272
"session": _row_payload(state.codex_session),
273273
"weekly": _row_payload(state.codex_weekly),
274+
"stale": state.codex_stale,
274275
},
275276
"projects": [
276277
{

tests/test_menubar.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1160,6 +1160,51 @@ def test_state_from_outcome_replaces_claude_reset_with_warning(
11601160
assert state.claude_session.reset_text == "⚠ 按目前速度 18分鐘 就會用完(重置還要 51分鐘)"
11611161

11621162

1163+
def test_codex_stale_state_uses_minutes_and_hides_fresh_data() -> None:
1164+
delegate = menubar.AppDelegate.alloc().initWithMock_interval_(False, 60)
1165+
delegate.language = "zh-TW"
1166+
now = datetime(2026, 1, 1, 1, 0, tzinfo=UTC).timestamp()
1167+
1168+
fresh = datetime(2026, 1, 1, 0, 50, tzinfo=UTC).isoformat()
1169+
stale = datetime(2026, 1, 1, 0, 30, tzinfo=UTC).isoformat()
1170+
1171+
assert delegate._codex_stale_state(fresh, now) is None
1172+
assert delegate._codex_stale_state(stale, now) == {"ageText": "約 30 分鐘前"}
1173+
1174+
1175+
def test_codex_stale_state_uses_hours_after_sixty_minutes() -> None:
1176+
delegate = menubar.AppDelegate.alloc().initWithMock_interval_(False, 60)
1177+
delegate.language = "zh-TW"
1178+
now = datetime(2026, 1, 1, 3, 0, tzinfo=UTC).timestamp()
1179+
stale = "2026-01-01T00:30:00Z"
1180+
1181+
assert delegate._codex_stale_state(stale, now) == {"ageText": "約 2 小時前"}
1182+
1183+
1184+
def test_codex_rows_ignores_invalid_stale_timestamp(monkeypatch: pytest.MonkeyPatch) -> None:
1185+
delegate = menubar.AppDelegate.alloc().initWithMock_interval_(False, 60)
1186+
monkeypatch.setattr("time.time", lambda: 1_700_000_000.0)
1187+
monkeypatch.setattr(
1188+
codex_loader,
1189+
"load_rate_limits",
1190+
lambda: codex_loader.CodexRateLimits(
1191+
five_hour_pct=12.0,
1192+
five_hour_resets_at=1_700_003_600.0,
1193+
seven_day_pct=34.0,
1194+
seven_day_resets_at=1_700_086_400.0,
1195+
model="gpt-test",
1196+
updated_at="not-a-timestamp",
1197+
),
1198+
)
1199+
1200+
rows, codex_5h_pct, model, stale = delegate._codex_rows()
1201+
1202+
assert rows[0].available is True
1203+
assert codex_5h_pct == 12
1204+
assert model == "gpt-test"
1205+
assert stale is None
1206+
1207+
11631208
def test_state_from_outcome_keeps_reset_when_burn_rate_is_not_positive(
11641209
monkeypatch: pytest.MonkeyPatch,
11651210
) -> None:

0 commit comments

Comments
 (0)