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.
@@ -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
@@ -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: list[dict[str, Any]] = []
700
- tot_input = 0
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
- 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):
976
1143
  session_total = s.input_tokens + s.output_tokens
977
- session_list.append({
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
- for d in buckets:
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 buckets)
1171
- tot_output = sum(d.output_tokens for d in buckets)
1172
- tot_cc = sum(d.cache_creation_tokens for d in buckets)
1173
- tot_cr = sum(d.cache_read_tokens for d in buckets)
1174
- tot_tokens = sum(d.total_tokens for d in buckets)
1175
- tot_cost = sum(d.cost_usd for d in buckets)
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
- 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):
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),