cctally 1.11.1 → 1.13.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.
@@ -2350,7 +2350,7 @@ def cmd_hook_tick(args: argparse.Namespace) -> int:
2350
2350
  cache_conn = open_cache_db()
2351
2351
  try:
2352
2352
  stats = sync_cache(cache_conn)
2353
- ingested = int(stats.rows_inserted)
2353
+ ingested = int(stats.rows_changed)
2354
2354
  finally:
2355
2355
  try:
2356
2356
  cache_conn.close()
@@ -1716,13 +1716,23 @@ def _setup_install(args: argparse.Namespace) -> int:
1716
1716
  cache_conn = c.open_cache_db()
1717
1717
  try:
1718
1718
  stats = c.sync_cache(cache_conn)
1719
- rows = int(stats.rows_inserted)
1719
+ rows = int(stats.rows_changed)
1720
1720
  finally:
1721
1721
  try:
1722
1722
  cache_conn.close()
1723
1723
  except Exception:
1724
1724
  pass
1725
1725
  bootstrap_rows = rows
1726
+ # `rows` counts both genuine INSERTs and ccusage-parity DO UPDATE
1727
+ # replacements (see IngestStats.rows_changed). On first install
1728
+ # this is always 0-vs-N pure inserts (cache is empty), so "N new
1729
+ # entries" is exactly accurate. On a re-install / upgrade path
1730
+ # with active sessions, `rows` also counts UPSERT replacements
1731
+ # (streaming-vs-final tiebreaker swaps), so the count is more
1732
+ # accurately "ingest activity" than "rows newly added" — but
1733
+ # we keep "new entries" because (a) it's still a useful signal
1734
+ # to the operator that the cache is alive, and (b) the dominant
1735
+ # case (first install) reads literally.
1726
1736
  out.append(f"✓ Synced session cache ({rows} new entries)")
1727
1737
  except Exception as exc:
1728
1738
  out.append(f"⚠ sync_cache during bootstrap failed: {exc}")
@@ -1234,11 +1234,18 @@ def _diff_render_section_table(
1234
1234
  used_pct_mode_a: str,
1235
1235
  used_pct_mode_b: str,
1236
1236
  threshold: "NoiseThreshold | None" = None,
1237
+ compact: bool = False,
1237
1238
  ) -> str:
1238
1239
  """Render one bordered table for a section. The Total row sums all rows
1239
1240
  (visible + hidden) — the caller passes the unfiltered aggregate map as
1240
- total_a/total_b so hidden rows still contribute (spec §4 invariant)."""
1241
+ total_a/total_b so hidden rows still contribute (spec §4 invariant).
1242
+
1243
+ ``compact`` (issue #91, Shape B) drops the 1-space cell padding to 0 on
1244
+ this content-sized table, which has no proportional-width path to force.
1245
+ ``pad == 1`` (the default) reproduces the prior output byte-for-byte."""
1241
1246
  boxes = _diff_box_chars()
1247
+ pad = 0 if compact else 1
1248
+ pad_s = " " * pad
1242
1249
  out: list = [_diff_section_heading(section.name, width), ""]
1243
1250
 
1244
1251
  header_cells: list = ["Model" if section.name == "models"
@@ -1318,7 +1325,7 @@ def _diff_render_section_table(
1318
1325
  fill = fill or boxes["h"]
1319
1326
  parts = [left]
1320
1327
  for i, w in enumerate(col_w):
1321
- parts.append(fill * (w + 2))
1328
+ parts.append(fill * (w + 2 * pad))
1322
1329
  parts.append(right if i == n_cols - 1 else mid)
1323
1330
  return "".join(parts)
1324
1331
 
@@ -1340,7 +1347,7 @@ def _diff_render_section_table(
1340
1347
  # result. Spaces stay outside the ANSI escape so column rules
1341
1348
  # align identically with or without color.
1342
1349
  styled = _style_ansi(padded, code, enabled=bool(code))
1343
- parts.append(f" {styled} ")
1350
+ parts.append(f"{pad_s}{styled}{pad_s}")
1344
1351
  parts.append(boxes["v"])
1345
1352
  out_lines.append("".join(parts))
1346
1353
  return "\n".join(out_lines)
@@ -1376,11 +1383,13 @@ def _diff_render_full_output(
1376
1383
  width: int,
1377
1384
  raw_aggregates: dict,
1378
1385
  tz: "ZoneInfo | None" = None,
1386
+ compact: bool = False,
1379
1387
  ) -> str:
1380
1388
  """Compose banner + window header + each section's table.
1381
1389
 
1382
1390
  ``tz`` is forwarded to ``_diff_render_window_header`` for the date
1383
- labels; ``tz=None`` means host-local.
1391
+ labels; ``tz=None`` means host-local. ``compact`` (issue #91, Shape B)
1392
+ is forwarded to each section table's pad-reduction branch.
1384
1393
  """
1385
1394
  parts: list = [
1386
1395
  _diff_render_banner(), "",
@@ -1395,6 +1404,7 @@ def _diff_render_full_output(
1395
1404
  used_pct_mode_a=result.used_pct_mode_a,
1396
1405
  used_pct_mode_b=result.used_pct_mode_b,
1397
1406
  threshold=result.threshold,
1407
+ compact=compact,
1398
1408
  ))
1399
1409
  return "\n".join(parts)
1400
1410
 
package/bin/_lib_jsonl.py CHANGED
@@ -39,6 +39,11 @@ class UsageEntry:
39
39
  model: str
40
40
  usage: dict[str, Any]
41
41
  cost_usd: float | None
42
+ source_path: str # REQUIRED — absolute JSONL path; basename used in
43
+ # --debug samples (issue #89). Always supply a
44
+ # non-empty path-like string; "" is invalid per
45
+ # spec R5 (no silent empty-string passthrough,
46
+ # crashes loudly at construction instead).
42
47
 
43
48
 
44
49
  @dataclass
@@ -59,14 +64,64 @@ class CodexEntry:
59
64
  source_path: str
60
65
 
61
66
 
67
+ def _entry_token_total(entry: "UsageEntry") -> int:
68
+ """Sum of the four billed token fields. Mirrors ccusage's
69
+ `usage_token_total` in rust/crates/ccusage/src/claude_loader.rs:516."""
70
+ u = entry.usage
71
+ return (
72
+ int(u.get("input_tokens", 0) or 0)
73
+ + int(u.get("output_tokens", 0) or 0)
74
+ + int(u.get("cache_creation_input_tokens", 0) or 0)
75
+ + int(u.get("cache_read_input_tokens", 0) or 0)
76
+ )
77
+
78
+
79
+ def _should_replace(
80
+ candidate: "UsageEntry", existing: "UsageEntry"
81
+ ) -> bool:
82
+ """Port of ccusage's `should_replace_deduped_entry` in
83
+ rust/crates/ccusage/src/claude_loader.rs:531. Higher token total wins;
84
+ on equal totals, the row with `speed` set (non-null) wins (the post-stream
85
+ finalization row carries `speed`; streaming intermediates don't).
86
+
87
+ The `usage.get("speed") is not None` check matches the SQL UPDATE WHERE
88
+ clause's `json_extract(..., '$.speed') IS NOT NULL` in `sync_cache`'s
89
+ INSERT … ON CONFLICT … DO UPDATE, keeping the direct-parse fallback and
90
+ cache-ingest paths in lockstep on the rare-but-possible "explicit JSON
91
+ null" payload.
92
+ """
93
+ c_total = _entry_token_total(candidate)
94
+ e_total = _entry_token_total(existing)
95
+ if c_total != e_total:
96
+ return c_total > e_total
97
+ return (candidate.usage.get("speed") is not None
98
+ and existing.usage.get("speed") is None)
99
+
100
+
62
101
  def _parse_usage_entries(
63
102
  jsonl_path: pathlib.Path,
64
103
  range_start: dt.datetime,
65
104
  range_end: dt.datetime,
66
- seen_hashes: set[str] | None = None,
105
+ *,
106
+ dedupe_map: "dict[str, UsageEntry]",
67
107
  ) -> list[UsageEntry]:
68
- """Parse assistant entries from a JSONL file within the given time range."""
69
- entries: list[UsageEntry] = []
108
+ """Parse one JSONL file's assistant entries within [range_start, range_end].
109
+
110
+ Dedup contract (matches ccusage's `push_deduped_entry`):
111
+ - Entries with non-null (msg_id, req_id) go into `dedupe_map`; if a key
112
+ already maps to an entry, replace iff `_should_replace(candidate, existing)`.
113
+ - Entries with null msg_id or null req_id (rare in modern Claude Code,
114
+ but possible on synthetic / legacy emissions) skip the dedup map and
115
+ land in a separate list — partial UNIQUE index on the cache mirrors
116
+ this behavior.
117
+ - `<synthetic>` model rows are dropped entirely (matches ccusage's
118
+ claude_loader.rs:454).
119
+
120
+ Caller is responsible for sorting the returned list by timestamp if
121
+ needed; `_collect_entries_direct` does this once across all files
122
+ after flattening `dedupe_map.values()`.
123
+ """
124
+ no_key_entries: list[UsageEntry] = []
70
125
  try:
71
126
  with open(jsonl_path, "r", encoding="utf-8", errors="replace") as fh:
72
127
  for line in fh:
@@ -96,6 +151,11 @@ def _parse_usage_entries(
96
151
  model = msg.get("model") or obj.get("model")
97
152
  if not isinstance(model, str) or not model.strip():
98
153
  continue
154
+ model = model.strip()
155
+ if model == "<synthetic>":
156
+ # Matches ccusage's claude_loader.rs:454 — synthetic
157
+ # placeholder rows carry no billable usage.
158
+ continue
99
159
 
100
160
  try:
101
161
  ts = dt.datetime.fromisoformat(
@@ -109,16 +169,8 @@ def _parse_usage_entries(
109
169
  if ts < range_start or ts > range_end:
110
170
  continue
111
171
 
112
- # Deduplicate by message.id + requestId (same as ccusage)
113
172
  msg_id = msg.get("id")
114
173
  req_id = obj.get("requestId")
115
- if msg_id is not None and req_id is not None:
116
- entry_hash = f"{msg_id}:{req_id}"
117
- if seen_hashes is not None:
118
- if entry_hash in seen_hashes:
119
- continue
120
- seen_hashes.add(entry_hash)
121
-
122
174
  cost_usd_raw = obj.get("costUSD")
123
175
  cost_usd = (
124
176
  float(cost_usd_raw)
@@ -126,19 +178,30 @@ def _parse_usage_entries(
126
178
  else None
127
179
  )
128
180
 
129
- entries.append(UsageEntry(
181
+ entry = UsageEntry(
130
182
  timestamp=ts,
131
- model=model.strip(),
183
+ model=model,
132
184
  usage=usage,
133
185
  cost_usd=cost_usd,
134
- ))
186
+ source_path=str(jsonl_path),
187
+ )
188
+
189
+ if msg_id is None or req_id is None:
190
+ no_key_entries.append(entry)
191
+ continue
192
+ key = f"{msg_id}:{req_id}"
193
+ existing = dedupe_map.get(key)
194
+ if existing is None or _should_replace(entry, existing):
195
+ dedupe_map[key] = entry
135
196
  except OSError as exc:
136
197
  _eprint(f"[cost] could not read {jsonl_path}: {exc}")
137
198
 
138
- return entries
199
+ # The function returns ONLY this file's no-key entries; the caller
200
+ # flattens `dedupe_map.values()` once at the end across all files.
201
+ return no_key_entries
139
202
 
140
203
 
141
- def _iter_jsonl_entries_with_offsets(fh):
204
+ def _iter_jsonl_entries_with_offsets(fh, path_str: str):
142
205
  """Yield (byte_offset, UsageEntry, msg_id, req_id) for each assistant
143
206
  entry starting from fh's current position.
144
207
 
@@ -185,6 +248,13 @@ def _iter_jsonl_entries_with_offsets(fh):
185
248
  model = msg.get("model") or obj.get("model")
186
249
  if not isinstance(model, str) or not model.strip():
187
250
  continue
251
+ model = model.strip()
252
+ if model == "<synthetic>":
253
+ # Matches ccusage's claude_loader.rs:454. Filtered at the
254
+ # iterator level so the cache ingest path can't accidentally
255
+ # store these rows even if a downstream loop forgets to
256
+ # double-check (see `sync_cache` in _cctally_cache.py).
257
+ continue
188
258
 
189
259
  try:
190
260
  ts = dt.datetime.fromisoformat(ts_raw.strip().replace("Z", "+00:00"))
@@ -202,9 +272,10 @@ def _iter_jsonl_entries_with_offsets(fh):
202
272
  offset,
203
273
  UsageEntry(
204
274
  timestamp=ts,
205
- model=model.strip(),
275
+ model=model,
206
276
  usage=usage,
207
277
  cost_usd=cost_usd,
278
+ source_path=path_str,
208
279
  ),
209
280
  msg_id,
210
281
  req_id,
@@ -134,6 +134,94 @@ def _format_block_start(*args, **kwargs):
134
134
  return sys.modules["cctally"]._format_block_start(*args, **kwargs)
135
135
 
136
136
 
137
+ def _ellipsize(content: str, width: int, unicode_ok: bool) -> str:
138
+ """Truncate ``content`` to ``width`` cells, marking the cut with an
139
+ ellipsis. Returns ``content`` unchanged when it already fits.
140
+
141
+ Used for BOTH header and (text) data cells so a column scaled below
142
+ its header-label width stays aligned with the border row (issue #102
143
+ (a)) — the header render previously padded without truncating, so a
144
+ label wider than its scaled column overflowed the box."""
145
+ if len(content) <= width:
146
+ return content
147
+ ell = "…" if unicode_ok else "..."
148
+ return content[: max(0, width - len(ell))] + ell
149
+
150
+
151
+ def _scale_down_col_widths(
152
+ nat_widths: list[int],
153
+ aligns: list[str],
154
+ data_widths: list[int],
155
+ available: int,
156
+ *,
157
+ grow_idx: int,
158
+ min_text: int = 4,
159
+ ) -> list[int]:
160
+ """Allocate the per-column widths for the ``--compact`` / auto-overflow
161
+ scale-down branch of the project + Claude-session renderers.
162
+
163
+ Policy (issue #102 — recommendation (a) + (b)):
164
+
165
+ * **Numeric (right-aligned) columns are protected.** Their hard floor
166
+ is the widest DATA value (``data_widths[i]``), so the full number
167
+ always shows; the row render must never ellipsis-truncate a
168
+ right-aligned cell. A silently-wrong figure (``12,345,…``) is worse
169
+ than honest overflow.
170
+ * **Text (left-aligned) columns absorb the squeeze** and may truncate
171
+ — including their header label, which the header render ellipsizes
172
+ the same way (so a column may drop below header width while staying
173
+ box-aligned).
174
+ * When the natural widths fit, they are used verbatim and the slack
175
+ goes to ``grow_idx``. When shrinking is required, widths scale
176
+ proportionally, clamped to ``[floor, natural]``; any residual after
177
+ reclaiming all text slack is accepted as honest overflow rather than
178
+ corrupting a number.
179
+
180
+ ``data_widths[i]`` is the widest cell EXCLUDING the header, so a numeric
181
+ column is sized to its number — its (possibly wider, truncatable) header
182
+ label does not inflate the protected floor.
183
+ """
184
+ num_cols = len(nat_widths)
185
+ floors = [
186
+ min(max(data_widths[i], 1), nat_widths[i]) if aligns[i] == "right"
187
+ else min(min_text, nat_widths[i])
188
+ for i in range(num_cols)
189
+ ]
190
+ total_nat = sum(nat_widths)
191
+ if total_nat <= available:
192
+ widths = list(nat_widths)
193
+ slack = available - total_nat
194
+ if slack > 0 and 0 <= grow_idx < num_cols:
195
+ widths[grow_idx] += slack
196
+ return widths
197
+
198
+ scale = available / total_nat if total_nat > 0 else 1.0
199
+ widths = [
200
+ min(nat_widths[i], max(int(nat_widths[i] * scale), floors[i]))
201
+ for i in range(num_cols)
202
+ ]
203
+ overflow = sum(widths) - available
204
+ if overflow > 0:
205
+ # Reclaim from text columns (most slack above floor first); numeric
206
+ # columns are immovable. Residual overflow is honest (issue #102 (b)).
207
+ text_idx = sorted(
208
+ (i for i in range(num_cols) if aligns[i] != "right"),
209
+ key=lambda i: widths[i] - floors[i],
210
+ reverse=True,
211
+ )
212
+ for i in text_idx:
213
+ if overflow <= 0:
214
+ break
215
+ take = min(widths[i] - floors[i], overflow)
216
+ widths[i] -= take
217
+ overflow -= take
218
+ else:
219
+ slack = available - sum(widths)
220
+ if slack > 0 and 0 <= grow_idx < num_cols:
221
+ widths[grow_idx] += slack
222
+ return widths
223
+
224
+
137
225
  # Optional dependency: zoneinfo.ZoneInfo is referenced only as a string
138
226
  # annotation in moved code; no runtime import needed.
139
227
 
@@ -143,6 +231,7 @@ def _render_blocks_table(
143
231
  *,
144
232
  now: dt.datetime | None = None,
145
233
  tz: "ZoneInfo | None" = None,
234
+ compact: bool = False,
146
235
  ) -> str:
147
236
  """Render blocks as a ccusage-style ANSI table with box-drawing borders.
148
237
 
@@ -158,6 +247,11 @@ def _render_blocks_table(
158
247
 
159
248
  ``tz`` is the resolved display zone (``None`` means host local).
160
249
  Block-start cells are rendered in this zone.
250
+
251
+ ``compact`` forces the scale-down code path regardless of the actual
252
+ terminal width (Session A ``--compact`` flag; spec §7.6.1). Mirrors
253
+ the same kwarg on ``_render_bucket_table``. Auto-detected
254
+ width-overflow continues to trigger the same path as before.
161
255
  """
162
256
  if not blocks:
163
257
  return "No session blocks found in the specified date range."
@@ -416,10 +510,12 @@ def _render_blocks_table(
416
510
 
417
511
  # Scale down only when table exceeds terminal width.
418
512
  # ccusage does NOT expand columns when the table fits — it uses the
419
- # padded content widths as-is.
513
+ # padded content widths as-is. Session A (spec §7.6.1): the
514
+ # ``compact`` kwarg forces this branch regardless of terminal width
515
+ # (Review-A P2-B; mirrors ``_render_bucket_table`` semantics).
420
516
  table_overhead = 3 * num_cols + 1
421
517
  available_width = term_width - table_overhead
422
- if sum(col_widths) + table_overhead > term_width:
518
+ if compact or (sum(col_widths) + table_overhead > term_width):
423
519
  scale_factor = available_width / sum(col_widths)
424
520
  col_widths = [
425
521
  max(
@@ -977,6 +1073,7 @@ def _render_bucket_table(
977
1073
  title_suffix: str,
978
1074
  compact_split_fn: Callable[[str], str],
979
1075
  breakdown: bool = False,
1076
+ compact: bool = False,
980
1077
  ) -> str:
981
1078
  """Render bucket aggregates as a ccusage-style ANSI table.
982
1079
 
@@ -985,6 +1082,8 @@ def _render_bucket_table(
985
1082
  title_suffix — banner text suffix ("Daily" or "Monthly").
986
1083
  compact_split_fn — function that splits a bucket string into
987
1084
  "YYYY\\n..." for compact-mode two-line display.
1085
+ compact — force compact layout regardless of terminal width
1086
+ (Session A `--compact` flag; spec §7.6.1).
988
1087
 
989
1088
  Mirrors ccusage's ResponsiveTable behavior: single-line headers and dates
990
1089
  when content fits the terminal; falls back to two-line compact headers
@@ -1109,7 +1208,11 @@ def _render_bucket_table(
1109
1208
  term_width = int(os.environ.get("COLUMNS", "120"))
1110
1209
 
1111
1210
  border_overhead = 3 * num_cols + 1
1112
- compact_mode = sum(col_widths) + border_overhead > term_width
1211
+ # Session A (spec §7.6.1): `compact=True` (set by `--compact` flag on
1212
+ # daily/monthly/weekly/blocks/...) forces compact-mode regardless of
1213
+ # the actual terminal width. Auto-detected width-overflow continues to
1214
+ # trigger compact mode as before.
1215
+ compact_mode = compact or (sum(col_widths) + border_overhead > term_width)
1113
1216
 
1114
1217
  if compact_mode:
1115
1218
  # Scale down proportionally with narrow minimums.
@@ -1272,6 +1375,7 @@ def _render_weekly_table(
1272
1375
  weeks: list["SubWeek"],
1273
1376
  compact_split_fn: Callable[[str], str],
1274
1377
  breakdown: bool = False,
1378
+ compact: bool = False,
1275
1379
  ) -> str:
1276
1380
  """Render weekly bucket aggregates as a ccusage-style ANSI table.
1277
1381
 
@@ -1292,6 +1396,10 @@ def _render_weekly_table(
1292
1396
 
1293
1397
  `first_col_name` and `title_suffix` are hardcoded to "Week" and
1294
1398
  "Weekly" respectively.
1399
+
1400
+ `compact` forces compact layout regardless of terminal width
1401
+ (Session A `--compact` flag; spec \u00a77.6.1). Mirrors the same kwarg
1402
+ on `_render_bucket_table` (Review-A P3-1).
1295
1403
  """
1296
1404
  assert len(week_pct_overlay) == len(buckets), (
1297
1405
  f"week_pct_overlay length {len(week_pct_overlay)} does not match "
@@ -1443,7 +1551,11 @@ def _render_weekly_table(
1443
1551
  term_width = int(os.environ.get("COLUMNS", "120"))
1444
1552
 
1445
1553
  border_overhead = 3 * num_cols + 1
1446
- compact_mode = sum(col_widths) + border_overhead > term_width
1554
+ # Session A (spec §7.6.1): `compact=True` (set by `--compact` flag on
1555
+ # daily/monthly/weekly/blocks/...) forces compact-mode regardless of
1556
+ # the actual terminal width. Auto-detected width-overflow continues to
1557
+ # trigger compact mode as before.
1558
+ compact_mode = compact or (sum(col_widths) + border_overhead > term_width)
1447
1559
 
1448
1560
  if compact_mode:
1449
1561
  # Scale down proportionally with narrow minimums.
@@ -2099,6 +2211,7 @@ def _render_claude_session_table(
2099
2211
  title: str = "Claude Token Usage Report - Sessions",
2100
2212
  breakdown: bool = False,
2101
2213
  tz: "ZoneInfo | None" = None,
2214
+ compact: bool = False,
2102
2215
  ) -> str:
2103
2216
  """Render Claude session aggregates matching upstream ccusage session view (11 cols).
2104
2217
 
@@ -2109,14 +2222,16 @@ def _render_claude_session_table(
2109
2222
  Structural clone of `_render_codex_session_table` with:
2110
2223
  - ``Reasoning`` column replaced by ``Cache Create`` (sourced from
2111
2224
  ``cache_creation_tokens`` instead of ``reasoning_output_tokens``).
2112
- - ``tz_name`` / ``force_compact`` parameters dropped — Claude-side
2113
- commands don't expose ``--timezone`` / ``--compact`` today; dates
2114
- render in local TZ via ``astimezone()`` and compact mode is
2115
- triggered by terminal width alone.
2116
2225
  - ``Session`` cell shows first 8 chars of ``session_id`` (full UUID
2117
2226
  lives in --json).
2118
2227
 
2119
2228
  ``breakdown`` toggles per-model sub-rows beneath each session row.
2229
+
2230
+ ``compact`` forces the proportional scale-down code path regardless
2231
+ of the actual terminal width (Session A ``--compact`` flag; spec
2232
+ §7.6.1; Review-A P2-B). Mirrors ``_render_codex_session_table``'s
2233
+ ``force_compact`` semantics. Auto-detected width overflow continues
2234
+ to trigger the same path.
2120
2235
  """
2121
2236
  color = _supports_color_stdout()
2122
2237
  unicode_ok = _supports_unicode_stdout()
@@ -2250,6 +2365,34 @@ def _render_claude_session_table(
2250
2365
 
2251
2366
  col_widths = [_wide_width(i, content_widths[i]) for i in range(num_cols)]
2252
2367
 
2368
+ try:
2369
+ term_width = os.get_terminal_size().columns
2370
+ except (OSError, ValueError):
2371
+ term_width = int(os.environ.get("COLUMNS", "120"))
2372
+
2373
+ border_overhead = 3 * num_cols + 1
2374
+ # Session A (spec \u00a77.6.1; Review-A P2-B): the scale-down branch
2375
+ # fires ONLY under explicit ``compact=True``. Pre-Session-A
2376
+ # ``_render_claude_session_table`` had no auto-overflow branch
2377
+ # (wide-by-default was the existing contract; 6 golden fixtures
2378
+ # in tests/fixtures/session/ encode that contract). The
2379
+ # Cross-Branch Reviewer flagged the gratuitous auto-detect arm
2380
+ # added in fdfee047; this restores the pre-Session-A behavior
2381
+ # while preserving the explicit-compact override.
2382
+ if compact:
2383
+ available = term_width - border_overhead
2384
+ # Per-column widest DATA value (excludes the header row), so numeric
2385
+ # columns are protected at their number width while header labels may
2386
+ # truncate (issue #102). data_cells/footer carry the values; the
2387
+ # header is added separately below.
2388
+ data_widths = [0] * num_cols
2389
+ for cells, _rt in raw_rows:
2390
+ for i, (text, _c) in enumerate(cells):
2391
+ data_widths[i] = max(data_widths[i], _max_line_width(text))
2392
+ col_widths = _scale_down_col_widths(
2393
+ col_widths, aligns, data_widths, available, grow_idx=1,
2394
+ )
2395
+
2253
2396
  def _split_cell(text: str) -> list[str]:
2254
2397
  return text.split("\n") if text else [""]
2255
2398
 
@@ -2290,13 +2433,15 @@ def _render_claude_session_table(
2290
2433
 
2291
2434
  out.append(_border_row(TL, T_DOWN, TR))
2292
2435
 
2293
- # Header
2436
+ # Header — labels ellipsize like data cells so a column scaled below
2437
+ # its header width stays box-aligned (issue #102 (a)).
2294
2438
  header_cells = [_split_cell(h) for h in headers]
2295
2439
  max_h = max(len(c) for c in header_cells)
2296
2440
  for li in range(max_h):
2297
2441
  parts = [_dim(V)]
2298
2442
  for i, cell in enumerate(header_cells):
2299
2443
  content = cell[li] if li < len(cell) else ""
2444
+ content = _ellipsize(content, col_widths[i], unicode_ok)
2300
2445
  parts.append(f" {_cyan(_pad_cell(content, col_widths[i], aligns[i]))} ")
2301
2446
  parts.append(_dim(V))
2302
2447
  out.append("".join(parts))
@@ -2312,7 +2457,15 @@ def _render_claude_session_table(
2312
2457
  parts = [_dim(V)]
2313
2458
  for i, (text, cfn) in enumerate(cells):
2314
2459
  content = split_cells[i][li] if li < len(split_cells[i]) else ""
2315
- padded = _pad_cell(content, col_widths[i], aligns[i])
2460
+ # Ellipsis-truncate only TEXT cells under --compact. Numeric
2461
+ # (right-aligned) cells are NEVER truncated — a wrong number
2462
+ # is worse than honest overflow (issue #102 (b)); the column
2463
+ # is floored at its full number width so this normally never
2464
+ # overflows. Mirrors _render_codex_session_table.
2465
+ w = col_widths[i]
2466
+ if aligns[i] != "right":
2467
+ content = _ellipsize(content, w, unicode_ok)
2468
+ padded = _pad_cell(content, w, aligns[i])
2316
2469
  if cfn is not None:
2317
2470
  padded = cfn(padded)
2318
2471
  parts.append(f" {padded} ")
@@ -2368,6 +2521,8 @@ def _render_project_table(
2368
2521
  weeks_missing_snapshot: int = 0,
2369
2522
  weeks_in_range: int = 1,
2370
2523
  no_color: bool = False,
2524
+ color: "bool | None" = None,
2525
+ compact: bool = False,
2371
2526
  ) -> str:
2372
2527
  """Render project rollup as a ccusage-style ANSI table.
2373
2528
 
@@ -2381,7 +2536,12 @@ def _render_project_table(
2381
2536
  first for width calc, ANSI applied at render time) and same banner /
2382
2537
  border / separator glyphs.
2383
2538
  """
2384
- color = False if no_color else _supports_color_stdout()
2539
+ # Session A (spec §7.3): caller may pass an explicit ``color`` bool
2540
+ # to override the auto-detect (so the new bool ``--color`` flag can
2541
+ # force ANSI under NO_COLOR=1 env). Legacy ``no_color`` kwarg path
2542
+ # is preserved for callers that haven't migrated.
2543
+ if color is None:
2544
+ color = False if no_color else _supports_color_stdout()
2385
2545
  unicode_ok = _supports_unicode_stdout()
2386
2546
 
2387
2547
  def _dim(s: str) -> str: return _style_ansi(s, "90", color)
@@ -2531,14 +2691,22 @@ def _render_project_table(
2531
2691
  term_width = int(os.environ.get("COLUMNS", "120"))
2532
2692
 
2533
2693
  border_overhead = 3 * num_cols + 1
2534
- if sum(col_widths) + border_overhead > term_width:
2694
+ # Issue #91 (Shape A): the ``compact`` kwarg forces this scale-down
2695
+ # branch regardless of terminal width, mirroring ``_render_blocks_table``
2696
+ # / ``_render_bucket_table``. Auto-detected width-overflow continues to
2697
+ # trigger the same path as before.
2698
+ if compact or (sum(col_widths) + border_overhead > term_width):
2535
2699
  available = term_width - border_overhead
2536
- total_col = sum(col_widths)
2537
- scale = available / total_col if total_col > 0 else 1.0
2538
- col_widths = [max(int(w * scale), 8) for w in col_widths]
2539
- remainder = available - sum(col_widths)
2540
- if remainder > 0:
2541
- col_widths[0] += remainder # grow Project column
2700
+ # Per-column widest DATA value (excludes the header row): numeric
2701
+ # columns are protected at their number width while header labels may
2702
+ # truncate (issue #102 (a) + (b)).
2703
+ data_widths = [0] * num_cols
2704
+ for cells, _rt in raw_rows:
2705
+ for i, (text, _c) in enumerate(cells):
2706
+ data_widths[i] = max(data_widths[i], _max_line_width(text))
2707
+ col_widths = _scale_down_col_widths(
2708
+ col_widths, aligns, data_widths, available, grow_idx=0,
2709
+ )
2542
2710
 
2543
2711
  def _split_cell(text: str) -> list[str]:
2544
2712
  return text.split("\n") if text else [""]
@@ -2585,6 +2753,7 @@ def _render_project_table(
2585
2753
  parts = [_dim(V)]
2586
2754
  for i, cell in enumerate(header_cells):
2587
2755
  content = cell[li] if li < len(cell) else ""
2756
+ content = _ellipsize(content, col_widths[i], unicode_ok)
2588
2757
  parts.append(f" {_cyan(_pad_cell(content, col_widths[i], aligns[i]))} ")
2589
2758
  parts.append(_dim(V))
2590
2759
  out.append("".join(parts))
@@ -2599,10 +2768,12 @@ def _render_project_table(
2599
2768
  parts = [_dim(V)]
2600
2769
  for i, (text, cfn) in enumerate(cells):
2601
2770
  content = split_cells[i][li] if li < len(split_cells[i]) else ""
2771
+ # Numeric (right-aligned) cells are never ellipsized \u2014 a
2772
+ # wrong number is worse than honest overflow (issue #102 (b));
2773
+ # text cells truncate so the column can shrink (a).
2602
2774
  w = col_widths[i]
2603
- if len(content) > w:
2604
- ell = "\u2026" if unicode_ok else "..."
2605
- content = content[: max(0, w - len(ell))] + ell
2775
+ if aligns[i] != "right":
2776
+ content = _ellipsize(content, w, unicode_ok)
2606
2777
  padded = _pad_cell(content, w, aligns[i])
2607
2778
  if cfn is not None:
2608
2779
  padded = cfn(padded)
@@ -2800,7 +2971,7 @@ def _render_five_hour_blocks_table(
2800
2971
  "", "", "",
2801
2972
  ])
2802
2973
 
2803
- print(_boxed_table(headers, rows, aligns))
2974
+ print(_boxed_table(headers, rows, aligns, compact=args.compact))
2804
2975
  glyph = " · ⚡ = block crossed weekly reset" if has_crossed else ""
2805
2976
  print(f"\n{len(block_dicts)} blocks · cost: ${total_cost:.2f}{glyph}")
2806
2977