usage-cli 0.29.32__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. adapters/__init__.py +5 -0
  2. adapters/agy.py +68 -0
  3. adapters/claude.py +215 -0
  4. adapters/codex.py +209 -0
  5. adapters/rate_limits.py +76 -0
  6. adapters/registry.py +17 -0
  7. adapters/types.py +139 -0
  8. agy_disk_cache.py +135 -0
  9. agy_loader.py +416 -0
  10. agy_quota_probe.py +748 -0
  11. agy_window_keeper.py +185 -0
  12. analyzer/__init__.py +5 -0
  13. analyzer/aggregator.py +139 -0
  14. analyzer/blocks.py +80 -0
  15. analyzer/diagnoser.py +638 -0
  16. analyzer/insights.py +277 -0
  17. analyzer/persona_loader.py +199 -0
  18. analyzer/reporter.py +989 -0
  19. analyzer/subscription.py +108 -0
  20. burn_rate.py +75 -0
  21. cache_quarantine.py +50 -0
  22. codex_disk_cache.py +227 -0
  23. codex_events.py +136 -0
  24. codex_fork_replay.py +111 -0
  25. codex_loader.py +1426 -0
  26. codex_paths.py +20 -0
  27. critter_frames.py +26 -0
  28. discussion_bridge.py +1196 -0
  29. discussion_cli.py +844 -0
  30. discussion_session.py +622 -0
  31. discussion_usage.py +13 -0
  32. discussion_window.py +955 -0
  33. disk_cache_common.py +132 -0
  34. disk_cache_lifecycle.py +39 -0
  35. doctor.py +452 -0
  36. fsevents_watch.py +207 -0
  37. history_disk_cache.py +110 -0
  38. history_loader.py +416 -0
  39. i18n.py +88 -0
  40. jsonl_limits.py +17 -0
  41. jsonl_utils.py +40 -0
  42. login_item.py +154 -0
  43. main.py +387 -0
  44. menubar.py +1201 -0
  45. menubar_actions.py +204 -0
  46. menubar_agy.py +193 -0
  47. menubar_chrome.py +156 -0
  48. menubar_menu.py +169 -0
  49. menubar_notify.py +102 -0
  50. menubar_popover.py +233 -0
  51. menubar_prefs.py +118 -0
  52. menubar_refresh.py +285 -0
  53. menubar_state.py +1200 -0
  54. menubar_title.py +157 -0
  55. menubar_update.py +123 -0
  56. panel_window.py +78 -0
  57. panel_window_state.py +159 -0
  58. panels/__init__.py +186 -0
  59. panels/base.py +83 -0
  60. panels/dynamic_height.py +140 -0
  61. panels/payload.py +178 -0
  62. panels/web_panel.py +513 -0
  63. panels/window_drag.py +56 -0
  64. prefs.py +44 -0
  65. pricing.py +452 -0
  66. project_resolver.py +112 -0
  67. service_status.py +383 -0
  68. session_hooks.py +1154 -0
  69. setup_app.py +171 -0
  70. setup_hook.py +1011 -0
  71. statusline_settings.py +160 -0
  72. talent_market_bridge.py +243 -0
  73. time_utils.py +24 -0
  74. tui.py +288 -0
  75. tui_sprite.py +206 -0
  76. ui/__init__.py +5 -0
  77. ui/html_report.py +923 -0
  78. ui/report_scripts.py +251 -0
  79. ui/report_styles.py +370 -0
  80. ui/tables.py +888 -0
  81. update_checker.py +156 -0
  82. update_gate.py +66 -0
  83. update_release_notes.py +49 -0
  84. usage_cli-0.29.32.data/data/share/usage/i18n.json +2427 -0
  85. usage_cli-0.29.32.dist-info/METADATA +223 -0
  86. usage_cli-0.29.32.dist-info/RECORD +109 -0
  87. usage_cli-0.29.32.dist-info/WHEEL +5 -0
  88. usage_cli-0.29.32.dist-info/entry_points.txt +3 -0
  89. usage_cli-0.29.32.dist-info/licenses/LICENSE +663 -0
  90. usage_cli-0.29.32.dist-info/top_level.txt +80 -0
  91. usage_cli.py +827 -0
  92. usage_client.py +487 -0
  93. usage_diagnosis_snapshot.py +143 -0
  94. usage_dir_sweeper.py +100 -0
  95. usage_lang.py +79 -0
  96. usage_logging.py +75 -0
  97. usage_notifications.py +96 -0
  98. usage_rate.py +97 -0
  99. usage_session_resume.py +913 -0
  100. usage_statusline.py +810 -0
  101. usage_statusline_agy.py +397 -0
  102. usage_statusline_forwarder.py +88 -0
  103. usage_terse_mode.py +223 -0
  104. usage_terse_reminder.py +151 -0
  105. win_login_item.py +53 -0
  106. window_keeper.py +264 -0
  107. windows_watch.py +443 -0
  108. wintray.py +2014 -0
  109. wintray_menu.py +136 -0
usage_cli.py ADDED
@@ -0,0 +1,827 @@
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
+ import csv
8
+ import json
9
+ import sys
10
+ from collections.abc import Callable
11
+ from datetime import UTC, datetime
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import codex_loader
16
+ from adapters import claude, codex
17
+ from adapters.rate_limits import load_rate_limits as load_claude_rate_limits
18
+ from adapters.registry import detect_agents
19
+ from adapters.types import AgentInfo, RateLimits
20
+ from analyzer.aggregator import aggregate_daily, aggregate_monthly, aggregate_sessions, aggregate_weekly
21
+ from analyzer.blocks import analyze_blocks, calculate_p90
22
+ from analyzer import persona_loader
23
+ from setup_hook import is_claude_setup, is_codex_setup, is_setup, setup, unsetup
24
+ from session_hooks import disable_session_resume, disable_terse_mode
25
+ from i18n import t
26
+ from ui.tables import (
27
+ AGENT_LABEL, console, render_daily, render_dashboard,
28
+ render_monthly, render_sessions, render_tab_bar, render_weekly,
29
+ )
30
+
31
+ AGENT_ALIASES = {"claude": "claude-code", "codex": "codex"}
32
+ AGENT_LOADERS = {"claude-code": claude, "codex": codex}
33
+
34
+
35
+ def _load_codex_rate_limits() -> RateLimits | None:
36
+ rate_limits = codex_loader.load_rate_limits()
37
+ if rate_limits is None:
38
+ return None
39
+ return RateLimits(
40
+ five_hour_pct=rate_limits.five_hour_pct,
41
+ five_hour_resets_at=(
42
+ int(rate_limits.five_hour_resets_at)
43
+ if rate_limits.five_hour_resets_at is not None
44
+ else None
45
+ ),
46
+ seven_day_pct=rate_limits.seven_day_pct,
47
+ seven_day_resets_at=(
48
+ int(rate_limits.seven_day_resets_at)
49
+ if rate_limits.seven_day_resets_at is not None
50
+ else None
51
+ ),
52
+ model=rate_limits.model or "",
53
+ updated_at=rate_limits.updated_at,
54
+ )
55
+
56
+
57
+ RATE_LIMIT_LOADERS = {"claude-code": load_claude_rate_limits, "codex": _load_codex_rate_limits}
58
+
59
+ SORT_KEYS = {
60
+ "tokens": ("total_tokens", True),
61
+ "cost": ("cost_usd", True),
62
+ "messages": ("message_count", True),
63
+ "sessions": ("session_count", True),
64
+ "time": None, # handled per-command
65
+ "input": ("input_tokens", True),
66
+ "output": ("output_tokens", True),
67
+ }
68
+
69
+ REPORT_HELP = """Usage: usage report [--last30|--all|--today|--last7|--week|--month] [--out PATH]
70
+
71
+ Generate an HTML usage report.
72
+
73
+ Options:
74
+ --last30 Include the last 30 days (default)
75
+ --all Include all usage data
76
+ --today Include today only
77
+ --last7 Include the last 7 days
78
+ --week Include this week
79
+ --month Include this month
80
+ --out PATH Save to a specific path
81
+ -h, --help Show this help
82
+ """
83
+
84
+ EXPORT_HELP = """Usage: usage export [--daily|--weekly|--monthly|--sessions] [--out PATH]
85
+
86
+ Export aggregated usage data as CSV.
87
+
88
+ Options:
89
+ --daily Export daily usage (default)
90
+ --weekly Export weekly usage
91
+ --monthly Export monthly usage
92
+ --sessions Export session usage
93
+ --out PATH Save to a specific path
94
+ -h, --help Show this help
95
+ """
96
+
97
+ STATUS_HELP = """Usage: usage status [--json]
98
+
99
+ Show local Claude Code and Codex quota status.
100
+
101
+ Options:
102
+ --json Print machine-readable JSON
103
+ -h, --help Show this help
104
+ """
105
+
106
+ _REPORT_EXPORT_OPTIONS = {
107
+ "report": {
108
+ "-h",
109
+ "--help",
110
+ "--last30",
111
+ "--today",
112
+ "--last7",
113
+ "--week",
114
+ "--month",
115
+ "--all",
116
+ "--out",
117
+ },
118
+ "export": {
119
+ "-h",
120
+ "--help",
121
+ "--daily",
122
+ "--weekly",
123
+ "--monthly",
124
+ "--sessions",
125
+ "--out",
126
+ },
127
+ }
128
+
129
+ EXPORT_FIELDS: dict[str, list[str]] = {
130
+ "daily": [
131
+ "agent_id", "date", "input_tokens", "output_tokens",
132
+ "cache_creation_tokens", "cache_read_tokens", "total_tokens",
133
+ "cost_usd", "session_count", "message_count",
134
+ ],
135
+ "weekly": [
136
+ "agent_id", "week", "week_start", "week_end", "input_tokens",
137
+ "output_tokens", "cache_creation_tokens", "cache_read_tokens",
138
+ "total_tokens", "cost_usd", "session_count", "message_count",
139
+ ],
140
+ "monthly": [
141
+ "agent_id", "month", "input_tokens", "output_tokens",
142
+ "cache_creation_tokens", "cache_read_tokens", "total_tokens",
143
+ "cost_usd", "session_count", "message_count",
144
+ ],
145
+ "sessions": [
146
+ "agent_id", "session_id", "project", "model", "start_time",
147
+ "end_time", "duration_minutes", "input_tokens", "output_tokens",
148
+ "cache_creation_tokens", "cache_read_tokens", "total_tokens",
149
+ "cost_usd", "message_count",
150
+ ],
151
+ }
152
+
153
+
154
+ def _parse_sort_args(args: list[str]) -> tuple[list[str], str | None, bool]:
155
+ """Extract --sort KEY and --asc from args, return (remaining, sort_key, descending)."""
156
+ remaining = []
157
+ sort_key = None
158
+ descending = True
159
+ i = 0
160
+ while i < len(args):
161
+ if args[i] == "--sort" and i + 1 < len(args):
162
+ sort_key = args[i + 1].lower()
163
+ i += 2
164
+ elif args[i] == "--asc":
165
+ descending = False
166
+ i += 1
167
+ elif args[i] == "--desc":
168
+ descending = True
169
+ i += 1
170
+ else:
171
+ remaining.append(args[i])
172
+ i += 1
173
+ return remaining, sort_key, descending
174
+
175
+
176
+ def _parse_report_args(args: list[str]) -> tuple[str, str | None, bool]:
177
+ period = "last30"
178
+ out_path, show_help = _parse_out_and_help(args, "report")
179
+ i = 0
180
+ while i < len(args):
181
+ arg = args[i]
182
+ if arg in {"-h", "--help"}:
183
+ pass
184
+ elif arg == "--last30":
185
+ period = "last30"
186
+ elif arg == "--today":
187
+ period = "today"
188
+ elif arg == "--last7":
189
+ period = "last7"
190
+ elif arg == "--week":
191
+ period = "week"
192
+ elif arg == "--month":
193
+ period = "month"
194
+ elif arg == "--all":
195
+ period = "all"
196
+ elif _is_out_arg(arg):
197
+ if arg == "--out":
198
+ i += 1
199
+ elif arg.startswith("-"):
200
+ _exit_unknown_option("report", arg)
201
+ else:
202
+ console.print(f"[red]Error:[/red] unexpected report argument: {arg}")
203
+ sys.exit(1)
204
+ i += 1
205
+ return period, out_path, show_help
206
+
207
+
208
+ def _parse_export_args(args: list[str]) -> tuple[str, str | None, bool]:
209
+ export_type = "daily"
210
+ out_path, show_help = _parse_out_and_help(args, "export")
211
+ i = 0
212
+ while i < len(args):
213
+ arg = args[i]
214
+ if arg in {"-h", "--help"}:
215
+ pass
216
+ elif arg == "--daily":
217
+ export_type = "daily"
218
+ elif arg == "--weekly":
219
+ export_type = "weekly"
220
+ elif arg == "--monthly":
221
+ export_type = "monthly"
222
+ elif arg == "--sessions":
223
+ export_type = "sessions"
224
+ elif _is_out_arg(arg):
225
+ if arg == "--out":
226
+ i += 1
227
+ elif arg.startswith("-"):
228
+ _exit_unknown_option("export", arg)
229
+ else:
230
+ console.print(f"[red]Error:[/red] unexpected export argument: {arg}")
231
+ sys.exit(1)
232
+ i += 1
233
+ return export_type, out_path, show_help
234
+
235
+
236
+ def _is_out_arg(arg: str) -> bool:
237
+ return arg == "--out" or arg.startswith("--out=")
238
+
239
+
240
+ def _exit_unknown_option(command: str, arg: str) -> None:
241
+ console.print(f"[red]Error:[/red] unknown {command} option: {arg}")
242
+ sys.exit(1)
243
+
244
+
245
+ def _parse_out_and_help(args: list[str], command: str) -> tuple[str | None, bool]:
246
+ out_path = None
247
+ show_help = False
248
+ i = 0
249
+ while i < len(args):
250
+ arg = args[i]
251
+ if arg in {"-h", "--help"}:
252
+ show_help = True
253
+ elif arg.startswith("--out="):
254
+ out_path = arg[6:]
255
+ elif arg == "--out":
256
+ if i + 1 >= len(args) or args[i + 1].startswith("--"):
257
+ console.print("[red]Error:[/red] --out requires a path")
258
+ sys.exit(1)
259
+ out_path = args[i + 1]
260
+ i += 1
261
+ elif arg.startswith("-") and arg not in _REPORT_EXPORT_OPTIONS[command]:
262
+ _exit_unknown_option(command, arg)
263
+ i += 1
264
+ return out_path, show_help
265
+
266
+
267
+ def _csv_row(stat: Any, fields: list[str]) -> dict[str, Any]:
268
+ row: dict[str, Any] = {}
269
+ for field in fields:
270
+ value = getattr(stat, field)
271
+ if field in {"start_time", "end_time"}:
272
+ value = value.isoformat()
273
+ row[field] = value
274
+ return row
275
+
276
+
277
+ def _write_export_csv(stats: list[Any], export_type: str, out_path: str | None) -> str | None:
278
+ fields = EXPORT_FIELDS[export_type]
279
+ if out_path:
280
+ path = Path(out_path)
281
+ with path.open("w", encoding="utf-8", newline="") as fh:
282
+ writer = csv.DictWriter(fh, fieldnames=fields, lineterminator="\n")
283
+ writer.writeheader()
284
+ for stat in stats:
285
+ writer.writerow(_csv_row(stat, fields))
286
+ return str(path)
287
+
288
+ writer = csv.DictWriter(sys.stdout, fieldnames=fields, lineterminator="\n")
289
+ writer.writeheader()
290
+ for stat in stats:
291
+ writer.writerow(_csv_row(stat, fields))
292
+ return None
293
+
294
+
295
+ def _status_agent(rate_limits: RateLimits | None) -> dict[str, Any]:
296
+ if rate_limits is None:
297
+ return {
298
+ "available": False,
299
+ "five_hour": {"used_percent": None, "resets_at": None},
300
+ "seven_day": {"used_percent": None, "resets_at": None},
301
+ "model": None,
302
+ "updated_at": None,
303
+ }
304
+ return {
305
+ "available": True,
306
+ "five_hour": {
307
+ "used_percent": rate_limits.five_hour_pct,
308
+ "resets_at": rate_limits.five_hour_resets_at,
309
+ },
310
+ "seven_day": {
311
+ "used_percent": rate_limits.seven_day_pct,
312
+ "resets_at": rate_limits.seven_day_resets_at,
313
+ },
314
+ "model": rate_limits.model,
315
+ "updated_at": rate_limits.updated_at,
316
+ }
317
+
318
+
319
+ def _status_payload() -> dict[str, Any]:
320
+ agents: dict[str, dict[str, Any]] = {}
321
+ for agent_id in ("claude-code", "codex"):
322
+ try:
323
+ rate_limits = RATE_LIMIT_LOADERS[agent_id]()
324
+ except Exception:
325
+ rate_limits = None
326
+ agents[agent_id] = _status_agent(rate_limits)
327
+ return {
328
+ "schema_version": 1,
329
+ "generated_at": datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z"),
330
+ "agents": agents,
331
+ }
332
+
333
+
334
+ def _status_summary(payload: dict[str, Any]) -> str:
335
+ summaries = []
336
+ for agent_id, status in payload["agents"].items():
337
+ if not status["available"]:
338
+ summaries.append(f"{agent_id} available=false")
339
+ continue
340
+ five_hour = status["five_hour"]["used_percent"]
341
+ seven_day = status["seven_day"]["used_percent"]
342
+ five_hour_text = "?" if five_hour is None else f"{five_hour:.1f}%"
343
+ seven_day_text = "?" if seven_day is None else f"{seven_day:.1f}%"
344
+ summaries.append(f"{agent_id} 5h={five_hour_text} 7d={seven_day_text}")
345
+ return " | ".join(summaries)
346
+
347
+
348
+ def _run_status(args: list[str]) -> None:
349
+ if any(arg in {"-h", "--help"} for arg in args):
350
+ print(STATUS_HELP)
351
+ return
352
+ unknown = next((arg for arg in args if arg != "--json"), None)
353
+ if unknown is not None:
354
+ print(f"Error: unknown status option: {unknown}", file=sys.stderr)
355
+ sys.exit(1)
356
+
357
+ payload = _status_payload()
358
+ if "--json" in args:
359
+ print(json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
360
+ else:
361
+ print(_status_summary(payload))
362
+
363
+
364
+ def _apply_sort(stats: list[Any], sort_key: str | None, descending: bool, default_attr: str, default_reverse: bool) -> None:
365
+ if sort_key is None:
366
+ stats.sort(key=lambda s: getattr(s, default_attr), reverse=default_reverse)
367
+ return
368
+ if sort_key not in SORT_KEYS:
369
+ valid = ", ".join(SORT_KEYS.keys())
370
+ console.print(f"[yellow]{t('unknown_sort_field', field=sort_key, valid=valid)}[/yellow]")
371
+ stats.sort(key=lambda s: getattr(s, default_attr), reverse=default_reverse)
372
+ return
373
+ mapping = SORT_KEYS[sort_key]
374
+ if mapping is None:
375
+ stats.sort(key=lambda s: getattr(s, default_attr), reverse=descending)
376
+ else:
377
+ attr, _ = mapping
378
+ stats.sort(key=lambda s: getattr(s, attr), reverse=descending)
379
+
380
+
381
+ def _load_entries(agent_id: str, hours_back: int = 0) -> list[Any]:
382
+ loader = AGENT_LOADERS.get(agent_id)
383
+ if loader is None:
384
+ return []
385
+ entries: list[Any] = loader.load_entries(hours_back=hours_back)
386
+ return entries
387
+
388
+
389
+ def _aggregate_per_agent(agents: list[AgentInfo], agg_fn: Callable[[list[Any]], list[Any]]) -> list[Any]:
390
+ stats: list[Any] = []
391
+ for a in agents:
392
+ entries = _load_entries(a.id)
393
+ for s in agg_fn(entries):
394
+ s.agent_id = a.id
395
+ stats.append(s)
396
+ return stats
397
+
398
+
399
+ def _show_agent_dashboard(agent_id: str) -> None:
400
+ agent_name = AGENT_LABEL.get(agent_id, agent_id)
401
+ data = _build_agent_data(agent_id, agent_name)
402
+ if not data:
403
+ console.print(f"[yellow]{t('no_token_data')}[/yellow]")
404
+ return
405
+ render_dashboard(**data)
406
+
407
+
408
+ def _build_agent_data(agent_id: str, agent_name: str) -> dict[str, Any] | None:
409
+ entries = _load_entries(agent_id)
410
+ if not entries:
411
+ return None
412
+ daily = aggregate_daily(entries)
413
+ weekly = aggregate_weekly(entries)
414
+ monthly = aggregate_monthly(entries)
415
+ sessions = aggregate_sessions(entries)
416
+ from datetime import datetime, timezone, timedelta
417
+ cutoff = datetime.now(timezone.utc) - timedelta(hours=48)
418
+ recent = [e for e in entries if e.timestamp >= cutoff]
419
+ blocks = analyze_blocks(recent)
420
+ rate_limits = RATE_LIMIT_LOADERS.get(agent_id, lambda: None)()
421
+ p90 = None
422
+ has_limits = rate_limits and (rate_limits.five_hour_pct is not None or rate_limits.seven_day_pct is not None)
423
+ if not has_limits:
424
+ p90 = calculate_p90(daily)
425
+ session_titles = _load_session_titles()
426
+ return dict(
427
+ daily_stats=daily, weekly_stats=weekly, monthly_stats=monthly,
428
+ sessions=sessions, blocks=blocks, rate_limits=rate_limits,
429
+ p90=p90, agents=[agent_name], session_titles=session_titles,
430
+ )
431
+
432
+
433
+ def _load_session_titles() -> dict[str, str] | None:
434
+ try:
435
+ titles = persona_loader.load_profile(30).titles_by_session
436
+ except Exception:
437
+ return None
438
+ return titles or None
439
+
440
+
441
+ def _initial_agent_index(agents: list[AgentInfo]) -> int:
442
+ import os
443
+
444
+ preferred = None
445
+ if os.environ.get("CODEX_THREAD_ID") or os.environ.get("CODEX_SANDBOX"):
446
+ preferred = "codex"
447
+ elif os.environ.get("CLAUDE_CONFIG_DIR") or os.environ.get("CLAUDECODE"):
448
+ preferred = "claude-code"
449
+
450
+ if preferred:
451
+ for i, agent in enumerate(agents):
452
+ if agent.id == preferred:
453
+ return i
454
+ return 0
455
+
456
+
457
+ def _fit_screen(text: str, height: int, scroll_offset: int) -> tuple[str, int]:
458
+ lines = text.splitlines()
459
+ if not lines:
460
+ return "", 0
461
+ max_body = max(1, height - 1)
462
+ max_scroll = max(0, len(lines) - max_body)
463
+ scroll_offset = max(0, min(scroll_offset, max_scroll))
464
+ visible = lines[:1] + lines[1 + scroll_offset:1 + scroll_offset + max_body - 1]
465
+ return "\n".join(visible), max_scroll
466
+
467
+
468
+ def _dashboard_sort_cycle() -> list[tuple[str, str, str]]:
469
+ return [
470
+ ("time", "start_time", t("sort_time")),
471
+ ("tokens", "total_tokens", t("sort_token")),
472
+ ("cost", "cost_usd", t("sort_cost")),
473
+ ("messages", "message_count", t("sort_messages")),
474
+ ]
475
+
476
+
477
+ def _show_interactive_dashboard(agents: list[AgentInfo]) -> None:
478
+ import shutil
479
+ from io import StringIO
480
+ from rich.console import Console as RichConsole
481
+ import ui.tables as _tables
482
+
483
+ agent_names = [a.name for a in agents]
484
+ current = _initial_agent_index(agents)
485
+ scroll_offset = 0
486
+ sort_idx = 0
487
+ sort_desc = True
488
+ session_limit = 30
489
+ orig = _tables.console
490
+
491
+ sys.stdout.write("\033[?1049h\033[?7l\033[2J\033[3J\033[H\033[?25l")
492
+ cache = {}
493
+ sort_cycle = _dashboard_sort_cycle()
494
+
495
+ try:
496
+ while True:
497
+ agent = agents[current]
498
+ if agent.id not in cache:
499
+ sys.stdout.write(f"\033[2J\033[3J\033[H\033[2m{t('loading')}\033[0m")
500
+ sys.stdout.flush()
501
+ cache[agent.id] = _build_agent_data(agent.id, agent.name)
502
+
503
+ size = shutil.get_terminal_size((80, 24))
504
+ width = size.columns
505
+ height = size.lines
506
+
507
+ data = cache[agent.id]
508
+ if data:
509
+ _, sort_attr, sort_label = sort_cycle[sort_idx]
510
+ sorted_sessions = sorted(
511
+ data["sessions"],
512
+ key=lambda s: getattr(s, sort_attr),
513
+ reverse=sort_desc,
514
+ )
515
+ arrow = "↓" if sort_desc else "↑"
516
+ session_title = t("session_title", limit=session_limit, label=sort_label, arrow=arrow)
517
+ else:
518
+ sorted_sessions = []
519
+ session_title = None
520
+
521
+ buf = StringIO()
522
+ _tables.console = RichConsole(
523
+ file=buf, width=width, force_terminal=True,
524
+ )
525
+ render_tab_bar(agent_names, current)
526
+ if data:
527
+ render_data = {**data, "sessions": sorted_sessions}
528
+ render_dashboard(**render_data, session_limit=session_limit, top_margin=False, session_title=session_title)
529
+ else:
530
+ _tables.console.print(f"[yellow]{t('no_data')}[/yellow]")
531
+ _tables.console = orig
532
+
533
+ screen, max_scroll = _fit_screen(buf.getvalue(), height, scroll_offset)
534
+ sys.stdout.write("\033[2J\033[3J\033[H" + screen)
535
+ sys.stdout.flush()
536
+
537
+ key = _read_key()
538
+ if key == "left":
539
+ current = (current - 1) % len(agents)
540
+ scroll_offset = 0
541
+ elif key == "right":
542
+ current = (current + 1) % len(agents)
543
+ scroll_offset = 0
544
+ elif key == "up":
545
+ scroll_offset = max(0, scroll_offset - 1)
546
+ elif key == "down":
547
+ scroll_offset = min(max_scroll, scroll_offset + 1)
548
+ elif key == "page_up":
549
+ scroll_offset = max(0, scroll_offset - max(1, height - 3))
550
+ elif key == "page_down":
551
+ scroll_offset = min(max_scroll, scroll_offset + max(1, height - 3))
552
+ elif key == "sort":
553
+ sort_idx = (sort_idx + 1) % len(sort_cycle)
554
+ scroll_offset = 0
555
+ elif key == "reverse":
556
+ sort_desc = not sort_desc
557
+ elif key == "more":
558
+ session_limit += 10
559
+ elif key == "less":
560
+ session_limit = max(10, session_limit - 10)
561
+ elif key == "report":
562
+ import time
563
+
564
+ from analyzer.reporter import build_report_data
565
+ from ui.html_report import save_and_open
566
+
567
+ report_data = build_report_data(agents, "month")
568
+ saved = save_and_open(report_data)
569
+ msg = f"\033[32m✓ Report saved: {saved}\033[0m"
570
+ sys.stdout.write(f"\033[{height};1H\033[2K{msg}")
571
+ sys.stdout.flush()
572
+ time.sleep(2)
573
+ elif key == "quit":
574
+ break
575
+ finally:
576
+ sys.stdout.write("\033[?7h\033[?25h\033[?1049l")
577
+ sys.stdout.flush()
578
+ _tables.console = orig
579
+
580
+
581
+ def _read_key_unix() -> str:
582
+ # The termios/tty attribute ignores are for mypy's win32 run, where the
583
+ # modules exist as stubs but expose no POSIX attributes; pyproject sets
584
+ # warn_unused_ignores=false for this module so the darwin run tolerates
585
+ # them (and the msvcrt ignores below, in reverse).
586
+ import os as _os
587
+ import select
588
+ import tty
589
+ import termios
590
+ fd = sys.stdin.fileno()
591
+ old = termios.tcgetattr(fd) # type: ignore[attr-defined]
592
+ try:
593
+ tty.setraw(fd) # type: ignore[attr-defined]
594
+ ch = _os.read(fd, 1)
595
+ if ch == b"\x1b":
596
+ if not select.select([fd], [], [], 0.05)[0]:
597
+ return "quit"
598
+ ch2 = _os.read(fd, 1)
599
+ if ch2 == b"[":
600
+ ch3 = _os.read(fd, 1)
601
+ if ch3 == b"D":
602
+ return "left"
603
+ if ch3 == b"C":
604
+ return "right"
605
+ if ch3 == b"A":
606
+ return "up"
607
+ if ch3 == b"B":
608
+ return "down"
609
+ if ch3 in (b"5", b"6"):
610
+ if select.select([fd], [], [], 0.05)[0]:
611
+ _os.read(fd, 1)
612
+ return "page_up" if ch3 == b"5" else "page_down"
613
+ return "other"
614
+ if ch == b"h":
615
+ return "left"
616
+ if ch == b"l":
617
+ return "right"
618
+ if ch == b"k":
619
+ return "up"
620
+ if ch == b"j":
621
+ return "down"
622
+ if ch == b"b":
623
+ return "page_up"
624
+ if ch == b"f":
625
+ return "page_down"
626
+ if ch == b"s":
627
+ return "sort"
628
+ if ch == b"r":
629
+ return "reverse"
630
+ if ch == b"e":
631
+ return "report"
632
+ if ch in (b"+", b"="):
633
+ return "more"
634
+ if ch in (b"-", b"_"):
635
+ return "less"
636
+ if ch in (b"q", b"Q", b"\x03"):
637
+ return "quit"
638
+ return "other"
639
+ finally:
640
+ termios.tcsetattr(fd, termios.TCSADRAIN, old) # type: ignore[attr-defined]
641
+
642
+
643
+ def _read_key_win() -> str:
644
+ import msvcrt
645
+ ch = msvcrt.getch() # type: ignore[attr-defined]
646
+ if ch in (b"\xe0", b"\x00"):
647
+ ch2 = msvcrt.getch() # type: ignore[attr-defined]
648
+ if ch2 == b"K":
649
+ return "left"
650
+ if ch2 == b"M":
651
+ return "right"
652
+ if ch2 == b"H":
653
+ return "up"
654
+ if ch2 == b"P":
655
+ return "down"
656
+ if ch2 == b"I":
657
+ return "page_up"
658
+ if ch2 == b"Q":
659
+ return "page_down"
660
+ return "other"
661
+ if ch == b"h":
662
+ return "left"
663
+ if ch == b"l":
664
+ return "right"
665
+ if ch == b"k":
666
+ return "up"
667
+ if ch == b"j":
668
+ return "down"
669
+ if ch == b"b":
670
+ return "page_up"
671
+ if ch == b"f":
672
+ return "page_down"
673
+ if ch == b"e":
674
+ return "report"
675
+ if ch in (b"q", b"Q", b"\x03", b"\x1b"):
676
+ return "quit"
677
+ return "other"
678
+
679
+
680
+ _read_key = _read_key_win if sys.platform == "win32" else _read_key_unix
681
+
682
+
683
+ def _get_version() -> str:
684
+ from importlib.metadata import version
685
+ return version("usage-cli")
686
+
687
+
688
+ def main() -> None:
689
+ args = sys.argv[1:]
690
+ command = args[0] if args else "dashboard"
691
+
692
+ if command in ("--version", "-v", "-V"):
693
+ print(f"usage {_get_version()}")
694
+ return
695
+ if command == "report" and any(arg in {"-h", "--help"} for arg in args[1:]):
696
+ console.print(REPORT_HELP)
697
+ return
698
+ if command == "export" and any(arg in {"-h", "--help"} for arg in args[1:]):
699
+ console.print(EXPORT_HELP)
700
+ return
701
+ if command == "status":
702
+ _run_status(args[1:])
703
+ return
704
+ if command == "setup":
705
+ setup()
706
+ import session_hooks
707
+
708
+ session_hooks._migrate_bundled_python_commands_if_needed()
709
+ return
710
+ if command == "unsetup":
711
+ disable_session_resume()
712
+ disable_terse_mode()
713
+ unsetup()
714
+ return
715
+
716
+ agents = detect_agents()
717
+ if not agents:
718
+ console.print(f"[red]{t('no_agent')}[/red]")
719
+ sys.exit(1)
720
+
721
+ agent_ids = {a.id for a in agents}
722
+
723
+ if command not in {"dashboard", "export"}:
724
+ console.print(f"[dim]{t('detected', agents=', '.join(a.name + ' ✓' for a in agents))}[/dim]")
725
+
726
+ hook_warning_needed = (
727
+ (command == "claude" and not is_claude_setup())
728
+ or (command == "codex" and not is_codex_setup())
729
+ or (command not in AGENT_ALIASES and command != "export" and not is_setup())
730
+ )
731
+ if hook_warning_needed:
732
+ console.print(f"[yellow]{t('hook_not_installed')}[/yellow]")
733
+
734
+ # usage claude / usage codex
735
+ if command in AGENT_ALIASES:
736
+ agent_id = AGENT_ALIASES[command]
737
+ if agent_id not in agent_ids:
738
+ console.print(f"[red]{t('agent_not_found', name=command)}[/red]")
739
+ sys.exit(1)
740
+ _show_agent_dashboard(agent_id)
741
+ return
742
+
743
+ if command == "dashboard":
744
+ agent_filter = args[1] if len(args) > 1 and args[1] in AGENT_ALIASES else None
745
+ if agent_filter:
746
+ agent_id = AGENT_ALIASES[agent_filter]
747
+ if agent_id not in agent_ids:
748
+ console.print(f"[red]{t('agent_not_found', name=agent_filter)}[/red]")
749
+ sys.exit(1)
750
+ _show_agent_dashboard(agent_id)
751
+ elif len(agents) > 1 and sys.stdin.isatty():
752
+ _show_interactive_dashboard(agents)
753
+ else:
754
+ _show_agent_dashboard(agents[0].id)
755
+ return
756
+
757
+ # 其他命令使用合并数据
758
+ agent_names = [a.name for a in agents]
759
+ rest_args, sort_key, sort_desc = _parse_sort_args(args[1:])
760
+
761
+ if command == "report":
762
+ period, out_path, show_help = _parse_report_args(args[1:])
763
+ if show_help:
764
+ console.print(REPORT_HELP)
765
+ return
766
+ from analyzer.reporter import build_report_data
767
+ from ui.html_report import save_and_open
768
+
769
+ data = build_report_data(agents, period)
770
+ saved = save_and_open(data, out_path)
771
+ console.print(f"[green]✓[/green] Report saved: {saved}")
772
+ elif command == "export":
773
+ export_type, out_path, show_help = _parse_export_args(args[1:])
774
+ if show_help:
775
+ console.print(EXPORT_HELP)
776
+ return
777
+ if export_type == "daily":
778
+ stats = _aggregate_per_agent(agents, aggregate_daily)
779
+ stats.sort(key=lambda s: s.date, reverse=True)
780
+ elif export_type == "weekly":
781
+ stats = _aggregate_per_agent(agents, aggregate_weekly)
782
+ stats.sort(key=lambda s: s.week, reverse=True)
783
+ elif export_type == "monthly":
784
+ stats = _aggregate_per_agent(agents, aggregate_monthly)
785
+ stats.sort(key=lambda s: s.month, reverse=True)
786
+ else:
787
+ stats = _aggregate_per_agent(agents, aggregate_sessions)
788
+ stats.sort(key=lambda s: s.start_time, reverse=True)
789
+
790
+ export_saved = _write_export_csv(stats, export_type, out_path)
791
+ if export_saved is not None:
792
+ console.print(f"[green]✓[/green] Export saved: {export_saved}")
793
+ elif command == "daily":
794
+ stats = _aggregate_per_agent(agents, aggregate_daily)
795
+ default_attr = "date" if sort_key == "time" else "total_tokens"
796
+ _apply_sort(stats, sort_key, sort_desc, default_attr, default_reverse=True)
797
+ render_daily(stats, agents=agent_names)
798
+ elif command == "weekly":
799
+ stats = _aggregate_per_agent(agents, aggregate_weekly)
800
+ default_attr = "week"
801
+ _apply_sort(stats, sort_key, sort_desc, default_attr, default_reverse=True)
802
+ render_weekly(stats, agents=agent_names)
803
+ elif command == "monthly":
804
+ stats = _aggregate_per_agent(agents, aggregate_monthly)
805
+ default_attr = "month"
806
+ _apply_sort(stats, sort_key, sort_desc, default_attr, default_reverse=False)
807
+ render_monthly(stats, agents=agent_names)
808
+ elif command == "sessions":
809
+ limit = 20
810
+ for a in rest_args:
811
+ try:
812
+ limit = int(a)
813
+ break
814
+ except ValueError:
815
+ pass
816
+ stats = _aggregate_per_agent(agents, aggregate_sessions)
817
+ default_attr = "start_time"
818
+ _apply_sort(stats, sort_key, sort_desc, default_attr, default_reverse=True)
819
+ render_sessions(stats, limit)
820
+ else:
821
+ console.print(f"[red]{t('unknown_cmd', cmd=command)}[/red]")
822
+ console.print(f"[dim]{t('available_cmds')}[/dim]")
823
+ sys.exit(1)
824
+
825
+
826
+ if __name__ == "__main__":
827
+ main()