cctally 1.18.0 → 1.20.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 +91 -23
- package/bin/_cctally_config.py +143 -0
- package/bin/_lib_aggregators.py +68 -19
- package/bin/_lib_blocks.py +55 -1
- package/bin/_lib_doctor.py +1 -1
- package/bin/_lib_render.py +212 -53
- package/bin/_lib_statusline.py +499 -0
- package/bin/cctally +903 -20
- package/bin/cctally-statusline +3 -0
- package/package.json +3 -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
|
|
@@ -979,6 +1073,57 @@ def _codex_bucket_to_json(
|
|
|
979
1073
|
return json.dumps({list_key: bucket_list, "totals": totals}, indent=2)
|
|
980
1074
|
|
|
981
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
|
+
|
|
982
1127
|
def _codex_sessions_to_json(sessions: list[CodexSessionUsage]) -> str:
|
|
983
1128
|
"""Serialize Codex session aggregates to JSON matching upstream exactly.
|
|
984
1129
|
|
|
@@ -990,9 +1135,13 @@ def _codex_sessions_to_json(sessions: list[CodexSessionUsage]) -> str:
|
|
|
990
1135
|
session_list: list[dict[str, Any]] = []
|
|
991
1136
|
tot_input = tot_cached = tot_output = tot_reasoning = tot_tokens = 0
|
|
992
1137
|
tot_cost = 0.0
|
|
993
|
-
|
|
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):
|
|
994
1143
|
session_total = s.input_tokens + s.output_tokens
|
|
995
|
-
|
|
1144
|
+
entry = {
|
|
996
1145
|
"sessionId": s.session_id_path,
|
|
997
1146
|
"lastActivity": _codex_last_activity_iso(s.last_activity),
|
|
998
1147
|
"sessionFile": s.session_file,
|
|
@@ -1004,7 +1153,13 @@ def _codex_sessions_to_json(sessions: list[CodexSessionUsage]) -> str:
|
|
|
1004
1153
|
"totalTokens": session_total,
|
|
1005
1154
|
"costUSD": s.cost_usd,
|
|
1006
1155
|
"models": _codex_models_dict(s.model_breakdowns),
|
|
1007
|
-
}
|
|
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)
|
|
1008
1163
|
tot_input += s.input_tokens
|
|
1009
1164
|
tot_cached += s.cached_input_tokens
|
|
1010
1165
|
tot_output += s.output_tokens
|
|
@@ -2095,14 +2250,18 @@ def _render_codex_session_table(
|
|
|
2095
2250
|
ROW_DATA, ROW_FOOTER = "data", "footer"
|
|
2096
2251
|
raw_rows: list[tuple[list[tuple[str, Any]], str]] = []
|
|
2097
2252
|
|
|
2098
|
-
|
|
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):
|
|
2099
2258
|
models_text = "\n".join(f"- {m}" for m in s.models) if s.models else ""
|
|
2100
2259
|
non_cached = max(0, s.input_tokens - s.cached_input_tokens)
|
|
2101
2260
|
session_total = s.input_tokens + s.output_tokens
|
|
2102
2261
|
data_cells = [
|
|
2103
2262
|
(_date_cell(s.last_activity), None),
|
|
2104
2263
|
(s.directory, None),
|
|
2105
|
-
(_session_cell(s.session_id), None),
|
|
2264
|
+
(_session_cell(s.session_id) + disambig.get(idx, ""), None),
|
|
2106
2265
|
(models_text, None),
|
|
2107
2266
|
(_fmt_num(non_cached), None),
|
|
2108
2267
|
(_fmt_num(s.output_tokens), None),
|