cctally 1.6.3 → 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.
@@ -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
+