cctally 1.12.0 → 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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,21 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.13.0] - 2026-05-25
9
+
10
+ ### Added
11
+ - **Every Claude reporting command now accepts the `ccusage` flag surface, so `ccusage <cmd> [flags]` invocations paste into `cctally` unchanged.** Across all 10 reporting subcommands (`daily`, `monthly`, `weekly`, `session`, `blocks`, `five-hour-blocks`, `project`, `diff`, `range-cost`, `cache-report`): `-z`/`--timezone` aliases the existing `--tz`; the 8 date-taking commands accept `--since`/`--until` in both `YYYY-MM-DD` and `YYYYMMDD` forms; `--compact` forces compact table layout; `--color`/`--no-color` (plus the `NO_COLOR` / `FORCE_COLOR` env vars) control ANSI on the color-emitting commands (`project`, `diff`) and are accepted-but-inert elsewhere; and `-O`/`--offline`, `--single-thread`, `-d`/`--debug`, and `--config` are accepted as documented no-ops where cctally has no divergent behavior. A top-level `-v` short alias for `--version` is also added. The pass is purely additive — no existing output changes. (#86)
12
+ - **`cctally session` gains `-i`/`--id <session-id>` to filter to a single session.** Exact-string match against the post-resume-merge `sessionId`; an unknown id renders empty and exits 0. (#86)
13
+ - **`--config <path>` is now a real per-invocation config override.** Previously a documented no-op from the flag-alias pass, `--config` now loads configuration from the given path for that invocation only, leaving the persisted `~/.local/share/cctally/config.json` untouched. (#88)
14
+ - **`-d`/`--debug` now emits a real "Pricing Mismatch Debug Report" on stderr for the Claude reporting commands.** The report compares each entry's recorded `costUSD` against the token-recomputed cost, surfacing totals + per-model stats + a sample of the largest discrepancies, matching ccusage's `printMismatchReport` shape. `--debug-samples N` caps the sample block (default 5; `N=0` prints totals only; negative N rejected at parse time). The report goes to stderr only — `--json` / `--format` pipelines stay byte-stable — and `diff` emits one report per window. (#89)
15
+ - **`--compact` now reshapes output on the 5 reporting commands where it was previously accepted-but-inert: `five-hour-blocks`, `project`, `diff`, `range-cost`, and `cache-report`.** Brings them in line with the commands that already honored `--compact`. (#91)
16
+ - **`codex-daily` / `codex-monthly` / `codex-weekly` / `codex-session` now accept `-d/--debug` + `--debug-samples N`**, extending the Claude-side `--debug` diagnostic surface to the Codex commands. Because Codex JSONL records no `costUSD` to diff against, the report is a Codex variant — a "Codex Pricing Debug Report" on stderr with totals (entries processed, models seen, total computed cost) plus a "Sample Top Entries" block of the N highest computed-cost entries (`Recorded cost: (none)` per sample; `gpt-5`-fallback models tagged `(fallback→gpt-5)`). `--debug-samples` caps the sample block (default 5; `N=0` prints totals only). (#92)
17
+
18
+ ### Fixed
19
+ - **`project --compact` and `session --compact` no longer corrupt numeric values or overflow the terminal on narrow widths.** The header-width column floor added to fix box misalignment had two flip-side bugs on sub-fit terminals: (A) any cell wider than its column was ellipsis-truncated — including numeric columns, so a token count like `12,345,678` rendered as the silently-wrong `12,345,…`; and (B) when the header floors summed past the available width nothing shrank them, so the box drew WIDER than the terminal. The scale-down policy is now: numeric (right-aligned) columns are floored at their full value width and are never ellipsis-truncated (a wrong number is worse than honest overflow), while text columns — and their header labels, which now truncate the same way data cells do — absorb the squeeze so the table fits when the numbers allow and stays box-aligned when they don't. Shared `_scale_down_col_widths` + `_ellipsize` helpers centralize the policy across both renderers. The codex session renderer is intentionally left as-is (ccusage/codex parity). Regression: `tests/test_compact_rendering.py` (numeric-never-truncated + box-fits + unit coverage); project goldens regenerated. (#102)
20
+ - **`cache-report --since/--until` again accept space-separated datetimes (`'2026-05-01 12:30:00'`) and ISO week-dates (`2026-W18-1`).** The Session A dual-form refactor replaced cache-report's date-only fallthrough with a parser that rejects anything other than `YYYY-MM-DD` / `YYYYMMDD`, silently dropping the other forms that `datetime.fromisoformat` (and the pre-refactor code) accepted — they now failed with `--since must be YYYY-MM-DD or YYYYMMDD format`. The `parse_iso_datetime` second-chance is restored for inputs the dual-form parser rejects; a full datetime is used verbatim (no `--until` end-of-day rounding), matching the old behavior. Genuinely invalid input (e.g. `26-01-01`) still surfaces the clearer centralized `YYYY-MM-DD or YYYYMMDD` diagnostic rather than the generic ISO message, and the dual-form parse is attempted silently so a successful second-chance never leaks a spurious error line to stderr. cache-report is the only date command with this leniency; `daily` / `monthly` / `weekly` / `blocks` accept the two canonical forms only. Regression: `tests/test_cache_report_since_until_fallthrough.py`. (#101)
21
+ - **`diff` and `project` no longer emit ANSI color into a piped or redirected stdout when `CI` is set.** The `--color` resolver placed its `CI` rung above the `stdout.isatty()` check, so on a CI runner `cctally diff … | cat` (or `> out.txt`) wrote raw escape sequences into the capture — a behavior change from the pre-Session-A contract, which keyed color on `sys.stdout.isatty()` alone and always produced clean text on a pipe. The `CI` rung is now gated behind `sys.stdout.isatty()`: CI still forces color on a real terminal (over a dumb `TERM`, matching picocolors), but a non-TTY stdout stays plain text regardless of `CI`. `FORCE_COLOR` / `NO_COLOR` / `--color` / `--no-color` precedence is unchanged. Regression: `tests/test_color_resolution.py::test_ci_with_piped_stdout_stays_uncolored` + `::test_ci_with_tty_stdout_enables`. (#100)
22
+
8
23
  ## [1.12.0] - 2026-05-24
9
24
 
10
25
  ### Fixed
@@ -661,7 +661,7 @@ def sync_cache(
661
661
  try:
662
662
  with open(jp, "r", encoding="utf-8", errors="replace") as fh:
663
663
  fh.seek(start_offset)
664
- for offset, entry, msg_id, req_id in _iter_jsonl_entries_with_offsets(fh):
664
+ for offset, entry, msg_id, req_id in _iter_jsonl_entries_with_offsets(fh, str(jp)):
665
665
  usage = entry.usage
666
666
  inp = int(usage.get("input_tokens", 0) or 0)
667
667
  out = int(usage.get("output_tokens", 0) or 0)
@@ -839,7 +839,8 @@ def iter_entries(
839
839
 
840
840
  sql = (
841
841
  "SELECT timestamp_utc, model, input_tokens, output_tokens, "
842
- "cache_create_tokens, cache_read_tokens, usage_extra_json, cost_usd_raw "
842
+ "cache_create_tokens, cache_read_tokens, usage_extra_json, "
843
+ "cost_usd_raw, source_path "
843
844
  "FROM session_entries "
844
845
  "WHERE timestamp_utc >= ? AND timestamp_utc <= ?"
845
846
  )
@@ -875,6 +876,7 @@ def iter_entries(
875
876
  model=row[1],
876
877
  usage=usage,
877
878
  cost_usd=row[7],
879
+ source_path=row[8],
878
880
  ))
879
881
  return entries
880
882
 
@@ -46,6 +46,7 @@ import json
46
46
  import os
47
47
  import secrets
48
48
  import sys
49
+ from pathlib import Path
49
50
  from typing import Any
50
51
 
51
52
 
@@ -162,17 +163,60 @@ def config_writer_lock():
162
163
  fh.close()
163
164
 
164
165
 
165
- def load_config() -> dict[str, Any]:
166
+ def _load_config_from_explicit_path(path: "str | Path") -> dict[str, Any]:
167
+ """Read config from an explicit per-invocation override path (issue #88).
168
+
169
+ Contract differs from the default ``load_config()``:
170
+ - Missing file → ``SystemExit(2)`` with a clear stderr message.
171
+ - Unreadable / malformed JSON / non-object root → ``SystemExit(2)``
172
+ with a clear stderr message.
173
+ - Never writes, never acquires ``config_writer_lock``, never
174
+ creates the on-disk default config — the override is read-only
175
+ for this invocation.
176
+
177
+ Used by the ccusage drop-in ``--config <path>`` flag wired onto the
178
+ 10 Claude reporting commands (spec §3 T1.6 / issue #86 Session A).
179
+ """
180
+ p = Path(path)
181
+ if not p.exists():
182
+ eprint(f"cctally: --config: file not found: {p}")
183
+ raise SystemExit(2)
184
+ try:
185
+ raw = p.read_text(encoding="utf-8")
186
+ except OSError as exc:
187
+ eprint(f"cctally: --config: read failed for {p}: {exc}")
188
+ raise SystemExit(2) from exc
189
+ try:
190
+ data = json.loads(raw)
191
+ except json.JSONDecodeError as exc:
192
+ eprint(f"cctally: --config: invalid JSON in {p}: {exc}")
193
+ raise SystemExit(2) from exc
194
+ if not isinstance(data, dict):
195
+ eprint(
196
+ f"cctally: --config: {p} top-level must be a JSON object"
197
+ )
198
+ raise SystemExit(2)
199
+ return data
200
+
201
+
202
+ def load_config(path: "str | Path | None" = None) -> dict[str, Any]:
166
203
  """Read config.json, falling back to in-memory defaults on corruption.
167
204
 
168
- Concurrent-safety: readers see either the pre-rename or post-rename
169
- contents thanks to save_config's atomic os.replace. On corrupt or
170
- non-object JSON, emits a one-shot stderr warning and returns
171
- in-memory defaults WITHOUT re-saving the next legitimate
172
- save_config call (under config_writer_lock) will overwrite the bad
173
- bytes atomically. On first run (file missing), creates the file
174
- with a fresh collector token under the writer lock so two parallel
175
- first-run processes don't clobber each other.
205
+ When ``path`` is None (default): reads the persisted user config at
206
+ ``_cctally_core.CONFIG_PATH``, creating it on first run with a fresh
207
+ collector token under the writer lock. Concurrent-safety: readers see
208
+ either the pre-rename or post-rename contents thanks to save_config's
209
+ atomic os.replace. On corrupt or non-object JSON, emits a one-shot
210
+ stderr warning and returns in-memory defaults WITHOUT re-saving the
211
+ next legitimate save_config call (under config_writer_lock) will
212
+ overwrite the bad bytes atomically.
213
+
214
+ When ``path`` is set (issue #88 ccusage drop-in ``--config <path>``):
215
+ reads from the explicit override path and bypasses the default-path
216
+ branch entirely. Missing / unreadable / malformed paths surface as
217
+ ``SystemExit(2)`` with a clear stderr message — see
218
+ ``_load_config_from_explicit_path``. No writes, no first-run create,
219
+ no mutation of the on-disk default config.
176
220
 
177
221
  DEADLOCK NOTE: `fcntl.flock` is per-fd even within the same
178
222
  process. Callers that already hold config_writer_lock MUST use
@@ -180,6 +224,8 @@ def load_config() -> dict[str, Any]:
180
224
  inside an outer lock would block forever (verified during issue
181
225
  #17 fix).
182
226
  """
227
+ if path is not None:
228
+ return _load_config_from_explicit_path(path)
183
229
  c = _cctally()
184
230
  ensure_dirs()
185
231
  parsed = _try_read_config()
@@ -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
@@ -178,6 +183,7 @@ def _parse_usage_entries(
178
183
  model=model,
179
184
  usage=usage,
180
185
  cost_usd=cost_usd,
186
+ source_path=str(jsonl_path),
181
187
  )
182
188
 
183
189
  if msg_id is None or req_id is None:
@@ -195,7 +201,7 @@ def _parse_usage_entries(
195
201
  return no_key_entries
196
202
 
197
203
 
198
- def _iter_jsonl_entries_with_offsets(fh):
204
+ def _iter_jsonl_entries_with_offsets(fh, path_str: str):
199
205
  """Yield (byte_offset, UsageEntry, msg_id, req_id) for each assistant
200
206
  entry starting from fh's current position.
201
207
 
@@ -269,6 +275,7 @@ def _iter_jsonl_entries_with_offsets(fh):
269
275
  model=model,
270
276
  usage=usage,
271
277
  cost_usd=cost_usd,
278
+ source_path=path_str,
272
279
  ),
273
280
  msg_id,
274
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
 
@@ -283,6 +283,7 @@ def _compute_subscription_weeks(
283
283
  conn: sqlite3.Connection,
284
284
  range_start: dt.datetime,
285
285
  range_end: dt.datetime,
286
+ config: "dict | None" = None,
286
287
  ) -> list[SubWeek]:
287
288
  """Generate the ordered list of subscription weeks overlapping [range_start, range_end].
288
289
 
@@ -292,6 +293,16 @@ def _compute_subscription_weeks(
292
293
  config-based calendar-week boundaries with every week tagged
293
294
  "extrapolated".
294
295
 
296
+ ``config`` (issue #88 ``--config`` surface): the resolved config dict
297
+ used by the no-snapshot Case-B calendar-week fallback. When the caller
298
+ already loaded config honoring the per-invocation ``--config <path>``
299
+ override (``_load_claude_config_for_args``), it MUST pass it here so the
300
+ fallback's ``collector.week_start`` matches the explicit override rather
301
+ than re-reading (and first-run-creating) the persisted default config.
302
+ ``None`` preserves the legacy bare-``load_config()`` behavior for callers
303
+ with no ``--config`` surface (dashboard) and for the monkeypatch
304
+ carve-out (tests reach ``load_config`` via ``ns["load_config"]``).
305
+
295
306
  Anthropic's reset day-of-week is not strictly stable across long spans —
296
307
  it can shift (observed: Thursday cycles in Feb, Friday cycles from Mar
297
308
  onward). A single-anchor 7-day-multiple extrapolation therefore generates
@@ -466,9 +477,16 @@ def _compute_subscription_weeks(
466
477
  return _apply_overlap_clamp_to_subweeks(weeks)
467
478
 
468
479
  # Case B: no snapshots — config-based calendar-week fallback.
469
- # `load_config` stays on the _cctally() accessor per spec §3.5
470
- # monkeypatch carve-out tests reach it via ``ns["load_config"]``.
471
- config = _cctally().load_config()
480
+ # Honor the caller's `--config <path>` override when supplied (issue
481
+ # #88): `cmd_weekly` / `cmd_project` pass the config resolved by
482
+ # `_load_claude_config_for_args` so this fallback reads the explicit
483
+ # path's `collector.week_start` instead of recreating / reading the
484
+ # persisted default. When `config is None` (dashboard, or the spec §3.5
485
+ # monkeypatch carve-out where tests reach `load_config` via
486
+ # `ns["load_config"]`), fall back to a bare `load_config()` on the
487
+ # `_cctally()` accessor — identical to the prior behavior.
488
+ if config is None:
489
+ config = _cctally().load_config()
472
490
  week_start_name = get_week_start_name(config)
473
491
  week_start_idx = WEEKDAY_MAP[week_start_name]
474
492
  # internal fallback: host-local intentional