Skip to content

Commit 3c31a56

Browse files
committed
Improve report CLI and project usage refresh
1 parent eae1b7a commit 3c31a56

6 files changed

Lines changed: 230 additions & 25 deletions

File tree

README.en.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,11 @@ To open it: find `usage.app` in Finder → right-click → Open → confirm Open
108108

109109
### First launch: set up the status line
110110

111-
The first time you open usage, if you have already used Codex, the Codex card can show data immediately. If usage needs to configure Codex status-line fields, or if you also want Claude Code integration, the popover may show a **"Set Up Status Line"** button at the bottom. Click it once to configure detected agents. Restart Codex afterward; if Claude Code was configured too, fully quit Claude Code (Cmd+Q) and re-open it.
111+
The first time you open usage, if you have already used Codex, the Codex card usually reads `~/.codex/sessions` and shows data directly. You do not need Claude Code installed, and you do not need extra setup just to read Codex history.
112112

113-
If the button doesn't show, usage is already reading data (e.g. you previously installed the third-party tool [stormzhang/token-tracker](https://github.com/stormzhang/token-tracker) and its status file works as a fallback) — nothing else to do.
113+
If you run from source and want to add Codex status-line fields, run `python3 main.py --setup`; it prioritizes detected Codex configuration, and only configures Claude Code integration if Claude Code is also present.
114+
115+
If you also want Claude Code integration, the popover may show a **"Set Up Status Line"** button when Claude Code needs app-side setup. Click it once, then fully quit Claude Code (Cmd+Q) and re-open it. If the button does not show, usage can already read data or there is no Claude Code setup action for the app to perform.
114116

115117
> **Fallback: install via curl**
116118
> If the in-app button doesn't work or you prefer the command line, run the following in Terminal (download first, inspect, then run):

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,11 @@ ln -s $(brew --prefix)/Cellar/usage/$(brew list --versions usage | awk '{print $
108108

109109
### 首次打開:設定狀態列
110110

111-
第一次打開 usage,如果你已經用 Codex 跑過對話,Codex 區塊會直接顯示。若需要補齊 Codex 狀態列欄位或你也想對接 Claude Code,popover 可能會顯示「設定狀態列」按鈕,按一下就會幫你設定可偵測到的 agent。設定後請重開 Codex;如果有設定 Claude Code,也請完全結束 Claude Code(Cmd+Q)再重新打開一次
111+
第一次打開 usage,如果你已經用 Codex 跑過對話,Codex 區塊通常會直接讀取 `~/.codex/sessions` 並顯示,不需要安裝 Claude Code,也不需要先做額外設定
112112

113-
如果按鈕沒出現(代表 usage 已經抓到資料了,例如你之前裝過第三方工具 [stormzhang/token-tracker](https://github.com/stormzhang/token-tracker)),就什麼都不用做。
113+
若你是從原始碼執行、想補齊 Codex status line 欄位,可以跑 `python3 main.py --setup`;它會優先設定可偵測到的 Codex,若你的環境也有 Claude Code,才會一併處理 Claude Code 對接。
114+
115+
如果你也想對接 Claude Code,popover 可能會在需要時顯示「設定狀態列」按鈕。按一下後請完全結束 Claude Code(Cmd+Q)再重新打開一次。若按鈕沒出現,代表 usage 已經能讀到資料或目前沒有需要 app 介入的 Claude Code 設定。
114116

115117
> **備援:手動 curl 安裝**
116118
> 若按鈕按了沒反應、或你想用指令模式裝,打開 Terminal(終端機)執行以下指令(先下載、確認內容後再執行,比較安全):

menubar.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,8 @@ class AppDelegate(NSObject):
334334
_refresh_in_flight = objc.ivar()
335335
_refresh_queued = objc.ivar()
336336
_fs_stream = objc.ivar()
337+
_history_entries_cache = objc.ivar()
338+
_history_entries_cache_fingerprint = objc.ivar()
337339
language = objc.ivar()
338340

339341
def initWithMock_interval_(self, mock: bool, interval: int) -> AppDelegate:
@@ -356,6 +358,8 @@ def initWithMock_interval_(self, mock: bool, interval: int) -> AppDelegate:
356358
self._refresh_in_flight = False
357359
self._refresh_queued = False
358360
self._fs_stream = None
361+
self._history_entries_cache = None
362+
self._history_entries_cache_fingerprint = None
359363
return self
360364

361365
def applicationDidFinishLaunching_(self, notification: Any) -> None:
@@ -992,9 +996,39 @@ def _codex_rows(self) -> tuple[tuple[QuotaRowState, QuotaRowState], int | None]:
992996
)
993997
return rows, codex_5h_pct
994998

999+
def _history_sources_fingerprint(self) -> tuple[tuple[str, int, float], ...]:
1000+
sources = (
1001+
Path.home() / ".claude",
1002+
Path.home() / ".codex" / "sessions",
1003+
)
1004+
fingerprint: list[tuple[str, int, float]] = []
1005+
for source in sources:
1006+
newest_mtime = 0.0
1007+
file_count = 0
1008+
try:
1009+
if source.exists():
1010+
for path in source.rglob("*.jsonl"):
1011+
try:
1012+
stat = path.stat()
1013+
except OSError:
1014+
continue
1015+
file_count += 1
1016+
newest_mtime = max(newest_mtime, stat.st_mtime)
1017+
except OSError:
1018+
pass
1019+
fingerprint.append((str(source), file_count, newest_mtime))
1020+
return tuple(fingerprint)
1021+
9951022
def _load_history_entries(self) -> list[UsageEntry]:
9961023
if self.mock:
9971024
return []
1025+
fingerprint = self._history_sources_fingerprint()
1026+
if (
1027+
self._history_entries_cache is not None
1028+
and self._history_entries_cache_fingerprint == fingerprint
1029+
):
1030+
return list(self._history_entries_cache)
1031+
9981032
entries: list[UsageEntry] = []
9991033
try:
10001034
entries.extend(load_entries(hours_back=0))
@@ -1006,6 +1040,8 @@ def _load_history_entries(self) -> list[UsageEntry]:
10061040
except Exception:
10071041
if os.environ.get("USAGE_DEBUG") == "1":
10081042
logger.warning("Codex project usage load failed", exc_info=True)
1043+
self._history_entries_cache = list(entries)
1044+
self._history_entries_cache_fingerprint = fingerprint
10091045
return entries
10101046

10111047
def _project_rows(

tests/test_menubar.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -603,6 +603,94 @@ def test_load_history_entries_includes_codex_entries(monkeypatch: pytest.MonkeyP
603603
assert entries == [claude_entry, codex_entry]
604604

605605

606+
def test_load_history_entries_reuses_cache_when_sources_do_not_change(
607+
monkeypatch: pytest.MonkeyPatch,
608+
) -> None:
609+
delegate = menubar.AppDelegate.alloc().initWithMock_interval_(False, 60)
610+
claude_entry = history_loader.UsageEntry(
611+
timestamp=datetime(2026, 5, 21, tzinfo=UTC),
612+
session_id="claude-session",
613+
message_id="claude-message",
614+
request_id="claude-request",
615+
model="claude",
616+
input_tokens=10,
617+
output_tokens=5,
618+
cache_creation_tokens=0,
619+
cache_read_tokens=0,
620+
cost_usd=0.01,
621+
project="ClaudeProject",
622+
)
623+
codex_entry = history_loader.UsageEntry(
624+
timestamp=datetime(2026, 5, 22, tzinfo=UTC),
625+
session_id="codex-session",
626+
message_id="codex-message",
627+
request_id="",
628+
model="gpt",
629+
input_tokens=20,
630+
output_tokens=7,
631+
cache_creation_tokens=0,
632+
cache_read_tokens=0,
633+
cost_usd=None,
634+
project="CodexProject",
635+
)
636+
calls = {"claude": 0, "codex": 0}
637+
638+
def fake_claude_entries(*, hours_back: int = 0) -> list[history_loader.UsageEntry]:
639+
calls["claude"] += 1
640+
return [claude_entry]
641+
642+
def fake_codex_entries(*, hours_back: int = 0) -> list[history_loader.UsageEntry]:
643+
calls["codex"] += 1
644+
return [codex_entry]
645+
646+
monkeypatch.setattr(delegate, "_history_sources_fingerprint", lambda: (("same", 1, 1.0),))
647+
monkeypatch.setattr(menubar, "load_entries", fake_claude_entries)
648+
monkeypatch.setattr(codex_loader, "load_entries", fake_codex_entries)
649+
650+
first = delegate._load_history_entries()
651+
second = delegate._load_history_entries()
652+
653+
assert first == [claude_entry, codex_entry]
654+
assert second == first
655+
assert calls == {"claude": 1, "codex": 1}
656+
657+
658+
def test_load_history_entries_refreshes_cache_when_sources_change(
659+
monkeypatch: pytest.MonkeyPatch,
660+
) -> None:
661+
delegate = menubar.AppDelegate.alloc().initWithMock_interval_(False, 60)
662+
entries = [
663+
history_loader.UsageEntry(
664+
timestamp=datetime(2026, 5, 22, tzinfo=UTC),
665+
session_id="codex-session",
666+
message_id="codex-message",
667+
request_id="",
668+
model="gpt",
669+
input_tokens=20,
670+
output_tokens=7,
671+
cache_creation_tokens=0,
672+
cache_read_tokens=0,
673+
cost_usd=None,
674+
project="CodexProject",
675+
)
676+
]
677+
calls = 0
678+
fingerprints = iter(((("old", 1, 1.0),), (("new", 2, 2.0),)))
679+
680+
def fake_codex_entries(*, hours_back: int = 0) -> list[history_loader.UsageEntry]:
681+
nonlocal calls
682+
calls += 1
683+
return entries
684+
685+
monkeypatch.setattr(delegate, "_history_sources_fingerprint", lambda: next(fingerprints))
686+
monkeypatch.setattr(menubar, "load_entries", lambda *, hours_back=0: [])
687+
monkeypatch.setattr(codex_loader, "load_entries", fake_codex_entries)
688+
689+
assert delegate._load_history_entries() == entries
690+
assert delegate._load_history_entries() == entries
691+
assert calls == 2
692+
693+
606694
def test_project_rows_top3(monkeypatch: pytest.MonkeyPatch) -> None:
607695
delegate = menubar.AppDelegate.alloc().initWithMock_interval_(False, 60)
608696
now = datetime.now(tz=UTC)

tests/test_usage_cli.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,46 @@ def test_main_report_defaults_to_all_time(
121121
assert calls == {"agents": [agent], "period": expected_period}
122122

123123

124+
def test_main_report_help_does_not_build_report(monkeypatch: pytest.MonkeyPatch) -> None:
125+
from analyzer import reporter
126+
127+
printed: list[str] = []
128+
129+
monkeypatch.setattr(sys, "argv", ["usage", "report", "--help"])
130+
monkeypatch.setattr(
131+
usage_cli,
132+
"detect_agents",
133+
lambda: pytest.fail("report help should not detect agents"),
134+
)
135+
monkeypatch.setattr(usage_cli, "is_setup", lambda: True)
136+
monkeypatch.setattr(usage_cli.console, "print", lambda value: printed.append(str(value)))
137+
monkeypatch.setattr(
138+
reporter,
139+
"build_report_data",
140+
lambda agents, period: pytest.fail("report help should not build a report"),
141+
)
142+
143+
usage_cli.main()
144+
145+
assert any("Usage: usage report" in line for line in printed)
146+
147+
148+
def test_main_report_rejects_unknown_option(monkeypatch: pytest.MonkeyPatch) -> None:
149+
agent = AgentInfo("codex", "Codex", "~/.codex", True)
150+
printed: list[str] = []
151+
152+
monkeypatch.setattr(sys, "argv", ["usage", "report", "--bogus"])
153+
monkeypatch.setattr(usage_cli, "detect_agents", lambda: [agent])
154+
monkeypatch.setattr(usage_cli, "is_setup", lambda: True)
155+
monkeypatch.setattr(usage_cli.console, "print", lambda value: printed.append(str(value)))
156+
157+
with pytest.raises(SystemExit) as exc_info:
158+
usage_cli.main()
159+
160+
assert exc_info.value.code == 1
161+
assert any("unknown report option" in line for line in printed)
162+
163+
124164
def test_main_exits_when_no_agents_detected(monkeypatch: pytest.MonkeyPatch) -> None:
125165
monkeypatch.setattr(sys, "argv", ["usage", "dashboard"])
126166
monkeypatch.setattr(usage_cli, "detect_agents", lambda: [])

usage_cli.py

Lines changed: 58 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,20 @@
2626
"output": ("output_tokens", True),
2727
}
2828

29+
REPORT_HELP = """Usage: usage report [--all|--last30|--today|--week|--month] [--out PATH]
30+
31+
Generate an HTML usage report.
32+
33+
Options:
34+
--all Include all usage data (default)
35+
--last30 Include the last 30 days
36+
--today Include today only
37+
--week Include this week
38+
--month Include this month
39+
--out PATH Save to a specific path
40+
-h, --help Show this help
41+
"""
42+
2943

3044
def _parse_sort_args(args: list[str]) -> tuple[list[str], str | None, bool]:
3145
"""Extract --sort KEY and --asc from args, return (remaining, sort_key, descending)."""
@@ -49,6 +63,43 @@ def _parse_sort_args(args: list[str]) -> tuple[list[str], str | None, bool]:
4963
return remaining, sort_key, descending
5064

5165

66+
def _parse_report_args(args: list[str]) -> tuple[str, str | None, bool]:
67+
period = "all"
68+
out_path = None
69+
show_help = False
70+
i = 0
71+
while i < len(args):
72+
arg = args[i]
73+
if arg in {"-h", "--help"}:
74+
show_help = True
75+
elif arg == "--last30":
76+
period = "last30"
77+
elif arg == "--today":
78+
period = "today"
79+
elif arg == "--week":
80+
period = "week"
81+
elif arg == "--month":
82+
period = "month"
83+
elif arg == "--all":
84+
period = "all"
85+
elif arg.startswith("--out="):
86+
out_path = arg[6:]
87+
elif arg == "--out":
88+
if i + 1 >= len(args) or args[i + 1].startswith("--"):
89+
console.print("[red]Error:[/red] --out requires a path")
90+
sys.exit(1)
91+
out_path = args[i + 1]
92+
i += 1
93+
elif arg.startswith("-"):
94+
console.print(f"[red]Error:[/red] unknown report option: {arg}")
95+
sys.exit(1)
96+
else:
97+
console.print(f"[red]Error:[/red] unexpected report argument: {arg}")
98+
sys.exit(1)
99+
i += 1
100+
return period, out_path, show_help
101+
102+
52103
def _apply_sort(stats, sort_key: str | None, descending: bool, default_attr: str, default_reverse: bool):
53104
if sort_key is None:
54105
stats.sort(key=lambda s: getattr(s, default_attr), reverse=default_reverse)
@@ -364,6 +415,9 @@ def main():
364415
if command in ("--version", "-v", "-V"):
365416
print(f"usage {_get_version()}")
366417
return
418+
if command == "report" and any(arg in {"-h", "--help"} for arg in args[1:]):
419+
console.print(REPORT_HELP)
420+
return
367421
if command == "setup":
368422
setup()
369423
return
@@ -412,27 +466,10 @@ def main():
412466
rest_args, sort_key, sort_desc = _parse_sort_args(args[1:])
413467

414468
if command == "report":
415-
period = "all"
416-
out_path = None
417-
i = 1
418-
while i < len(args):
419-
arg = args[i]
420-
if arg == "--last30":
421-
period = "last30"
422-
elif arg == "--today":
423-
period = "today"
424-
elif arg == "--week":
425-
period = "week"
426-
elif arg == "--month":
427-
period = "month"
428-
elif arg == "--all":
429-
period = "all"
430-
elif arg.startswith("--out="):
431-
out_path = arg[6:]
432-
elif arg == "--out" and i + 1 < len(args):
433-
out_path = args[i + 1]
434-
i += 1
435-
i += 1
469+
period, out_path, show_help = _parse_report_args(args[1:])
470+
if show_help:
471+
console.print(REPORT_HELP)
472+
return
436473
from analyzer.reporter import build_report_data
437474
from ui.html_report import save_and_open
438475

0 commit comments

Comments
 (0)