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.
@@ -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
- local = ts.astimezone(tz)
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
- total_minutes = int(total_seconds / 60)
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 = 0
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
- show_pct = max_completed_tokens > 0
321
- if show_pct:
322
- token_limit = max_completed_tokens
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 token_limit > 0:
409
- pct_val = (block.total_tokens / token_limit) * 100.0
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 token_limit > 0:
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(token_limit - active_tokens, 0)
442
- remaining_pct = (remaining_tokens / token_limit) * 100.0
443
- rem_label = f"(assuming {_fmt_num(token_limit)} token limit)"
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 / token_limit) * 100.0 if token_limit > 0 else 0
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(banner_top)
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
- for s in sessions:
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
- session_list.append({
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
- for s in sessions:
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),