cctally 1.7.0 → 1.7.1
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.
- package/CHANGELOG.md +7 -0
- package/bin/_cctally_alerts.py +231 -0
- package/bin/_cctally_cache.py +1432 -0
- package/bin/_cctally_config.py +560 -0
- package/bin/_cctally_dashboard.py +5218 -0
- package/bin/_cctally_db.py +1729 -0
- package/bin/_cctally_record.py +2120 -0
- package/bin/_cctally_refresh.py +812 -0
- package/bin/_cctally_release.py +751 -0
- package/bin/_cctally_setup.py +1571 -0
- package/bin/_cctally_sync_week.py +110 -0
- package/bin/_cctally_tui.py +4381 -0
- package/bin/_cctally_update.py +2132 -0
- package/bin/_lib_aggregators.py +712 -0
- package/bin/_lib_alerts_payload.py +194 -0
- package/bin/_lib_blocks.py +414 -0
- package/bin/_lib_diff_kernel.py +1618 -0
- package/bin/_lib_display_tz.py +361 -0
- package/bin/_lib_doctor.py +58 -0
- package/bin/_lib_five_hour.py +82 -0
- package/bin/_lib_jsonl.py +403 -0
- package/bin/_lib_pricing.py +520 -0
- package/bin/_lib_render.py +2785 -0
- package/bin/_lib_semver.py +105 -0
- package/bin/_lib_subscription_weeks.py +492 -0
- package/bin/cctally +11034 -35415
- package/package.json +24 -1
|
@@ -0,0 +1,2785 @@
|
|
|
1
|
+
"""Render kernel for cctally reporting subcommands.
|
|
2
|
+
|
|
3
|
+
Pure-fn layer (no I/O at import time): holds every ANSI-table renderer
|
|
4
|
+
and JSON shaper used by the ``daily`` / ``monthly`` / ``weekly`` /
|
|
5
|
+
``session`` / ``blocks`` / ``project`` / ``codex-{daily,monthly,session}`` /
|
|
6
|
+
``five-hour-blocks`` subcommands. Two contiguous source regions
|
|
7
|
+
collapse into one sibling here:
|
|
8
|
+
|
|
9
|
+
* Region A (was bin/cctally L2175-L4661, ~2,486 LOC): block /
|
|
10
|
+
bucket / weekly / codex-bucket / codex-session / claude-session /
|
|
11
|
+
project renderers and their JSON-shape siblings, plus the
|
|
12
|
+
``_CODEX_MONTHS`` table and the project-row label disambiguator.
|
|
13
|
+
* Region B (was bin/cctally L14350-L14507, ~158 LOC): the
|
|
14
|
+
5h-blocks-table render pair (``_five_hour_blocks_to_json`` +
|
|
15
|
+
``_render_five_hour_blocks_table``).
|
|
16
|
+
|
|
17
|
+
Sibling dependencies (loaded at module-load time via ``_load_lib``):
|
|
18
|
+
|
|
19
|
+
* ``_lib_blocks`` — ``Block`` (typing for ``_render_blocks_table``)
|
|
20
|
+
and ``BLOCK_DURATION`` (block-duration fallback).
|
|
21
|
+
* ``_lib_aggregators`` — the four bucket / session dataclasses
|
|
22
|
+
consumed by the bucket / weekly / codex / claude-session renderers
|
|
23
|
+
(``BucketUsage``, ``CodexBucketUsage``, ``CodexSessionUsage``,
|
|
24
|
+
``ClaudeSessionUsage``).
|
|
25
|
+
* ``_lib_subscription_weeks`` — ``SubWeek`` (typing + runtime field
|
|
26
|
+
access in ``_weekly_to_json`` / ``_render_weekly_table``).
|
|
27
|
+
* ``_lib_pricing`` — ``_short_model_name`` (model-name shortener
|
|
28
|
+
used across every breakdown-aware table).
|
|
29
|
+
* ``_lib_display_tz`` — ``_resolve_tz`` (IANA tz resolution for the
|
|
30
|
+
Codex session-table date columns).
|
|
31
|
+
|
|
32
|
+
``bin/cctally`` back-references via module-level callable shims
|
|
33
|
+
(spec §5.5; same precedent as ``bin/_cctally_record.py``'s 34 shims):
|
|
34
|
+
|
|
35
|
+
* ``_supports_color_stdout`` / ``_supports_unicode_stdout`` /
|
|
36
|
+
``_style_ansi`` — ANSI capability + style primitives.
|
|
37
|
+
* ``_fmt_num`` / ``_truncate_num`` — numeric formatting helpers
|
|
38
|
+
used by every render path.
|
|
39
|
+
* ``_boxed_table`` — generic boxed-table renderer reused by
|
|
40
|
+
``_render_five_hour_blocks_table``.
|
|
41
|
+
* ``_format_block_start`` — 5h-block Block-Start cell formatter
|
|
42
|
+
(consumed only by the 5h-blocks renderer).
|
|
43
|
+
|
|
44
|
+
Each shim resolves ``sys.modules['cctally'].X`` at CALL TIME (not
|
|
45
|
+
bind time), so monkeypatches on cctally's namespace propagate into the
|
|
46
|
+
moved code unchanged.
|
|
47
|
+
|
|
48
|
+
``bin/cctally`` eager-re-exports every public symbol below so the ~25
|
|
49
|
+
internal call sites + SourceFileLoader-based tests resolve unchanged.
|
|
50
|
+
|
|
51
|
+
Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
|
|
52
|
+
"""
|
|
53
|
+
from __future__ import annotations
|
|
54
|
+
|
|
55
|
+
import argparse
|
|
56
|
+
import datetime as dt
|
|
57
|
+
import json
|
|
58
|
+
import math
|
|
59
|
+
import os
|
|
60
|
+
import pathlib
|
|
61
|
+
import re
|
|
62
|
+
import sys
|
|
63
|
+
from typing import Any, Callable
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _cctally():
|
|
67
|
+
"""Resolve the current ``cctally`` module at call-time (spec §5.5)."""
|
|
68
|
+
return sys.modules["cctally"]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _load_lib(name: str):
|
|
72
|
+
cached = sys.modules.get(name)
|
|
73
|
+
if cached is not None:
|
|
74
|
+
return cached
|
|
75
|
+
import importlib.util as _ilu
|
|
76
|
+
p = pathlib.Path(__file__).resolve().parent / f"{name}.py"
|
|
77
|
+
spec = _ilu.spec_from_file_location(name, p)
|
|
78
|
+
mod = _ilu.module_from_spec(spec)
|
|
79
|
+
sys.modules[name] = mod
|
|
80
|
+
spec.loader.exec_module(mod)
|
|
81
|
+
return mod
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
_lib_blocks = _load_lib("_lib_blocks")
|
|
85
|
+
Block = _lib_blocks.Block
|
|
86
|
+
BLOCK_DURATION = _lib_blocks.BLOCK_DURATION
|
|
87
|
+
|
|
88
|
+
_lib_aggregators = _load_lib("_lib_aggregators")
|
|
89
|
+
BucketUsage = _lib_aggregators.BucketUsage
|
|
90
|
+
CodexBucketUsage = _lib_aggregators.CodexBucketUsage
|
|
91
|
+
CodexSessionUsage = _lib_aggregators.CodexSessionUsage
|
|
92
|
+
ClaudeSessionUsage = _lib_aggregators.ClaudeSessionUsage
|
|
93
|
+
|
|
94
|
+
_lib_subscription_weeks = _load_lib("_lib_subscription_weeks")
|
|
95
|
+
SubWeek = _lib_subscription_weeks.SubWeek
|
|
96
|
+
|
|
97
|
+
_lib_pricing = _load_lib("_lib_pricing")
|
|
98
|
+
_short_model_name = _lib_pricing._short_model_name
|
|
99
|
+
|
|
100
|
+
_lib_display_tz = _load_lib("_lib_display_tz")
|
|
101
|
+
_resolve_tz = _lib_display_tz._resolve_tz
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# Module-level back-ref shims. Each shim resolves
|
|
105
|
+
# ``sys.modules['cctally'].X`` at CALL TIME (not bind time), so
|
|
106
|
+
# monkeypatches on cctally's namespace propagate into the moved code
|
|
107
|
+
# unchanged. Mirrors the precedent established in
|
|
108
|
+
# ``bin/_cctally_record.py`` / ``bin/_cctally_cache.py``.
|
|
109
|
+
def _supports_color_stdout(*args, **kwargs):
|
|
110
|
+
return sys.modules["cctally"]._supports_color_stdout(*args, **kwargs)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _supports_unicode_stdout(*args, **kwargs):
|
|
114
|
+
return sys.modules["cctally"]._supports_unicode_stdout(*args, **kwargs)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _style_ansi(*args, **kwargs):
|
|
118
|
+
return sys.modules["cctally"]._style_ansi(*args, **kwargs)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _fmt_num(*args, **kwargs):
|
|
122
|
+
return sys.modules["cctally"]._fmt_num(*args, **kwargs)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _truncate_num(*args, **kwargs):
|
|
126
|
+
return sys.modules["cctally"]._truncate_num(*args, **kwargs)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _boxed_table(*args, **kwargs):
|
|
130
|
+
return sys.modules["cctally"]._boxed_table(*args, **kwargs)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _format_block_start(*args, **kwargs):
|
|
134
|
+
return sys.modules["cctally"]._format_block_start(*args, **kwargs)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# Optional dependency: zoneinfo.ZoneInfo is referenced only as a string
|
|
138
|
+
# annotation in moved code; no runtime import needed.
|
|
139
|
+
|
|
140
|
+
def _render_blocks_table(
|
|
141
|
+
blocks: list[Block],
|
|
142
|
+
breakdown: bool = False,
|
|
143
|
+
*,
|
|
144
|
+
now: dt.datetime | None = None,
|
|
145
|
+
tz: "ZoneInfo | None" = None,
|
|
146
|
+
) -> str:
|
|
147
|
+
"""Render blocks as a ccusage-style ANSI table with box-drawing borders.
|
|
148
|
+
|
|
149
|
+
Uses a two-pass approach matching upstream ccusage's ResponsiveTable:
|
|
150
|
+
Pass 1 - Build all cell content as plain strings (no ANSI, no padding).
|
|
151
|
+
Pass 2 - Compute column widths from content, then render with borders,
|
|
152
|
+
padding, and ANSI colors.
|
|
153
|
+
|
|
154
|
+
``now`` pins the current instant for ACTIVE-row elapsed/remaining
|
|
155
|
+
calculations (typically via ``_command_as_of()``). Defaults to wall-
|
|
156
|
+
clock UTC so production behavior is unchanged; fixture-based tests
|
|
157
|
+
pass a pinned value so goldens stay byte-stable.
|
|
158
|
+
|
|
159
|
+
``tz`` is the resolved display zone (``None`` means host local).
|
|
160
|
+
Block-start cells are rendered in this zone.
|
|
161
|
+
"""
|
|
162
|
+
if not blocks:
|
|
163
|
+
return "No session blocks found in the specified date range."
|
|
164
|
+
color = _supports_color_stdout()
|
|
165
|
+
unicode_ok = _supports_unicode_stdout()
|
|
166
|
+
if now is None:
|
|
167
|
+
now = dt.datetime.now(dt.timezone.utc)
|
|
168
|
+
|
|
169
|
+
# ── ANSI helpers ────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
def _dim(s: str) -> str:
|
|
172
|
+
return _style_ansi(s, "90", color)
|
|
173
|
+
|
|
174
|
+
def _cyan(s: str) -> str:
|
|
175
|
+
return _style_ansi(s, "36", color)
|
|
176
|
+
|
|
177
|
+
def _bold(s: str) -> str:
|
|
178
|
+
return _style_ansi(s, "1", color)
|
|
179
|
+
|
|
180
|
+
def _green(s: str) -> str:
|
|
181
|
+
return _style_ansi(s, "32", color)
|
|
182
|
+
|
|
183
|
+
def _blue(s: str) -> str:
|
|
184
|
+
return _style_ansi(s, "34", color)
|
|
185
|
+
|
|
186
|
+
def _yellow(s: str) -> str:
|
|
187
|
+
return _style_ansi(s, "33", color)
|
|
188
|
+
|
|
189
|
+
# ── time formatting ─────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
def _fmt_time_local(ts: dt.datetime) -> str:
|
|
192
|
+
local = ts.astimezone(tz)
|
|
193
|
+
hour_12 = local.hour % 12
|
|
194
|
+
if hour_12 == 0:
|
|
195
|
+
hour_12 = 12
|
|
196
|
+
ampm = "a.m." if local.hour < 12 else "p.m."
|
|
197
|
+
return (
|
|
198
|
+
f"{local.year}-{local.month:02d}-{local.day:02d}, "
|
|
199
|
+
f"{hour_12}:{local.minute:02d}:{local.second:02d} {ampm}"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def _fmt_duration_hm(total_seconds: float) -> str:
|
|
203
|
+
total_minutes = int(total_seconds / 60)
|
|
204
|
+
h = total_minutes // 60
|
|
205
|
+
m = total_minutes % 60
|
|
206
|
+
return f"{h}h {m:02d}m"
|
|
207
|
+
|
|
208
|
+
def _fmt_gap_duration(total_seconds: float) -> str:
|
|
209
|
+
hours = round(total_seconds / 3600)
|
|
210
|
+
if hours < 1:
|
|
211
|
+
hours = 1
|
|
212
|
+
return f"{hours}h gap"
|
|
213
|
+
|
|
214
|
+
# ── determine if % column is needed ─────────────────────────────────
|
|
215
|
+
max_completed_tokens = 0
|
|
216
|
+
for b in blocks:
|
|
217
|
+
if not b.is_gap and not b.is_active and b.total_tokens > 0:
|
|
218
|
+
if b.total_tokens > max_completed_tokens:
|
|
219
|
+
max_completed_tokens = b.total_tokens
|
|
220
|
+
token_limit = 0
|
|
221
|
+
active_block: Block | None = None
|
|
222
|
+
for b in blocks:
|
|
223
|
+
if b.is_active and not b.is_gap:
|
|
224
|
+
active_block = b
|
|
225
|
+
show_pct = max_completed_tokens > 0
|
|
226
|
+
if show_pct:
|
|
227
|
+
token_limit = max_completed_tokens
|
|
228
|
+
|
|
229
|
+
# ── column layout ───────────────────────────────────────────────────
|
|
230
|
+
headers = ["Block Start", "Duration/\u2026", "Models", "Tokens"]
|
|
231
|
+
aligns = ["left", "left", "left", "right"]
|
|
232
|
+
if show_pct:
|
|
233
|
+
headers.append("%")
|
|
234
|
+
aligns.append("right")
|
|
235
|
+
headers.append("Cost")
|
|
236
|
+
aligns.append("right")
|
|
237
|
+
num_cols = len(headers)
|
|
238
|
+
|
|
239
|
+
def _empty_cells() -> list[str]:
|
|
240
|
+
return [""] * num_cols
|
|
241
|
+
|
|
242
|
+
# ── Pass 1: build all row data as plain strings ─────────────────────
|
|
243
|
+
# Each "row" is a list of display lines, each line is a list[str] of
|
|
244
|
+
# cells. We also track per-row metadata for colorizing in pass 2.
|
|
245
|
+
|
|
246
|
+
ROW_NORMAL = "normal"
|
|
247
|
+
ROW_GAP = "gap"
|
|
248
|
+
ROW_ACTIVE = "active"
|
|
249
|
+
ROW_REMAINING = "remaining"
|
|
250
|
+
ROW_PROJECTED = "projected"
|
|
251
|
+
|
|
252
|
+
# (lines, row_type)
|
|
253
|
+
all_rows: list[tuple[list[list[str]], str]] = []
|
|
254
|
+
|
|
255
|
+
for block in blocks:
|
|
256
|
+
if block.is_gap:
|
|
257
|
+
gap_seconds = (block.end_time - block.start_time).total_seconds()
|
|
258
|
+
gap_dur = _fmt_gap_duration(gap_seconds)
|
|
259
|
+
# Build gap text as single string; wrapping happens later if needed
|
|
260
|
+
gap_text = (
|
|
261
|
+
f"{_fmt_time_local(block.start_time)} - "
|
|
262
|
+
f"{_fmt_time_local(block.end_time)} ({gap_dur})"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
cells1 = _empty_cells()
|
|
266
|
+
cells1[0] = gap_text
|
|
267
|
+
cells1[1] = "(inactive)"
|
|
268
|
+
cells1[2] = "-"
|
|
269
|
+
cells1[3] = "-"
|
|
270
|
+
if show_pct:
|
|
271
|
+
cells1[4] = "-"
|
|
272
|
+
cells1[-1] = "-"
|
|
273
|
+
else:
|
|
274
|
+
cells1[-1] = "-"
|
|
275
|
+
|
|
276
|
+
all_rows.append(([cells1], ROW_GAP))
|
|
277
|
+
|
|
278
|
+
else:
|
|
279
|
+
if block.is_active:
|
|
280
|
+
elapsed_secs = (now - block.start_time).total_seconds()
|
|
281
|
+
remaining_secs = max(
|
|
282
|
+
(block.end_time - now).total_seconds(), 0
|
|
283
|
+
)
|
|
284
|
+
dur_str = (
|
|
285
|
+
f"{_fmt_duration_hm(elapsed_secs)} "
|
|
286
|
+
f"elapsed, {_fmt_duration_hm(remaining_secs)} remaining)"
|
|
287
|
+
)
|
|
288
|
+
duration_col = "ACTIVE"
|
|
289
|
+
row_type = ROW_ACTIVE
|
|
290
|
+
else:
|
|
291
|
+
if block.actual_end_time:
|
|
292
|
+
elapsed_secs = (
|
|
293
|
+
block.actual_end_time - block.start_time
|
|
294
|
+
).total_seconds()
|
|
295
|
+
else:
|
|
296
|
+
elapsed_secs = BLOCK_DURATION.total_seconds()
|
|
297
|
+
dur_str = f"{_fmt_duration_hm(elapsed_secs)})"
|
|
298
|
+
duration_col = ""
|
|
299
|
+
row_type = ROW_NORMAL
|
|
300
|
+
|
|
301
|
+
time_str = _fmt_time_local(block.start_time)
|
|
302
|
+
if not block.is_gap and block.anchor == "heuristic":
|
|
303
|
+
time_str = f"~{time_str}"
|
|
304
|
+
start_text = f"{time_str} ({dur_str}"
|
|
305
|
+
|
|
306
|
+
short_models = [
|
|
307
|
+
f"- {_short_model_name(m)}" for m in block.models
|
|
308
|
+
]
|
|
309
|
+
if not short_models:
|
|
310
|
+
short_models = [""]
|
|
311
|
+
|
|
312
|
+
pct_str = ""
|
|
313
|
+
if show_pct and token_limit > 0:
|
|
314
|
+
pct_val = (block.total_tokens / token_limit) * 100.0
|
|
315
|
+
pct_str = f"{pct_val:.1f}%"
|
|
316
|
+
|
|
317
|
+
tokens_str = _fmt_num(block.total_tokens)
|
|
318
|
+
cost_str = f"${block.cost_usd:.2f}"
|
|
319
|
+
|
|
320
|
+
# First line
|
|
321
|
+
cells1 = _empty_cells()
|
|
322
|
+
cells1[0] = start_text # may overflow; wrapping handled later
|
|
323
|
+
cells1[1] = duration_col
|
|
324
|
+
cells1[2] = short_models[0]
|
|
325
|
+
cells1[3] = tokens_str
|
|
326
|
+
ci = 4
|
|
327
|
+
if show_pct:
|
|
328
|
+
cells1[ci] = pct_str
|
|
329
|
+
ci += 1
|
|
330
|
+
cells1[ci] = cost_str
|
|
331
|
+
|
|
332
|
+
display_lines: list[list[str]] = [cells1]
|
|
333
|
+
|
|
334
|
+
# Continuation lines for remaining models
|
|
335
|
+
for mi in range(1, len(short_models)):
|
|
336
|
+
cont = _empty_cells()
|
|
337
|
+
cont[2] = short_models[mi]
|
|
338
|
+
display_lines.append(cont)
|
|
339
|
+
|
|
340
|
+
all_rows.append((display_lines, row_type))
|
|
341
|
+
|
|
342
|
+
# Footer rows (REMAINING, PROJECTED)
|
|
343
|
+
footer_rows: list[tuple[list[list[str]], str]] = []
|
|
344
|
+
if show_pct and token_limit > 0:
|
|
345
|
+
active_tokens = active_block.total_tokens if active_block else 0
|
|
346
|
+
remaining_tokens = max(token_limit - active_tokens, 0)
|
|
347
|
+
remaining_pct = (remaining_tokens / token_limit) * 100.0
|
|
348
|
+
rem_label = f"(assuming {_fmt_num(token_limit)} token limit)"
|
|
349
|
+
|
|
350
|
+
rem_cells = _empty_cells()
|
|
351
|
+
rem_cells[0] = rem_label
|
|
352
|
+
rem_cells[1] = "REMAINING"
|
|
353
|
+
rem_cells[3] = _fmt_num(remaining_tokens)
|
|
354
|
+
ci = 4
|
|
355
|
+
if show_pct:
|
|
356
|
+
rem_cells[ci] = f"{remaining_pct:.1f}%"
|
|
357
|
+
ci += 1
|
|
358
|
+
footer_rows.append(([rem_cells], ROW_REMAINING))
|
|
359
|
+
|
|
360
|
+
if active_block and active_block.projection:
|
|
361
|
+
proj = active_block.projection
|
|
362
|
+
proj_tokens = proj.get("totalTokens", 0)
|
|
363
|
+
proj_pct = (
|
|
364
|
+
(proj_tokens / token_limit) * 100.0 if token_limit > 0 else 0
|
|
365
|
+
)
|
|
366
|
+
proj_cost = proj.get("totalCost", 0.0)
|
|
367
|
+
|
|
368
|
+
proj_cells = _empty_cells()
|
|
369
|
+
proj_cells[0] = "(assuming current burn rate)"
|
|
370
|
+
proj_cells[1] = "PROJECTED"
|
|
371
|
+
proj_cells[3] = _fmt_num(proj_tokens)
|
|
372
|
+
ci = 4
|
|
373
|
+
if show_pct:
|
|
374
|
+
proj_cells[ci] = f"{proj_pct:.1f}%"
|
|
375
|
+
ci += 1
|
|
376
|
+
proj_cells[ci] = f"${proj_cost:.2f}"
|
|
377
|
+
footer_rows.append(([proj_cells], ROW_PROJECTED))
|
|
378
|
+
|
|
379
|
+
# ── Pass 2: compute column widths from content ──────────────────────
|
|
380
|
+
|
|
381
|
+
# Measure max content width per column from headers + all cell data.
|
|
382
|
+
content_widths = [len(h) for h in headers]
|
|
383
|
+
|
|
384
|
+
def _measure_rows(
|
|
385
|
+
rows: list[tuple[list[list[str]], str]],
|
|
386
|
+
skip_col0: bool = False,
|
|
387
|
+
) -> None:
|
|
388
|
+
for display_lines, _ in rows:
|
|
389
|
+
for line_cells in display_lines:
|
|
390
|
+
for i, cell in enumerate(line_cells):
|
|
391
|
+
if skip_col0 and i == 0:
|
|
392
|
+
continue
|
|
393
|
+
content_widths[i] = max(content_widths[i], len(cell))
|
|
394
|
+
|
|
395
|
+
_measure_rows(all_rows)
|
|
396
|
+
# Footer labels (col 0) are right-justified into whatever width col 0
|
|
397
|
+
# gets — they should not inflate it.
|
|
398
|
+
_measure_rows(footer_rows, skip_col0=True)
|
|
399
|
+
|
|
400
|
+
# Add padding matching upstream ccusage's ResponsiveTable.
|
|
401
|
+
col_widths: list[int] = []
|
|
402
|
+
for i, cw in enumerate(content_widths):
|
|
403
|
+
if aligns[i] == "right":
|
|
404
|
+
col_widths.append(max(cw + 3, 11))
|
|
405
|
+
elif i == 1: # Duration column
|
|
406
|
+
col_widths.append(max(cw + 2, 15))
|
|
407
|
+
else:
|
|
408
|
+
col_widths.append(max(cw + 2, 10))
|
|
409
|
+
|
|
410
|
+
# Get terminal width (matches ccusage's ResponsiveTable.toString()).
|
|
411
|
+
try:
|
|
412
|
+
term_width = os.get_terminal_size().columns
|
|
413
|
+
except (OSError, ValueError):
|
|
414
|
+
term_width = int(os.environ.get("COLUMNS", "120"))
|
|
415
|
+
|
|
416
|
+
# Scale down only when table exceeds terminal width.
|
|
417
|
+
# ccusage does NOT expand columns when the table fits — it uses the
|
|
418
|
+
# padded content widths as-is.
|
|
419
|
+
table_overhead = 3 * num_cols + 1
|
|
420
|
+
available_width = term_width - table_overhead
|
|
421
|
+
if sum(col_widths) + table_overhead > term_width:
|
|
422
|
+
scale_factor = available_width / sum(col_widths)
|
|
423
|
+
col_widths = [
|
|
424
|
+
max(
|
|
425
|
+
math.floor(w * scale_factor),
|
|
426
|
+
10 if aligns[i] == "right"
|
|
427
|
+
else 10 if i == 0
|
|
428
|
+
else 12 if i == 1
|
|
429
|
+
else 8,
|
|
430
|
+
)
|
|
431
|
+
for i, w in enumerate(col_widths)
|
|
432
|
+
]
|
|
433
|
+
|
|
434
|
+
# ── box-drawing characters ──────────────────────────────────────────
|
|
435
|
+
if unicode_ok:
|
|
436
|
+
ch = {
|
|
437
|
+
"tl": "\u250c", "tm": "\u252c", "tr": "\u2510",
|
|
438
|
+
"ml": "\u251c", "mm": "\u253c", "mr": "\u2524",
|
|
439
|
+
"bl": "\u2514", "bm": "\u2534", "br": "\u2518",
|
|
440
|
+
"h": "\u2500", "v": "\u2502",
|
|
441
|
+
}
|
|
442
|
+
else:
|
|
443
|
+
ch = {k: c for k, c in zip(
|
|
444
|
+
["tl", "tm", "tr", "ml", "mm", "mr", "bl", "bm", "br", "h", "v"],
|
|
445
|
+
"+++++++++-|",
|
|
446
|
+
)}
|
|
447
|
+
|
|
448
|
+
def hline(left: str, mid: str, right: str) -> str:
|
|
449
|
+
segs = [ch["h"] * (col_widths[i] + 2) for i in range(num_cols)]
|
|
450
|
+
return _dim(left + mid.join(segs) + right)
|
|
451
|
+
|
|
452
|
+
def padcell(text: str, width: int, align: str) -> str:
|
|
453
|
+
"""Pad cell content to width. Text may contain ANSI codes, so we
|
|
454
|
+
compute visible length by stripping escape sequences."""
|
|
455
|
+
vis_len = len(re.sub(r"\033\[[0-9;]*m", "", text))
|
|
456
|
+
pad_needed = width - vis_len
|
|
457
|
+
if pad_needed <= 0:
|
|
458
|
+
return text
|
|
459
|
+
if align == "right":
|
|
460
|
+
return " " * pad_needed + text
|
|
461
|
+
return text + " " * pad_needed
|
|
462
|
+
|
|
463
|
+
def make_row(cells: list[str]) -> str:
|
|
464
|
+
parts: list[str] = []
|
|
465
|
+
for i, cell_text in enumerate(cells):
|
|
466
|
+
padded = padcell(cell_text, col_widths[i], aligns[i])
|
|
467
|
+
parts.append(f" {padded} ")
|
|
468
|
+
v = _dim(ch["v"])
|
|
469
|
+
return v + v.join(parts) + v
|
|
470
|
+
|
|
471
|
+
# ── Wrap + colorize helpers ─────────────────────────────────────────
|
|
472
|
+
|
|
473
|
+
def _wrap_col0(text: str, width: int) -> list[str]:
|
|
474
|
+
"""Wrap column-0 text to fit *width*, breaking at word boundaries."""
|
|
475
|
+
if len(text) <= width:
|
|
476
|
+
return [text]
|
|
477
|
+
# Try to break at a word boundary.
|
|
478
|
+
split_at = width
|
|
479
|
+
space_idx = text.rfind(" ", 0, width + 1)
|
|
480
|
+
if space_idx > width // 2:
|
|
481
|
+
split_at = space_idx + 1
|
|
482
|
+
part1 = text[:split_at].rstrip()
|
|
483
|
+
part2 = text[split_at:].lstrip()
|
|
484
|
+
lines_out = [part1]
|
|
485
|
+
if part2:
|
|
486
|
+
lines_out.extend(_wrap_col0(part2, width))
|
|
487
|
+
return lines_out
|
|
488
|
+
|
|
489
|
+
def _colorize_cell(text: str, col_idx: int, row_type: str) -> str:
|
|
490
|
+
"""Apply ANSI color to a cell based on row type and column."""
|
|
491
|
+
if row_type == ROW_GAP:
|
|
492
|
+
return _dim(text) if text else text
|
|
493
|
+
if col_idx == 1:
|
|
494
|
+
if row_type == ROW_ACTIVE:
|
|
495
|
+
return _green(text) if text == "ACTIVE" else text
|
|
496
|
+
if row_type == ROW_REMAINING:
|
|
497
|
+
return _blue(text) if text == "REMAINING" else text
|
|
498
|
+
if row_type == ROW_PROJECTED:
|
|
499
|
+
return _yellow(text) if text == "PROJECTED" else text
|
|
500
|
+
return text
|
|
501
|
+
|
|
502
|
+
# ── Render output ───────────────────────────────────────────────────
|
|
503
|
+
|
|
504
|
+
# Title banner
|
|
505
|
+
title = "Claude Code Token Usage Report - Session Blocks"
|
|
506
|
+
title_padded = f" {title} "
|
|
507
|
+
tw = len(title_padded)
|
|
508
|
+
dash = "\u2500" if unicode_ok else "-"
|
|
509
|
+
vb = "\u2502" if unicode_ok else "|"
|
|
510
|
+
if unicode_ok:
|
|
511
|
+
banner_top = f" \u256d{dash * tw}\u256e"
|
|
512
|
+
banner_bot = f" \u2570{dash * tw}\u256f"
|
|
513
|
+
else:
|
|
514
|
+
banner_top = f" +{'-' * tw}+"
|
|
515
|
+
banner_bot = f" +{'-' * tw}+"
|
|
516
|
+
|
|
517
|
+
lines: list[str] = []
|
|
518
|
+
lines.append(banner_top)
|
|
519
|
+
lines.append(f" {vb}" + " " * tw + vb)
|
|
520
|
+
lines.append(f" {vb}" + _bold(title_padded) + vb)
|
|
521
|
+
lines.append(f" {vb}" + " " * tw + vb)
|
|
522
|
+
lines.append(banner_bot)
|
|
523
|
+
lines.append("")
|
|
524
|
+
|
|
525
|
+
# Header
|
|
526
|
+
lines.append(hline(ch["tl"], ch["tm"], ch["tr"]))
|
|
527
|
+
header_cells = [_cyan(h) for h in headers]
|
|
528
|
+
lines.append(make_row(header_cells))
|
|
529
|
+
lines.append(hline(ch["ml"], ch["mm"], ch["mr"]))
|
|
530
|
+
|
|
531
|
+
# Data rows
|
|
532
|
+
col0_w = col_widths[0]
|
|
533
|
+
|
|
534
|
+
def _render_block_row(
|
|
535
|
+
display_lines: list[list[str]], row_type: str
|
|
536
|
+
) -> None:
|
|
537
|
+
"""Render one block's display lines, wrapping col 0 and truncating
|
|
538
|
+
the Tokens column as needed."""
|
|
539
|
+
for li, line_cells in enumerate(display_lines):
|
|
540
|
+
# Wrap column 0 if it overflows.
|
|
541
|
+
col0_text = line_cells[0]
|
|
542
|
+
col0_parts = _wrap_col0(col0_text, col0_w) if col0_text else [""]
|
|
543
|
+
|
|
544
|
+
for wi, c0_part in enumerate(col0_parts):
|
|
545
|
+
cells = _empty_cells()
|
|
546
|
+
cells[0] = _colorize_cell(c0_part, 0, row_type)
|
|
547
|
+
if wi == 0:
|
|
548
|
+
# First wrap-line carries the real cell data.
|
|
549
|
+
for ci in range(1, num_cols):
|
|
550
|
+
raw = line_cells[ci]
|
|
551
|
+
# Truncate tokens column if needed.
|
|
552
|
+
if ci == 3 and raw and raw != "-":
|
|
553
|
+
raw = _truncate_num(raw, col_widths[ci])
|
|
554
|
+
cells[ci] = _colorize_cell(raw, ci, row_type)
|
|
555
|
+
lines.append(make_row(cells))
|
|
556
|
+
|
|
557
|
+
for idx, (display_lines, row_type) in enumerate(all_rows):
|
|
558
|
+
_render_block_row(display_lines, row_type)
|
|
559
|
+
if idx < len(all_rows) - 1:
|
|
560
|
+
lines.append(hline(ch["ml"], ch["mm"], ch["mr"]))
|
|
561
|
+
|
|
562
|
+
# Footer rows
|
|
563
|
+
for fi, (display_lines, row_type) in enumerate(footer_rows):
|
|
564
|
+
lines.append(hline(ch["ml"], ch["mm"], ch["mr"]))
|
|
565
|
+
# Right-align the label in column 0 for footer rows.
|
|
566
|
+
for line_cells in display_lines:
|
|
567
|
+
line_cells[0] = line_cells[0].rjust(col0_w)
|
|
568
|
+
_render_block_row(display_lines, row_type)
|
|
569
|
+
|
|
570
|
+
# Bottom border
|
|
571
|
+
lines.append(hline(ch["bl"], ch["bm"], ch["br"]))
|
|
572
|
+
|
|
573
|
+
rendered = "\n".join(lines)
|
|
574
|
+
has_heuristic = any(
|
|
575
|
+
(not b.is_gap) and b.anchor == "heuristic" for b in blocks
|
|
576
|
+
)
|
|
577
|
+
if has_heuristic:
|
|
578
|
+
legend = _dim(
|
|
579
|
+
"~ = approximate start "
|
|
580
|
+
"(no recorded Anthropic reset for this window)"
|
|
581
|
+
)
|
|
582
|
+
rendered = f"{rendered}\n{legend}"
|
|
583
|
+
return rendered
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _bucket_to_json(
|
|
587
|
+
buckets: list[BucketUsage],
|
|
588
|
+
*,
|
|
589
|
+
list_key: str,
|
|
590
|
+
date_key: str,
|
|
591
|
+
) -> str:
|
|
592
|
+
"""Serialize bucket aggregates to JSON matching upstream ccusage's shape.
|
|
593
|
+
|
|
594
|
+
`list_key` is the top-level array name ("daily" or "monthly").
|
|
595
|
+
`date_key` is the per-item bucket field name ("date" or "month").
|
|
596
|
+
|
|
597
|
+
Key order inside each item matches ccusage:
|
|
598
|
+
date_key, inputTokens, outputTokens, cacheCreationTokens,
|
|
599
|
+
cacheReadTokens, totalTokens, totalCost, modelsUsed, modelBreakdowns.
|
|
600
|
+
Totals key order (note: totalCost BEFORE totalTokens, per ccusage).
|
|
601
|
+
"""
|
|
602
|
+
bucket_list: list[dict[str, Any]] = []
|
|
603
|
+
tot_input = 0
|
|
604
|
+
tot_output = 0
|
|
605
|
+
tot_cc = 0
|
|
606
|
+
tot_cr = 0
|
|
607
|
+
tot_cost = 0.0
|
|
608
|
+
tot_tokens = 0
|
|
609
|
+
for d in buckets:
|
|
610
|
+
bucket_list.append({
|
|
611
|
+
date_key: d.bucket,
|
|
612
|
+
"inputTokens": d.input_tokens,
|
|
613
|
+
"outputTokens": d.output_tokens,
|
|
614
|
+
"cacheCreationTokens": d.cache_creation_tokens,
|
|
615
|
+
"cacheReadTokens": d.cache_read_tokens,
|
|
616
|
+
"totalTokens": d.total_tokens,
|
|
617
|
+
"totalCost": d.cost_usd,
|
|
618
|
+
"modelsUsed": list(d.models),
|
|
619
|
+
"modelBreakdowns": list(d.model_breakdowns),
|
|
620
|
+
})
|
|
621
|
+
tot_input += d.input_tokens
|
|
622
|
+
tot_output += d.output_tokens
|
|
623
|
+
tot_cc += d.cache_creation_tokens
|
|
624
|
+
tot_cr += d.cache_read_tokens
|
|
625
|
+
tot_cost += d.cost_usd
|
|
626
|
+
tot_tokens += d.total_tokens
|
|
627
|
+
|
|
628
|
+
totals = {
|
|
629
|
+
"inputTokens": tot_input,
|
|
630
|
+
"outputTokens": tot_output,
|
|
631
|
+
"cacheCreationTokens": tot_cc,
|
|
632
|
+
"cacheReadTokens": tot_cr,
|
|
633
|
+
"totalCost": tot_cost,
|
|
634
|
+
"totalTokens": tot_tokens,
|
|
635
|
+
}
|
|
636
|
+
return json.dumps({list_key: bucket_list, "totals": totals}, indent=2)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _weekly_to_json(
|
|
640
|
+
buckets: list[BucketUsage],
|
|
641
|
+
weeks: list[SubWeek],
|
|
642
|
+
week_pct_overlay: list[tuple[float | None, float | None]],
|
|
643
|
+
) -> str:
|
|
644
|
+
"""Serialize weekly rollup to JSON.
|
|
645
|
+
|
|
646
|
+
Shape:
|
|
647
|
+
{
|
|
648
|
+
"weekly": [
|
|
649
|
+
{
|
|
650
|
+
"week": "YYYY-MM-DD", # API-derived week_start_date (stable contract / lookup key)
|
|
651
|
+
"displayWeek": "YYYY-MM-DD", # effective post-reset start; equals `week` for non-reset weeks
|
|
652
|
+
"weekStartAt": "...ISO...",
|
|
653
|
+
"weekEndAt": "...ISO...",
|
|
654
|
+
"weekSource": "snapshot" | "extrapolated",
|
|
655
|
+
"inputTokens": int, "cacheCreationTokens": int, "cacheReadTokens": int,
|
|
656
|
+
"outputTokens": int, "totalTokens": int, "totalCost": float,
|
|
657
|
+
"usedPct": float | null, "dollarsPerPercent": float | null,
|
|
658
|
+
"modelsUsed": [...], "modelBreakdowns": [...]
|
|
659
|
+
}, ...
|
|
660
|
+
],
|
|
661
|
+
"totals": { inputTokens, cacheCreationTokens, cacheReadTokens,
|
|
662
|
+
outputTokens, totalTokens, totalCost }
|
|
663
|
+
}
|
|
664
|
+
"""
|
|
665
|
+
assert len(week_pct_overlay) == len(buckets), (
|
|
666
|
+
f"week_pct_overlay length {len(week_pct_overlay)} does not match "
|
|
667
|
+
f"buckets length {len(buckets)} — caller contract violated"
|
|
668
|
+
)
|
|
669
|
+
# Build dict lookup from week-start-date ISO → SubWeek for metadata.
|
|
670
|
+
week_by_key = {w.start_date.isoformat(): w for w in weeks}
|
|
671
|
+
|
|
672
|
+
weekly_list: list[dict[str, Any]] = []
|
|
673
|
+
tot_input = tot_cache_c = tot_cache_r = tot_output = tot_total = 0
|
|
674
|
+
tot_cost = 0.0
|
|
675
|
+
for i, bucket in enumerate(buckets):
|
|
676
|
+
w = week_by_key.get(bucket.bucket)
|
|
677
|
+
if w is None:
|
|
678
|
+
# Defensive: bucket key should always match a SubWeek (_aggregate_weekly
|
|
679
|
+
# only emits keys derived from the provided weeks list). Raise loud
|
|
680
|
+
# rather than silently emit partial data.
|
|
681
|
+
raise ValueError(
|
|
682
|
+
f"bucket key {bucket.bucket!r} has no matching SubWeek in `weeks`"
|
|
683
|
+
)
|
|
684
|
+
pct, dpc = week_pct_overlay[i]
|
|
685
|
+
weekly_list.append({
|
|
686
|
+
"week": bucket.bucket,
|
|
687
|
+
"displayWeek": w.display_start_date.isoformat(),
|
|
688
|
+
"weekStartAt": w.start_ts,
|
|
689
|
+
"weekEndAt": w.end_ts,
|
|
690
|
+
"weekSource": w.source,
|
|
691
|
+
"inputTokens": bucket.input_tokens,
|
|
692
|
+
"cacheCreationTokens": bucket.cache_creation_tokens,
|
|
693
|
+
"cacheReadTokens": bucket.cache_read_tokens,
|
|
694
|
+
"outputTokens": bucket.output_tokens,
|
|
695
|
+
"totalTokens": bucket.total_tokens,
|
|
696
|
+
"totalCost": bucket.cost_usd,
|
|
697
|
+
"usedPct": pct,
|
|
698
|
+
"dollarsPerPercent": dpc,
|
|
699
|
+
"modelsUsed": bucket.models,
|
|
700
|
+
"modelBreakdowns": bucket.model_breakdowns,
|
|
701
|
+
})
|
|
702
|
+
tot_input += bucket.input_tokens
|
|
703
|
+
tot_cache_c += bucket.cache_creation_tokens
|
|
704
|
+
tot_cache_r += bucket.cache_read_tokens
|
|
705
|
+
tot_output += bucket.output_tokens
|
|
706
|
+
tot_total += bucket.total_tokens
|
|
707
|
+
tot_cost += bucket.cost_usd
|
|
708
|
+
|
|
709
|
+
payload = {
|
|
710
|
+
"weekly": weekly_list,
|
|
711
|
+
"totals": {
|
|
712
|
+
"inputTokens": tot_input,
|
|
713
|
+
"cacheCreationTokens": tot_cache_c,
|
|
714
|
+
"cacheReadTokens": tot_cache_r,
|
|
715
|
+
"outputTokens": tot_output,
|
|
716
|
+
"totalTokens": tot_total,
|
|
717
|
+
"totalCost": tot_cost,
|
|
718
|
+
},
|
|
719
|
+
}
|
|
720
|
+
return json.dumps(payload, indent=2)
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _daily_compact_split(bucket: str) -> str:
|
|
724
|
+
"""YYYY-MM-DD → "YYYY\\nMM-DD" for compact-mode Date column."""
|
|
725
|
+
m = re.match(r"^(\d{4})-(\d{2})-(\d{2})$", bucket)
|
|
726
|
+
return f"{m.group(1)}\n{m.group(2)}-{m.group(3)}" if m else bucket
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _monthly_compact_split(bucket: str) -> str:
|
|
730
|
+
"""YYYY-MM → "YYYY\\nMM" for compact-mode Month column.
|
|
731
|
+
|
|
732
|
+
Deliberate deviation from ccusage: upstream renders a synthetic "-01"
|
|
733
|
+
day component in compact mode because its formatter is daily-oriented.
|
|
734
|
+
We omit it — same information, less visual noise.
|
|
735
|
+
"""
|
|
736
|
+
m = re.match(r"^(\d{4})-(\d{2})$", bucket)
|
|
737
|
+
return f"{m.group(1)}\n{m.group(2)}" if m else bucket
|
|
738
|
+
|
|
739
|
+
_CODEX_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
740
|
+
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _codex_daily_bucket_display(bucket: str) -> str:
|
|
744
|
+
"""YYYY-MM-DD → "Mon DD, YYYY" (e.g. "Dec 25, 2025"). Upstream shape."""
|
|
745
|
+
m = re.match(r"^(\d{4})-(\d{2})-(\d{2})$", bucket)
|
|
746
|
+
if not m:
|
|
747
|
+
return bucket
|
|
748
|
+
return f"{_CODEX_MONTHS[int(m.group(2)) - 1]} {int(m.group(3)):02d}, {m.group(1)}"
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _codex_monthly_bucket_display(bucket: str) -> str:
|
|
752
|
+
"""YYYY-MM → "Mon YYYY" (e.g. "Dec 2025"). Upstream shape."""
|
|
753
|
+
m = re.match(r"^(\d{4})-(\d{2})$", bucket)
|
|
754
|
+
if not m:
|
|
755
|
+
return bucket
|
|
756
|
+
return f"{_CODEX_MONTHS[int(m.group(2)) - 1]} {m.group(1)}"
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def _codex_last_activity_iso(ts: dt.datetime) -> str:
|
|
760
|
+
"""ISO-8601 UTC with milliseconds and Z suffix (e.g. "2025-12-25T10:03:52.375Z").
|
|
761
|
+
|
|
762
|
+
Matches upstream ccusage-codex's session `lastActivity` format byte-exactly.
|
|
763
|
+
"""
|
|
764
|
+
utc = ts.astimezone(dt.timezone.utc)
|
|
765
|
+
# Python's .isoformat() defaults to microseconds (6 digits); upstream uses
|
|
766
|
+
# milliseconds (3 digits). Truncate and append Z.
|
|
767
|
+
return utc.strftime("%Y-%m-%dT%H:%M:%S") + f".{utc.microsecond // 1000:03d}Z"
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _emit_codex_no_data(args: argparse.Namespace, list_key: str) -> None:
|
|
771
|
+
"""Print upstream's empty-result sentinel for codex-{daily,monthly,session}.
|
|
772
|
+
|
|
773
|
+
Matches ccusage-codex byte-exactly:
|
|
774
|
+
- JSON: ``{"<list_key>":[],"totals":null}`` (compact separators, no
|
|
775
|
+
whitespace — upstream uses ``JSON.stringify(...)`` with no indent
|
|
776
|
+
argument for the empty case, even though the happy-path uses indent=2).
|
|
777
|
+
- Text: ``"No Codex usage data found."`` when no filters are in effect,
|
|
778
|
+
or ``"No Codex usage data found for provided filters."`` when --since
|
|
779
|
+
or --until is set (matching upstream's filter-aware messaging).
|
|
780
|
+
"""
|
|
781
|
+
filter_applied = bool(getattr(args, "since", None) or getattr(args, "until", None))
|
|
782
|
+
if getattr(args, "json", False):
|
|
783
|
+
# Compact separators to match Node's `JSON.stringify(obj)` output exactly.
|
|
784
|
+
print(json.dumps({list_key: [], "totals": None}, separators=(",", ":")))
|
|
785
|
+
else:
|
|
786
|
+
if filter_applied:
|
|
787
|
+
print("No Codex usage data found for provided filters.")
|
|
788
|
+
else:
|
|
789
|
+
print("No Codex usage data found.")
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def _codex_models_dict(
|
|
793
|
+
model_breakdowns: list[dict[str, Any]],
|
|
794
|
+
) -> dict[str, dict[str, Any]]:
|
|
795
|
+
"""Convert our internal list-of-breakdowns into upstream's models dict.
|
|
796
|
+
|
|
797
|
+
Input: list of {modelName, inputTokens, cachedInputTokens, outputTokens,
|
|
798
|
+
reasoningOutputTokens, totalTokens, cost, isFallback}.
|
|
799
|
+
Output: {<modelName>: {inputTokens, cachedInputTokens, outputTokens,
|
|
800
|
+
reasoningOutputTokens, totalTokens, isFallback}}
|
|
801
|
+
Insertion order: whatever the caller passed (aggregators sort by cost desc).
|
|
802
|
+
Note: the per-model `cost` / `modelName` keys from the list are dropped
|
|
803
|
+
— upstream's dict doesn't include them at the per-model level.
|
|
804
|
+
"""
|
|
805
|
+
out: dict[str, dict[str, Any]] = {}
|
|
806
|
+
for mb in model_breakdowns:
|
|
807
|
+
out[mb["modelName"]] = {
|
|
808
|
+
"inputTokens": mb["inputTokens"],
|
|
809
|
+
"cachedInputTokens": mb["cachedInputTokens"],
|
|
810
|
+
"outputTokens": mb["outputTokens"],
|
|
811
|
+
"reasoningOutputTokens": mb["reasoningOutputTokens"],
|
|
812
|
+
"totalTokens": mb["totalTokens"],
|
|
813
|
+
"isFallback": mb["isFallback"],
|
|
814
|
+
}
|
|
815
|
+
return out
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def _codex_bucket_to_json(
|
|
819
|
+
buckets: list[CodexBucketUsage],
|
|
820
|
+
*,
|
|
821
|
+
list_key: str, # "daily" or "monthly"
|
|
822
|
+
date_key: str, # "date" or "month"
|
|
823
|
+
display_fn: Callable[[str], str], # maps bucket key → human display
|
|
824
|
+
) -> str:
|
|
825
|
+
"""Serialize Codex bucket aggregates to JSON matching upstream exactly.
|
|
826
|
+
|
|
827
|
+
Per-entry shape:
|
|
828
|
+
{<date_key>, inputTokens, cachedInputTokens, outputTokens,
|
|
829
|
+
reasoningOutputTokens, totalTokens, costUSD, models}
|
|
830
|
+
Totals:
|
|
831
|
+
{inputTokens, cachedInputTokens, outputTokens,
|
|
832
|
+
reasoningOutputTokens, totalTokens, costUSD}
|
|
833
|
+
"""
|
|
834
|
+
bucket_list: list[dict[str, Any]] = []
|
|
835
|
+
tot_input = tot_cached = tot_output = tot_reasoning = tot_tokens = 0
|
|
836
|
+
tot_cost = 0.0
|
|
837
|
+
for b in buckets:
|
|
838
|
+
bucket_total = b.input_tokens + b.output_tokens
|
|
839
|
+
bucket_list.append({
|
|
840
|
+
date_key: display_fn(b.bucket),
|
|
841
|
+
"inputTokens": b.input_tokens,
|
|
842
|
+
"cachedInputTokens": b.cached_input_tokens,
|
|
843
|
+
"outputTokens": b.output_tokens,
|
|
844
|
+
"reasoningOutputTokens": b.reasoning_output_tokens,
|
|
845
|
+
"totalTokens": bucket_total,
|
|
846
|
+
"costUSD": b.cost_usd,
|
|
847
|
+
"models": _codex_models_dict(b.model_breakdowns),
|
|
848
|
+
})
|
|
849
|
+
tot_input += b.input_tokens
|
|
850
|
+
tot_cached += b.cached_input_tokens
|
|
851
|
+
tot_output += b.output_tokens
|
|
852
|
+
tot_reasoning += b.reasoning_output_tokens
|
|
853
|
+
tot_tokens += bucket_total
|
|
854
|
+
tot_cost += b.cost_usd
|
|
855
|
+
|
|
856
|
+
totals = {
|
|
857
|
+
"inputTokens": tot_input,
|
|
858
|
+
"cachedInputTokens": tot_cached,
|
|
859
|
+
"outputTokens": tot_output,
|
|
860
|
+
"reasoningOutputTokens": tot_reasoning,
|
|
861
|
+
"totalTokens": tot_tokens,
|
|
862
|
+
"costUSD": tot_cost,
|
|
863
|
+
}
|
|
864
|
+
return json.dumps({list_key: bucket_list, "totals": totals}, indent=2)
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def _codex_sessions_to_json(sessions: list[CodexSessionUsage]) -> str:
|
|
868
|
+
"""Serialize Codex session aggregates to JSON matching upstream exactly.
|
|
869
|
+
|
|
870
|
+
Per-session shape:
|
|
871
|
+
{sessionId, lastActivity, sessionFile, directory,
|
|
872
|
+
inputTokens, cachedInputTokens, outputTokens,
|
|
873
|
+
reasoningOutputTokens, totalTokens, costUSD, models}
|
|
874
|
+
"""
|
|
875
|
+
session_list: list[dict[str, Any]] = []
|
|
876
|
+
tot_input = tot_cached = tot_output = tot_reasoning = tot_tokens = 0
|
|
877
|
+
tot_cost = 0.0
|
|
878
|
+
for s in sessions:
|
|
879
|
+
session_total = s.input_tokens + s.output_tokens
|
|
880
|
+
session_list.append({
|
|
881
|
+
"sessionId": s.session_id_path,
|
|
882
|
+
"lastActivity": _codex_last_activity_iso(s.last_activity),
|
|
883
|
+
"sessionFile": s.session_file,
|
|
884
|
+
"directory": s.directory,
|
|
885
|
+
"inputTokens": s.input_tokens,
|
|
886
|
+
"cachedInputTokens": s.cached_input_tokens,
|
|
887
|
+
"outputTokens": s.output_tokens,
|
|
888
|
+
"reasoningOutputTokens": s.reasoning_output_tokens,
|
|
889
|
+
"totalTokens": session_total,
|
|
890
|
+
"costUSD": s.cost_usd,
|
|
891
|
+
"models": _codex_models_dict(s.model_breakdowns),
|
|
892
|
+
})
|
|
893
|
+
tot_input += s.input_tokens
|
|
894
|
+
tot_cached += s.cached_input_tokens
|
|
895
|
+
tot_output += s.output_tokens
|
|
896
|
+
tot_reasoning += s.reasoning_output_tokens
|
|
897
|
+
tot_tokens += session_total
|
|
898
|
+
tot_cost += s.cost_usd
|
|
899
|
+
|
|
900
|
+
totals = {
|
|
901
|
+
"inputTokens": tot_input,
|
|
902
|
+
"cachedInputTokens": tot_cached,
|
|
903
|
+
"outputTokens": tot_output,
|
|
904
|
+
"reasoningOutputTokens": tot_reasoning,
|
|
905
|
+
"totalTokens": tot_tokens,
|
|
906
|
+
"costUSD": tot_cost,
|
|
907
|
+
}
|
|
908
|
+
return json.dumps({"sessions": session_list, "totals": totals}, indent=2)
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def _claude_sessions_to_json(sessions: list[ClaudeSessionUsage]) -> str:
|
|
912
|
+
"""Serialize Claude sessions to JSON per spec A2.8.
|
|
913
|
+
|
|
914
|
+
Per-session: sessionId, projectPath, sourcePaths (list), firstActivity
|
|
915
|
+
/ lastActivity ISO strings, modelsUsed, token counts
|
|
916
|
+
(input/cacheCreation/cacheRead/output/total), totalCost, modelBreakdowns
|
|
917
|
+
(camelCased token field names, cost).
|
|
918
|
+
|
|
919
|
+
totals: same 6 numeric fields aggregated across sessions.
|
|
920
|
+
"""
|
|
921
|
+
sess_list: list[dict[str, Any]] = []
|
|
922
|
+
tot_i = tot_cc = tot_cr = tot_o = tot_t = 0
|
|
923
|
+
tot_cost = 0.0
|
|
924
|
+
|
|
925
|
+
for s in sessions:
|
|
926
|
+
sess_list.append({
|
|
927
|
+
"sessionId": s.session_id,
|
|
928
|
+
"projectPath": s.project_path,
|
|
929
|
+
"sourcePaths": list(s.source_paths),
|
|
930
|
+
"firstActivity": s.first_activity.isoformat(),
|
|
931
|
+
"lastActivity": s.last_activity.isoformat(),
|
|
932
|
+
"modelsUsed": list(s.models),
|
|
933
|
+
"inputTokens": s.input_tokens,
|
|
934
|
+
"cacheCreationTokens": s.cache_creation_tokens,
|
|
935
|
+
"cacheReadTokens": s.cache_read_tokens,
|
|
936
|
+
"outputTokens": s.output_tokens,
|
|
937
|
+
"totalTokens": s.total_tokens,
|
|
938
|
+
"totalCost": s.cost_usd,
|
|
939
|
+
"modelBreakdowns": [
|
|
940
|
+
{
|
|
941
|
+
"model": mb["model"],
|
|
942
|
+
"inputTokens": mb["input"],
|
|
943
|
+
"cacheCreationTokens": mb["cache_create"],
|
|
944
|
+
"cacheReadTokens": mb["cache_read"],
|
|
945
|
+
"outputTokens": mb["output"],
|
|
946
|
+
"cost": mb["cost"],
|
|
947
|
+
}
|
|
948
|
+
for mb in s.model_breakdowns
|
|
949
|
+
],
|
|
950
|
+
})
|
|
951
|
+
tot_i += s.input_tokens
|
|
952
|
+
tot_cc += s.cache_creation_tokens
|
|
953
|
+
tot_cr += s.cache_read_tokens
|
|
954
|
+
tot_o += s.output_tokens
|
|
955
|
+
tot_t += s.total_tokens
|
|
956
|
+
tot_cost += s.cost_usd
|
|
957
|
+
|
|
958
|
+
payload = {
|
|
959
|
+
"sessions": sess_list,
|
|
960
|
+
"totals": {
|
|
961
|
+
"inputTokens": tot_i,
|
|
962
|
+
"cacheCreationTokens": tot_cc,
|
|
963
|
+
"cacheReadTokens": tot_cr,
|
|
964
|
+
"outputTokens": tot_o,
|
|
965
|
+
"totalTokens": tot_t,
|
|
966
|
+
"totalCost": tot_cost,
|
|
967
|
+
},
|
|
968
|
+
}
|
|
969
|
+
return json.dumps(payload, indent=2)
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def _render_bucket_table(
|
|
973
|
+
buckets: list[BucketUsage],
|
|
974
|
+
*,
|
|
975
|
+
first_col_name: str,
|
|
976
|
+
title_suffix: str,
|
|
977
|
+
compact_split_fn: Callable[[str], str],
|
|
978
|
+
breakdown: bool = False,
|
|
979
|
+
) -> str:
|
|
980
|
+
"""Render bucket aggregates as a ccusage-style ANSI table.
|
|
981
|
+
|
|
982
|
+
Shared between `daily` and `monthly` subcommands. Parameters:
|
|
983
|
+
first_col_name — header for the bucket column ("Date" or "Month").
|
|
984
|
+
title_suffix — banner text suffix ("Daily" or "Monthly").
|
|
985
|
+
compact_split_fn — function that splits a bucket string into
|
|
986
|
+
"YYYY\\n..." for compact-mode two-line display.
|
|
987
|
+
|
|
988
|
+
Mirrors ccusage's ResponsiveTable behavior: single-line headers and dates
|
|
989
|
+
when content fits the terminal; falls back to two-line compact headers
|
|
990
|
+
("Cache"/"Create") and dates ("YYYY"/"MM-DD") with numeric truncation when
|
|
991
|
+
scaling is needed. Breakdown rows are a single line (" └─ model") in
|
|
992
|
+
gray; the Total row is colored yellow.
|
|
993
|
+
"""
|
|
994
|
+
color = _supports_color_stdout()
|
|
995
|
+
unicode_ok = _supports_unicode_stdout()
|
|
996
|
+
|
|
997
|
+
def _dim(s: str) -> str:
|
|
998
|
+
return _style_ansi(s, "90", color)
|
|
999
|
+
|
|
1000
|
+
def _cyan(s: str) -> str:
|
|
1001
|
+
return _style_ansi(s, "36", color)
|
|
1002
|
+
|
|
1003
|
+
def _bold(s: str) -> str:
|
|
1004
|
+
return _style_ansi(s, "1", color)
|
|
1005
|
+
|
|
1006
|
+
def _yellow(s: str) -> str:
|
|
1007
|
+
return _style_ansi(s, "33", color)
|
|
1008
|
+
|
|
1009
|
+
def _gray(s: str) -> str:
|
|
1010
|
+
return _style_ansi(s, "90", color)
|
|
1011
|
+
|
|
1012
|
+
headers = [
|
|
1013
|
+
first_col_name, "Models", "Input", "Output",
|
|
1014
|
+
"Cache Create", "Cache Read", "Total Tokens", "Cost (USD)",
|
|
1015
|
+
]
|
|
1016
|
+
aligns = ["left", "left", "right", "right", "right", "right", "right", "right"]
|
|
1017
|
+
num_cols = len(headers)
|
|
1018
|
+
|
|
1019
|
+
arrow = " \u2514\u2500" if unicode_ok else " |_"
|
|
1020
|
+
|
|
1021
|
+
# ── Build raw rows: each is (cells, row_type) where a cell is the
|
|
1022
|
+
# tuple (text, color_fn_or_none). `text` may contain '\n' for
|
|
1023
|
+
# multi-line cells (Models list, compact Date).
|
|
1024
|
+
ROW_DATA, ROW_BREAKDOWN, ROW_FOOTER = "data", "breakdown", "footer"
|
|
1025
|
+
raw_rows: list[tuple[list[tuple[str, Any]], str]] = []
|
|
1026
|
+
|
|
1027
|
+
for d in buckets:
|
|
1028
|
+
# ccusage formatModelsDisplayMultiline: uniq → sort alphabetical
|
|
1029
|
+
short_models = sorted({_short_model_name(m) for m in d.models})
|
|
1030
|
+
models_text = "\n".join(f"- {m}" for m in short_models) if short_models else ""
|
|
1031
|
+
data_cells = [
|
|
1032
|
+
(d.bucket, None),
|
|
1033
|
+
(models_text, None),
|
|
1034
|
+
(_fmt_num(d.input_tokens), None),
|
|
1035
|
+
(_fmt_num(d.output_tokens), None),
|
|
1036
|
+
(_fmt_num(d.cache_creation_tokens), None),
|
|
1037
|
+
(_fmt_num(d.cache_read_tokens), None),
|
|
1038
|
+
(_fmt_num(d.total_tokens), None),
|
|
1039
|
+
(f"${d.cost_usd:.2f}", None),
|
|
1040
|
+
]
|
|
1041
|
+
raw_rows.append((data_cells, ROW_DATA))
|
|
1042
|
+
|
|
1043
|
+
if breakdown:
|
|
1044
|
+
for mb in d.model_breakdowns:
|
|
1045
|
+
short = _short_model_name(mb["modelName"])
|
|
1046
|
+
mb_input = int(mb["inputTokens"])
|
|
1047
|
+
mb_output = int(mb["outputTokens"])
|
|
1048
|
+
mb_cc = int(mb["cacheCreationTokens"])
|
|
1049
|
+
mb_cr = int(mb["cacheReadTokens"])
|
|
1050
|
+
mb_total = mb_input + mb_output + mb_cc + mb_cr
|
|
1051
|
+
mb_cost = float(mb["cost"])
|
|
1052
|
+
bd_cells = [
|
|
1053
|
+
(f"{arrow} {short}", _gray),
|
|
1054
|
+
("", None),
|
|
1055
|
+
(_fmt_num(mb_input), _gray),
|
|
1056
|
+
(_fmt_num(mb_output), _gray),
|
|
1057
|
+
(_fmt_num(mb_cc), _gray),
|
|
1058
|
+
(_fmt_num(mb_cr), _gray),
|
|
1059
|
+
(_fmt_num(mb_total), _gray),
|
|
1060
|
+
(f"${mb_cost:.2f}", _gray),
|
|
1061
|
+
]
|
|
1062
|
+
raw_rows.append((bd_cells, ROW_BREAKDOWN))
|
|
1063
|
+
|
|
1064
|
+
# Total footer row — yellow on all populated cells.
|
|
1065
|
+
tot_input = sum(d.input_tokens for d in buckets)
|
|
1066
|
+
tot_output = sum(d.output_tokens for d in buckets)
|
|
1067
|
+
tot_cc = sum(d.cache_creation_tokens for d in buckets)
|
|
1068
|
+
tot_cr = sum(d.cache_read_tokens for d in buckets)
|
|
1069
|
+
tot_tokens = sum(d.total_tokens for d in buckets)
|
|
1070
|
+
tot_cost = sum(d.cost_usd for d in buckets)
|
|
1071
|
+
footer_cells = [
|
|
1072
|
+
("Total", _yellow),
|
|
1073
|
+
("", None),
|
|
1074
|
+
(_fmt_num(tot_input), _yellow),
|
|
1075
|
+
(_fmt_num(tot_output), _yellow),
|
|
1076
|
+
(_fmt_num(tot_cc), _yellow),
|
|
1077
|
+
(_fmt_num(tot_cr), _yellow),
|
|
1078
|
+
(_fmt_num(tot_tokens), _yellow),
|
|
1079
|
+
(f"${tot_cost:.2f}", _yellow),
|
|
1080
|
+
]
|
|
1081
|
+
raw_rows.append((footer_cells, ROW_FOOTER))
|
|
1082
|
+
|
|
1083
|
+
# ── Compute content widths (single-line form: header as-is, dates
|
|
1084
|
+
# single-line). Multi-line cell width = longest line.
|
|
1085
|
+
def _max_line_width(s: str) -> int:
|
|
1086
|
+
if not s:
|
|
1087
|
+
return 0
|
|
1088
|
+
return max(len(line) for line in s.split("\n"))
|
|
1089
|
+
|
|
1090
|
+
content_widths = [len(h) for h in headers]
|
|
1091
|
+
for cells, _rt in raw_rows:
|
|
1092
|
+
for i, (text, _c) in enumerate(cells):
|
|
1093
|
+
content_widths[i] = max(content_widths[i], _max_line_width(text))
|
|
1094
|
+
|
|
1095
|
+
# ── Wide-mode column widths (ccusage formula) ───────────────────────
|
|
1096
|
+
def _wide_width(i: int, content: int) -> int:
|
|
1097
|
+
if aligns[i] == "right":
|
|
1098
|
+
return max(content + 3, 11)
|
|
1099
|
+
if i == 1: # Models
|
|
1100
|
+
return max(content + 2, 15)
|
|
1101
|
+
return max(content + 2, 10)
|
|
1102
|
+
|
|
1103
|
+
col_widths = [_wide_width(i, content_widths[i]) for i in range(num_cols)]
|
|
1104
|
+
|
|
1105
|
+
try:
|
|
1106
|
+
term_width = os.get_terminal_size().columns
|
|
1107
|
+
except (OSError, ValueError):
|
|
1108
|
+
term_width = int(os.environ.get("COLUMNS", "120"))
|
|
1109
|
+
|
|
1110
|
+
border_overhead = 3 * num_cols + 1
|
|
1111
|
+
compact_mode = sum(col_widths) + border_overhead > term_width
|
|
1112
|
+
|
|
1113
|
+
if compact_mode:
|
|
1114
|
+
# Scale down proportionally with narrow minimums.
|
|
1115
|
+
available = term_width - border_overhead
|
|
1116
|
+
total_col = sum(col_widths)
|
|
1117
|
+
scale = available / total_col if total_col > 0 else 1.0
|
|
1118
|
+
|
|
1119
|
+
def _narrow_min(i: int) -> int:
|
|
1120
|
+
if aligns[i] == "right":
|
|
1121
|
+
return 10
|
|
1122
|
+
if i == 0: # Date
|
|
1123
|
+
return 10
|
|
1124
|
+
if i == 1: # Models
|
|
1125
|
+
return 12
|
|
1126
|
+
return 8
|
|
1127
|
+
|
|
1128
|
+
col_widths = [
|
|
1129
|
+
max(int(w * scale), _narrow_min(i))
|
|
1130
|
+
for i, w in enumerate(col_widths)
|
|
1131
|
+
]
|
|
1132
|
+
remainder = available - sum(col_widths)
|
|
1133
|
+
if remainder > 0:
|
|
1134
|
+
col_widths[1] += remainder
|
|
1135
|
+
|
|
1136
|
+
# ── Choose header presentation: single-line in wide mode;
|
|
1137
|
+
# split multi-word headers to 2 lines when compact.
|
|
1138
|
+
if compact_mode:
|
|
1139
|
+
header_display = [h.replace(" ", "\n") for h in headers]
|
|
1140
|
+
else:
|
|
1141
|
+
header_display = headers[:]
|
|
1142
|
+
|
|
1143
|
+
# ── Convert raw rows to multi-line display rows. In compact mode
|
|
1144
|
+
# dates split to 2 lines ("YYYY" / "MM-DD").
|
|
1145
|
+
def _split_cell(text: str) -> list[str]:
|
|
1146
|
+
return text.split("\n") if text else [""]
|
|
1147
|
+
|
|
1148
|
+
def _split_bucket_if_compact(text: str) -> str:
|
|
1149
|
+
if compact_mode:
|
|
1150
|
+
return compact_split_fn(text)
|
|
1151
|
+
return text
|
|
1152
|
+
|
|
1153
|
+
display_rows: list[tuple[list[list[tuple[str, Any]]], str]] = []
|
|
1154
|
+
for cells, row_type in raw_rows:
|
|
1155
|
+
processed: list[tuple[str, Any]] = []
|
|
1156
|
+
for i, (text, cfn) in enumerate(cells):
|
|
1157
|
+
t = _split_bucket_if_compact(text) if i == 0 else text
|
|
1158
|
+
processed.append((t, cfn))
|
|
1159
|
+
line_counts = [len(_split_cell(t)) for t, _ in processed]
|
|
1160
|
+
n_lines = max(line_counts) if line_counts else 1
|
|
1161
|
+
row_lines: list[list[tuple[str, Any]]] = []
|
|
1162
|
+
for li in range(n_lines):
|
|
1163
|
+
row_cells: list[tuple[str, Any]] = []
|
|
1164
|
+
for (text, cfn) in processed:
|
|
1165
|
+
parts = _split_cell(text)
|
|
1166
|
+
row_cells.append((parts[li] if li < len(parts) else "", cfn))
|
|
1167
|
+
row_lines.append(row_cells)
|
|
1168
|
+
display_rows.append((row_lines, row_type))
|
|
1169
|
+
|
|
1170
|
+
# Header display lines (multi-line in compact mode).
|
|
1171
|
+
header_line_counts = [len(_split_cell(h)) for h in header_display]
|
|
1172
|
+
header_n_lines = max(header_line_counts) if header_line_counts else 1
|
|
1173
|
+
header_lines: list[list[str]] = []
|
|
1174
|
+
for li in range(header_n_lines):
|
|
1175
|
+
line = []
|
|
1176
|
+
for h in header_display:
|
|
1177
|
+
parts = _split_cell(h)
|
|
1178
|
+
line.append(parts[li] if li < len(parts) else "")
|
|
1179
|
+
header_lines.append(line)
|
|
1180
|
+
|
|
1181
|
+
# ── Box-drawing chars ───────────────────────────────────────────────
|
|
1182
|
+
if unicode_ok:
|
|
1183
|
+
ch = {
|
|
1184
|
+
"tl": "\u250c", "tm": "\u252c", "tr": "\u2510",
|
|
1185
|
+
"ml": "\u251c", "mm": "\u253c", "mr": "\u2524",
|
|
1186
|
+
"bl": "\u2514", "bm": "\u2534", "br": "\u2518",
|
|
1187
|
+
"h": "\u2500", "v": "\u2502",
|
|
1188
|
+
}
|
|
1189
|
+
else:
|
|
1190
|
+
ch = {k: c for k, c in zip(
|
|
1191
|
+
["tl", "tm", "tr", "ml", "mm", "mr", "bl", "bm", "br", "h", "v"],
|
|
1192
|
+
"+++++++++-|",
|
|
1193
|
+
)}
|
|
1194
|
+
|
|
1195
|
+
def hline(left: str, mid: str, right: str) -> str:
|
|
1196
|
+
segs = [ch["h"] * (col_widths[i] + 2) for i in range(num_cols)]
|
|
1197
|
+
return _dim(left + mid.join(segs) + right)
|
|
1198
|
+
|
|
1199
|
+
def padcell(text: str, width: int, align: str) -> str:
|
|
1200
|
+
vis_len = len(re.sub(r"\033\[[0-9;]*m", "", text))
|
|
1201
|
+
pad_needed = width - vis_len
|
|
1202
|
+
if pad_needed <= 0:
|
|
1203
|
+
return text
|
|
1204
|
+
if align == "right":
|
|
1205
|
+
return " " * pad_needed + text
|
|
1206
|
+
return text + " " * pad_needed
|
|
1207
|
+
|
|
1208
|
+
def make_row(cells: list[str]) -> str:
|
|
1209
|
+
parts: list[str] = []
|
|
1210
|
+
for i, cell_text in enumerate(cells):
|
|
1211
|
+
padded = padcell(cell_text, col_widths[i], aligns[i])
|
|
1212
|
+
parts.append(f" {padded} ")
|
|
1213
|
+
v = _dim(ch["v"])
|
|
1214
|
+
return v + v.join(parts) + v
|
|
1215
|
+
|
|
1216
|
+
# ── Title banner ────────────────────────────────────────────────────
|
|
1217
|
+
lines: list[str] = []
|
|
1218
|
+
lines.append("")
|
|
1219
|
+
title = f"Claude Code Token Usage Report - {title_suffix}"
|
|
1220
|
+
title_padded = f" {title} "
|
|
1221
|
+
tw = len(title_padded)
|
|
1222
|
+
dash = "\u2500" if unicode_ok else "-"
|
|
1223
|
+
vb = "\u2502" if unicode_ok else "|"
|
|
1224
|
+
if unicode_ok:
|
|
1225
|
+
banner_top = f" \u256d{dash * tw}\u256e"
|
|
1226
|
+
banner_bot = f" \u2570{dash * tw}\u256f"
|
|
1227
|
+
else:
|
|
1228
|
+
banner_top = f" +{'-' * tw}+"
|
|
1229
|
+
banner_bot = f" +{'-' * tw}+"
|
|
1230
|
+
lines.append(banner_top)
|
|
1231
|
+
lines.append(f" {vb}" + " " * tw + vb)
|
|
1232
|
+
lines.append(f" {vb}" + _bold(title_padded) + vb)
|
|
1233
|
+
lines.append(f" {vb}" + " " * tw + vb)
|
|
1234
|
+
lines.append(banner_bot)
|
|
1235
|
+
lines.append("")
|
|
1236
|
+
|
|
1237
|
+
# ── Header ──────────────────────────────────────────────────────────
|
|
1238
|
+
lines.append(hline(ch["tl"], ch["tm"], ch["tr"]))
|
|
1239
|
+
for line_cells in header_lines:
|
|
1240
|
+
lines.append(make_row([_cyan(c) for c in line_cells]))
|
|
1241
|
+
lines.append(hline(ch["ml"], ch["mm"], ch["mr"]))
|
|
1242
|
+
|
|
1243
|
+
# ── Data + footer rows, with separators between every row ──────────
|
|
1244
|
+
numeric_cols = (2, 3, 4, 5, 6, 7) # Input, Output, CacheC, CacheR, Total, Cost
|
|
1245
|
+
|
|
1246
|
+
def _render_display_row(row_lines: list[list[tuple[str, Any]]]) -> None:
|
|
1247
|
+
for line_cells in row_lines:
|
|
1248
|
+
rendered: list[str] = []
|
|
1249
|
+
for ci, (text, cfn) in enumerate(line_cells):
|
|
1250
|
+
out = text
|
|
1251
|
+
if compact_mode and ci in numeric_cols and out:
|
|
1252
|
+
out = _truncate_num(out, col_widths[ci])
|
|
1253
|
+
if cfn is not None and out:
|
|
1254
|
+
out = cfn(out)
|
|
1255
|
+
rendered.append(out)
|
|
1256
|
+
lines.append(make_row(rendered))
|
|
1257
|
+
|
|
1258
|
+
for idx, (row_lines, _rt) in enumerate(display_rows):
|
|
1259
|
+
_render_display_row(row_lines)
|
|
1260
|
+
if idx < len(display_rows) - 1:
|
|
1261
|
+
lines.append(hline(ch["ml"], ch["mm"], ch["mr"]))
|
|
1262
|
+
|
|
1263
|
+
lines.append(hline(ch["bl"], ch["bm"], ch["br"]))
|
|
1264
|
+
return "\n".join(lines)
|
|
1265
|
+
|
|
1266
|
+
|
|
1267
|
+
def _render_weekly_table(
|
|
1268
|
+
buckets: list[BucketUsage],
|
|
1269
|
+
week_pct_overlay: list[tuple[float | None, float | None]],
|
|
1270
|
+
*,
|
|
1271
|
+
weeks: list["SubWeek"],
|
|
1272
|
+
compact_split_fn: Callable[[str], str],
|
|
1273
|
+
breakdown: bool = False,
|
|
1274
|
+
) -> str:
|
|
1275
|
+
"""Render weekly bucket aggregates as a ccusage-style ANSI table.
|
|
1276
|
+
|
|
1277
|
+
`weeks` is the parallel `SubWeek` metadata list \u2014 each `bucket.bucket`
|
|
1278
|
+
key (`start_date.isoformat()`) maps to one `SubWeek` via a local
|
|
1279
|
+
lookup. The Week column is rendered from `display_start_date` so that
|
|
1280
|
+
post-early-reset weeks show their effective start (e.g., 2026-04-13)
|
|
1281
|
+
rather than the API-derived backdated `start_date` (e.g., 2026-04-11);
|
|
1282
|
+
for non-reset weeks the two are equal and the rendering is unchanged.
|
|
1283
|
+
|
|
1284
|
+
Near-clone of `_render_bucket_table` with two additional right-edge
|
|
1285
|
+
columns, `Used %` and `$/1%`, whose per-week values are supplied by
|
|
1286
|
+
the caller as a parallel list `week_pct_overlay[i] = (used_pct, dpc)`.
|
|
1287
|
+
Missing overlay values render as "\u2014" (em-dash). Breakdown sub-rows
|
|
1288
|
+
emit empty cells in the new columns (they are per-model, not per-week).
|
|
1289
|
+
The Total footer emits "\u2014" in both (summing percentages is not
|
|
1290
|
+
meaningful).
|
|
1291
|
+
|
|
1292
|
+
`first_col_name` and `title_suffix` are hardcoded to "Week" and
|
|
1293
|
+
"Weekly" respectively.
|
|
1294
|
+
"""
|
|
1295
|
+
assert len(week_pct_overlay) == len(buckets), (
|
|
1296
|
+
f"week_pct_overlay length {len(week_pct_overlay)} does not match "
|
|
1297
|
+
f"buckets length {len(buckets)} — caller contract violated"
|
|
1298
|
+
)
|
|
1299
|
+
# Lookup map for the Week-cell label: bucket key (= API-derived
|
|
1300
|
+
# start_date) → SubWeek, so we can read display_start_date without
|
|
1301
|
+
# changing the bucket aggregation key.
|
|
1302
|
+
week_by_key = {w.start_date.isoformat(): w for w in weeks}
|
|
1303
|
+
first_col_name = "Week"
|
|
1304
|
+
title_suffix = "Weekly"
|
|
1305
|
+
|
|
1306
|
+
color = _supports_color_stdout()
|
|
1307
|
+
unicode_ok = _supports_unicode_stdout()
|
|
1308
|
+
|
|
1309
|
+
def _dim(s: str) -> str:
|
|
1310
|
+
return _style_ansi(s, "90", color)
|
|
1311
|
+
|
|
1312
|
+
def _cyan(s: str) -> str:
|
|
1313
|
+
return _style_ansi(s, "36", color)
|
|
1314
|
+
|
|
1315
|
+
def _bold(s: str) -> str:
|
|
1316
|
+
return _style_ansi(s, "1", color)
|
|
1317
|
+
|
|
1318
|
+
def _yellow(s: str) -> str:
|
|
1319
|
+
return _style_ansi(s, "33", color)
|
|
1320
|
+
|
|
1321
|
+
def _gray(s: str) -> str:
|
|
1322
|
+
return _style_ansi(s, "90", color)
|
|
1323
|
+
|
|
1324
|
+
headers = [
|
|
1325
|
+
first_col_name, "Models", "Input", "Output",
|
|
1326
|
+
"Cache Create", "Cache Read", "Total Tokens", "Cost (USD)",
|
|
1327
|
+
"Used %", "$/1%",
|
|
1328
|
+
]
|
|
1329
|
+
aligns = [
|
|
1330
|
+
"left", "left", "right", "right", "right", "right", "right", "right",
|
|
1331
|
+
"right", "right",
|
|
1332
|
+
]
|
|
1333
|
+
num_cols = len(headers)
|
|
1334
|
+
|
|
1335
|
+
arrow = " \u2514\u2500" if unicode_ok else " |_"
|
|
1336
|
+
em_dash = "\u2014" if unicode_ok else "-"
|
|
1337
|
+
|
|
1338
|
+
# ── Build raw rows: each is (cells, row_type) where a cell is the
|
|
1339
|
+
# tuple (text, color_fn_or_none). `text` may contain '\n' for
|
|
1340
|
+
# multi-line cells (Models list, compact Date).
|
|
1341
|
+
ROW_DATA, ROW_BREAKDOWN, ROW_FOOTER = "data", "breakdown", "footer"
|
|
1342
|
+
raw_rows: list[tuple[list[tuple[str, Any]], str]] = []
|
|
1343
|
+
|
|
1344
|
+
for i, d in enumerate(buckets):
|
|
1345
|
+
# ccusage formatModelsDisplayMultiline: uniq → sort alphabetical
|
|
1346
|
+
short_models = sorted({_short_model_name(m) for m in d.models})
|
|
1347
|
+
models_text = "\n".join(f"- {m}" for m in short_models) if short_models else ""
|
|
1348
|
+
used_pct, dpc = week_pct_overlay[i]
|
|
1349
|
+
used_pct_text = f"{used_pct:.1f}%" if used_pct is not None else em_dash
|
|
1350
|
+
dpc_text = f"{dpc:.3f}" if dpc is not None else em_dash
|
|
1351
|
+
# Render the Week column from display_start_date — equals d.bucket
|
|
1352
|
+
# for non-reset weeks; shifted forward for post-early-reset weeks.
|
|
1353
|
+
# The bucket-aggregation contract guarantees a SubWeek for every
|
|
1354
|
+
# bucket key, so a missing key here is a contract violation; raise
|
|
1355
|
+
# KeyError loudly to mirror the dashboard's `next(...)`-raises-
|
|
1356
|
+
# StopIteration call site at _dashboard_build_weekly_periods.
|
|
1357
|
+
sw = week_by_key[d.bucket]
|
|
1358
|
+
display_label = sw.display_start_date.isoformat()
|
|
1359
|
+
data_cells = [
|
|
1360
|
+
(display_label, None),
|
|
1361
|
+
(models_text, None),
|
|
1362
|
+
(_fmt_num(d.input_tokens), None),
|
|
1363
|
+
(_fmt_num(d.output_tokens), None),
|
|
1364
|
+
(_fmt_num(d.cache_creation_tokens), None),
|
|
1365
|
+
(_fmt_num(d.cache_read_tokens), None),
|
|
1366
|
+
(_fmt_num(d.total_tokens), None),
|
|
1367
|
+
(f"${d.cost_usd:.2f}", None),
|
|
1368
|
+
(used_pct_text, None),
|
|
1369
|
+
(dpc_text, None),
|
|
1370
|
+
]
|
|
1371
|
+
raw_rows.append((data_cells, ROW_DATA))
|
|
1372
|
+
|
|
1373
|
+
if breakdown:
|
|
1374
|
+
for mb in d.model_breakdowns:
|
|
1375
|
+
short = _short_model_name(mb["modelName"])
|
|
1376
|
+
mb_input = int(mb["inputTokens"])
|
|
1377
|
+
mb_output = int(mb["outputTokens"])
|
|
1378
|
+
mb_cc = int(mb["cacheCreationTokens"])
|
|
1379
|
+
mb_cr = int(mb["cacheReadTokens"])
|
|
1380
|
+
mb_total = mb_input + mb_output + mb_cc + mb_cr
|
|
1381
|
+
mb_cost = float(mb["cost"])
|
|
1382
|
+
bd_cells = [
|
|
1383
|
+
(f"{arrow} {short}", _gray),
|
|
1384
|
+
("", None),
|
|
1385
|
+
(_fmt_num(mb_input), _gray),
|
|
1386
|
+
(_fmt_num(mb_output), _gray),
|
|
1387
|
+
(_fmt_num(mb_cc), _gray),
|
|
1388
|
+
(_fmt_num(mb_cr), _gray),
|
|
1389
|
+
(_fmt_num(mb_total), _gray),
|
|
1390
|
+
(f"${mb_cost:.2f}", _gray),
|
|
1391
|
+
("", None),
|
|
1392
|
+
("", None),
|
|
1393
|
+
]
|
|
1394
|
+
raw_rows.append((bd_cells, ROW_BREAKDOWN))
|
|
1395
|
+
|
|
1396
|
+
# Total footer row — yellow on all populated cells.
|
|
1397
|
+
tot_input = sum(d.input_tokens for d in buckets)
|
|
1398
|
+
tot_output = sum(d.output_tokens for d in buckets)
|
|
1399
|
+
tot_cc = sum(d.cache_creation_tokens for d in buckets)
|
|
1400
|
+
tot_cr = sum(d.cache_read_tokens for d in buckets)
|
|
1401
|
+
tot_tokens = sum(d.total_tokens for d in buckets)
|
|
1402
|
+
tot_cost = sum(d.cost_usd for d in buckets)
|
|
1403
|
+
footer_cells = [
|
|
1404
|
+
("Total", _yellow),
|
|
1405
|
+
("", None),
|
|
1406
|
+
(_fmt_num(tot_input), _yellow),
|
|
1407
|
+
(_fmt_num(tot_output), _yellow),
|
|
1408
|
+
(_fmt_num(tot_cc), _yellow),
|
|
1409
|
+
(_fmt_num(tot_cr), _yellow),
|
|
1410
|
+
(_fmt_num(tot_tokens), _yellow),
|
|
1411
|
+
(f"${tot_cost:.2f}", _yellow),
|
|
1412
|
+
(em_dash, _yellow),
|
|
1413
|
+
(em_dash, _yellow),
|
|
1414
|
+
]
|
|
1415
|
+
raw_rows.append((footer_cells, ROW_FOOTER))
|
|
1416
|
+
|
|
1417
|
+
# ── Compute content widths (single-line form: header as-is, dates
|
|
1418
|
+
# single-line). Multi-line cell width = longest line.
|
|
1419
|
+
def _max_line_width(s: str) -> int:
|
|
1420
|
+
if not s:
|
|
1421
|
+
return 0
|
|
1422
|
+
return max(len(line) for line in s.split("\n"))
|
|
1423
|
+
|
|
1424
|
+
content_widths = [len(h) for h in headers]
|
|
1425
|
+
for cells, _rt in raw_rows:
|
|
1426
|
+
for i, (text, _c) in enumerate(cells):
|
|
1427
|
+
content_widths[i] = max(content_widths[i], _max_line_width(text))
|
|
1428
|
+
|
|
1429
|
+
# ── Wide-mode column widths (ccusage formula) ───────────────────────
|
|
1430
|
+
def _wide_width(i: int, content: int) -> int:
|
|
1431
|
+
if aligns[i] == "right":
|
|
1432
|
+
return max(content + 3, 11)
|
|
1433
|
+
if i == 1: # Models
|
|
1434
|
+
return max(content + 2, 15)
|
|
1435
|
+
return max(content + 2, 10)
|
|
1436
|
+
|
|
1437
|
+
col_widths = [_wide_width(i, content_widths[i]) for i in range(num_cols)]
|
|
1438
|
+
|
|
1439
|
+
try:
|
|
1440
|
+
term_width = os.get_terminal_size().columns
|
|
1441
|
+
except (OSError, ValueError):
|
|
1442
|
+
term_width = int(os.environ.get("COLUMNS", "120"))
|
|
1443
|
+
|
|
1444
|
+
border_overhead = 3 * num_cols + 1
|
|
1445
|
+
compact_mode = sum(col_widths) + border_overhead > term_width
|
|
1446
|
+
|
|
1447
|
+
if compact_mode:
|
|
1448
|
+
# Scale down proportionally with narrow minimums.
|
|
1449
|
+
available = term_width - border_overhead
|
|
1450
|
+
total_col = sum(col_widths)
|
|
1451
|
+
scale = available / total_col if total_col > 0 else 1.0
|
|
1452
|
+
|
|
1453
|
+
def _narrow_min(i: int) -> int:
|
|
1454
|
+
if aligns[i] == "right":
|
|
1455
|
+
return 10
|
|
1456
|
+
if i == 0: # Week
|
|
1457
|
+
return 10
|
|
1458
|
+
if i == 1: # Models
|
|
1459
|
+
return 12
|
|
1460
|
+
return 8
|
|
1461
|
+
|
|
1462
|
+
col_widths = [
|
|
1463
|
+
max(int(w * scale), _narrow_min(i))
|
|
1464
|
+
for i, w in enumerate(col_widths)
|
|
1465
|
+
]
|
|
1466
|
+
remainder = available - sum(col_widths)
|
|
1467
|
+
if remainder > 0:
|
|
1468
|
+
col_widths[1] += remainder
|
|
1469
|
+
|
|
1470
|
+
# ── Choose header presentation: single-line in wide mode;
|
|
1471
|
+
# split multi-word headers to 2 lines when compact.
|
|
1472
|
+
if compact_mode:
|
|
1473
|
+
header_display = [h.replace(" ", "\n") for h in headers]
|
|
1474
|
+
else:
|
|
1475
|
+
header_display = headers[:]
|
|
1476
|
+
|
|
1477
|
+
# ── Convert raw rows to multi-line display rows. In compact mode
|
|
1478
|
+
# dates split to 2 lines ("YYYY" / "MM-DD").
|
|
1479
|
+
def _split_cell(text: str) -> list[str]:
|
|
1480
|
+
return text.split("\n") if text else [""]
|
|
1481
|
+
|
|
1482
|
+
def _split_bucket_if_compact(text: str) -> str:
|
|
1483
|
+
if compact_mode:
|
|
1484
|
+
return compact_split_fn(text)
|
|
1485
|
+
return text
|
|
1486
|
+
|
|
1487
|
+
display_rows: list[tuple[list[list[tuple[str, Any]]], str]] = []
|
|
1488
|
+
for cells, row_type in raw_rows:
|
|
1489
|
+
processed: list[tuple[str, Any]] = []
|
|
1490
|
+
for i, (text, cfn) in enumerate(cells):
|
|
1491
|
+
t = _split_bucket_if_compact(text) if i == 0 else text
|
|
1492
|
+
processed.append((t, cfn))
|
|
1493
|
+
line_counts = [len(_split_cell(t)) for t, _ in processed]
|
|
1494
|
+
n_lines = max(line_counts) if line_counts else 1
|
|
1495
|
+
row_lines: list[list[tuple[str, Any]]] = []
|
|
1496
|
+
for li in range(n_lines):
|
|
1497
|
+
row_cells: list[tuple[str, Any]] = []
|
|
1498
|
+
for (text, cfn) in processed:
|
|
1499
|
+
parts = _split_cell(text)
|
|
1500
|
+
row_cells.append((parts[li] if li < len(parts) else "", cfn))
|
|
1501
|
+
row_lines.append(row_cells)
|
|
1502
|
+
display_rows.append((row_lines, row_type))
|
|
1503
|
+
|
|
1504
|
+
# Header display lines (multi-line in compact mode).
|
|
1505
|
+
header_line_counts = [len(_split_cell(h)) for h in header_display]
|
|
1506
|
+
header_n_lines = max(header_line_counts) if header_line_counts else 1
|
|
1507
|
+
header_lines: list[list[str]] = []
|
|
1508
|
+
for li in range(header_n_lines):
|
|
1509
|
+
line = []
|
|
1510
|
+
for h in header_display:
|
|
1511
|
+
parts = _split_cell(h)
|
|
1512
|
+
line.append(parts[li] if li < len(parts) else "")
|
|
1513
|
+
header_lines.append(line)
|
|
1514
|
+
|
|
1515
|
+
# ── Box-drawing chars ───────────────────────────────────────────────
|
|
1516
|
+
if unicode_ok:
|
|
1517
|
+
ch = {
|
|
1518
|
+
"tl": "\u250c", "tm": "\u252c", "tr": "\u2510",
|
|
1519
|
+
"ml": "\u251c", "mm": "\u253c", "mr": "\u2524",
|
|
1520
|
+
"bl": "\u2514", "bm": "\u2534", "br": "\u2518",
|
|
1521
|
+
"h": "\u2500", "v": "\u2502",
|
|
1522
|
+
}
|
|
1523
|
+
else:
|
|
1524
|
+
ch = {k: c for k, c in zip(
|
|
1525
|
+
["tl", "tm", "tr", "ml", "mm", "mr", "bl", "bm", "br", "h", "v"],
|
|
1526
|
+
"+++++++++-|",
|
|
1527
|
+
)}
|
|
1528
|
+
|
|
1529
|
+
def hline(left: str, mid: str, right: str) -> str:
|
|
1530
|
+
segs = [ch["h"] * (col_widths[i] + 2) for i in range(num_cols)]
|
|
1531
|
+
return _dim(left + mid.join(segs) + right)
|
|
1532
|
+
|
|
1533
|
+
def padcell(text: str, width: int, align: str) -> str:
|
|
1534
|
+
vis_len = len(re.sub(r"\033\[[0-9;]*m", "", text))
|
|
1535
|
+
pad_needed = width - vis_len
|
|
1536
|
+
if pad_needed <= 0:
|
|
1537
|
+
return text
|
|
1538
|
+
if align == "right":
|
|
1539
|
+
return " " * pad_needed + text
|
|
1540
|
+
return text + " " * pad_needed
|
|
1541
|
+
|
|
1542
|
+
def make_row(cells: list[str]) -> str:
|
|
1543
|
+
parts: list[str] = []
|
|
1544
|
+
for i, cell_text in enumerate(cells):
|
|
1545
|
+
padded = padcell(cell_text, col_widths[i], aligns[i])
|
|
1546
|
+
parts.append(f" {padded} ")
|
|
1547
|
+
v = _dim(ch["v"])
|
|
1548
|
+
return v + v.join(parts) + v
|
|
1549
|
+
|
|
1550
|
+
# ── Title banner ────────────────────────────────────────────────────
|
|
1551
|
+
lines: list[str] = []
|
|
1552
|
+
lines.append("")
|
|
1553
|
+
title = f"Claude Code Token Usage Report - {title_suffix}"
|
|
1554
|
+
title_padded = f" {title} "
|
|
1555
|
+
tw = len(title_padded)
|
|
1556
|
+
dash = "\u2500" if unicode_ok else "-"
|
|
1557
|
+
vb = "\u2502" if unicode_ok else "|"
|
|
1558
|
+
if unicode_ok:
|
|
1559
|
+
banner_top = f" \u256d{dash * tw}\u256e"
|
|
1560
|
+
banner_bot = f" \u2570{dash * tw}\u256f"
|
|
1561
|
+
else:
|
|
1562
|
+
banner_top = f" +{'-' * tw}+"
|
|
1563
|
+
banner_bot = f" +{'-' * tw}+"
|
|
1564
|
+
lines.append(banner_top)
|
|
1565
|
+
lines.append(f" {vb}" + " " * tw + vb)
|
|
1566
|
+
lines.append(f" {vb}" + _bold(title_padded) + vb)
|
|
1567
|
+
lines.append(f" {vb}" + " " * tw + vb)
|
|
1568
|
+
lines.append(banner_bot)
|
|
1569
|
+
lines.append("")
|
|
1570
|
+
|
|
1571
|
+
# ── Header ──────────────────────────────────────────────────────────
|
|
1572
|
+
lines.append(hline(ch["tl"], ch["tm"], ch["tr"]))
|
|
1573
|
+
for line_cells in header_lines:
|
|
1574
|
+
lines.append(make_row([_cyan(c) for c in line_cells]))
|
|
1575
|
+
lines.append(hline(ch["ml"], ch["mm"], ch["mr"]))
|
|
1576
|
+
|
|
1577
|
+
# ── Data + footer rows, with separators between every row ──────────
|
|
1578
|
+
# Input, Output, CacheC, CacheR, Total, Cost, Used %, $/1%
|
|
1579
|
+
numeric_cols = (2, 3, 4, 5, 6, 7, 8, 9)
|
|
1580
|
+
|
|
1581
|
+
def _render_display_row(row_lines: list[list[tuple[str, Any]]]) -> None:
|
|
1582
|
+
for line_cells in row_lines:
|
|
1583
|
+
rendered: list[str] = []
|
|
1584
|
+
for ci, (text, cfn) in enumerate(line_cells):
|
|
1585
|
+
out = text
|
|
1586
|
+
if compact_mode and ci in numeric_cols and out:
|
|
1587
|
+
out = _truncate_num(out, col_widths[ci])
|
|
1588
|
+
if cfn is not None and out:
|
|
1589
|
+
out = cfn(out)
|
|
1590
|
+
rendered.append(out)
|
|
1591
|
+
lines.append(make_row(rendered))
|
|
1592
|
+
|
|
1593
|
+
for idx, (row_lines, _rt) in enumerate(display_rows):
|
|
1594
|
+
_render_display_row(row_lines)
|
|
1595
|
+
if idx < len(display_rows) - 1:
|
|
1596
|
+
lines.append(hline(ch["ml"], ch["mm"], ch["mr"]))
|
|
1597
|
+
|
|
1598
|
+
lines.append(hline(ch["bl"], ch["bm"], ch["br"]))
|
|
1599
|
+
return "\n".join(lines)
|
|
1600
|
+
|
|
1601
|
+
|
|
1602
|
+
def _render_codex_bucket_table(
|
|
1603
|
+
buckets: list[CodexBucketUsage],
|
|
1604
|
+
*,
|
|
1605
|
+
first_col_name: str, # "Date" or "Month"
|
|
1606
|
+
title: str, # banner title text
|
|
1607
|
+
compact_split_fn: Callable[[str], str],
|
|
1608
|
+
bucket_display_fn: Callable[[str], str],
|
|
1609
|
+
breakdown: bool = False,
|
|
1610
|
+
force_compact: bool = False,
|
|
1611
|
+
) -> str:
|
|
1612
|
+
"""Render Codex bucket aggregates matching upstream ccusage-codex daily/monthly tables.
|
|
1613
|
+
|
|
1614
|
+
Byte-parity-targeted against upstream `ccusage-codex daily|monthly`:
|
|
1615
|
+
- banner indented by 1 space; 2-space padding around title text
|
|
1616
|
+
- inter-row separator (├┼...┤) between every data row AND between
|
|
1617
|
+
last data row and footer
|
|
1618
|
+
- 8 columns: <Date|Month> | Models | Input | Output | Reasoning |
|
|
1619
|
+
Cache Read | Total Tokens | Cost (USD)
|
|
1620
|
+
- Input column = input_tokens - cached_input_tokens (non-cached)
|
|
1621
|
+
- Total Tokens column = input_tokens + output_tokens (derived)
|
|
1622
|
+
"""
|
|
1623
|
+
color = _supports_color_stdout()
|
|
1624
|
+
unicode_ok = _supports_unicode_stdout()
|
|
1625
|
+
|
|
1626
|
+
def _dim(s: str) -> str: return _style_ansi(s, "90", color)
|
|
1627
|
+
def _cyan(s: str) -> str: return _style_ansi(s, "36", color)
|
|
1628
|
+
def _yellow(s: str) -> str: return _style_ansi(s, "33", color)
|
|
1629
|
+
def _gray(s: str) -> str: return _style_ansi(s, "90", color)
|
|
1630
|
+
|
|
1631
|
+
headers = [
|
|
1632
|
+
first_col_name, "Models", "Input", "Output",
|
|
1633
|
+
"Reasoning", "Cache Read", "Total Tokens", "Cost (USD)",
|
|
1634
|
+
]
|
|
1635
|
+
aligns = ["left", "left", "right", "right", "right", "right", "right", "right"]
|
|
1636
|
+
num_cols = len(headers)
|
|
1637
|
+
|
|
1638
|
+
arrow = " \u2514\u2500" if unicode_ok else " |_"
|
|
1639
|
+
|
|
1640
|
+
ROW_DATA, ROW_BREAKDOWN, ROW_FOOTER = "data", "breakdown", "footer"
|
|
1641
|
+
raw_rows: list[tuple[list[tuple[str, Any]], str]] = []
|
|
1642
|
+
|
|
1643
|
+
for b in buckets:
|
|
1644
|
+
models_text = "\n".join(f"- {m}" for m in b.models) if b.models else ""
|
|
1645
|
+
non_cached = max(0, b.input_tokens - b.cached_input_tokens)
|
|
1646
|
+
bucket_total = b.input_tokens + b.output_tokens
|
|
1647
|
+
data_cells = [
|
|
1648
|
+
(bucket_display_fn(b.bucket), None),
|
|
1649
|
+
(models_text, None),
|
|
1650
|
+
(_fmt_num(non_cached), None),
|
|
1651
|
+
(_fmt_num(b.output_tokens), None),
|
|
1652
|
+
(_fmt_num(b.reasoning_output_tokens), None),
|
|
1653
|
+
(_fmt_num(b.cached_input_tokens), None),
|
|
1654
|
+
(_fmt_num(bucket_total), None),
|
|
1655
|
+
(f"${b.cost_usd:.2f}", None),
|
|
1656
|
+
]
|
|
1657
|
+
raw_rows.append((data_cells, ROW_DATA))
|
|
1658
|
+
|
|
1659
|
+
if breakdown:
|
|
1660
|
+
for mb in b.model_breakdowns:
|
|
1661
|
+
name = mb["modelName"]
|
|
1662
|
+
mb_input_inclusive = int(mb["inputTokens"])
|
|
1663
|
+
mb_cached = int(mb["cachedInputTokens"])
|
|
1664
|
+
mb_output = int(mb["outputTokens"])
|
|
1665
|
+
mb_reasoning = int(mb["reasoningOutputTokens"])
|
|
1666
|
+
mb_non_cached = max(0, mb_input_inclusive - mb_cached)
|
|
1667
|
+
mb_total = mb_input_inclusive + mb_output
|
|
1668
|
+
mb_cost = float(mb["cost"])
|
|
1669
|
+
bd_cells = [
|
|
1670
|
+
(f"{arrow} {name}", _gray),
|
|
1671
|
+
("", None),
|
|
1672
|
+
(_fmt_num(mb_non_cached), _gray),
|
|
1673
|
+
(_fmt_num(mb_output), _gray),
|
|
1674
|
+
(_fmt_num(mb_reasoning), _gray),
|
|
1675
|
+
(_fmt_num(mb_cached), _gray),
|
|
1676
|
+
(_fmt_num(mb_total), _gray),
|
|
1677
|
+
(f"${mb_cost:.2f}", _gray),
|
|
1678
|
+
]
|
|
1679
|
+
raw_rows.append((bd_cells, ROW_BREAKDOWN))
|
|
1680
|
+
|
|
1681
|
+
tot_input_inclusive = sum(b.input_tokens for b in buckets)
|
|
1682
|
+
tot_cached = sum(b.cached_input_tokens for b in buckets)
|
|
1683
|
+
tot_output = sum(b.output_tokens for b in buckets)
|
|
1684
|
+
tot_reasoning = sum(b.reasoning_output_tokens for b in buckets)
|
|
1685
|
+
tot_non_cached = max(0, tot_input_inclusive - tot_cached)
|
|
1686
|
+
tot_tokens = tot_input_inclusive + tot_output
|
|
1687
|
+
tot_cost = sum(b.cost_usd for b in buckets)
|
|
1688
|
+
footer_cells = [
|
|
1689
|
+
("Total", _yellow),
|
|
1690
|
+
("", None),
|
|
1691
|
+
(_fmt_num(tot_non_cached), _yellow),
|
|
1692
|
+
(_fmt_num(tot_output), _yellow),
|
|
1693
|
+
(_fmt_num(tot_reasoning), _yellow),
|
|
1694
|
+
(_fmt_num(tot_cached), _yellow),
|
|
1695
|
+
(_fmt_num(tot_tokens), _yellow),
|
|
1696
|
+
(f"${tot_cost:.2f}", _yellow),
|
|
1697
|
+
]
|
|
1698
|
+
raw_rows.append((footer_cells, ROW_FOOTER))
|
|
1699
|
+
|
|
1700
|
+
def _max_line_width(s: str) -> int:
|
|
1701
|
+
if not s:
|
|
1702
|
+
return 0
|
|
1703
|
+
return max(len(line) for line in s.split("\n"))
|
|
1704
|
+
|
|
1705
|
+
content_widths = [len(h) for h in headers]
|
|
1706
|
+
for cells, _rt in raw_rows:
|
|
1707
|
+
for i, (text, _c) in enumerate(cells):
|
|
1708
|
+
content_widths[i] = max(content_widths[i], _max_line_width(text))
|
|
1709
|
+
|
|
1710
|
+
def _wide_width(i: int, content: int) -> int:
|
|
1711
|
+
if aligns[i] == "right":
|
|
1712
|
+
return max(content + 3, 11)
|
|
1713
|
+
if i == 1:
|
|
1714
|
+
return max(content + 2, 15)
|
|
1715
|
+
return max(content + 2, 10)
|
|
1716
|
+
|
|
1717
|
+
col_widths = [_wide_width(i, content_widths[i]) for i in range(num_cols)]
|
|
1718
|
+
|
|
1719
|
+
try:
|
|
1720
|
+
term_width = os.get_terminal_size().columns
|
|
1721
|
+
except (OSError, ValueError):
|
|
1722
|
+
term_width = int(os.environ.get("COLUMNS", "120"))
|
|
1723
|
+
|
|
1724
|
+
border_overhead = 3 * num_cols + 1
|
|
1725
|
+
# `force_compact` (from --compact) short-circuits the width-based
|
|
1726
|
+
# auto-detect. Matches upstream's `--compact` behavior of always
|
|
1727
|
+
# rendering the narrow layout regardless of terminal width.
|
|
1728
|
+
compact_mode = force_compact or (sum(col_widths) + border_overhead > term_width)
|
|
1729
|
+
|
|
1730
|
+
if compact_mode:
|
|
1731
|
+
available = term_width - border_overhead
|
|
1732
|
+
total_col = sum(col_widths)
|
|
1733
|
+
scale = available / total_col if total_col > 0 else 1.0
|
|
1734
|
+
|
|
1735
|
+
def _narrow_min(i: int) -> int:
|
|
1736
|
+
if aligns[i] == "right":
|
|
1737
|
+
return 10
|
|
1738
|
+
if i == 0:
|
|
1739
|
+
return 10
|
|
1740
|
+
if i == 1:
|
|
1741
|
+
return 12
|
|
1742
|
+
return 8
|
|
1743
|
+
|
|
1744
|
+
col_widths = [
|
|
1745
|
+
max(int(w * scale), _narrow_min(i))
|
|
1746
|
+
for i, w in enumerate(col_widths)
|
|
1747
|
+
]
|
|
1748
|
+
remainder = available - sum(col_widths)
|
|
1749
|
+
if remainder > 0:
|
|
1750
|
+
col_widths[1] += remainder
|
|
1751
|
+
|
|
1752
|
+
if compact_mode:
|
|
1753
|
+
header_display = [h.replace(" ", "\n") for h in headers]
|
|
1754
|
+
else:
|
|
1755
|
+
header_display = headers[:]
|
|
1756
|
+
|
|
1757
|
+
def _split_cell(text: str) -> list[str]:
|
|
1758
|
+
return text.split("\n") if text else [""]
|
|
1759
|
+
|
|
1760
|
+
def _split_bucket_if_compact(text: str) -> str:
|
|
1761
|
+
if compact_mode:
|
|
1762
|
+
return compact_split_fn(text)
|
|
1763
|
+
return text
|
|
1764
|
+
|
|
1765
|
+
display_rows: list[tuple[list[list[str]], str, list[Any]]] = []
|
|
1766
|
+
for cells, rt in raw_rows:
|
|
1767
|
+
display_cells: list[list[str]] = []
|
|
1768
|
+
colors: list[Any] = []
|
|
1769
|
+
for i, (text, cfn) in enumerate(cells):
|
|
1770
|
+
if rt == ROW_DATA and i == 0:
|
|
1771
|
+
text = _split_bucket_if_compact(text)
|
|
1772
|
+
lines = _split_cell(text)
|
|
1773
|
+
w = col_widths[i]
|
|
1774
|
+
truncated: list[str] = []
|
|
1775
|
+
for ln in lines:
|
|
1776
|
+
if len(ln) <= w:
|
|
1777
|
+
truncated.append(ln)
|
|
1778
|
+
else:
|
|
1779
|
+
ell = "\u2026" if unicode_ok else "..."
|
|
1780
|
+
truncated.append(ln[: max(0, w - len(ell))] + ell)
|
|
1781
|
+
display_cells.append(truncated)
|
|
1782
|
+
colors.append(cfn)
|
|
1783
|
+
display_rows.append((display_cells, rt, colors))
|
|
1784
|
+
|
|
1785
|
+
# Box-drawing
|
|
1786
|
+
if unicode_ok:
|
|
1787
|
+
TL, TR, BL, BR = "\u250c", "\u2510", "\u2514", "\u2518"
|
|
1788
|
+
H, V = "\u2500", "\u2502"
|
|
1789
|
+
T_DOWN, T_UP, T_LEFT, T_RIGHT, CROSS = "\u252c", "\u2534", "\u2524", "\u251c", "\u253c"
|
|
1790
|
+
RTL, RTR, RBL, RBR = "\u256d", "\u256e", "\u2570", "\u256f"
|
|
1791
|
+
else:
|
|
1792
|
+
TL = TR = BL = BR = "+"
|
|
1793
|
+
H, V = "-", "|"
|
|
1794
|
+
T_DOWN = T_UP = T_LEFT = T_RIGHT = CROSS = "+"
|
|
1795
|
+
RTL = RTR = RBL = RBR = "+"
|
|
1796
|
+
|
|
1797
|
+
def _border_row(left: str, mid: str, right: str) -> str:
|
|
1798
|
+
parts = [left]
|
|
1799
|
+
for i, w in enumerate(col_widths):
|
|
1800
|
+
parts.append(H * (w + 2))
|
|
1801
|
+
parts.append(mid if i < num_cols - 1 else right)
|
|
1802
|
+
return _dim("".join(parts))
|
|
1803
|
+
|
|
1804
|
+
def _pad_cell(text: str, w: int, align: str) -> str:
|
|
1805
|
+
if align == "right":
|
|
1806
|
+
return text.rjust(w)
|
|
1807
|
+
return text.ljust(w)
|
|
1808
|
+
|
|
1809
|
+
def _render_row(display_cells: list[list[str]], colors: list[Any]) -> list[str]:
|
|
1810
|
+
max_h = max(len(c) for c in display_cells) if display_cells else 1
|
|
1811
|
+
out_lines: list[str] = []
|
|
1812
|
+
for li in range(max_h):
|
|
1813
|
+
parts: list[str] = [_dim(V)]
|
|
1814
|
+
for i, cell in enumerate(display_cells):
|
|
1815
|
+
content = cell[li] if li < len(cell) else ""
|
|
1816
|
+
padded = _pad_cell(content, col_widths[i], aligns[i])
|
|
1817
|
+
if colors[i] is not None:
|
|
1818
|
+
padded = colors[i](padded)
|
|
1819
|
+
parts.append(f" {padded} ")
|
|
1820
|
+
parts.append(_dim(V))
|
|
1821
|
+
out_lines.append("".join(parts))
|
|
1822
|
+
return out_lines
|
|
1823
|
+
|
|
1824
|
+
# Banner — 1-space leading indent on each line, 2-space padding around title
|
|
1825
|
+
banner_inner_width = max(len(title) + 4, 60)
|
|
1826
|
+
left_pad = 2
|
|
1827
|
+
right_pad = banner_inner_width - len(title) - left_pad
|
|
1828
|
+
indent = " " # upstream banner indents by 1 space
|
|
1829
|
+
top = indent + RTL + H * banner_inner_width + RTR
|
|
1830
|
+
blank = indent + V + " " * banner_inner_width + V
|
|
1831
|
+
text_line = indent + V + " " * left_pad + title + " " * right_pad + V
|
|
1832
|
+
bottom = indent + RBL + H * banner_inner_width + RBR
|
|
1833
|
+
banner_lines = [_dim(top), _dim(blank), _dim(text_line), _dim(blank), _dim(bottom)]
|
|
1834
|
+
|
|
1835
|
+
# Assemble
|
|
1836
|
+
out: list[str] = []
|
|
1837
|
+
out.extend(banner_lines)
|
|
1838
|
+
out.append("") # blank line between banner and table (matches upstream)
|
|
1839
|
+
out.append(_border_row(TL, T_DOWN, TR))
|
|
1840
|
+
|
|
1841
|
+
# Header row (cyan per cell)
|
|
1842
|
+
header_display_cells = [_split_cell(h) for h in header_display]
|
|
1843
|
+
max_h = max(len(c) for c in header_display_cells)
|
|
1844
|
+
for li in range(max_h):
|
|
1845
|
+
parts: list[str] = [_dim(V)]
|
|
1846
|
+
for i, cell in enumerate(header_display_cells):
|
|
1847
|
+
content = cell[li] if li < len(cell) else ""
|
|
1848
|
+
padded = _pad_cell(content, col_widths[i], aligns[i])
|
|
1849
|
+
parts.append(f" {_cyan(padded)} ")
|
|
1850
|
+
parts.append(_dim(V))
|
|
1851
|
+
out.append("".join(parts))
|
|
1852
|
+
out.append(_border_row(T_RIGHT, CROSS, T_LEFT))
|
|
1853
|
+
|
|
1854
|
+
# Data + breakdown + footer, with inter-row separators
|
|
1855
|
+
sep = _border_row(T_RIGHT, CROSS, T_LEFT)
|
|
1856
|
+
for idx, (display_cells, rt, colors) in enumerate(display_rows):
|
|
1857
|
+
for ln in _render_row(display_cells, colors):
|
|
1858
|
+
out.append(ln)
|
|
1859
|
+
if idx < len(display_rows) - 1:
|
|
1860
|
+
# Separator between every row (data, breakdown, and between last
|
|
1861
|
+
# data row and footer) — matches upstream.
|
|
1862
|
+
out.append(sep)
|
|
1863
|
+
|
|
1864
|
+
out.append(_border_row(BL, T_UP, BR))
|
|
1865
|
+
return "\n".join(out)
|
|
1866
|
+
|
|
1867
|
+
|
|
1868
|
+
def _render_codex_session_table(
|
|
1869
|
+
sessions: list[CodexSessionUsage],
|
|
1870
|
+
*,
|
|
1871
|
+
title: str,
|
|
1872
|
+
force_compact: bool = False,
|
|
1873
|
+
tz_name: str | None = None,
|
|
1874
|
+
) -> str:
|
|
1875
|
+
"""Render Codex session aggregates matching upstream ccusage-codex session (11 cols).
|
|
1876
|
+
|
|
1877
|
+
Columns:
|
|
1878
|
+
Date | Directory | Session | Models | Input | Output | Reasoning |
|
|
1879
|
+
Cache Read | Total Tokens | Cost (USD) | Last Activity
|
|
1880
|
+
|
|
1881
|
+
Structural parity with Task 8's _render_codex_bucket_table:
|
|
1882
|
+
- banner with 1-space leading indent + 2-space title padding
|
|
1883
|
+
- inter-row separators (├┼...┤) between every row and before footer
|
|
1884
|
+
- Input column = non_cached_input (derived)
|
|
1885
|
+
- Total Tokens column = input + output (derived)
|
|
1886
|
+
|
|
1887
|
+
``force_compact`` honors upstream's ``--compact`` flag by always
|
|
1888
|
+
rendering the narrow layout. ``tz_name`` (from upstream's
|
|
1889
|
+
``--timezone``) selects the IANA zone used to format Date /
|
|
1890
|
+
Last Activity cells; default falls back to local OS tz.
|
|
1891
|
+
"""
|
|
1892
|
+
color = _supports_color_stdout()
|
|
1893
|
+
unicode_ok = _supports_unicode_stdout()
|
|
1894
|
+
|
|
1895
|
+
def _dim(s: str) -> str: return _style_ansi(s, "90", color)
|
|
1896
|
+
def _cyan(s: str) -> str: return _style_ansi(s, "36", color)
|
|
1897
|
+
def _yellow(s: str) -> str: return _style_ansi(s, "33", color)
|
|
1898
|
+
|
|
1899
|
+
headers = [
|
|
1900
|
+
"Date", "Directory", "Session", "Models",
|
|
1901
|
+
"Input", "Output", "Reasoning", "Cache Read",
|
|
1902
|
+
"Total Tokens", "Cost (USD)", "Last Activity",
|
|
1903
|
+
]
|
|
1904
|
+
aligns = [
|
|
1905
|
+
"left", "left", "left", "left",
|
|
1906
|
+
"right", "right", "right", "right",
|
|
1907
|
+
"right", "right", "left",
|
|
1908
|
+
]
|
|
1909
|
+
num_cols = len(headers)
|
|
1910
|
+
|
|
1911
|
+
_display_tz = _resolve_tz(tz_name)
|
|
1912
|
+
|
|
1913
|
+
def _to_display_tz(ts: dt.datetime) -> dt.datetime:
|
|
1914
|
+
# internal fallback: host-local intentional (AM/PM render via attribute access)
|
|
1915
|
+
return ts.astimezone(_display_tz) if _display_tz is not None else ts.astimezone()
|
|
1916
|
+
|
|
1917
|
+
def _date_cell(ts: dt.datetime) -> str:
|
|
1918
|
+
local = _to_display_tz(ts)
|
|
1919
|
+
return f"{_CODEX_MONTHS[local.month - 1]} {local.day:02d},\n{local.year}"
|
|
1920
|
+
|
|
1921
|
+
def _last_activity_cell(ts: dt.datetime) -> str:
|
|
1922
|
+
local = _to_display_tz(ts)
|
|
1923
|
+
hour_12 = local.hour % 12
|
|
1924
|
+
if hour_12 == 0:
|
|
1925
|
+
hour_12 = 12
|
|
1926
|
+
ampm = "a.m." if local.hour < 12 else "p.m."
|
|
1927
|
+
return f"{local.year}-{local.month:02d}-{local.day:02d}\n{hour_12}:{local.minute:02d}\n{ampm}"
|
|
1928
|
+
|
|
1929
|
+
def _session_cell(session_id: str) -> str:
|
|
1930
|
+
if not session_id:
|
|
1931
|
+
return ""
|
|
1932
|
+
tail = session_id.split("-")[-1][-4:] if "-" in session_id else session_id[-4:]
|
|
1933
|
+
return f"\u2026{tail}\u2026" if unicode_ok else f"...{tail}..."
|
|
1934
|
+
|
|
1935
|
+
ROW_DATA, ROW_FOOTER = "data", "footer"
|
|
1936
|
+
raw_rows: list[tuple[list[tuple[str, Any]], str]] = []
|
|
1937
|
+
|
|
1938
|
+
for s in sessions:
|
|
1939
|
+
models_text = "\n".join(f"- {m}" for m in s.models) if s.models else ""
|
|
1940
|
+
non_cached = max(0, s.input_tokens - s.cached_input_tokens)
|
|
1941
|
+
session_total = s.input_tokens + s.output_tokens
|
|
1942
|
+
data_cells = [
|
|
1943
|
+
(_date_cell(s.last_activity), None),
|
|
1944
|
+
(s.directory, None),
|
|
1945
|
+
(_session_cell(s.session_id), None),
|
|
1946
|
+
(models_text, None),
|
|
1947
|
+
(_fmt_num(non_cached), None),
|
|
1948
|
+
(_fmt_num(s.output_tokens), None),
|
|
1949
|
+
(_fmt_num(s.reasoning_output_tokens), None),
|
|
1950
|
+
(_fmt_num(s.cached_input_tokens), None),
|
|
1951
|
+
(_fmt_num(session_total), None),
|
|
1952
|
+
(f"${s.cost_usd:.2f}", None),
|
|
1953
|
+
(_last_activity_cell(s.last_activity), None),
|
|
1954
|
+
]
|
|
1955
|
+
raw_rows.append((data_cells, ROW_DATA))
|
|
1956
|
+
|
|
1957
|
+
tot_input_inclusive = sum(s.input_tokens for s in sessions)
|
|
1958
|
+
tot_cached = sum(s.cached_input_tokens for s in sessions)
|
|
1959
|
+
tot_output = sum(s.output_tokens for s in sessions)
|
|
1960
|
+
tot_reasoning = sum(s.reasoning_output_tokens for s in sessions)
|
|
1961
|
+
tot_non_cached = max(0, tot_input_inclusive - tot_cached)
|
|
1962
|
+
tot_tokens = tot_input_inclusive + tot_output
|
|
1963
|
+
tot_cost = sum(s.cost_usd for s in sessions)
|
|
1964
|
+
footer_cells = [
|
|
1965
|
+
("Total", _yellow),
|
|
1966
|
+
("", None), ("", None), ("", None),
|
|
1967
|
+
(_fmt_num(tot_non_cached), _yellow),
|
|
1968
|
+
(_fmt_num(tot_output), _yellow),
|
|
1969
|
+
(_fmt_num(tot_reasoning), _yellow),
|
|
1970
|
+
(_fmt_num(tot_cached), _yellow),
|
|
1971
|
+
(_fmt_num(tot_tokens), _yellow),
|
|
1972
|
+
(f"${tot_cost:.2f}", _yellow),
|
|
1973
|
+
("", None),
|
|
1974
|
+
]
|
|
1975
|
+
raw_rows.append((footer_cells, ROW_FOOTER))
|
|
1976
|
+
|
|
1977
|
+
def _max_line_width(s: str) -> int:
|
|
1978
|
+
if not s:
|
|
1979
|
+
return 0
|
|
1980
|
+
return max(len(line) for line in s.split("\n"))
|
|
1981
|
+
|
|
1982
|
+
content_widths = [len(h) for h in headers]
|
|
1983
|
+
for cells, _rt in raw_rows:
|
|
1984
|
+
for i, (text, _c) in enumerate(cells):
|
|
1985
|
+
content_widths[i] = max(content_widths[i], _max_line_width(text))
|
|
1986
|
+
|
|
1987
|
+
def _wide_width(i: int, content: int) -> int:
|
|
1988
|
+
if aligns[i] == "right":
|
|
1989
|
+
return max(content + 3, 11)
|
|
1990
|
+
if i == 0:
|
|
1991
|
+
return max(content + 2, 12)
|
|
1992
|
+
if i == 1:
|
|
1993
|
+
return max(content + 2, 15)
|
|
1994
|
+
return max(content + 2, 12)
|
|
1995
|
+
|
|
1996
|
+
col_widths = [_wide_width(i, content_widths[i]) for i in range(num_cols)]
|
|
1997
|
+
|
|
1998
|
+
try:
|
|
1999
|
+
term_width = os.get_terminal_size().columns
|
|
2000
|
+
except (OSError, ValueError):
|
|
2001
|
+
term_width = int(os.environ.get("COLUMNS", "120"))
|
|
2002
|
+
|
|
2003
|
+
border_overhead = 3 * num_cols + 1
|
|
2004
|
+
# `force_compact` (from --compact) short-circuits the width-based
|
|
2005
|
+
# auto-detect so the narrow layout renders regardless of terminal width.
|
|
2006
|
+
if force_compact or (sum(col_widths) + border_overhead > term_width):
|
|
2007
|
+
available = term_width - border_overhead
|
|
2008
|
+
total_col = sum(col_widths)
|
|
2009
|
+
scale = available / total_col if total_col > 0 else 1.0
|
|
2010
|
+
col_widths = [max(int(w * scale), 8) for w in col_widths]
|
|
2011
|
+
remainder = available - sum(col_widths)
|
|
2012
|
+
if remainder > 0:
|
|
2013
|
+
col_widths[3] += remainder # grow Models column
|
|
2014
|
+
|
|
2015
|
+
def _split_cell(text: str) -> list[str]:
|
|
2016
|
+
return text.split("\n") if text else [""]
|
|
2017
|
+
|
|
2018
|
+
def _pad_cell(text: str, w: int, align: str) -> str:
|
|
2019
|
+
if align == "right":
|
|
2020
|
+
return text.rjust(w)
|
|
2021
|
+
return text.ljust(w)
|
|
2022
|
+
|
|
2023
|
+
if unicode_ok:
|
|
2024
|
+
TL, TR, BL, BR = "\u250c", "\u2510", "\u2514", "\u2518"
|
|
2025
|
+
H, V = "\u2500", "\u2502"
|
|
2026
|
+
T_DOWN, T_UP, T_LEFT, T_RIGHT, CROSS = "\u252c", "\u2534", "\u2524", "\u251c", "\u253c"
|
|
2027
|
+
RTL, RTR, RBL, RBR = "\u256d", "\u256e", "\u2570", "\u256f"
|
|
2028
|
+
else:
|
|
2029
|
+
TL = TR = BL = BR = "+"
|
|
2030
|
+
H, V = "-", "|"
|
|
2031
|
+
T_DOWN = T_UP = T_LEFT = T_RIGHT = CROSS = "+"
|
|
2032
|
+
RTL = RTR = RBL = RBR = "+"
|
|
2033
|
+
|
|
2034
|
+
def _border_row(left: str, mid: str, right: str) -> str:
|
|
2035
|
+
parts = [left]
|
|
2036
|
+
for i, w in enumerate(col_widths):
|
|
2037
|
+
parts.append(H * (w + 2))
|
|
2038
|
+
parts.append(mid if i < num_cols - 1 else right)
|
|
2039
|
+
return _dim("".join(parts))
|
|
2040
|
+
|
|
2041
|
+
# Banner — 1-space leading indent + 2-space title padding
|
|
2042
|
+
banner_inner_width = max(len(title) + 4, 60)
|
|
2043
|
+
left_pad = 2
|
|
2044
|
+
right_pad = banner_inner_width - len(title) - left_pad
|
|
2045
|
+
indent = " "
|
|
2046
|
+
top = indent + RTL + H * banner_inner_width + RTR
|
|
2047
|
+
blank = indent + V + " " * banner_inner_width + V
|
|
2048
|
+
text_line = indent + V + " " * left_pad + title + " " * right_pad + V
|
|
2049
|
+
bottom = indent + RBL + H * banner_inner_width + RBR
|
|
2050
|
+
out: list[str] = [_dim(top), _dim(blank), _dim(text_line), _dim(blank), _dim(bottom)]
|
|
2051
|
+
out.append("") # blank line between banner and table
|
|
2052
|
+
|
|
2053
|
+
out.append(_border_row(TL, T_DOWN, TR))
|
|
2054
|
+
|
|
2055
|
+
# Header
|
|
2056
|
+
header_cells = [_split_cell(h) for h in headers]
|
|
2057
|
+
max_h = max(len(c) for c in header_cells)
|
|
2058
|
+
for li in range(max_h):
|
|
2059
|
+
parts = [_dim(V)]
|
|
2060
|
+
for i, cell in enumerate(header_cells):
|
|
2061
|
+
content = cell[li] if li < len(cell) else ""
|
|
2062
|
+
parts.append(f" {_cyan(_pad_cell(content, col_widths[i], aligns[i]))} ")
|
|
2063
|
+
parts.append(_dim(V))
|
|
2064
|
+
out.append("".join(parts))
|
|
2065
|
+
out.append(_border_row(T_RIGHT, CROSS, T_LEFT))
|
|
2066
|
+
|
|
2067
|
+
# Data + footer with inter-row separators
|
|
2068
|
+
sep = _border_row(T_RIGHT, CROSS, T_LEFT)
|
|
2069
|
+
display_rows = list(raw_rows)
|
|
2070
|
+
for idx, (cells, rt) in enumerate(display_rows):
|
|
2071
|
+
split_cells = [_split_cell(t) for t, _c in cells]
|
|
2072
|
+
max_h = max(len(c) for c in split_cells) if split_cells else 1
|
|
2073
|
+
for li in range(max_h):
|
|
2074
|
+
parts = [_dim(V)]
|
|
2075
|
+
for i, (text, cfn) in enumerate(cells):
|
|
2076
|
+
content = split_cells[i][li] if li < len(split_cells[i]) else ""
|
|
2077
|
+
# Truncate with ellipsis if cell content exceeds column width
|
|
2078
|
+
w = col_widths[i]
|
|
2079
|
+
if len(content) > w:
|
|
2080
|
+
ell = "\u2026" if unicode_ok else "..."
|
|
2081
|
+
content = content[: max(0, w - len(ell))] + ell
|
|
2082
|
+
padded = _pad_cell(content, w, aligns[i])
|
|
2083
|
+
if cfn is not None:
|
|
2084
|
+
padded = cfn(padded)
|
|
2085
|
+
parts.append(f" {padded} ")
|
|
2086
|
+
parts.append(_dim(V))
|
|
2087
|
+
out.append("".join(parts))
|
|
2088
|
+
if idx < len(display_rows) - 1:
|
|
2089
|
+
out.append(sep)
|
|
2090
|
+
|
|
2091
|
+
out.append(_border_row(BL, T_UP, BR))
|
|
2092
|
+
return "\n".join(out)
|
|
2093
|
+
|
|
2094
|
+
|
|
2095
|
+
def _render_claude_session_table(
|
|
2096
|
+
sessions: list[ClaudeSessionUsage],
|
|
2097
|
+
*,
|
|
2098
|
+
title: str = "Claude Token Usage Report - Sessions",
|
|
2099
|
+
breakdown: bool = False,
|
|
2100
|
+
tz: "ZoneInfo | None" = None,
|
|
2101
|
+
) -> str:
|
|
2102
|
+
"""Render Claude session aggregates matching upstream ccusage session view (11 cols).
|
|
2103
|
+
|
|
2104
|
+
Columns:
|
|
2105
|
+
Date | Directory | Session | Models | Input | Cache Create |
|
|
2106
|
+
Cache Read | Output | Total Tokens | Cost (USD) | Last Activity
|
|
2107
|
+
|
|
2108
|
+
Structural clone of `_render_codex_session_table` with:
|
|
2109
|
+
- ``Reasoning`` column replaced by ``Cache Create`` (sourced from
|
|
2110
|
+
``cache_creation_tokens`` instead of ``reasoning_output_tokens``).
|
|
2111
|
+
- ``tz_name`` / ``force_compact`` parameters dropped — Claude-side
|
|
2112
|
+
commands don't expose ``--timezone`` / ``--compact`` today; dates
|
|
2113
|
+
render in local TZ via ``astimezone()`` and compact mode is
|
|
2114
|
+
triggered by terminal width alone.
|
|
2115
|
+
- ``Session`` cell shows first 8 chars of ``session_id`` (full UUID
|
|
2116
|
+
lives in --json).
|
|
2117
|
+
|
|
2118
|
+
``breakdown`` toggles per-model sub-rows beneath each session row.
|
|
2119
|
+
"""
|
|
2120
|
+
color = _supports_color_stdout()
|
|
2121
|
+
unicode_ok = _supports_unicode_stdout()
|
|
2122
|
+
|
|
2123
|
+
def _dim(s: str) -> str: return _style_ansi(s, "90", color)
|
|
2124
|
+
def _cyan(s: str) -> str: return _style_ansi(s, "36", color)
|
|
2125
|
+
def _yellow(s: str) -> str: return _style_ansi(s, "33", color)
|
|
2126
|
+
def _gray(s: str) -> str: return _style_ansi(s, "90", color)
|
|
2127
|
+
|
|
2128
|
+
headers = [
|
|
2129
|
+
"Date", "Directory", "Session", "Models",
|
|
2130
|
+
"Input", "Cache Create", "Cache Read", "Output",
|
|
2131
|
+
"Total Tokens", "Cost (USD)", "Last Activity",
|
|
2132
|
+
]
|
|
2133
|
+
aligns = [
|
|
2134
|
+
"left", "left", "left", "left",
|
|
2135
|
+
"right", "right", "right", "right",
|
|
2136
|
+
"right", "right", "left",
|
|
2137
|
+
]
|
|
2138
|
+
num_cols = len(headers)
|
|
2139
|
+
|
|
2140
|
+
def _to_display_tz(ts: dt.datetime) -> dt.datetime:
|
|
2141
|
+
return ts.astimezone(tz)
|
|
2142
|
+
|
|
2143
|
+
def _date_cell(ts: dt.datetime) -> str:
|
|
2144
|
+
local = _to_display_tz(ts)
|
|
2145
|
+
return f"{_CODEX_MONTHS[local.month - 1]} {local.day:02d},\n{local.year}"
|
|
2146
|
+
|
|
2147
|
+
def _last_activity_cell(ts: dt.datetime) -> str:
|
|
2148
|
+
local = _to_display_tz(ts)
|
|
2149
|
+
hour_12 = local.hour % 12
|
|
2150
|
+
if hour_12 == 0:
|
|
2151
|
+
hour_12 = 12
|
|
2152
|
+
ampm = "a.m." if local.hour < 12 else "p.m."
|
|
2153
|
+
return f"{local.year}-{local.month:02d}-{local.day:02d}\n{hour_12}:{local.minute:02d}\n{ampm}"
|
|
2154
|
+
|
|
2155
|
+
def _session_cell(session_id: str) -> str:
|
|
2156
|
+
if not session_id:
|
|
2157
|
+
return ""
|
|
2158
|
+
return session_id[:8]
|
|
2159
|
+
|
|
2160
|
+
arrow = " \u2514\u2500" if unicode_ok else " |_"
|
|
2161
|
+
|
|
2162
|
+
ROW_DATA, ROW_BREAKDOWN, ROW_FOOTER = "data", "breakdown", "footer"
|
|
2163
|
+
raw_rows: list[tuple[list[tuple[str, Any]], str]] = []
|
|
2164
|
+
|
|
2165
|
+
for s in sessions:
|
|
2166
|
+
short_models = sorted({_short_model_name(m) for m in s.models})
|
|
2167
|
+
models_text = "\n".join(f"- {m}" for m in short_models) if short_models else ""
|
|
2168
|
+
# Spec A2.8: Total Tokens = input + output (cache shown separately,
|
|
2169
|
+
# not summed). Parallels `_render_codex_session_table` line ~4644.
|
|
2170
|
+
session_total = s.input_tokens + s.output_tokens
|
|
2171
|
+
data_cells = [
|
|
2172
|
+
(_date_cell(s.last_activity), None),
|
|
2173
|
+
(s.project_path, None),
|
|
2174
|
+
(_session_cell(s.session_id), None),
|
|
2175
|
+
(models_text, None),
|
|
2176
|
+
(_fmt_num(s.input_tokens), None),
|
|
2177
|
+
(_fmt_num(s.cache_creation_tokens), None),
|
|
2178
|
+
(_fmt_num(s.cache_read_tokens), None),
|
|
2179
|
+
(_fmt_num(s.output_tokens), None),
|
|
2180
|
+
(_fmt_num(session_total), None),
|
|
2181
|
+
(f"${s.cost_usd:.2f}", None),
|
|
2182
|
+
(_last_activity_cell(s.last_activity), None),
|
|
2183
|
+
]
|
|
2184
|
+
raw_rows.append((data_cells, ROW_DATA))
|
|
2185
|
+
|
|
2186
|
+
if breakdown:
|
|
2187
|
+
for mb in s.model_breakdowns:
|
|
2188
|
+
name = _short_model_name(mb["model"])
|
|
2189
|
+
mb_input = int(mb["input"])
|
|
2190
|
+
mb_cc = int(mb["cache_create"])
|
|
2191
|
+
mb_cr = int(mb["cache_read"])
|
|
2192
|
+
mb_output = int(mb["output"])
|
|
2193
|
+
# Spec A2.8: Total Tokens = input + output only.
|
|
2194
|
+
mb_total = mb_input + mb_output
|
|
2195
|
+
mb_cost = float(mb["cost"])
|
|
2196
|
+
bd_cells = [
|
|
2197
|
+
(f"{arrow} {name}", _gray),
|
|
2198
|
+
("", None),
|
|
2199
|
+
("", None),
|
|
2200
|
+
("", None),
|
|
2201
|
+
(_fmt_num(mb_input), _gray),
|
|
2202
|
+
(_fmt_num(mb_cc), _gray),
|
|
2203
|
+
(_fmt_num(mb_cr), _gray),
|
|
2204
|
+
(_fmt_num(mb_output), _gray),
|
|
2205
|
+
(_fmt_num(mb_total), _gray),
|
|
2206
|
+
(f"${mb_cost:.2f}", _gray),
|
|
2207
|
+
("", None),
|
|
2208
|
+
]
|
|
2209
|
+
raw_rows.append((bd_cells, ROW_BREAKDOWN))
|
|
2210
|
+
|
|
2211
|
+
tot_input = sum(s.input_tokens for s in sessions)
|
|
2212
|
+
tot_cc = sum(s.cache_creation_tokens for s in sessions)
|
|
2213
|
+
tot_cr = sum(s.cache_read_tokens for s in sessions)
|
|
2214
|
+
tot_output = sum(s.output_tokens for s in sessions)
|
|
2215
|
+
# Spec A2.8: Total Tokens = input + output only.
|
|
2216
|
+
tot_tokens = tot_input + tot_output
|
|
2217
|
+
tot_cost = sum(s.cost_usd for s in sessions)
|
|
2218
|
+
footer_cells = [
|
|
2219
|
+
("Total", _yellow),
|
|
2220
|
+
("", None), ("", None), ("", None),
|
|
2221
|
+
(_fmt_num(tot_input), _yellow),
|
|
2222
|
+
(_fmt_num(tot_cc), _yellow),
|
|
2223
|
+
(_fmt_num(tot_cr), _yellow),
|
|
2224
|
+
(_fmt_num(tot_output), _yellow),
|
|
2225
|
+
(_fmt_num(tot_tokens), _yellow),
|
|
2226
|
+
(f"${tot_cost:.2f}", _yellow),
|
|
2227
|
+
("", None),
|
|
2228
|
+
]
|
|
2229
|
+
raw_rows.append((footer_cells, ROW_FOOTER))
|
|
2230
|
+
|
|
2231
|
+
def _max_line_width(s: str) -> int:
|
|
2232
|
+
if not s:
|
|
2233
|
+
return 0
|
|
2234
|
+
return max(len(line) for line in s.split("\n"))
|
|
2235
|
+
|
|
2236
|
+
content_widths = [len(h) for h in headers]
|
|
2237
|
+
for cells, _rt in raw_rows:
|
|
2238
|
+
for i, (text, _c) in enumerate(cells):
|
|
2239
|
+
content_widths[i] = max(content_widths[i], _max_line_width(text))
|
|
2240
|
+
|
|
2241
|
+
def _wide_width(i: int, content: int) -> int:
|
|
2242
|
+
if aligns[i] == "right":
|
|
2243
|
+
return max(content + 3, 11)
|
|
2244
|
+
if i == 0:
|
|
2245
|
+
return max(content + 2, 12)
|
|
2246
|
+
if i == 1:
|
|
2247
|
+
return max(content + 2, 15)
|
|
2248
|
+
return max(content + 2, 12)
|
|
2249
|
+
|
|
2250
|
+
col_widths = [_wide_width(i, content_widths[i]) for i in range(num_cols)]
|
|
2251
|
+
|
|
2252
|
+
def _split_cell(text: str) -> list[str]:
|
|
2253
|
+
return text.split("\n") if text else [""]
|
|
2254
|
+
|
|
2255
|
+
def _pad_cell(text: str, w: int, align: str) -> str:
|
|
2256
|
+
if align == "right":
|
|
2257
|
+
return text.rjust(w)
|
|
2258
|
+
return text.ljust(w)
|
|
2259
|
+
|
|
2260
|
+
if unicode_ok:
|
|
2261
|
+
TL, TR, BL, BR = "\u250c", "\u2510", "\u2514", "\u2518"
|
|
2262
|
+
H, V = "\u2500", "\u2502"
|
|
2263
|
+
T_DOWN, T_UP, T_LEFT, T_RIGHT, CROSS = "\u252c", "\u2534", "\u2524", "\u251c", "\u253c"
|
|
2264
|
+
RTL, RTR, RBL, RBR = "\u256d", "\u256e", "\u2570", "\u256f"
|
|
2265
|
+
else:
|
|
2266
|
+
TL = TR = BL = BR = "+"
|
|
2267
|
+
H, V = "-", "|"
|
|
2268
|
+
T_DOWN = T_UP = T_LEFT = T_RIGHT = CROSS = "+"
|
|
2269
|
+
RTL = RTR = RBL = RBR = "+"
|
|
2270
|
+
|
|
2271
|
+
def _border_row(left: str, mid: str, right: str) -> str:
|
|
2272
|
+
parts = [left]
|
|
2273
|
+
for i, w in enumerate(col_widths):
|
|
2274
|
+
parts.append(H * (w + 2))
|
|
2275
|
+
parts.append(mid if i < num_cols - 1 else right)
|
|
2276
|
+
return _dim("".join(parts))
|
|
2277
|
+
|
|
2278
|
+
# Banner — 1-space leading indent + 2-space title padding
|
|
2279
|
+
banner_inner_width = max(len(title) + 4, 60)
|
|
2280
|
+
left_pad = 2
|
|
2281
|
+
right_pad = banner_inner_width - len(title) - left_pad
|
|
2282
|
+
indent = " "
|
|
2283
|
+
top = indent + RTL + H * banner_inner_width + RTR
|
|
2284
|
+
blank = indent + V + " " * banner_inner_width + V
|
|
2285
|
+
text_line = indent + V + " " * left_pad + title + " " * right_pad + V
|
|
2286
|
+
bottom = indent + RBL + H * banner_inner_width + RBR
|
|
2287
|
+
out: list[str] = [_dim(top), _dim(blank), _dim(text_line), _dim(blank), _dim(bottom)]
|
|
2288
|
+
out.append("") # blank line between banner and table
|
|
2289
|
+
|
|
2290
|
+
out.append(_border_row(TL, T_DOWN, TR))
|
|
2291
|
+
|
|
2292
|
+
# Header
|
|
2293
|
+
header_cells = [_split_cell(h) for h in headers]
|
|
2294
|
+
max_h = max(len(c) for c in header_cells)
|
|
2295
|
+
for li in range(max_h):
|
|
2296
|
+
parts = [_dim(V)]
|
|
2297
|
+
for i, cell in enumerate(header_cells):
|
|
2298
|
+
content = cell[li] if li < len(cell) else ""
|
|
2299
|
+
parts.append(f" {_cyan(_pad_cell(content, col_widths[i], aligns[i]))} ")
|
|
2300
|
+
parts.append(_dim(V))
|
|
2301
|
+
out.append("".join(parts))
|
|
2302
|
+
out.append(_border_row(T_RIGHT, CROSS, T_LEFT))
|
|
2303
|
+
|
|
2304
|
+
# Data + footer with inter-row separators
|
|
2305
|
+
sep = _border_row(T_RIGHT, CROSS, T_LEFT)
|
|
2306
|
+
display_rows = list(raw_rows)
|
|
2307
|
+
for idx, (cells, rt) in enumerate(display_rows):
|
|
2308
|
+
split_cells = [_split_cell(t) for t, _c in cells]
|
|
2309
|
+
max_h = max(len(c) for c in split_cells) if split_cells else 1
|
|
2310
|
+
for li in range(max_h):
|
|
2311
|
+
parts = [_dim(V)]
|
|
2312
|
+
for i, (text, cfn) in enumerate(cells):
|
|
2313
|
+
content = split_cells[i][li] if li < len(split_cells[i]) else ""
|
|
2314
|
+
padded = _pad_cell(content, col_widths[i], aligns[i])
|
|
2315
|
+
if cfn is not None:
|
|
2316
|
+
padded = cfn(padded)
|
|
2317
|
+
parts.append(f" {padded} ")
|
|
2318
|
+
parts.append(_dim(V))
|
|
2319
|
+
out.append("".join(parts))
|
|
2320
|
+
if idx < len(display_rows) - 1:
|
|
2321
|
+
out.append(sep)
|
|
2322
|
+
|
|
2323
|
+
out.append(_border_row(BL, T_UP, BR))
|
|
2324
|
+
return "\n".join(out)
|
|
2325
|
+
|
|
2326
|
+
|
|
2327
|
+
def _project_disambiguate_labels(rows: list[dict]) -> dict[int, str]:
|
|
2328
|
+
"""Return ``{row_index: disambiguated_label}`` for project rows whose
|
|
2329
|
+
bare ``display_key`` collides with another row's basename.
|
|
2330
|
+
|
|
2331
|
+
When two projects share a basename (e.g., two ``app`` directories under
|
|
2332
|
+
different parents), suffix the colliding rows with the parent-directory
|
|
2333
|
+
segment ("(work)" / "(personal)") so they remain visually and
|
|
2334
|
+
semantically distinct. Prefer ``key.git_root`` as the disambiguation
|
|
2335
|
+
source when present; fall back to ``key.bucket_path`` for no-git rows.
|
|
2336
|
+
|
|
2337
|
+
Used by:
|
|
2338
|
+
- ``_render_project_table`` (terminal table render).
|
|
2339
|
+
- ``_build_project_snapshot`` (share artifact table + chart) — without
|
|
2340
|
+
this, two same-basename projects collapse to a single anonymous
|
|
2341
|
+
``project-N`` after scrub, losing rank meaning AND uniqueness.
|
|
2342
|
+
|
|
2343
|
+
Rows that do not collide are absent from the returned dict; callers
|
|
2344
|
+
fall back to ``key.display_key`` for those.
|
|
2345
|
+
"""
|
|
2346
|
+
display_counts: dict[str, int] = {}
|
|
2347
|
+
for r in rows:
|
|
2348
|
+
dk = r["key"].display_key
|
|
2349
|
+
display_counts[dk] = display_counts.get(dk, 0) + 1
|
|
2350
|
+
augmented: dict[int, str] = {}
|
|
2351
|
+
for idx, r in enumerate(rows):
|
|
2352
|
+
if display_counts[r["key"].display_key] > 1:
|
|
2353
|
+
source_path = r["key"].git_root or r["key"].bucket_path
|
|
2354
|
+
if source_path:
|
|
2355
|
+
parent = (
|
|
2356
|
+
os.path.basename(os.path.dirname(source_path)) or "/"
|
|
2357
|
+
)
|
|
2358
|
+
augmented[idx] = f"{r['key'].display_key} ({parent})"
|
|
2359
|
+
return augmented
|
|
2360
|
+
|
|
2361
|
+
|
|
2362
|
+
def _render_project_table(
|
|
2363
|
+
rows: list[dict],
|
|
2364
|
+
*,
|
|
2365
|
+
title: str,
|
|
2366
|
+
breakdown: bool = False,
|
|
2367
|
+
weeks_missing_snapshot: int = 0,
|
|
2368
|
+
weeks_in_range: int = 1,
|
|
2369
|
+
no_color: bool = False,
|
|
2370
|
+
) -> str:
|
|
2371
|
+
"""Render project rollup as a ccusage-style ANSI table.
|
|
2372
|
+
|
|
2373
|
+
Columns: Project | Sessions | First Seen | Last Seen | Input |
|
|
2374
|
+
Cache Create | Cache Read | Output | Cost (USD) | Used % | $/1%
|
|
2375
|
+
|
|
2376
|
+
Parent rows show all columns; breakdown child rows show per-model
|
|
2377
|
+
aggregates with blank Sessions/Used%/$/1% cells (those only make
|
|
2378
|
+
sense at the project level). Structural clone of
|
|
2379
|
+
`_render_claude_session_table` — same two-pass layout (plain cells
|
|
2380
|
+
first for width calc, ANSI applied at render time) and same banner /
|
|
2381
|
+
border / separator glyphs.
|
|
2382
|
+
"""
|
|
2383
|
+
color = False if no_color else _supports_color_stdout()
|
|
2384
|
+
unicode_ok = _supports_unicode_stdout()
|
|
2385
|
+
|
|
2386
|
+
def _dim(s: str) -> str: return _style_ansi(s, "90", color)
|
|
2387
|
+
def _cyan(s: str) -> str: return _style_ansi(s, "36", color)
|
|
2388
|
+
def _gray(s: str) -> str: return _style_ansi(s, "90", color)
|
|
2389
|
+
def _green(s: str) -> str: return _style_ansi(s, "32", color)
|
|
2390
|
+
def _yellow(s: str) -> str: return _style_ansi(s, "33", color)
|
|
2391
|
+
def _red(s: str) -> str: return _style_ansi(s, "31", color)
|
|
2392
|
+
|
|
2393
|
+
headers = [
|
|
2394
|
+
"Project", "Sessions", "First Seen", "Last Seen",
|
|
2395
|
+
"Input", "Cache Create", "Cache Read", "Output",
|
|
2396
|
+
"Cost (USD)", "Used %", "$/1%",
|
|
2397
|
+
]
|
|
2398
|
+
aligns = [
|
|
2399
|
+
"left", "right", "left", "left",
|
|
2400
|
+
"right", "right", "right", "right",
|
|
2401
|
+
"right", "right", "right",
|
|
2402
|
+
]
|
|
2403
|
+
num_cols = len(headers)
|
|
2404
|
+
|
|
2405
|
+
if not rows:
|
|
2406
|
+
return ""
|
|
2407
|
+
|
|
2408
|
+
def _to_display_tz(ts: dt.datetime) -> dt.datetime:
|
|
2409
|
+
# internal fallback: host-local intentional (AM/PM render via attribute access)
|
|
2410
|
+
return ts.astimezone()
|
|
2411
|
+
|
|
2412
|
+
def _date_cell(ts: dt.datetime) -> str:
|
|
2413
|
+
local = _to_display_tz(ts)
|
|
2414
|
+
hour_12 = local.hour % 12
|
|
2415
|
+
if hour_12 == 0:
|
|
2416
|
+
hour_12 = 12
|
|
2417
|
+
ampm = "a.m." if local.hour < 12 else "p.m."
|
|
2418
|
+
return f"{local.year}-{local.month:02d}-{local.day:02d}\n{hour_12}:{local.minute:02d}\n{ampm}"
|
|
2419
|
+
|
|
2420
|
+
# Basename-collision disambiguation: hoisted to a module-level helper
|
|
2421
|
+
# so the share-snapshot builder can reuse the same logic (without it,
|
|
2422
|
+
# two same-basename projects collapse to a single anonymous `project-N`
|
|
2423
|
+
# after scrub, breaking both privacy uniqueness AND chart rank meaning).
|
|
2424
|
+
augmented = _project_disambiguate_labels(rows)
|
|
2425
|
+
|
|
2426
|
+
def _project_cell(idx: int, r: dict) -> tuple[str, Any]:
|
|
2427
|
+
"""Return (plain_text, color_fn_or_None) for the Project cell.
|
|
2428
|
+
|
|
2429
|
+
`color_fn_or_None` is applied to the padded cell in Pass 2 so it
|
|
2430
|
+
doesn't perturb column-width math.
|
|
2431
|
+
"""
|
|
2432
|
+
k = r["key"]
|
|
2433
|
+
if k.is_unknown:
|
|
2434
|
+
return ("(unknown)", _gray)
|
|
2435
|
+
base = augmented.get(idx, k.display_key)
|
|
2436
|
+
if k.is_no_git:
|
|
2437
|
+
# Append a dimmed `(no-git)` marker. The dim style is applied
|
|
2438
|
+
# to the whole cell at render time; keeping the plain text
|
|
2439
|
+
# unified here gives a clean width calc.
|
|
2440
|
+
return (f"{base} (no-git)", _gray)
|
|
2441
|
+
return (base, None)
|
|
2442
|
+
|
|
2443
|
+
def _used_pct_color(pct: float) -> Any:
|
|
2444
|
+
if pct < 10:
|
|
2445
|
+
return _green
|
|
2446
|
+
if pct < 25:
|
|
2447
|
+
return _yellow
|
|
2448
|
+
return _red
|
|
2449
|
+
|
|
2450
|
+
def _used_pct_cell(ap: float | None) -> tuple[str, Any]:
|
|
2451
|
+
if ap is None:
|
|
2452
|
+
return ("\u2014", _gray) # em-dash for unknown
|
|
2453
|
+
base = f"{ap:.1f}%"
|
|
2454
|
+
if weeks_in_range > 1:
|
|
2455
|
+
# Count weeks the user asked about; surface via `(Nwk)` suffix
|
|
2456
|
+
# (spec §3). Keep the suffix short so column width stays sane.
|
|
2457
|
+
base = f"{base} ({weeks_in_range}wk)"
|
|
2458
|
+
return (base, _used_pct_color(ap))
|
|
2459
|
+
|
|
2460
|
+
def _cost_per_pct_cell(cpp: float | None) -> tuple[str, Any]:
|
|
2461
|
+
if cpp is None or cpp <= 0:
|
|
2462
|
+
return ("\u2014", _gray)
|
|
2463
|
+
return (f"${cpp:.2f}", None)
|
|
2464
|
+
|
|
2465
|
+
arrow = " \u2514\u2500" if unicode_ok else " |_"
|
|
2466
|
+
|
|
2467
|
+
ROW_DATA, ROW_BREAKDOWN = "data", "breakdown"
|
|
2468
|
+
raw_rows: list[tuple[list[tuple[str, Any]], str]] = []
|
|
2469
|
+
|
|
2470
|
+
for idx, r in enumerate(rows):
|
|
2471
|
+
proj_text, proj_cfn = _project_cell(idx, r)
|
|
2472
|
+
used_text, used_cfn = _used_pct_cell(r.get("attributed_pct"))
|
|
2473
|
+
cpp_text, cpp_cfn = _cost_per_pct_cell(r.get("cost_per_pct"))
|
|
2474
|
+
data_cells = [
|
|
2475
|
+
(proj_text, proj_cfn),
|
|
2476
|
+
(str(len(r["sessions"])), None),
|
|
2477
|
+
(_date_cell(r["first_seen"]), None),
|
|
2478
|
+
(_date_cell(r["last_seen"]), None),
|
|
2479
|
+
(_fmt_num(r["input"]), None),
|
|
2480
|
+
(_fmt_num(r["cache_write"]), None),
|
|
2481
|
+
(_fmt_num(r["cache_read"]), None),
|
|
2482
|
+
(_fmt_num(r["output"]), None),
|
|
2483
|
+
(f"${r['cost_usd']:.2f}", None),
|
|
2484
|
+
(used_text, used_cfn),
|
|
2485
|
+
(cpp_text, cpp_cfn),
|
|
2486
|
+
]
|
|
2487
|
+
raw_rows.append((data_cells, ROW_DATA))
|
|
2488
|
+
|
|
2489
|
+
if breakdown:
|
|
2490
|
+
for model_name, mb in sorted(r["models"].items()):
|
|
2491
|
+
short = _short_model_name(model_name)
|
|
2492
|
+
bd_cells = [
|
|
2493
|
+
(f"{arrow} {short}", _gray),
|
|
2494
|
+
("", None),
|
|
2495
|
+
(_date_cell(mb["first_seen"]), _gray),
|
|
2496
|
+
(_date_cell(mb["last_seen"]), _gray),
|
|
2497
|
+
(_fmt_num(mb["input"]), _gray),
|
|
2498
|
+
(_fmt_num(mb["cache_write"]), _gray),
|
|
2499
|
+
(_fmt_num(mb["cache_read"]), _gray),
|
|
2500
|
+
(_fmt_num(mb["output"]), _gray),
|
|
2501
|
+
(f"${mb['cost_usd']:.2f}", _gray),
|
|
2502
|
+
("", None),
|
|
2503
|
+
("", None),
|
|
2504
|
+
]
|
|
2505
|
+
raw_rows.append((bd_cells, ROW_BREAKDOWN))
|
|
2506
|
+
|
|
2507
|
+
def _max_line_width(s: str) -> int:
|
|
2508
|
+
if not s:
|
|
2509
|
+
return 0
|
|
2510
|
+
return max(len(line) for line in s.split("\n"))
|
|
2511
|
+
|
|
2512
|
+
content_widths = [len(h) for h in headers]
|
|
2513
|
+
for cells, _rt in raw_rows:
|
|
2514
|
+
for i, (text, _c) in enumerate(cells):
|
|
2515
|
+
content_widths[i] = max(content_widths[i], _max_line_width(text))
|
|
2516
|
+
|
|
2517
|
+
def _wide_width(i: int, content: int) -> int:
|
|
2518
|
+
if aligns[i] == "right":
|
|
2519
|
+
return max(content + 3, 11)
|
|
2520
|
+
if i == 0:
|
|
2521
|
+
return max(content + 2, 14) # Project column
|
|
2522
|
+
# Date columns (First Seen / Last Seen)
|
|
2523
|
+
return max(content + 2, 12)
|
|
2524
|
+
|
|
2525
|
+
col_widths = [_wide_width(i, content_widths[i]) for i in range(num_cols)]
|
|
2526
|
+
|
|
2527
|
+
try:
|
|
2528
|
+
term_width = os.get_terminal_size().columns
|
|
2529
|
+
except (OSError, ValueError):
|
|
2530
|
+
term_width = int(os.environ.get("COLUMNS", "120"))
|
|
2531
|
+
|
|
2532
|
+
border_overhead = 3 * num_cols + 1
|
|
2533
|
+
if sum(col_widths) + border_overhead > term_width:
|
|
2534
|
+
available = term_width - border_overhead
|
|
2535
|
+
total_col = sum(col_widths)
|
|
2536
|
+
scale = available / total_col if total_col > 0 else 1.0
|
|
2537
|
+
col_widths = [max(int(w * scale), 8) for w in col_widths]
|
|
2538
|
+
remainder = available - sum(col_widths)
|
|
2539
|
+
if remainder > 0:
|
|
2540
|
+
col_widths[0] += remainder # grow Project column
|
|
2541
|
+
|
|
2542
|
+
def _split_cell(text: str) -> list[str]:
|
|
2543
|
+
return text.split("\n") if text else [""]
|
|
2544
|
+
|
|
2545
|
+
def _pad_cell(text: str, w: int, align: str) -> str:
|
|
2546
|
+
if align == "right":
|
|
2547
|
+
return text.rjust(w)
|
|
2548
|
+
return text.ljust(w)
|
|
2549
|
+
|
|
2550
|
+
if unicode_ok:
|
|
2551
|
+
TL, TR, BL, BR = "\u250c", "\u2510", "\u2514", "\u2518"
|
|
2552
|
+
H, V = "\u2500", "\u2502"
|
|
2553
|
+
T_DOWN, T_UP, T_LEFT, T_RIGHT, CROSS = "\u252c", "\u2534", "\u2524", "\u251c", "\u253c"
|
|
2554
|
+
RTL, RTR, RBL, RBR = "\u256d", "\u256e", "\u2570", "\u256f"
|
|
2555
|
+
else:
|
|
2556
|
+
TL = TR = BL = BR = "+"
|
|
2557
|
+
H, V = "-", "|"
|
|
2558
|
+
T_DOWN = T_UP = T_LEFT = T_RIGHT = CROSS = "+"
|
|
2559
|
+
RTL = RTR = RBL = RBR = "+"
|
|
2560
|
+
|
|
2561
|
+
def _border_row(left: str, mid: str, right: str) -> str:
|
|
2562
|
+
parts = [left]
|
|
2563
|
+
for i, w in enumerate(col_widths):
|
|
2564
|
+
parts.append(H * (w + 2))
|
|
2565
|
+
parts.append(mid if i < num_cols - 1 else right)
|
|
2566
|
+
return _dim("".join(parts))
|
|
2567
|
+
|
|
2568
|
+
banner_inner_width = max(len(title) + 4, 60)
|
|
2569
|
+
left_pad = 2
|
|
2570
|
+
right_pad = banner_inner_width - len(title) - left_pad
|
|
2571
|
+
indent = " "
|
|
2572
|
+
top = indent + RTL + H * banner_inner_width + RTR
|
|
2573
|
+
blank = indent + V + " " * banner_inner_width + V
|
|
2574
|
+
text_line = indent + V + " " * left_pad + title + " " * right_pad + V
|
|
2575
|
+
bottom = indent + RBL + H * banner_inner_width + RBR
|
|
2576
|
+
out: list[str] = [_dim(top), _dim(blank), _dim(text_line), _dim(blank), _dim(bottom)]
|
|
2577
|
+
out.append("")
|
|
2578
|
+
|
|
2579
|
+
out.append(_border_row(TL, T_DOWN, TR))
|
|
2580
|
+
|
|
2581
|
+
header_cells = [_split_cell(h) for h in headers]
|
|
2582
|
+
max_h = max(len(c) for c in header_cells)
|
|
2583
|
+
for li in range(max_h):
|
|
2584
|
+
parts = [_dim(V)]
|
|
2585
|
+
for i, cell in enumerate(header_cells):
|
|
2586
|
+
content = cell[li] if li < len(cell) else ""
|
|
2587
|
+
parts.append(f" {_cyan(_pad_cell(content, col_widths[i], aligns[i]))} ")
|
|
2588
|
+
parts.append(_dim(V))
|
|
2589
|
+
out.append("".join(parts))
|
|
2590
|
+
out.append(_border_row(T_RIGHT, CROSS, T_LEFT))
|
|
2591
|
+
|
|
2592
|
+
sep = _border_row(T_RIGHT, CROSS, T_LEFT)
|
|
2593
|
+
display_rows = list(raw_rows)
|
|
2594
|
+
for idx, (cells, rt) in enumerate(display_rows):
|
|
2595
|
+
split_cells = [_split_cell(t) for t, _c in cells]
|
|
2596
|
+
max_h = max(len(c) for c in split_cells) if split_cells else 1
|
|
2597
|
+
for li in range(max_h):
|
|
2598
|
+
parts = [_dim(V)]
|
|
2599
|
+
for i, (text, cfn) in enumerate(cells):
|
|
2600
|
+
content = split_cells[i][li] if li < len(split_cells[i]) else ""
|
|
2601
|
+
w = col_widths[i]
|
|
2602
|
+
if len(content) > w:
|
|
2603
|
+
ell = "\u2026" if unicode_ok else "..."
|
|
2604
|
+
content = content[: max(0, w - len(ell))] + ell
|
|
2605
|
+
padded = _pad_cell(content, w, aligns[i])
|
|
2606
|
+
if cfn is not None:
|
|
2607
|
+
padded = cfn(padded)
|
|
2608
|
+
parts.append(f" {padded} ")
|
|
2609
|
+
parts.append(_dim(V))
|
|
2610
|
+
out.append("".join(parts))
|
|
2611
|
+
if idx < len(display_rows) - 1:
|
|
2612
|
+
out.append(sep)
|
|
2613
|
+
|
|
2614
|
+
out.append(_border_row(BL, T_UP, BR))
|
|
2615
|
+
|
|
2616
|
+
if weeks_missing_snapshot > 0:
|
|
2617
|
+
plural = "s" if weeks_missing_snapshot != 1 else ""
|
|
2618
|
+
out.append(
|
|
2619
|
+
_dim(
|
|
2620
|
+
f"Note: Used % unavailable for {weeks_missing_snapshot} "
|
|
2621
|
+
f"week{plural} \u2014 no usage snapshots recorded."
|
|
2622
|
+
)
|
|
2623
|
+
)
|
|
2624
|
+
|
|
2625
|
+
return "\n".join(out)
|
|
2626
|
+
|
|
2627
|
+
|
|
2628
|
+
def _five_hour_blocks_to_json(
|
|
2629
|
+
block_dicts: list[dict],
|
|
2630
|
+
since_iso: str | None,
|
|
2631
|
+
until_iso: str | None,
|
|
2632
|
+
cap: int | None,
|
|
2633
|
+
truncated: bool,
|
|
2634
|
+
breakdown_axis: str | None,
|
|
2635
|
+
) -> dict:
|
|
2636
|
+
"""Build the camelCase JSON envelope for ``cmd_five_hour_blocks``.
|
|
2637
|
+
|
|
2638
|
+
Stable schema; the ``window`` object lets consumers detect default-cap
|
|
2639
|
+
truncation. Only one of ``modelBreakdowns`` / ``projectBreakdowns`` is
|
|
2640
|
+
present per block (per the requested ``--breakdown`` axis); both are
|
|
2641
|
+
omitted when ``--breakdown`` is unset.
|
|
2642
|
+
"""
|
|
2643
|
+
blocks_out = []
|
|
2644
|
+
for d in block_dicts:
|
|
2645
|
+
crossed = bool(d.get("crossed_seven_day_reset"))
|
|
2646
|
+
p_start = d.get("seven_day_pct_at_block_start")
|
|
2647
|
+
p_end = d.get("seven_day_pct_at_block_end")
|
|
2648
|
+
delta = (
|
|
2649
|
+
None if (crossed or p_start is None or p_end is None)
|
|
2650
|
+
else round(p_end - p_start, 9)
|
|
2651
|
+
)
|
|
2652
|
+
pct = d["final_five_hour_percent"]
|
|
2653
|
+
cost = d["total_cost_usd"]
|
|
2654
|
+
dollar_per_pct = (
|
|
2655
|
+
round(cost / pct, 9) if pct >= 0.5 else None
|
|
2656
|
+
)
|
|
2657
|
+
out = {
|
|
2658
|
+
"blockStartAt": d["block_start_at"],
|
|
2659
|
+
"fiveHourWindowKey": d["five_hour_window_key"],
|
|
2660
|
+
"fiveHourResetsAt": d["five_hour_resets_at"],
|
|
2661
|
+
"lastObservedAtUtc": d["last_observed_at_utc"],
|
|
2662
|
+
"status": "active" if d["__is_active"] else "closed",
|
|
2663
|
+
"finalFiveHourPercent": round(pct, 1),
|
|
2664
|
+
"totalCost": round(cost, 9),
|
|
2665
|
+
"dollarsPerPercent": dollar_per_pct,
|
|
2666
|
+
"inputTokens": d["total_input_tokens"],
|
|
2667
|
+
"outputTokens": d["total_output_tokens"],
|
|
2668
|
+
"cacheCreationTokens": d["total_cache_create_tokens"],
|
|
2669
|
+
"cacheReadTokens": d["total_cache_read_tokens"],
|
|
2670
|
+
"sevenDayPctAtBlockStart": p_start,
|
|
2671
|
+
"sevenDayPctAtBlockEnd": p_end,
|
|
2672
|
+
"sevenDayPctDeltaPp": delta,
|
|
2673
|
+
"crossedSevenDayReset": crossed,
|
|
2674
|
+
}
|
|
2675
|
+
if breakdown_axis == "model":
|
|
2676
|
+
out["modelBreakdowns"] = [
|
|
2677
|
+
{
|
|
2678
|
+
"modelName": r["model"],
|
|
2679
|
+
"inputTokens": r["input_tokens"],
|
|
2680
|
+
"outputTokens": r["output_tokens"],
|
|
2681
|
+
"cacheCreationTokens": r["cache_create_tokens"],
|
|
2682
|
+
"cacheReadTokens": r["cache_read_tokens"],
|
|
2683
|
+
"cost": round(r["cost_usd"], 9),
|
|
2684
|
+
"entryCount": r["entry_count"],
|
|
2685
|
+
}
|
|
2686
|
+
for r in d.get("__breakdown_rows", [])
|
|
2687
|
+
]
|
|
2688
|
+
elif breakdown_axis == "project":
|
|
2689
|
+
out["projectBreakdowns"] = [
|
|
2690
|
+
{
|
|
2691
|
+
"projectPath": r["project_path"],
|
|
2692
|
+
"inputTokens": r["input_tokens"],
|
|
2693
|
+
"outputTokens": r["output_tokens"],
|
|
2694
|
+
"cacheCreationTokens": r["cache_create_tokens"],
|
|
2695
|
+
"cacheReadTokens": r["cache_read_tokens"],
|
|
2696
|
+
"cost": round(r["cost_usd"], 9),
|
|
2697
|
+
"entryCount": r["entry_count"],
|
|
2698
|
+
}
|
|
2699
|
+
for r in d.get("__breakdown_rows", [])
|
|
2700
|
+
]
|
|
2701
|
+
blocks_out.append(out)
|
|
2702
|
+
|
|
2703
|
+
return {
|
|
2704
|
+
"schemaVersion": 1,
|
|
2705
|
+
"window": {
|
|
2706
|
+
"since": since_iso,
|
|
2707
|
+
"until": until_iso,
|
|
2708
|
+
"limit": cap,
|
|
2709
|
+
"order": "desc",
|
|
2710
|
+
"count": len(blocks_out),
|
|
2711
|
+
"truncated": truncated,
|
|
2712
|
+
},
|
|
2713
|
+
"blocks": blocks_out,
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
|
|
2717
|
+
def _render_five_hour_blocks_table(
|
|
2718
|
+
block_dicts: list[dict], args: argparse.Namespace,
|
|
2719
|
+
) -> None:
|
|
2720
|
+
"""Render the human-readable boxed table for ``cmd_five_hour_blocks``.
|
|
2721
|
+
|
|
2722
|
+
7-column layout: Block Start · Status · 5h % · Cost · $/1% · 7d % range
|
|
2723
|
+
· Δ7d. Crossed-reset rows are marked with a ``⚡ `` prefix on the Block
|
|
2724
|
+
Start cell (mirroring the ``~`` heuristic-anchor convention used by
|
|
2725
|
+
``cctally blocks``). Footer summarizes block count + total cost; the
|
|
2726
|
+
⚡ legend appears when at least one row crossed the weekly reset.
|
|
2727
|
+
"""
|
|
2728
|
+
if not block_dicts:
|
|
2729
|
+
print("No 5h blocks recorded.")
|
|
2730
|
+
return
|
|
2731
|
+
headers = ["Block Start", "Status", "5h %", "Cost", "$/1%",
|
|
2732
|
+
"7d % range", "Δ7d"]
|
|
2733
|
+
aligns = ["left", "left", "right", "right", "right",
|
|
2734
|
+
"left", "right"]
|
|
2735
|
+
rows: list[list[str]] = []
|
|
2736
|
+
total_cost = 0.0
|
|
2737
|
+
has_crossed = False
|
|
2738
|
+
for d in block_dicts:
|
|
2739
|
+
crossed = bool(d.get("crossed_seven_day_reset"))
|
|
2740
|
+
has_crossed = has_crossed or crossed
|
|
2741
|
+
p_start = d.get("seven_day_pct_at_block_start")
|
|
2742
|
+
p_end = d.get("seven_day_pct_at_block_end")
|
|
2743
|
+
delta = (
|
|
2744
|
+
None if (crossed or p_start is None or p_end is None)
|
|
2745
|
+
else (p_end - p_start)
|
|
2746
|
+
)
|
|
2747
|
+
pct = d["final_five_hour_percent"]
|
|
2748
|
+
cost = d["total_cost_usd"]
|
|
2749
|
+
total_cost += cost
|
|
2750
|
+
dpp = (cost / pct) if pct >= 0.5 else None
|
|
2751
|
+
|
|
2752
|
+
formatted_start = _format_block_start(d["block_start_at"], args._resolved_tz)
|
|
2753
|
+
if crossed:
|
|
2754
|
+
formatted_start = f"⚡ {formatted_start}"
|
|
2755
|
+
rows.append([
|
|
2756
|
+
formatted_start,
|
|
2757
|
+
"ACTIVE" if d["__is_active"] else "closed",
|
|
2758
|
+
f"{pct:.1f}%",
|
|
2759
|
+
f"${cost:.2f}",
|
|
2760
|
+
("—" if dpp is None else f"${dpp:.2f}"),
|
|
2761
|
+
(
|
|
2762
|
+
f"{p_start:.1f}→{p_end:.1f}"
|
|
2763
|
+
if p_start is not None and p_end is not None
|
|
2764
|
+
else (f"—→{p_end:.1f}" if p_end is not None else "—")
|
|
2765
|
+
),
|
|
2766
|
+
("—" if delta is None else f"{delta:+.1f}"),
|
|
2767
|
+
])
|
|
2768
|
+
# Breakdown child rows.
|
|
2769
|
+
for child in d.get("__breakdown_rows", []):
|
|
2770
|
+
label = (
|
|
2771
|
+
child.get("model") if args.breakdown == "model"
|
|
2772
|
+
else child.get("project_path")
|
|
2773
|
+
)
|
|
2774
|
+
rows.append([
|
|
2775
|
+
f" └ {label}",
|
|
2776
|
+
"",
|
|
2777
|
+
"",
|
|
2778
|
+
f"${child['cost_usd']:.2f}",
|
|
2779
|
+
"", "", "",
|
|
2780
|
+
])
|
|
2781
|
+
|
|
2782
|
+
print(_boxed_table(headers, rows, aligns))
|
|
2783
|
+
glyph = " · ⚡ = block crossed weekly reset" if has_crossed else ""
|
|
2784
|
+
print(f"\n{len(block_dicts)} blocks · cost: ${total_cost:.2f}{glyph}")
|
|
2785
|
+
|