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
ui/tables.py ADDED
@@ -0,0 +1,888 @@
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 os
8
+ from collections import defaultdict
9
+ from collections.abc import Iterable
10
+ from datetime import datetime, timezone
11
+ from typing import Any, Protocol
12
+
13
+ from rich import box
14
+ from rich.console import Console
15
+ from rich.panel import Panel
16
+ from rich.table import Table
17
+ from rich.text import Text
18
+
19
+ from adapters.types import (
20
+ DailyStats,
21
+ MonthlyStats,
22
+ P90Limits,
23
+ RateLimits,
24
+ SessionBlock,
25
+ SessionStats,
26
+ WeeklyStats,
27
+ )
28
+ from i18n import t
29
+
30
+ console = Console()
31
+
32
+
33
+ def _is_light_theme() -> bool:
34
+ theme = os.environ.get("TT_THEME", "").lower()
35
+ if theme == "light":
36
+ return True
37
+ if theme == "dark":
38
+ return False
39
+ colorfgbg = os.environ.get("COLORFGBG", "")
40
+ if colorfgbg:
41
+ parts = colorfgbg.split(";")
42
+ try:
43
+ return int(parts[-1]) > 8
44
+ except (ValueError, IndexError):
45
+ pass
46
+ return False
47
+
48
+
49
+ class _S:
50
+ """语义化样式,根据终端主题自动切换"""
51
+ light = _is_light_theme()
52
+ dim = "grey50" if light else "dim"
53
+ token = "dark_cyan" if light else "dim cyan"
54
+ token_bold = "bold dark_cyan" if light else "bold cyan"
55
+ cost = "rgb(180,130,0)" if light else "dim yellow"
56
+ cost_bold = "bold rgb(180,130,0)" if light else "bold yellow"
57
+ accent = "bold dark_green" if light else "bold green"
58
+ bar_low = "dark_green" if light else "green"
59
+ bar_mid = "rgb(200,150,0)" if light else "yellow"
60
+ bar_high = "red"
61
+ good = "dark_green" if light else "green"
62
+ warn = "rgb(200,150,0)" if light else "yellow"
63
+ bad = "red"
64
+
65
+
66
+ def _width_mode() -> str:
67
+ w = console.width
68
+ if w < 100:
69
+ return "compact"
70
+ if w < 120:
71
+ return "medium"
72
+ return "wide"
73
+
74
+
75
+ AGENT_SHORT = {"claude-code": "CC", "codex": "Codex"}
76
+
77
+ AGENT_LABEL = {"claude-code": "Claude Code", "codex": "Codex"}
78
+
79
+
80
+ class _HasAgentId(Protocol):
81
+ agent_id: str
82
+
83
+
84
+ def _is_multi_agent(stats: Iterable[_HasAgentId]) -> bool:
85
+ return len(set(s.agent_id for s in stats if s.agent_id)) > 1
86
+
87
+
88
+ def _group_by_agent[StatT: _HasAgentId](stats: Iterable[StatT]) -> dict[str, list[StatT]]:
89
+ by_agent: dict[str, list[StatT]] = defaultdict(list)
90
+ for s in stats:
91
+ by_agent[s.agent_id].append(s)
92
+ return by_agent
93
+
94
+
95
+ MODEL_SHORT = {
96
+ "claude-opus-4-6": "Opus 4.6",
97
+ "claude-opus-4-7": "Opus 4.7",
98
+ "claude-opus-4-8": "Opus 4.8",
99
+ "claude-sonnet-4-6": "Sonnet 4.6",
100
+ "claude-sonnet-5": "Sonnet 5",
101
+ "claude-sonnet": "Sonnet",
102
+ "claude-haiku-4-5-20251001": "Haiku 4.5",
103
+ "claude-haiku": "Haiku",
104
+ "claude-fable-5": "Fable 5",
105
+ }
106
+
107
+
108
+ def _model_short(model: str) -> str:
109
+ if model in MODEL_SHORT:
110
+ return MODEL_SHORT[model]
111
+ if "/" in model:
112
+ return model.split("/")[-1][:16]
113
+ return model[:16]
114
+
115
+
116
+ def _fmt_tokens(n: int) -> str:
117
+ if n >= 1_000_000_000:
118
+ return f"{n / 1_000_000_000:.2f}B"
119
+ if n >= 1_000_000:
120
+ return f"{n / 1_000_000:.1f}M"
121
+ if n >= 1_000:
122
+ return f"{n / 1_000:.1f}K"
123
+ return str(n)
124
+
125
+
126
+ def _fmt_cost(usd: float | None) -> str:
127
+ if usd is None:
128
+ return "--"
129
+ if usd >= 100:
130
+ return f"${usd:.0f}"
131
+ if usd >= 1:
132
+ return f"${usd:.2f}"
133
+ if usd > 0:
134
+ return f"${usd:.3f}"
135
+ return "$0"
136
+
137
+
138
+ def _fmt_duration(minutes: float) -> str:
139
+ if minutes >= 60:
140
+ h = int(minutes // 60)
141
+ m = int(minutes % 60)
142
+ return f"{h}h{m:02d}m"
143
+ return f"{int(minutes)}min"
144
+
145
+
146
+ def _display_width(s: str) -> int:
147
+ w = 0
148
+ for ch in s:
149
+ w += 2 if ord(ch) > 0x7F else 1
150
+ return w
151
+
152
+
153
+ def _append_bar(lines: Text, label: str, pct: float,
154
+ bar_width: int, suffix: str = "") -> None:
155
+ filled = int(pct / 100 * bar_width)
156
+ bar = "█" * filled + "░" * (bar_width - filled)
157
+ bar_style = _S.bar_high if pct > 80 else _S.bar_mid if pct > 50 else _S.bar_low
158
+ lines.append(label, style=_S.dim)
159
+ lines.append(bar, style=bar_style)
160
+ lines.append(f" {pct:.0f}%", style=bar_style)
161
+ if suffix:
162
+ lines.append(suffix, style=_S.dim)
163
+ lines.append("\n")
164
+
165
+
166
+ def _append_trend(lines: Text, current: float, previous: float) -> None:
167
+ arrow = "↑" if current >= previous else "↓"
168
+ style = _S.bad if current >= previous else _S.good
169
+ lines.append(f"{arrow}", style=style)
170
+
171
+
172
+ def _token_heat_style(ratio: float) -> str:
173
+ if ratio > 0.8:
174
+ return f"bold {_S.bad}"
175
+ if ratio > 0.5:
176
+ return f"bold {_S.warn}"
177
+ return "bold"
178
+
179
+
180
+ def _pct_style(pct: float) -> str:
181
+ return _S.bar_high if pct > 80 else _S.bar_mid if pct > 50 else _S.bar_low
182
+
183
+
184
+ def _render_rate_bar(lines: Text, label: str, pct: float,
185
+ resets_at: int | None, bar_width: int,
186
+ date_fmt: str = "%H:%M") -> None:
187
+ reset_suffix = ""
188
+ if resets_at:
189
+ reset_dt = datetime.fromtimestamp(resets_at, tz=timezone.utc)
190
+ reset_suffix = f" {t('reset_at', time=reset_dt.strftime(date_fmt))}"
191
+ _append_bar(lines, f" {label} ", pct, bar_width, reset_suffix)
192
+
193
+
194
+ def _render_week_section(lines: Text, week: WeeklyStats,
195
+ last_week: WeeklyStats | None = None) -> None:
196
+ now = datetime.now(timezone.utc)
197
+ elapsed_days = now.weekday() + 1
198
+ daily_avg_cost = week.cost_usd / elapsed_days if elapsed_days > 0 else 0
199
+ lines.append(f" Token {_fmt_tokens(week.total_tokens)}", style=_S.token)
200
+ if last_week:
201
+ _append_trend(lines, week.total_tokens, last_week.total_tokens)
202
+ lines.append(f" Output: {_fmt_tokens(week.output_tokens)}", style=_S.dim)
203
+ lines.append(f" {t('rate_per_day', rate=_fmt_tokens(week.total_tokens // elapsed_days))}\n", style=_S.dim)
204
+ lines.append(f" {t('cost_label')} {_fmt_cost(week.cost_usd)}", style=_S.cost)
205
+ lines.append(f" {t('daily_avg', cost=_fmt_cost(daily_avg_cost))}", style=_S.dim)
206
+ lines.append("\n")
207
+ lines.append(f" {t('msg_session', msgs=week.message_count, sessions=week.session_count)}", style=_S.dim)
208
+
209
+
210
+ def render_tab_bar(agent_names: list[str], current: int) -> None:
211
+ line = Text()
212
+ line.append(" ")
213
+ compact = console.width < 72
214
+ for i, name in enumerate(agent_names):
215
+ if i > 0:
216
+ line.append(" │ ", style=_S.dim)
217
+ label = AGENT_SHORT.get("claude-code" if name == "Claude Code" else name.lower(), name)
218
+ if compact and name == "Claude Code":
219
+ label = "CC"
220
+ elif compact:
221
+ label = name[:8]
222
+ if i == current:
223
+ line.append(f" {label} ", style="bold reverse")
224
+ else:
225
+ line.append(f" {label} ", style=_S.dim)
226
+ help_text = t("tab_help_compact") if compact else t("tab_help")
227
+ line.append(help_text, style=_S.dim)
228
+ console.print(line)
229
+
230
+
231
+ def _project_short(project: str) -> str:
232
+ return project if project else "unknown"
233
+
234
+
235
+ def _render_header(agents: list[str], total_tokens: int, total_cost: float,
236
+ total_sessions: int, total_messages: int, days: int,
237
+ top_margin: bool = True) -> None:
238
+ agent_text = " ".join(f"[{_S.good}]●[/{_S.good}] {a}" for a in agents)
239
+ if top_margin:
240
+ console.print()
241
+ console.print(Panel(
242
+ f"[bold]Token Tracker[/bold] {agent_text}",
243
+ border_style="blue",
244
+ padding=(0, 1),
245
+ ))
246
+
247
+ lines = Text()
248
+ lines.append(t("history_overview"), style="bold")
249
+ lines.append(" Token: ", style=_S.dim)
250
+ lines.append(f"{_fmt_tokens(total_tokens)}", style=_S.token_bold)
251
+ lines.append(f" {t('cost_colon')}", style=_S.dim)
252
+ lines.append(f"{_fmt_cost(total_cost)}", style=_S.cost_bold)
253
+ lines.append(f" {t('sessions_colon')}", style=_S.dim)
254
+ lines.append(f"{total_sessions}", style="bold")
255
+ lines.append(f" {t('messages_colon')}", style=_S.dim)
256
+ lines.append(f"{total_messages}", style="bold")
257
+ lines.append(f" {t('days_colon')}", style=_S.dim)
258
+ lines.append(f"{days}", style=_S.accent)
259
+ console.print(lines)
260
+
261
+
262
+ def _render_agent_summaries(stats_list: list[DailyStats], multi_agent: bool) -> None:
263
+ if not multi_agent:
264
+ return
265
+ by_agent: dict[str, dict[str, Any]] = defaultdict(lambda: {"tokens": 0, "cost": 0.0, "sessions": 0, "messages": 0})
266
+ for s in stats_list:
267
+ if not s.agent_id:
268
+ continue
269
+ a = by_agent[s.agent_id]
270
+ a["tokens"] += s.total_tokens
271
+ a["cost"] += s.cost_usd
272
+ a["sessions"] += s.session_count
273
+ a["messages"] += s.message_count
274
+ if len(by_agent) < 2:
275
+ return
276
+ for agent_id, d in sorted(by_agent.items()):
277
+ lines = Text()
278
+ label = AGENT_LABEL.get(agent_id, agent_id)
279
+ lines.append(f"{label}", style="bold")
280
+ lines.append(" Token: ", style=_S.dim)
281
+ lines.append(f"{_fmt_tokens(d['tokens'])}", style=_S.token_bold)
282
+ lines.append(f" {t('cost_colon')}", style=_S.dim)
283
+ lines.append(f"{_fmt_cost(d['cost'])}", style=_S.cost_bold)
284
+ lines.append(f" {t('sessions_colon')}", style=_S.dim)
285
+ lines.append(f"{d['sessions']}", style="bold")
286
+ lines.append(f" {t('messages_colon')}", style=_S.dim)
287
+ lines.append(f"{d['messages']}", style="bold")
288
+ console.print(lines)
289
+
290
+
291
+ def render_dashboard(
292
+ daily_stats: list[DailyStats],
293
+ weekly_stats: list[WeeklyStats],
294
+ monthly_stats: list[MonthlyStats],
295
+ sessions: list[SessionStats],
296
+ blocks: list[SessionBlock],
297
+ rate_limits: RateLimits | None = None,
298
+ p90: P90Limits | None = None,
299
+ agents: list[str] | None = None,
300
+ session_limit: int = 10,
301
+ top_margin: bool = True,
302
+ session_title: str | None = None,
303
+ session_titles: dict[str, str] | None = None,
304
+ ) -> None:
305
+ if not daily_stats:
306
+ console.print(f"[{_S.warn}]{t('no_data')}[/{_S.warn}]")
307
+ return
308
+
309
+ total_tokens = sum(s.total_tokens for s in daily_stats)
310
+ total_cost = sum(s.cost_usd for s in daily_stats)
311
+ total_msgs = sum(s.message_count for s in daily_stats)
312
+ total_sessions = sum(s.session_count for s in daily_stats)
313
+
314
+ _render_header(
315
+ agents or ["Claude Code"],
316
+ total_tokens,
317
+ total_cost,
318
+ total_sessions,
319
+ total_msgs,
320
+ len(daily_stats),
321
+ top_margin=top_margin,
322
+ )
323
+
324
+ # --- 本月概览 ---
325
+ if monthly_stats:
326
+ last_month = monthly_stats[-2] if len(monthly_stats) >= 2 else None
327
+ _render_month_overview(monthly_stats[-1], last_month)
328
+
329
+ # --- 数据面板 ---
330
+ cur_week = weekly_stats[-1] if weekly_stats else None
331
+ last_week = weekly_stats[-2] if len(weekly_stats) >= 2 else None
332
+
333
+ has_limits = rate_limits and (rate_limits.five_hour_pct is not None or rate_limits.seven_day_pct is not None)
334
+ if p90 and not has_limits:
335
+ today = daily_stats[-1] if daily_stats else None
336
+ yesterday = daily_stats[-2] if len(daily_stats) >= 2 else None
337
+ if today:
338
+ _render_daily_panel(today, yesterday, p90, cur_week, last_week)
339
+ else:
340
+ active_blocks = [b for b in blocks if not b.is_gap and b.is_active]
341
+ finished_blocks = [b for b in blocks if not b.is_gap and not b.is_active]
342
+ last_block = finished_blocks[-1] if finished_blocks else None
343
+ if active_blocks:
344
+ for b in active_blocks:
345
+ _render_active_block(b, rate_limits, cur_week, last_block, last_week)
346
+ elif rate_limits:
347
+ _render_idle_panel(rate_limits, cur_week, last_week)
348
+
349
+ # --- 最近会话 ---
350
+ if sessions and session_limit > 0:
351
+ _render_recent_sessions(
352
+ sessions[:session_limit],
353
+ title=session_title,
354
+ session_titles=session_titles,
355
+ )
356
+
357
+ console.print()
358
+
359
+
360
+ def _render_month_overview(month: MonthlyStats, last_month: MonthlyStats | None = None) -> None:
361
+ now = datetime.now(timezone.utc)
362
+ elapsed_days = now.day
363
+ daily_avg_cost = month.cost_usd / elapsed_days if elapsed_days > 0 else 0
364
+
365
+ lines = Text()
366
+ lines.append(t("month_overview"), style="bold")
367
+
368
+ lines.append(" Token: ", style=_S.dim)
369
+ lines.append(f"{_fmt_tokens(month.total_tokens)}", style=_S.token_bold)
370
+ if last_month:
371
+ _append_trend(lines, month.total_tokens, last_month.total_tokens)
372
+
373
+ lines.append(f" {t('cost_colon')}", style=_S.dim)
374
+ lines.append(f"{_fmt_cost(month.cost_usd)}", style=_S.cost_bold)
375
+
376
+ lines.append(f" {t('sessions_colon')}", style=_S.dim)
377
+ lines.append(f"{month.session_count}", style="bold")
378
+ lines.append(f" {t('messages_colon')}", style=_S.dim)
379
+ lines.append(f"{month.message_count}", style="bold")
380
+ lines.append(f" {t('daily_avg_colon')}", style=_S.dim)
381
+ lines.append(f"{_fmt_cost(daily_avg_cost)}", style=_S.cost)
382
+
383
+ console.print(lines)
384
+
385
+
386
+ def _render_recent_sessions(
387
+ stats: list[SessionStats],
388
+ title: str | None = None,
389
+ session_titles: dict[str, str] | None = None,
390
+ ) -> None:
391
+ multi_agent = _is_multi_agent(stats)
392
+ mode = _width_mode()
393
+ table = Table(
394
+ title=title or t("recent_sessions"),
395
+ box=box.SIMPLE_HEAVY,
396
+ header_style="bold",
397
+ padding=(0, 1),
398
+ expand=True,
399
+ )
400
+ table.add_column(t("col_time"), style=_S.token, no_wrap=True)
401
+ if multi_agent:
402
+ table.add_column(t("col_source"), no_wrap=True)
403
+ table.add_column(t("col_project"), no_wrap=True, max_width=14)
404
+ if mode != "compact":
405
+ table.add_column(t("col_session_title"), no_wrap=True, max_width=20)
406
+ table.add_column(t("col_model"), style=_S.cost, no_wrap=True)
407
+ table.add_column("Input", justify="right")
408
+ table.add_column("Output", justify="right")
409
+ table.add_column(t("col_total_tokens"), justify="right", style=_S.token_bold)
410
+ table.add_column(t("col_cost"), justify="right", style=_S.good)
411
+ table.add_column(t("col_messages"), justify="right", style=_S.dim)
412
+
413
+ for s in stats:
414
+ row: list[Any] = [s.start_time.strftime("%m-%d %H:%M")]
415
+ if multi_agent:
416
+ row.append(AGENT_SHORT.get(s.agent_id, s.agent_id))
417
+ row.append(_project_short(s.project))
418
+ if mode != "compact":
419
+ row.append((session_titles or {}).get(s.session_id, ""))
420
+ row.append(_model_short(s.model))
421
+ row += [
422
+ _fmt_tokens(s.input_tokens),
423
+ _fmt_tokens(s.output_tokens),
424
+ Text(_fmt_tokens(s.total_tokens), style=_S.token_bold),
425
+ _fmt_cost(s.cost_usd),
426
+ str(s.message_count),
427
+ ]
428
+ table.add_row(*row)
429
+
430
+ console.print(table)
431
+
432
+
433
+ def render_daily(stats: list[DailyStats], agents: list[str] | None = None) -> None:
434
+ if not stats:
435
+ console.print(f"[{_S.warn}]{t('no_data')}[/{_S.warn}]")
436
+ return
437
+
438
+ multi_agent = _is_multi_agent(stats)
439
+ dates = set(s.date for s in stats)
440
+ total_tokens = sum(s.total_tokens for s in stats)
441
+ total_cost = sum(s.cost_usd for s in stats)
442
+ total_msgs = sum(s.message_count for s in stats)
443
+ total_sessions = sum(s.session_count for s in stats)
444
+
445
+ _render_header(agents or ["Claude Code"], total_tokens, total_cost, total_sessions, total_msgs, len(dates))
446
+ _render_agent_summaries(stats, multi_agent)
447
+
448
+ mode = _width_mode()
449
+ table = Table(box=box.SIMPLE_HEAVY, header_style="bold", padding=(0, 1), expand=True)
450
+ table.add_column(t("col_date"), style=_S.token, no_wrap=True)
451
+ if multi_agent:
452
+ table.add_column(t("col_source"), no_wrap=True)
453
+ if mode != "compact":
454
+ table.add_column("Input", justify="right")
455
+ table.add_column("Output", justify="right")
456
+ if mode == "wide":
457
+ table.add_column("Cache", justify="right")
458
+ table.add_column(t("col_total_tokens"), justify="right", style="bold")
459
+ table.add_column(t("col_cost"), justify="right", style=_S.good)
460
+ table.add_column(t("col_sessions"), justify="right", style=_S.dim)
461
+ table.add_column(t("col_messages"), justify="right", style=_S.dim)
462
+
463
+ max_tokens = max(s.total_tokens for s in stats) if stats else 1
464
+
465
+ for s in stats:
466
+ cache_total = s.cache_creation_tokens + s.cache_read_tokens
467
+ row: list[Any] = [s.date]
468
+ if multi_agent:
469
+ row.append(AGENT_SHORT.get(s.agent_id, s.agent_id))
470
+ if mode != "compact":
471
+ row += [_fmt_tokens(s.input_tokens), _fmt_tokens(s.output_tokens)]
472
+ if mode == "wide":
473
+ row.append(_fmt_tokens(cache_total))
474
+ row += [
475
+ Text(_fmt_tokens(s.total_tokens), style=_token_heat_style(s.total_tokens / max_tokens)),
476
+ _fmt_cost(s.cost_usd),
477
+ str(s.session_count),
478
+ str(s.message_count),
479
+ ]
480
+ table.add_row(*row)
481
+
482
+ console.print(table)
483
+ console.print()
484
+
485
+
486
+ def _render_weekly_table(stats: list[WeeklyStats], title: str | None = None) -> None:
487
+ mode = _width_mode()
488
+ table = Table(
489
+ title=title, title_style="bold", box=box.SIMPLE_HEAVY,
490
+ header_style="bold", padding=(0, 1), expand=True,
491
+ )
492
+ table.add_column(t("col_week"), style=_S.token, no_wrap=True)
493
+ if mode != "compact":
494
+ table.add_column("Input", justify="right")
495
+ table.add_column("Output", justify="right")
496
+ if mode == "wide":
497
+ table.add_column("Cache", justify="right")
498
+ table.add_column(t("col_total_tokens"), justify="right", style="bold")
499
+ table.add_column(t("col_cost"), justify="right", style=_S.good)
500
+ table.add_column(t("col_sessions"), justify="right", style=_S.dim)
501
+ table.add_column(t("col_messages"), justify="right", style=_S.dim)
502
+
503
+ max_tokens = max(s.total_tokens for s in stats) if stats else 1
504
+
505
+ for s in stats:
506
+ cache_total = s.cache_creation_tokens + s.cache_read_tokens
507
+ week_label = f"{s.week_start} ~ {s.week_end}"
508
+ row: list[Any] = [week_label]
509
+ if mode != "compact":
510
+ row += [_fmt_tokens(s.input_tokens), _fmt_tokens(s.output_tokens)]
511
+ if mode == "wide":
512
+ row.append(_fmt_tokens(cache_total))
513
+ row += [
514
+ Text(_fmt_tokens(s.total_tokens), style=_token_heat_style(s.total_tokens / max_tokens)),
515
+ _fmt_cost(s.cost_usd),
516
+ str(s.session_count),
517
+ str(s.message_count),
518
+ ]
519
+ table.add_row(*row)
520
+
521
+ table.add_section()
522
+ total_row: list[Any] = [f"[bold]{t('total_row')}[/bold]"]
523
+ if mode != "compact":
524
+ total_row += [
525
+ _fmt_tokens(sum(s.input_tokens for s in stats)),
526
+ _fmt_tokens(sum(s.output_tokens for s in stats)),
527
+ ]
528
+ if mode == "wide":
529
+ total_row.append(_fmt_tokens(sum(s.cache_creation_tokens + s.cache_read_tokens for s in stats)))
530
+ total_row += [
531
+ f"[{_S.token_bold}]{_fmt_tokens(sum(s.total_tokens for s in stats))}[/{_S.token_bold}]",
532
+ f"[{_S.cost_bold}]{_fmt_cost(sum(s.cost_usd for s in stats))}[/{_S.cost_bold}]",
533
+ str(sum(s.session_count for s in stats)),
534
+ str(sum(s.message_count for s in stats)),
535
+ ]
536
+ table.add_row(*total_row)
537
+
538
+ console.print(table)
539
+
540
+
541
+ def render_weekly(stats: list[WeeklyStats], agents: list[str] | None = None) -> None:
542
+ if not stats:
543
+ console.print(f"[{_S.warn}]{t('no_data')}[/{_S.warn}]")
544
+ return
545
+
546
+ multi_agent = _is_multi_agent(stats)
547
+ weeks = set(s.week for s in stats)
548
+ total_tokens = sum(s.total_tokens for s in stats)
549
+ total_cost = sum(s.cost_usd for s in stats)
550
+ total_msgs = sum(s.message_count for s in stats)
551
+ total_sessions = sum(s.session_count for s in stats)
552
+
553
+ _render_header(agents or ["Claude Code"], total_tokens, total_cost, total_sessions, total_msgs, len(weeks) * 7)
554
+
555
+ if multi_agent:
556
+ for agent_id, group in sorted(_group_by_agent(stats).items()):
557
+ _render_weekly_table(group, title=AGENT_LABEL.get(agent_id, agent_id))
558
+ else:
559
+ _render_weekly_table(stats)
560
+
561
+ console.print()
562
+
563
+
564
+ def _render_monthly_table(stats: list[MonthlyStats], title: str | None = None) -> None:
565
+ mode = _width_mode()
566
+ table = Table(
567
+ title=title, title_style="bold", box=box.SIMPLE_HEAVY,
568
+ header_style="bold", padding=(0, 1), expand=True,
569
+ )
570
+ table.add_column(t("col_month"), style=_S.token, no_wrap=True)
571
+ if mode != "compact":
572
+ table.add_column("Input", justify="right")
573
+ table.add_column("Output", justify="right")
574
+ if mode == "wide":
575
+ table.add_column(t("col_cache_create"), justify="right")
576
+ table.add_column(t("col_cache_read"), justify="right")
577
+ table.add_column(t("col_total_tokens"), justify="right", style="bold")
578
+ table.add_column(t("col_cost"), justify="right", style=_S.good)
579
+ table.add_column(t("col_sessions"), justify="right", style=_S.dim)
580
+ table.add_column(t("col_messages"), justify="right", style=_S.dim)
581
+
582
+ for s in stats:
583
+ row: list[Any] = [s.month]
584
+ if mode != "compact":
585
+ row += [_fmt_tokens(s.input_tokens), _fmt_tokens(s.output_tokens)]
586
+ if mode == "wide":
587
+ row += [_fmt_tokens(s.cache_creation_tokens), _fmt_tokens(s.cache_read_tokens)]
588
+ row += [
589
+ _fmt_tokens(s.total_tokens),
590
+ _fmt_cost(s.cost_usd),
591
+ str(s.session_count),
592
+ str(s.message_count),
593
+ ]
594
+ table.add_row(*row)
595
+
596
+ table.add_section()
597
+ total_row: list[Any] = [f"[bold]{t('total_row')}[/bold]"]
598
+ if mode != "compact":
599
+ total_row += [
600
+ _fmt_tokens(sum(s.input_tokens for s in stats)),
601
+ _fmt_tokens(sum(s.output_tokens for s in stats)),
602
+ ]
603
+ if mode == "wide":
604
+ total_row += [
605
+ _fmt_tokens(sum(s.cache_creation_tokens for s in stats)),
606
+ _fmt_tokens(sum(s.cache_read_tokens for s in stats)),
607
+ ]
608
+ total_row += [
609
+ f"[{_S.token_bold}]{_fmt_tokens(sum(s.total_tokens for s in stats))}[/{_S.token_bold}]",
610
+ f"[{_S.cost_bold}]{_fmt_cost(sum(s.cost_usd for s in stats))}[/{_S.cost_bold}]",
611
+ str(sum(s.session_count for s in stats)),
612
+ str(sum(s.message_count for s in stats)),
613
+ ]
614
+ table.add_row(*total_row)
615
+
616
+ console.print(table)
617
+
618
+
619
+ def render_monthly(stats: list[MonthlyStats], agents: list[str] | None = None) -> None:
620
+ if not stats:
621
+ console.print(f"[{_S.warn}]{t('no_data')}[/{_S.warn}]")
622
+ return
623
+
624
+ multi_agent = _is_multi_agent(stats)
625
+ months = set(s.month for s in stats)
626
+ total_tokens = sum(s.total_tokens for s in stats)
627
+ total_cost = sum(s.cost_usd for s in stats)
628
+ total_msgs = sum(s.message_count for s in stats)
629
+ total_sessions = sum(s.session_count for s in stats)
630
+ days = len(months) * 30
631
+
632
+ _render_header(agents or ["Claude Code"], total_tokens, total_cost, total_sessions, total_msgs, days)
633
+
634
+ if multi_agent:
635
+ for agent_id, group in sorted(_group_by_agent(stats).items()):
636
+ _render_monthly_table(group, title=AGENT_LABEL.get(agent_id, agent_id))
637
+ else:
638
+ _render_monthly_table(stats)
639
+
640
+ if len(stats) > 1:
641
+ console.print()
642
+ _render_model_breakdown(stats)
643
+
644
+ console.print()
645
+
646
+
647
+ def _render_model_breakdown(stats: list[MonthlyStats]) -> None:
648
+ all_models: dict[str, int] = {}
649
+ for s in stats:
650
+ for model, tokens in s.models.items():
651
+ all_models[model] = all_models.get(model, 0) + tokens
652
+
653
+ if not all_models:
654
+ return
655
+
656
+ total = sum(all_models.values())
657
+ sorted_models = sorted(all_models.items(), key=lambda x: x[1], reverse=True)
658
+
659
+ table = Table(
660
+ title=t("model_breakdown"),
661
+ box=box.SIMPLE,
662
+ header_style="bold",
663
+ padding=(0, 1),
664
+ expand=True,
665
+ )
666
+ table.add_column(t("col_model"), style=_S.cost, no_wrap=True)
667
+ table.add_column("Token", justify="right")
668
+ table.add_column(t("col_ratio"), justify="right")
669
+ table.add_column("", min_width=20)
670
+
671
+ for model, tokens in sorted_models[:8]:
672
+ pct = tokens / total * 100 if total > 0 else 0
673
+ bar_width = int(pct / 100 * 20)
674
+ bar_text = "█" * bar_width + "░" * (20 - bar_width)
675
+
676
+ if pct > 50:
677
+ bar_style = _S.token_bold
678
+ elif pct > 20:
679
+ bar_style = "blue"
680
+ else:
681
+ bar_style = _S.dim
682
+
683
+ table.add_row(
684
+ _model_short(model),
685
+ _fmt_tokens(tokens),
686
+ f"{pct:.1f}%",
687
+ Text(bar_text, style=bar_style),
688
+ )
689
+
690
+ console.print(table)
691
+
692
+
693
+ def render_sessions(stats: list[SessionStats], limit: int = 20) -> None:
694
+ if not stats:
695
+ console.print(f"[{_S.warn}]{t('no_data')}[/{_S.warn}]")
696
+ return
697
+
698
+ multi_agent = _is_multi_agent(stats)
699
+ shown = stats[:limit]
700
+ total_tokens = sum(s.total_tokens for s in shown)
701
+ total_cost = sum(s.cost_usd for s in shown)
702
+
703
+ console.print()
704
+ console.print(Panel(
705
+ f"[bold]Token Tracker[/bold] {t('session_summary', shown=len(shown), total=len(stats))} "
706
+ f"Token: [{_S.token_bold}]{_fmt_tokens(total_tokens)}[/{_S.token_bold}] "
707
+ f"{t('cost_colon')}[{_S.cost_bold}]{_fmt_cost(total_cost)}[/{_S.cost_bold}]",
708
+ border_style="blue",
709
+ padding=(0, 1),
710
+ ))
711
+
712
+ mode = _width_mode()
713
+ table = Table(box=box.SIMPLE_HEAVY, header_style="bold", padding=(0, 1))
714
+ table.add_column(t("col_time"), style=_S.token, no_wrap=True)
715
+ if multi_agent:
716
+ table.add_column(t("col_source"), no_wrap=True)
717
+ table.add_column(t("col_project"), no_wrap=True, max_width=14)
718
+ if mode != "compact":
719
+ table.add_column(t("col_model"), style=_S.cost, no_wrap=True)
720
+ table.add_column(t("col_duration"), justify="right")
721
+ if mode == "wide":
722
+ table.add_column("Input", justify="right")
723
+ table.add_column("Output", justify="right")
724
+ table.add_column(t("col_total_tokens"), justify="right", style="bold")
725
+ table.add_column(t("col_cost"), justify="right", style=_S.good)
726
+ table.add_column(t("col_messages"), justify="right", style=_S.dim)
727
+
728
+ max_tokens = max(s.total_tokens for s in shown) if shown else 1
729
+
730
+ for s in shown:
731
+ row: list[Any] = [s.start_time.strftime("%m-%d %H:%M")]
732
+ if multi_agent:
733
+ row.append(AGENT_SHORT.get(s.agent_id, s.agent_id))
734
+ row.append(_project_short(s.project))
735
+ if mode != "compact":
736
+ row += [_model_short(s.model), _fmt_duration(s.duration_minutes)]
737
+ if mode == "wide":
738
+ row += [_fmt_tokens(s.input_tokens), _fmt_tokens(s.output_tokens)]
739
+ row += [
740
+ Text(_fmt_tokens(s.total_tokens), style=_token_heat_style(s.total_tokens / max_tokens)),
741
+ _fmt_cost(s.cost_usd),
742
+ str(s.message_count),
743
+ ]
744
+ table.add_row(*row)
745
+
746
+ console.print(table)
747
+ console.print()
748
+
749
+
750
+ def _render_daily_panel(
751
+ today: DailyStats,
752
+ yesterday: DailyStats | None,
753
+ p90: P90Limits,
754
+ week: WeeklyStats | None = None,
755
+ last_week: WeeklyStats | None = None,
756
+ ) -> None:
757
+ bar_width = 20 if _width_mode() == "compact" else 30
758
+ lines = Text()
759
+ lines.append(f"{t('daily_panel_title')}\n\n", style="bold")
760
+
761
+ p90_items = [
762
+ ("Token Usage", today.total_tokens, p90.token_limit, _fmt_tokens),
763
+ ("Cost Usage", today.cost_usd, p90.cost_limit, _fmt_cost),
764
+ ("Msg Usage", today.message_count, p90.message_limit, lambda x: t("msg_unit", n=x)),
765
+ ]
766
+ max_pct = 0.0
767
+ for label, current, limit, unit_fmt in p90_items:
768
+ pct = min(current / limit * 100, 100) if limit > 0 else 0
769
+ max_pct = max(max_pct, pct)
770
+ display_label = f" {label}" + " " * (14 - _display_width(label))
771
+ suffix = f" {unit_fmt(current)} / {unit_fmt(limit)}"
772
+ _append_bar(lines, display_label, pct, bar_width, suffix)
773
+ lines.append("\n")
774
+
775
+ lines.append(f" Token {_fmt_tokens(today.total_tokens)}", style=_S.token)
776
+ if yesterday:
777
+ _append_trend(lines, today.total_tokens, yesterday.total_tokens)
778
+ lines.append(f" Output: {_fmt_tokens(today.output_tokens)}", style=_S.dim)
779
+ lines.append(f" Cache: {_fmt_tokens(today.cache_creation_tokens + today.cache_read_tokens)}\n", style=_S.dim)
780
+ lines.append(f" {t('cost_label')} {_fmt_cost(today.cost_usd)}", style=_S.cost)
781
+ if yesterday:
782
+ _append_trend(lines, today.cost_usd, yesterday.cost_usd)
783
+ lines.append(f" {t('session_msg', sessions=today.session_count, msgs=today.message_count)}", style=_S.dim)
784
+ if today.message_count > 0:
785
+ tokens_per_msg = today.total_tokens // today.message_count
786
+ lines.append(f" {t('rate_per_msg', rate=_fmt_tokens(tokens_per_msg))}", style=_S.dim)
787
+
788
+ if week:
789
+ now = datetime.now(timezone.utc)
790
+ elapsed_days = now.weekday() + 1
791
+ daily_avg_cost = week.cost_usd / elapsed_days if elapsed_days > 0 else 0
792
+
793
+ lines.append(f"\n\n {t('week_token', tokens=_fmt_tokens(week.total_tokens))}", style=_S.token)
794
+ if last_week:
795
+ _append_trend(lines, week.total_tokens, last_week.total_tokens)
796
+ lines.append(f" Output: {_fmt_tokens(week.output_tokens)}", style=_S.dim)
797
+ lines.append(f" {t('rate_per_day', rate=_fmt_tokens(week.total_tokens // elapsed_days))}\n", style=_S.dim)
798
+ lines.append(f" {t('week_cost')} {_fmt_cost(week.cost_usd)}", style=_S.cost)
799
+ if last_week:
800
+ _append_trend(lines, week.cost_usd, last_week.cost_usd)
801
+ lines.append(f" {t('daily_avg', cost=_fmt_cost(daily_avg_cost))}", style=_S.dim)
802
+ lines.append(f" {t('session_msg', sessions=week.session_count, msgs=week.message_count)}", style=_S.dim)
803
+
804
+ lines.append("\n")
805
+
806
+ console.print(Panel(lines, border_style=_pct_style(max_pct), padding=(0, 1)))
807
+
808
+
809
+ def _render_active_block(
810
+ b: SessionBlock,
811
+ rate_limits: RateLimits | None = None,
812
+ week: WeeklyStats | None = None,
813
+ last_block: SessionBlock | None = None,
814
+ last_week: WeeklyStats | None = None,
815
+ ) -> None:
816
+ now = datetime.now(timezone.utc)
817
+ elapsed = (now - b.start_time).total_seconds()
818
+ remaining = (b.end_time - now).total_seconds()
819
+
820
+ elapsed_min = int(elapsed / 60)
821
+ remaining_min = int(remaining / 60)
822
+ remaining_h = remaining_min // 60
823
+ remaining_m = remaining_min % 60
824
+
825
+ bar_width = 20 if _width_mode() == "compact" else 30
826
+
827
+ lines = Text()
828
+ lines.append(f"{t('active_panel_title')}\n\n", style="bold")
829
+
830
+ if rate_limits and rate_limits.five_hour_pct is not None:
831
+ _render_rate_bar(lines, t("limit_5h"), rate_limits.five_hour_pct,
832
+ rate_limits.five_hour_resets_at, bar_width)
833
+
834
+ lines.append(f" {t('time_label')} ", style=_S.dim)
835
+ lines.append(f"{t('time_elapsed', elapsed=elapsed_min, h=remaining_h, m=remaining_m)}\n", style=_S.dim)
836
+
837
+ lines.append(f" Token {_fmt_tokens(b.total_tokens)}", style=_S.token)
838
+ if last_block:
839
+ _append_trend(lines, b.total_tokens, last_block.total_tokens)
840
+ lines.append(f" Output: {_fmt_tokens(b.output_tokens)}", style=_S.dim)
841
+ lines.append(f" {t('rate_per_min', rate=_fmt_tokens(int(b.burn_rate)))}\n", style=_S.dim)
842
+ lines.append(f" {t('cost_label')} {_fmt_cost(b.cost_usd)}", style=_S.cost)
843
+ if rate_limits and rate_limits.model:
844
+ lines.append(f" {t('model_label', model=rate_limits.model)}", style=_S.dim)
845
+ lines.append("\n")
846
+ lines.append(f" {t('msg_count', n=len(b.entries))}", style=_S.dim)
847
+
848
+ if rate_limits and rate_limits.seven_day_pct is not None:
849
+ lines.append("\n\n")
850
+ _render_rate_bar(lines, t("limit_7d"), rate_limits.seven_day_pct,
851
+ rate_limits.seven_day_resets_at, bar_width, "%m-%d %H:%M")
852
+ if week:
853
+ _render_week_section(lines, week, last_week)
854
+
855
+ lines.append("\n")
856
+
857
+ pct = rate_limits.five_hour_pct if rate_limits and rate_limits.five_hour_pct is not None else 0
858
+ console.print(Panel(lines, border_style=_pct_style(pct), padding=(0, 1)))
859
+
860
+
861
+ def _render_idle_panel(
862
+ rate_limits: RateLimits,
863
+ week: WeeklyStats | None = None,
864
+ last_week: WeeklyStats | None = None,
865
+ ) -> None:
866
+ bar_width = 20 if _width_mode() == "compact" else 30
867
+ lines = Text()
868
+ lines.append(f"{t('idle_panel_title')}\n\n", style="bold")
869
+
870
+ if rate_limits.five_hour_pct is not None:
871
+ _render_rate_bar(lines, t("limit_5h"), rate_limits.five_hour_pct,
872
+ rate_limits.five_hour_resets_at, bar_width)
873
+
874
+ if rate_limits.seven_day_pct is not None:
875
+ if rate_limits.five_hour_pct is not None:
876
+ lines.append("\n")
877
+ _render_rate_bar(lines, t("limit_7d"), rate_limits.seven_day_pct,
878
+ rate_limits.seven_day_resets_at, bar_width, "%m-%d %H:%M")
879
+ if week:
880
+ _render_week_section(lines, week, last_week)
881
+
882
+ if rate_limits.model:
883
+ lines.append(f"\n {t('model_label', model=rate_limits.model)}", style=_S.dim)
884
+
885
+ lines.append("\n")
886
+
887
+ max_pct = max(rate_limits.five_hour_pct or 0, rate_limits.seven_day_pct or 0)
888
+ console.print(Panel(lines, border_style=_pct_style(max_pct), padding=(0, 1)))