cctally 1.17.0 → 1.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/bin/_cctally_cache.py +108 -24
- package/bin/_lib_aggregators.py +103 -19
- package/bin/_lib_blocks.py +55 -1
- package/bin/_lib_doctor.py +1 -1
- package/bin/_lib_render.py +296 -95
- package/bin/cctally +399 -47
- package/package.json +1 -1
package/bin/_lib_render.py
CHANGED
|
@@ -225,6 +225,124 @@ def _scale_down_col_widths(
|
|
|
225
225
|
# Optional dependency: zoneinfo.ZoneInfo is referenced only as a string
|
|
226
226
|
# annotation in moved code; no runtime import needed.
|
|
227
227
|
|
|
228
|
+
|
|
229
|
+
def _render_title_banner(title: str, *, unicode_ok: bool, color: bool) -> str:
|
|
230
|
+
"""ccusage-style rounded title banner (the box around a report title).
|
|
231
|
+
|
|
232
|
+
Returns the multi-line banner WITHOUT a trailing blank line; callers add
|
|
233
|
+
spacing. Shared by `_render_blocks_table` and `_render_active_block_box`.
|
|
234
|
+
"""
|
|
235
|
+
def _bold(s: str) -> str:
|
|
236
|
+
return _style_ansi(s, "1", color)
|
|
237
|
+
title_padded = f" {title} "
|
|
238
|
+
tw = len(title_padded)
|
|
239
|
+
dash = "─" if unicode_ok else "-"
|
|
240
|
+
vb = "│" if unicode_ok else "|"
|
|
241
|
+
if unicode_ok:
|
|
242
|
+
top = f" ╭{dash * tw}╮"
|
|
243
|
+
bot = f" ╰{dash * tw}╯"
|
|
244
|
+
else:
|
|
245
|
+
top = f" +{'-' * tw}+"
|
|
246
|
+
bot = f" +{'-' * tw}+"
|
|
247
|
+
return "\n".join([
|
|
248
|
+
top,
|
|
249
|
+
f" {vb}" + " " * tw + vb,
|
|
250
|
+
f" {vb}" + _bold(title_padded) + vb,
|
|
251
|
+
f" {vb}" + " " * tw + vb,
|
|
252
|
+
bot,
|
|
253
|
+
])
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _fmt_block_time_local(ts: dt.datetime, tz: "ZoneInfo | None") -> str:
|
|
257
|
+
"""Block-start timestamp in display tz, ccusage `toLocaleString`-style."""
|
|
258
|
+
local = ts.astimezone(tz)
|
|
259
|
+
hour_12 = local.hour % 12 or 12
|
|
260
|
+
ampm = "a.m." if local.hour < 12 else "p.m."
|
|
261
|
+
return (
|
|
262
|
+
f"{local.year}-{local.month:02d}-{local.day:02d}, "
|
|
263
|
+
f"{hour_12}:{local.minute:02d}:{local.second:02d} {ampm}"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _fmt_block_duration_hm(total_seconds: float) -> str:
|
|
268
|
+
total_minutes = int(total_seconds / 60)
|
|
269
|
+
return f"{total_minutes // 60}h {total_minutes % 60:02d}m"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _render_active_block_box(
|
|
273
|
+
block: "Block",
|
|
274
|
+
*,
|
|
275
|
+
now: dt.datetime,
|
|
276
|
+
tz: "ZoneInfo | None",
|
|
277
|
+
token_limit_explicit: int | None,
|
|
278
|
+
color: bool,
|
|
279
|
+
unicode_ok: bool,
|
|
280
|
+
) -> str:
|
|
281
|
+
"""ccusage `-a` 'Current Session Block Status' box (#86 Session F).
|
|
282
|
+
|
|
283
|
+
Token Limit Status sub-block appears only when `-t` was explicitly passed
|
|
284
|
+
(`token_limit_explicit` not None and > 0). Heuristic-anchored active blocks
|
|
285
|
+
get a `~`-prefixed start + approximate-start legend (Codex F2).
|
|
286
|
+
"""
|
|
287
|
+
def _b(s: str) -> str:
|
|
288
|
+
return _style_ansi(s, "1", color)
|
|
289
|
+
|
|
290
|
+
lines: list[str] = [
|
|
291
|
+
_render_title_banner("Current Session Block Status",
|
|
292
|
+
unicode_ok=unicode_ok, color=color),
|
|
293
|
+
]
|
|
294
|
+
started = _fmt_block_time_local(block.start_time, tz)
|
|
295
|
+
approx = block.anchor == "heuristic"
|
|
296
|
+
if approx:
|
|
297
|
+
started = f"~{started}"
|
|
298
|
+
elapsed = max((now - block.start_time).total_seconds(), 0)
|
|
299
|
+
remaining = max((block.end_time - now).total_seconds(), 0)
|
|
300
|
+
lines.append(f"Block Started: {started} "
|
|
301
|
+
f"({_fmt_block_duration_hm(elapsed)} ago)")
|
|
302
|
+
lines.append(f"Time Remaining: {_fmt_block_duration_hm(remaining)}")
|
|
303
|
+
if approx:
|
|
304
|
+
# Keep this wording in sync with the table footer legend below.
|
|
305
|
+
lines.append("~ = approximate start "
|
|
306
|
+
"(no recorded Anthropic reset for this window)")
|
|
307
|
+
|
|
308
|
+
lines += ["", _b("Current Usage:"),
|
|
309
|
+
f" Input Tokens: {_fmt_num(block.input_tokens)}",
|
|
310
|
+
f" Output Tokens: {_fmt_num(block.output_tokens)}",
|
|
311
|
+
f" Total Cost: ${block.cost_usd:.2f}"]
|
|
312
|
+
|
|
313
|
+
if block.burn_rate:
|
|
314
|
+
lines += ["", _b("Burn Rate:"),
|
|
315
|
+
f" Tokens/minute: "
|
|
316
|
+
f"{_fmt_num(int(block.burn_rate['tokensPerMinute']))}",
|
|
317
|
+
f" Cost/hour: ${block.burn_rate['costPerHour']:.2f}"]
|
|
318
|
+
|
|
319
|
+
if block.projection:
|
|
320
|
+
proj = block.projection
|
|
321
|
+
lines += ["", _b("Projected Usage (if current rate continues):"),
|
|
322
|
+
f" Total Tokens: {_fmt_num(proj['totalTokens'])}",
|
|
323
|
+
f" Total Cost: ${proj['totalCost']:.2f}"]
|
|
324
|
+
if token_limit_explicit is not None and token_limit_explicit > 0:
|
|
325
|
+
limit = token_limit_explicit
|
|
326
|
+
current = block.total_tokens
|
|
327
|
+
remaining_tokens = max(limit - current, 0)
|
|
328
|
+
pct_used = (proj["totalTokens"] / limit) * 100.0
|
|
329
|
+
cur_pct = (current / limit) * 100.0
|
|
330
|
+
# Keep the EXCEEDS/WARNING/OK thresholds (>100 / >80) in sync with
|
|
331
|
+
# the JSON status ladder in _lib_blocks._blocks_to_json.
|
|
332
|
+
if pct_used > 100:
|
|
333
|
+
status = _style_ansi("EXCEEDS LIMIT", "31", color)
|
|
334
|
+
elif pct_used > 80:
|
|
335
|
+
status = _style_ansi("WARNING", "33", color)
|
|
336
|
+
else:
|
|
337
|
+
status = _style_ansi("OK", "32", color)
|
|
338
|
+
lines += ["", _b("Token Limit Status:"),
|
|
339
|
+
f" Limit: {_fmt_num(limit)} tokens",
|
|
340
|
+
f" Current Usage: {_fmt_num(current)} ({cur_pct:.1f}%)",
|
|
341
|
+
f" Remaining: {_fmt_num(remaining_tokens)} tokens",
|
|
342
|
+
f" Projected Usage: {pct_used:.1f}% {status}"]
|
|
343
|
+
return "\n".join(lines)
|
|
344
|
+
|
|
345
|
+
|
|
228
346
|
def _render_blocks_table(
|
|
229
347
|
blocks: list[Block],
|
|
230
348
|
breakdown: bool = False,
|
|
@@ -232,6 +350,7 @@ def _render_blocks_table(
|
|
|
232
350
|
now: dt.datetime | None = None,
|
|
233
351
|
tz: "ZoneInfo | None" = None,
|
|
234
352
|
compact: bool = False,
|
|
353
|
+
token_limit: int | None = None,
|
|
235
354
|
) -> str:
|
|
236
355
|
"""Render blocks as a ccusage-style ANSI table with box-drawing borders.
|
|
237
356
|
|
|
@@ -268,9 +387,6 @@ def _render_blocks_table(
|
|
|
268
387
|
def _cyan(s: str) -> str:
|
|
269
388
|
return _style_ansi(s, "36", color)
|
|
270
389
|
|
|
271
|
-
def _bold(s: str) -> str:
|
|
272
|
-
return _style_ansi(s, "1", color)
|
|
273
|
-
|
|
274
390
|
def _green(s: str) -> str:
|
|
275
391
|
return _style_ansi(s, "32", color)
|
|
276
392
|
|
|
@@ -283,21 +399,10 @@ def _render_blocks_table(
|
|
|
283
399
|
# ── time formatting ─────────────────────────────────────────────────
|
|
284
400
|
|
|
285
401
|
def _fmt_time_local(ts: dt.datetime) -> str:
|
|
286
|
-
|
|
287
|
-
hour_12 = local.hour % 12
|
|
288
|
-
if hour_12 == 0:
|
|
289
|
-
hour_12 = 12
|
|
290
|
-
ampm = "a.m." if local.hour < 12 else "p.m."
|
|
291
|
-
return (
|
|
292
|
-
f"{local.year}-{local.month:02d}-{local.day:02d}, "
|
|
293
|
-
f"{hour_12}:{local.minute:02d}:{local.second:02d} {ampm}"
|
|
294
|
-
)
|
|
402
|
+
return _fmt_block_time_local(ts, tz)
|
|
295
403
|
|
|
296
404
|
def _fmt_duration_hm(total_seconds: float) -> str:
|
|
297
|
-
|
|
298
|
-
h = total_minutes // 60
|
|
299
|
-
m = total_minutes % 60
|
|
300
|
-
return f"{h}h {m:02d}m"
|
|
405
|
+
return _fmt_block_duration_hm(total_seconds)
|
|
301
406
|
|
|
302
407
|
def _fmt_gap_duration(total_seconds: float) -> str:
|
|
303
408
|
total_minutes = int(total_seconds / 60)
|
|
@@ -307,19 +412,23 @@ def _render_blocks_table(
|
|
|
307
412
|
return f"{h}h {m:02d}m gap" if m else f"{h}h gap"
|
|
308
413
|
|
|
309
414
|
# ── determine if % column is needed ─────────────────────────────────
|
|
310
|
-
max_completed_tokens =
|
|
311
|
-
for b in blocks:
|
|
312
|
-
if not b.is_gap and not b.is_active and b.total_tokens > 0:
|
|
313
|
-
if b.total_tokens > max_completed_tokens:
|
|
314
|
-
max_completed_tokens = b.total_tokens
|
|
315
|
-
token_limit = 0
|
|
415
|
+
max_completed_tokens = _lib_blocks._max_completed_block_tokens(blocks)
|
|
316
416
|
active_block: Block | None = None
|
|
317
417
|
for b in blocks:
|
|
318
418
|
if b.is_active and not b.is_gap:
|
|
319
419
|
active_block = b
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
420
|
+
# ``token_limit`` param (#86 Session F): when caller passes an explicit
|
|
421
|
+
# limit, honor it directly (forces the %/REMAINING/PROJECTED surface even
|
|
422
|
+
# with no completed history, suppressed only when <= 0). When None, fall
|
|
423
|
+
# back to the internal auto-max recompute so the renderer stays
|
|
424
|
+
# standalone-callable and the default ``cmd_blocks`` path (which passes the
|
|
425
|
+
# resolved auto-max) is byte-identical.
|
|
426
|
+
if token_limit is not None:
|
|
427
|
+
show_pct = token_limit > 0
|
|
428
|
+
eff_limit = token_limit if token_limit > 0 else 0
|
|
429
|
+
else:
|
|
430
|
+
show_pct = max_completed_tokens > 0
|
|
431
|
+
eff_limit = max_completed_tokens if show_pct else 0
|
|
323
432
|
|
|
324
433
|
# ── column layout ───────────────────────────────────────────────────
|
|
325
434
|
headers = ["Block Start", "Duration/\u2026", "Models", "Tokens"]
|
|
@@ -405,8 +514,8 @@ def _render_blocks_table(
|
|
|
405
514
|
short_models = [""]
|
|
406
515
|
|
|
407
516
|
pct_str = ""
|
|
408
|
-
if show_pct and
|
|
409
|
-
pct_val = (block.total_tokens /
|
|
517
|
+
if show_pct and eff_limit > 0:
|
|
518
|
+
pct_val = (block.total_tokens / eff_limit) * 100.0
|
|
410
519
|
pct_str = f"{pct_val:.1f}%"
|
|
411
520
|
|
|
412
521
|
tokens_str = _fmt_num(block.total_tokens)
|
|
@@ -436,11 +545,11 @@ def _render_blocks_table(
|
|
|
436
545
|
|
|
437
546
|
# Footer rows (REMAINING, PROJECTED)
|
|
438
547
|
footer_rows: list[tuple[list[list[str]], str]] = []
|
|
439
|
-
if show_pct and
|
|
548
|
+
if show_pct and eff_limit > 0:
|
|
440
549
|
active_tokens = active_block.total_tokens if active_block else 0
|
|
441
|
-
remaining_tokens = max(
|
|
442
|
-
remaining_pct = (remaining_tokens /
|
|
443
|
-
rem_label = f"(assuming {_fmt_num(
|
|
550
|
+
remaining_tokens = max(eff_limit - active_tokens, 0)
|
|
551
|
+
remaining_pct = (remaining_tokens / eff_limit) * 100.0
|
|
552
|
+
rem_label = f"(assuming {_fmt_num(eff_limit)} token limit)"
|
|
444
553
|
|
|
445
554
|
rem_cells = _empty_cells()
|
|
446
555
|
rem_cells[0] = rem_label
|
|
@@ -456,7 +565,7 @@ def _render_blocks_table(
|
|
|
456
565
|
proj = active_block.projection
|
|
457
566
|
proj_tokens = proj.get("totalTokens", 0)
|
|
458
567
|
proj_pct = (
|
|
459
|
-
(proj_tokens /
|
|
568
|
+
(proj_tokens / eff_limit) * 100.0 if eff_limit > 0 else 0
|
|
460
569
|
)
|
|
461
570
|
proj_cost = proj.get("totalCost", 0.0)
|
|
462
571
|
|
|
@@ -600,23 +709,8 @@ def _render_blocks_table(
|
|
|
600
709
|
|
|
601
710
|
# Title banner
|
|
602
711
|
title = "Claude Code Token Usage Report - Session Blocks"
|
|
603
|
-
title_padded = f" {title} "
|
|
604
|
-
tw = len(title_padded)
|
|
605
|
-
dash = "\u2500" if unicode_ok else "-"
|
|
606
|
-
vb = "\u2502" if unicode_ok else "|"
|
|
607
|
-
if unicode_ok:
|
|
608
|
-
banner_top = f" \u256d{dash * tw}\u256e"
|
|
609
|
-
banner_bot = f" \u2570{dash * tw}\u256f"
|
|
610
|
-
else:
|
|
611
|
-
banner_top = f" +{'-' * tw}+"
|
|
612
|
-
banner_bot = f" +{'-' * tw}+"
|
|
613
|
-
|
|
614
712
|
lines: list[str] = []
|
|
615
|
-
lines.append(
|
|
616
|
-
lines.append(f" {vb}" + " " * tw + vb)
|
|
617
|
-
lines.append(f" {vb}" + _bold(title_padded) + vb)
|
|
618
|
-
lines.append(f" {vb}" + " " * tw + vb)
|
|
619
|
-
lines.append(banner_bot)
|
|
713
|
+
lines.append(_render_title_banner(title, unicode_ok=unicode_ok, color=color))
|
|
620
714
|
lines.append("")
|
|
621
715
|
|
|
622
716
|
# Header
|
|
@@ -680,6 +774,40 @@ def _render_blocks_table(
|
|
|
680
774
|
return rendered
|
|
681
775
|
|
|
682
776
|
|
|
777
|
+
def _daily_row_dict(d: BucketUsage, *, date_key: str) -> dict[str, Any]:
|
|
778
|
+
"""Single bucket → upstream-shaped row dict.
|
|
779
|
+
|
|
780
|
+
Shared by `_bucket_to_json` and `_bucket_by_project_to_json` so the per-row
|
|
781
|
+
shape (field set + order) can never drift. Key order matches ccusage:
|
|
782
|
+
date_key, inputTokens, outputTokens, cacheCreationTokens,
|
|
783
|
+
cacheReadTokens, totalTokens, totalCost, modelsUsed, modelBreakdowns.
|
|
784
|
+
"""
|
|
785
|
+
return {
|
|
786
|
+
date_key: d.bucket,
|
|
787
|
+
"inputTokens": d.input_tokens,
|
|
788
|
+
"outputTokens": d.output_tokens,
|
|
789
|
+
"cacheCreationTokens": d.cache_creation_tokens,
|
|
790
|
+
"cacheReadTokens": d.cache_read_tokens,
|
|
791
|
+
"totalTokens": d.total_tokens,
|
|
792
|
+
"totalCost": d.cost_usd,
|
|
793
|
+
"modelsUsed": list(d.models),
|
|
794
|
+
"modelBreakdowns": list(d.model_breakdowns),
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _bucket_totals_dict(buckets) -> dict[str, Any]:
|
|
799
|
+
"""Aggregate totals across buckets (key order matches ccusage; note
|
|
800
|
+
totalCost BEFORE totalTokens)."""
|
|
801
|
+
return {
|
|
802
|
+
"inputTokens": sum(b.input_tokens for b in buckets),
|
|
803
|
+
"outputTokens": sum(b.output_tokens for b in buckets),
|
|
804
|
+
"cacheCreationTokens": sum(b.cache_creation_tokens for b in buckets),
|
|
805
|
+
"cacheReadTokens": sum(b.cache_read_tokens for b in buckets),
|
|
806
|
+
"totalCost": sum(b.cost_usd for b in buckets),
|
|
807
|
+
"totalTokens": sum(b.total_tokens for b in buckets),
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
|
|
683
811
|
def _bucket_to_json(
|
|
684
812
|
buckets: list[BucketUsage],
|
|
685
813
|
*,
|
|
@@ -696,43 +824,27 @@ def _bucket_to_json(
|
|
|
696
824
|
cacheReadTokens, totalTokens, totalCost, modelsUsed, modelBreakdowns.
|
|
697
825
|
Totals key order (note: totalCost BEFORE totalTokens, per ccusage).
|
|
698
826
|
"""
|
|
699
|
-
bucket_list
|
|
700
|
-
|
|
701
|
-
tot_output = 0
|
|
702
|
-
tot_cc = 0
|
|
703
|
-
tot_cr = 0
|
|
704
|
-
tot_cost = 0.0
|
|
705
|
-
tot_tokens = 0
|
|
706
|
-
for d in buckets:
|
|
707
|
-
bucket_list.append({
|
|
708
|
-
date_key: d.bucket,
|
|
709
|
-
"inputTokens": d.input_tokens,
|
|
710
|
-
"outputTokens": d.output_tokens,
|
|
711
|
-
"cacheCreationTokens": d.cache_creation_tokens,
|
|
712
|
-
"cacheReadTokens": d.cache_read_tokens,
|
|
713
|
-
"totalTokens": d.total_tokens,
|
|
714
|
-
"totalCost": d.cost_usd,
|
|
715
|
-
"modelsUsed": list(d.models),
|
|
716
|
-
"modelBreakdowns": list(d.model_breakdowns),
|
|
717
|
-
})
|
|
718
|
-
tot_input += d.input_tokens
|
|
719
|
-
tot_output += d.output_tokens
|
|
720
|
-
tot_cc += d.cache_creation_tokens
|
|
721
|
-
tot_cr += d.cache_read_tokens
|
|
722
|
-
tot_cost += d.cost_usd
|
|
723
|
-
tot_tokens += d.total_tokens
|
|
724
|
-
|
|
725
|
-
totals = {
|
|
726
|
-
"inputTokens": tot_input,
|
|
727
|
-
"outputTokens": tot_output,
|
|
728
|
-
"cacheCreationTokens": tot_cc,
|
|
729
|
-
"cacheReadTokens": tot_cr,
|
|
730
|
-
"totalCost": tot_cost,
|
|
731
|
-
"totalTokens": tot_tokens,
|
|
732
|
-
}
|
|
827
|
+
bucket_list = [_daily_row_dict(d, date_key=date_key) for d in buckets]
|
|
828
|
+
totals = _bucket_totals_dict(buckets)
|
|
733
829
|
return json.dumps({list_key: bucket_list, "totals": totals}, indent=2)
|
|
734
830
|
|
|
735
831
|
|
|
832
|
+
def _bucket_by_project_to_json(project_groups, *, date_key: str = "date") -> str:
|
|
833
|
+
"""Serialize ``[(label, [BucketUsage]), ...]`` to ``{projects:{label:[rows]},
|
|
834
|
+
totals}`` (upstream ccusage daily --instances shape). Caller passes the
|
|
835
|
+
disambiguated (unique, non-aliased) label per group; insertion order is
|
|
836
|
+
preserved as the JSON key order."""
|
|
837
|
+
projects: dict[str, Any] = {}
|
|
838
|
+
all_buckets: list = []
|
|
839
|
+
for label, buckets in project_groups:
|
|
840
|
+
projects[label] = [_daily_row_dict(b, date_key=date_key) for b in buckets]
|
|
841
|
+
all_buckets.extend(buckets)
|
|
842
|
+
return json.dumps(
|
|
843
|
+
{"projects": projects, "totals": _bucket_totals_dict(all_buckets)},
|
|
844
|
+
indent=2,
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
|
|
736
848
|
def _weekly_to_json(
|
|
737
849
|
buckets: list[BucketUsage],
|
|
738
850
|
weeks: list[SubWeek],
|
|
@@ -961,6 +1073,57 @@ def _codex_bucket_to_json(
|
|
|
961
1073
|
return json.dumps({list_key: bucket_list, "totals": totals}, indent=2)
|
|
962
1074
|
|
|
963
1075
|
|
|
1076
|
+
def _codex_root_short_labels(roots: list[str]) -> list[str]:
|
|
1077
|
+
"""Given >=2 distinct root paths, return a short distinguishing label per
|
|
1078
|
+
root: the first path segment after their longest common ancestor — e.g.
|
|
1079
|
+
{".../rootA/.codex", ".../rootB/.codex"} -> ["rootA", "rootB"]. If a single
|
|
1080
|
+
segment isn't unique, fall back to the full post-ancestor tail, then to the
|
|
1081
|
+
whole path, so the returned labels are always pairwise distinct.
|
|
1082
|
+
|
|
1083
|
+
Order-preserving: labels[i] corresponds to roots[i].
|
|
1084
|
+
"""
|
|
1085
|
+
parts = [pathlib.PurePosixPath(r).parts for r in roots]
|
|
1086
|
+
common = 0
|
|
1087
|
+
for seg in zip(*parts):
|
|
1088
|
+
if len(set(seg)) == 1:
|
|
1089
|
+
common += 1
|
|
1090
|
+
else:
|
|
1091
|
+
break
|
|
1092
|
+
singles = [pr[common] if common < len(pr) else (pr[-1] if pr else "")
|
|
1093
|
+
for pr in parts]
|
|
1094
|
+
if len(set(singles)) == len(singles):
|
|
1095
|
+
return singles
|
|
1096
|
+
tails = ["/".join(pr[common:]) or (pr[-1] if pr else "") for pr in parts]
|
|
1097
|
+
if len(set(tails)) == len(tails):
|
|
1098
|
+
return tails
|
|
1099
|
+
return list(roots)
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def _codex_session_disambiguate(sessions: list[CodexSessionUsage]) -> dict[int, str]:
|
|
1103
|
+
"""Return ``{row_index: " (label)"}`` for codex-session rows whose
|
|
1104
|
+
``session_id_path`` collides with another row's — a genuine
|
|
1105
|
+
cross-$CODEX_HOME-root collision (issue #110). Non-colliding rows are absent
|
|
1106
|
+
from the dict; callers render them byte-identically (single-root data never
|
|
1107
|
+
collides, so this returns ``{}`` and output is unchanged).
|
|
1108
|
+
|
|
1109
|
+
``label`` is a short distinguishing segment of the matched $CODEX_HOME root
|
|
1110
|
+
(see ``_codex_root_short_labels``). Mirrors ``_project_disambiguate_labels``.
|
|
1111
|
+
"""
|
|
1112
|
+
path_counts: dict[str, int] = {}
|
|
1113
|
+
for s in sessions:
|
|
1114
|
+
path_counts[s.session_id_path] = path_counts.get(s.session_id_path, 0) + 1
|
|
1115
|
+
groups: dict[str, list[int]] = {}
|
|
1116
|
+
for idx, s in enumerate(sessions):
|
|
1117
|
+
if path_counts[s.session_id_path] > 1:
|
|
1118
|
+
groups.setdefault(s.session_id_path, []).append(idx)
|
|
1119
|
+
out: dict[int, str] = {}
|
|
1120
|
+
for idxs in groups.values():
|
|
1121
|
+
labels = _codex_root_short_labels([sessions[i].codex_root for i in idxs])
|
|
1122
|
+
for i, label in zip(idxs, labels):
|
|
1123
|
+
out[i] = f" ({label})"
|
|
1124
|
+
return out
|
|
1125
|
+
|
|
1126
|
+
|
|
964
1127
|
def _codex_sessions_to_json(sessions: list[CodexSessionUsage]) -> str:
|
|
965
1128
|
"""Serialize Codex session aggregates to JSON matching upstream exactly.
|
|
966
1129
|
|
|
@@ -972,9 +1135,13 @@ def _codex_sessions_to_json(sessions: list[CodexSessionUsage]) -> str:
|
|
|
972
1135
|
session_list: list[dict[str, Any]] = []
|
|
973
1136
|
tot_input = tot_cached = tot_output = tot_reasoning = tot_tokens = 0
|
|
974
1137
|
tot_cost = 0.0
|
|
975
|
-
|
|
1138
|
+
# Issue #110: only when two cross-root sessions share `sessionId` (the
|
|
1139
|
+
# relative path) does this map a colliding row to a label; otherwise empty,
|
|
1140
|
+
# so the per-session shape stays upstream-byte-identical (no `codexRoot`).
|
|
1141
|
+
disambig = _codex_session_disambiguate(sessions)
|
|
1142
|
+
for idx, s in enumerate(sessions):
|
|
976
1143
|
session_total = s.input_tokens + s.output_tokens
|
|
977
|
-
|
|
1144
|
+
entry = {
|
|
978
1145
|
"sessionId": s.session_id_path,
|
|
979
1146
|
"lastActivity": _codex_last_activity_iso(s.last_activity),
|
|
980
1147
|
"sessionFile": s.session_file,
|
|
@@ -986,7 +1153,13 @@ def _codex_sessions_to_json(sessions: list[CodexSessionUsage]) -> str:
|
|
|
986
1153
|
"totalTokens": session_total,
|
|
987
1154
|
"costUSD": s.cost_usd,
|
|
988
1155
|
"models": _codex_models_dict(s.model_breakdowns),
|
|
989
|
-
}
|
|
1156
|
+
}
|
|
1157
|
+
if idx in disambig:
|
|
1158
|
+
# Additive disambiguator — `sessionId` keeps its upstream-compatible
|
|
1159
|
+
# relative-path value; consumers key off `codexRoot` to tell the
|
|
1160
|
+
# (correctly separate) colliding rows apart.
|
|
1161
|
+
entry["codexRoot"] = s.codex_root
|
|
1162
|
+
session_list.append(entry)
|
|
990
1163
|
tot_input += s.input_tokens
|
|
991
1164
|
tot_cached += s.cached_input_tokens
|
|
992
1165
|
tot_output += s.output_tokens
|
|
@@ -1079,6 +1252,7 @@ def _render_bucket_table(
|
|
|
1079
1252
|
compact_split_fn: Callable[[str], str],
|
|
1080
1253
|
breakdown: bool = False,
|
|
1081
1254
|
compact: bool = False,
|
|
1255
|
+
project_groups=None,
|
|
1082
1256
|
) -> str:
|
|
1083
1257
|
"""Render bucket aggregates as a ccusage-style ANSI table.
|
|
1084
1258
|
|
|
@@ -1089,6 +1263,12 @@ def _render_bucket_table(
|
|
|
1089
1263
|
"YYYY\\n..." for compact-mode two-line display.
|
|
1090
1264
|
compact — force compact layout regardless of terminal width
|
|
1091
1265
|
(Session A `--compact` flag; spec §7.6.1).
|
|
1266
|
+
project_groups — when provided as ``[(label, [BucketUsage]), ...]``,
|
|
1267
|
+
render section layout: a ``Project: <label>`` header
|
|
1268
|
+
per group with one global Total footer summed across
|
|
1269
|
+
all groups (`daily -i/--instances`; issue #86 Session
|
|
1270
|
+
E). Default ``None`` = today's flat single-table
|
|
1271
|
+
behavior driven by ``buckets``.
|
|
1092
1272
|
|
|
1093
1273
|
Mirrors ccusage's ResponsiveTable behavior: single-line headers and dates
|
|
1094
1274
|
when content fits the terminal; falls back to two-line compact headers
|
|
@@ -1126,10 +1306,10 @@ def _render_bucket_table(
|
|
|
1126
1306
|
# ── Build raw rows: each is (cells, row_type) where a cell is the
|
|
1127
1307
|
# tuple (text, color_fn_or_none). `text` may contain '\n' for
|
|
1128
1308
|
# multi-line cells (Models list, compact Date).
|
|
1129
|
-
ROW_DATA, ROW_BREAKDOWN, ROW_FOOTER = "data", "breakdown", "footer"
|
|
1309
|
+
ROW_DATA, ROW_BREAKDOWN, ROW_FOOTER, ROW_PROJECT = "data", "breakdown", "footer", "project"
|
|
1130
1310
|
raw_rows: list[tuple[list[tuple[str, Any]], str]] = []
|
|
1131
1311
|
|
|
1132
|
-
|
|
1312
|
+
def _emit_data_and_breakdown(d):
|
|
1133
1313
|
# ccusage formatModelsDisplayMultiline: uniq → sort alphabetical
|
|
1134
1314
|
short_models = sorted({_short_model_name(m) for m in d.models})
|
|
1135
1315
|
models_text = "\n".join(f"- {m}" for m in short_models) if short_models else ""
|
|
@@ -1166,13 +1346,30 @@ def _render_bucket_table(
|
|
|
1166
1346
|
]
|
|
1167
1347
|
raw_rows.append((bd_cells, ROW_BREAKDOWN))
|
|
1168
1348
|
|
|
1349
|
+
if project_groups is not None:
|
|
1350
|
+
# Project-aware section layout (daily --instances): one cyan
|
|
1351
|
+
# `Project: <label>` header per group, then that group's normal
|
|
1352
|
+
# ROW_DATA (+ ROW_BREAKDOWN children); the single footer is computed
|
|
1353
|
+
# by flattening all groups' buckets.
|
|
1354
|
+
footer_buckets: list = []
|
|
1355
|
+
for label, group_buckets in project_groups:
|
|
1356
|
+
header_cells = [(f"Project: {label}", _cyan)] + [("", None)] * (num_cols - 1)
|
|
1357
|
+
raw_rows.append((header_cells, ROW_PROJECT))
|
|
1358
|
+
for d in group_buckets:
|
|
1359
|
+
_emit_data_and_breakdown(d)
|
|
1360
|
+
footer_buckets.append(d)
|
|
1361
|
+
else:
|
|
1362
|
+
for d in buckets:
|
|
1363
|
+
_emit_data_and_breakdown(d)
|
|
1364
|
+
footer_buckets = buckets
|
|
1365
|
+
|
|
1169
1366
|
# Total footer row — yellow on all populated cells.
|
|
1170
|
-
tot_input = sum(d.input_tokens for d in
|
|
1171
|
-
tot_output = sum(d.output_tokens for d in
|
|
1172
|
-
tot_cc = sum(d.cache_creation_tokens for d in
|
|
1173
|
-
tot_cr = sum(d.cache_read_tokens for d in
|
|
1174
|
-
tot_tokens = sum(d.total_tokens for d in
|
|
1175
|
-
tot_cost = sum(d.cost_usd for d in
|
|
1367
|
+
tot_input = sum(d.input_tokens for d in footer_buckets)
|
|
1368
|
+
tot_output = sum(d.output_tokens for d in footer_buckets)
|
|
1369
|
+
tot_cc = sum(d.cache_creation_tokens for d in footer_buckets)
|
|
1370
|
+
tot_cr = sum(d.cache_read_tokens for d in footer_buckets)
|
|
1371
|
+
tot_tokens = sum(d.total_tokens for d in footer_buckets)
|
|
1372
|
+
tot_cost = sum(d.cost_usd for d in footer_buckets)
|
|
1176
1373
|
footer_cells = [
|
|
1177
1374
|
("Total", _yellow),
|
|
1178
1375
|
("", None),
|
|
@@ -2053,14 +2250,18 @@ def _render_codex_session_table(
|
|
|
2053
2250
|
ROW_DATA, ROW_FOOTER = "data", "footer"
|
|
2054
2251
|
raw_rows: list[tuple[list[tuple[str, Any]], str]] = []
|
|
2055
2252
|
|
|
2056
|
-
|
|
2253
|
+
# Issue #110: suffix the Session cell with the matched $CODEX_HOME root
|
|
2254
|
+
# ONLY for cross-root collisions (two rows sharing session_id_path); empty
|
|
2255
|
+
# for single-root data, so the table stays byte-identical.
|
|
2256
|
+
disambig = _codex_session_disambiguate(sessions)
|
|
2257
|
+
for idx, s in enumerate(sessions):
|
|
2057
2258
|
models_text = "\n".join(f"- {m}" for m in s.models) if s.models else ""
|
|
2058
2259
|
non_cached = max(0, s.input_tokens - s.cached_input_tokens)
|
|
2059
2260
|
session_total = s.input_tokens + s.output_tokens
|
|
2060
2261
|
data_cells = [
|
|
2061
2262
|
(_date_cell(s.last_activity), None),
|
|
2062
2263
|
(s.directory, None),
|
|
2063
|
-
(_session_cell(s.session_id), None),
|
|
2264
|
+
(_session_cell(s.session_id) + disambig.get(idx, ""), None),
|
|
2064
2265
|
(models_text, None),
|
|
2065
2266
|
(_fmt_num(non_cached), None),
|
|
2066
2267
|
(_fmt_num(s.output_tokens), None),
|