cctally 1.12.0 → 1.14.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/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(
@@ -910,13 +1006,18 @@ def _codex_sessions_to_json(sessions: list[CodexSessionUsage]) -> str:
910
1006
 
911
1007
 
912
1008
  def _claude_sessions_to_json(sessions: list[ClaudeSessionUsage]) -> str:
913
- """Serialize Claude sessions to JSON per spec A2.8.
1009
+ """Serialize Claude sessions to JSON (spec A2.8, amended by issue #104).
914
1010
 
915
1011
  Per-session: sessionId, projectPath, sourcePaths (list), firstActivity
916
1012
  / lastActivity ISO strings, modelsUsed, token counts
917
1013
  (input/cacheCreation/cacheRead/output/total), totalCost, modelBreakdowns
918
1014
  (camelCased token field names, cost).
919
1015
 
1016
+ `totalTokens` (per-session + totals) sums ALL four token components
1017
+ (input + output + cacheCreation + cacheRead) per issue #104 — matching
1018
+ `daily`/`monthly` and ccusage v20. (The field name/shape is unchanged;
1019
+ only the value definition widened to include cache.)
1020
+
920
1021
  totals: same 6 numeric fields aggregated across sessions.
921
1022
  """
922
1023
  sess_list: list[dict[str, Any]] = []
@@ -977,6 +1078,7 @@ def _render_bucket_table(
977
1078
  title_suffix: str,
978
1079
  compact_split_fn: Callable[[str], str],
979
1080
  breakdown: bool = False,
1081
+ compact: bool = False,
980
1082
  ) -> str:
981
1083
  """Render bucket aggregates as a ccusage-style ANSI table.
982
1084
 
@@ -985,6 +1087,8 @@ def _render_bucket_table(
985
1087
  title_suffix — banner text suffix ("Daily" or "Monthly").
986
1088
  compact_split_fn — function that splits a bucket string into
987
1089
  "YYYY\\n..." for compact-mode two-line display.
1090
+ compact — force compact layout regardless of terminal width
1091
+ (Session A `--compact` flag; spec §7.6.1).
988
1092
 
989
1093
  Mirrors ccusage's ResponsiveTable behavior: single-line headers and dates
990
1094
  when content fits the terminal; falls back to two-line compact headers
@@ -1109,7 +1213,11 @@ def _render_bucket_table(
1109
1213
  term_width = int(os.environ.get("COLUMNS", "120"))
1110
1214
 
1111
1215
  border_overhead = 3 * num_cols + 1
1112
- compact_mode = sum(col_widths) + border_overhead > term_width
1216
+ # Session A (spec §7.6.1): `compact=True` (set by `--compact` flag on
1217
+ # daily/monthly/weekly/blocks/...) forces compact-mode regardless of
1218
+ # the actual terminal width. Auto-detected width-overflow continues to
1219
+ # trigger compact mode as before.
1220
+ compact_mode = compact or (sum(col_widths) + border_overhead > term_width)
1113
1221
 
1114
1222
  if compact_mode:
1115
1223
  # Scale down proportionally with narrow minimums.
@@ -1272,6 +1380,7 @@ def _render_weekly_table(
1272
1380
  weeks: list["SubWeek"],
1273
1381
  compact_split_fn: Callable[[str], str],
1274
1382
  breakdown: bool = False,
1383
+ compact: bool = False,
1275
1384
  ) -> str:
1276
1385
  """Render weekly bucket aggregates as a ccusage-style ANSI table.
1277
1386
 
@@ -1292,6 +1401,10 @@ def _render_weekly_table(
1292
1401
 
1293
1402
  `first_col_name` and `title_suffix` are hardcoded to "Week" and
1294
1403
  "Weekly" respectively.
1404
+
1405
+ `compact` forces compact layout regardless of terminal width
1406
+ (Session A `--compact` flag; spec \u00a77.6.1). Mirrors the same kwarg
1407
+ on `_render_bucket_table` (Review-A P3-1).
1295
1408
  """
1296
1409
  assert len(week_pct_overlay) == len(buckets), (
1297
1410
  f"week_pct_overlay length {len(week_pct_overlay)} does not match "
@@ -1443,7 +1556,11 @@ def _render_weekly_table(
1443
1556
  term_width = int(os.environ.get("COLUMNS", "120"))
1444
1557
 
1445
1558
  border_overhead = 3 * num_cols + 1
1446
- compact_mode = sum(col_widths) + border_overhead > term_width
1559
+ # Session A (spec §7.6.1): `compact=True` (set by `--compact` flag on
1560
+ # daily/monthly/weekly/blocks/...) forces compact-mode regardless of
1561
+ # the actual terminal width. Auto-detected width-overflow continues to
1562
+ # trigger compact mode as before.
1563
+ compact_mode = compact or (sum(col_widths) + border_overhead > term_width)
1447
1564
 
1448
1565
  if compact_mode:
1449
1566
  # Scale down proportionally with narrow minimums.
@@ -2006,12 +2123,23 @@ def _render_codex_session_table(
2006
2123
  # auto-detect so the narrow layout renders regardless of terminal width.
2007
2124
  if force_compact or (sum(col_widths) + border_overhead > term_width):
2008
2125
  available = term_width - border_overhead
2009
- total_col = sum(col_widths)
2010
- scale = available / total_col if total_col > 0 else 1.0
2011
- col_widths = [max(int(w * scale), 8) for w in col_widths]
2012
- remainder = available - sum(col_widths)
2013
- if remainder > 0:
2014
- col_widths[3] += remainder # grow Models column
2126
+ # Issue #99 / #102: the prior bare-`8` floor could scale a column
2127
+ # below its header label, and headers are padded (never truncated)
2128
+ # in the header render so the header row grew wider than the box
2129
+ # border and the grid misaligned on narrow terminals. Mirror the
2130
+ # sibling renderers (`_render_claude_session_table` + the project
2131
+ # renderer) via the shared `_scale_down_col_widths` chokepoint:
2132
+ # numeric columns are protected at their widest DATA value while
2133
+ # text columns (incl. header labels) absorb the squeeze and may
2134
+ # truncate, keeping every box line the same width. Grows the Models
2135
+ # column (index 3) with any slack, preserving prior behavior.
2136
+ data_widths = [0] * num_cols
2137
+ for cells, _rt in raw_rows:
2138
+ for i, (text, _c) in enumerate(cells):
2139
+ data_widths[i] = max(data_widths[i], _max_line_width(text))
2140
+ col_widths = _scale_down_col_widths(
2141
+ col_widths, aligns, data_widths, available, grow_idx=3,
2142
+ )
2015
2143
 
2016
2144
  def _split_cell(text: str) -> list[str]:
2017
2145
  return text.split("\n") if text else [""]
@@ -2053,13 +2181,17 @@ def _render_codex_session_table(
2053
2181
 
2054
2182
  out.append(_border_row(TL, T_DOWN, TR))
2055
2183
 
2056
- # Header
2184
+ # Header — labels ellipsize like data cells so a column scaled below
2185
+ # its header width stays box-aligned (issue #99 / #102 (a)). Previously
2186
+ # the header padded without truncating, so a label wider than its
2187
+ # scaled column overflowed the box border.
2057
2188
  header_cells = [_split_cell(h) for h in headers]
2058
2189
  max_h = max(len(c) for c in header_cells)
2059
2190
  for li in range(max_h):
2060
2191
  parts = [_dim(V)]
2061
2192
  for i, cell in enumerate(header_cells):
2062
2193
  content = cell[li] if li < len(cell) else ""
2194
+ content = _ellipsize(content, col_widths[i], unicode_ok)
2063
2195
  parts.append(f" {_cyan(_pad_cell(content, col_widths[i], aligns[i]))} ")
2064
2196
  parts.append(_dim(V))
2065
2197
  out.append("".join(parts))
@@ -2075,11 +2207,14 @@ def _render_codex_session_table(
2075
2207
  parts = [_dim(V)]
2076
2208
  for i, (text, cfn) in enumerate(cells):
2077
2209
  content = split_cells[i][li] if li < len(split_cells[i]) else ""
2078
- # Truncate with ellipsis if cell content exceeds column width
2210
+ # Ellipsis-truncate only TEXT cells. Numeric (right-aligned)
2211
+ # cells are NEVER truncated \u2014 a wrong number is worse than
2212
+ # honest overflow (issue #102 (b)); _scale_down_col_widths
2213
+ # floors numeric columns at their full number width so this
2214
+ # normally never overflows. Mirrors the sibling renderers.
2079
2215
  w = col_widths[i]
2080
- if len(content) > w:
2081
- ell = "\u2026" if unicode_ok else "..."
2082
- content = content[: max(0, w - len(ell))] + ell
2216
+ if aligns[i] != "right":
2217
+ content = _ellipsize(content, w, unicode_ok)
2083
2218
  padded = _pad_cell(content, w, aligns[i])
2084
2219
  if cfn is not None:
2085
2220
  padded = cfn(padded)
@@ -2099,6 +2234,7 @@ def _render_claude_session_table(
2099
2234
  title: str = "Claude Token Usage Report - Sessions",
2100
2235
  breakdown: bool = False,
2101
2236
  tz: "ZoneInfo | None" = None,
2237
+ compact: bool = False,
2102
2238
  ) -> str:
2103
2239
  """Render Claude session aggregates matching upstream ccusage session view (11 cols).
2104
2240
 
@@ -2109,14 +2245,16 @@ def _render_claude_session_table(
2109
2245
  Structural clone of `_render_codex_session_table` with:
2110
2246
  - ``Reasoning`` column replaced by ``Cache Create`` (sourced from
2111
2247
  ``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
2248
  - ``Session`` cell shows first 8 chars of ``session_id`` (full UUID
2117
2249
  lives in --json).
2118
2250
 
2119
2251
  ``breakdown`` toggles per-model sub-rows beneath each session row.
2252
+
2253
+ ``compact`` forces the proportional scale-down code path regardless
2254
+ of the actual terminal width (Session A ``--compact`` flag; spec
2255
+ §7.6.1; Review-A P2-B). Mirrors ``_render_codex_session_table``'s
2256
+ ``force_compact`` semantics. Auto-detected width overflow continues
2257
+ to trigger the same path.
2120
2258
  """
2121
2259
  color = _supports_color_stdout()
2122
2260
  unicode_ok = _supports_unicode_stdout()
@@ -2166,9 +2304,10 @@ def _render_claude_session_table(
2166
2304
  for s in sessions:
2167
2305
  short_models = sorted({_short_model_name(m) for m in s.models})
2168
2306
  models_text = "\n".join(f"- {m}" for m in short_models) if short_models else ""
2169
- # Spec A2.8: Total Tokens = input + output (cache shown separately,
2170
- # not summed). Parallels `_render_codex_session_table` line ~4644.
2171
- session_total = s.input_tokens + s.output_tokens
2307
+ # Issue #104: Total Tokens = all four components (input + output +
2308
+ # cache), matching daily/monthly + ccusage v20. Read the single
2309
+ # source of truth on the aggregate rather than recomputing.
2310
+ session_total = s.total_tokens
2172
2311
  data_cells = [
2173
2312
  (_date_cell(s.last_activity), None),
2174
2313
  (s.project_path, None),
@@ -2191,8 +2330,8 @@ def _render_claude_session_table(
2191
2330
  mb_cc = int(mb["cache_create"])
2192
2331
  mb_cr = int(mb["cache_read"])
2193
2332
  mb_output = int(mb["output"])
2194
- # Spec A2.8: Total Tokens = input + output only.
2195
- mb_total = mb_input + mb_output
2333
+ # Issue #104: per-model Total Tokens sums all four components.
2334
+ mb_total = mb_input + mb_output + mb_cc + mb_cr
2196
2335
  mb_cost = float(mb["cost"])
2197
2336
  bd_cells = [
2198
2337
  (f"{arrow} {name}", _gray),
@@ -2213,8 +2352,8 @@ def _render_claude_session_table(
2213
2352
  tot_cc = sum(s.cache_creation_tokens for s in sessions)
2214
2353
  tot_cr = sum(s.cache_read_tokens for s in sessions)
2215
2354
  tot_output = sum(s.output_tokens for s in sessions)
2216
- # Spec A2.8: Total Tokens = input + output only.
2217
- tot_tokens = tot_input + tot_output
2355
+ # Issue #104: Total Tokens footer sums all four components.
2356
+ tot_tokens = sum(s.total_tokens for s in sessions)
2218
2357
  tot_cost = sum(s.cost_usd for s in sessions)
2219
2358
  footer_cells = [
2220
2359
  ("Total", _yellow),
@@ -2250,6 +2389,34 @@ def _render_claude_session_table(
2250
2389
 
2251
2390
  col_widths = [_wide_width(i, content_widths[i]) for i in range(num_cols)]
2252
2391
 
2392
+ try:
2393
+ term_width = os.get_terminal_size().columns
2394
+ except (OSError, ValueError):
2395
+ term_width = int(os.environ.get("COLUMNS", "120"))
2396
+
2397
+ border_overhead = 3 * num_cols + 1
2398
+ # Session A (spec \u00a77.6.1; Review-A P2-B): the scale-down branch
2399
+ # fires ONLY under explicit ``compact=True``. Pre-Session-A
2400
+ # ``_render_claude_session_table`` had no auto-overflow branch
2401
+ # (wide-by-default was the existing contract; 6 golden fixtures
2402
+ # in tests/fixtures/session/ encode that contract). The
2403
+ # Cross-Branch Reviewer flagged the gratuitous auto-detect arm
2404
+ # added in fdfee047; this restores the pre-Session-A behavior
2405
+ # while preserving the explicit-compact override.
2406
+ if compact:
2407
+ available = term_width - border_overhead
2408
+ # Per-column widest DATA value (excludes the header row), so numeric
2409
+ # columns are protected at their number width while header labels may
2410
+ # truncate (issue #102). data_cells/footer carry the values; the
2411
+ # header is added separately below.
2412
+ data_widths = [0] * num_cols
2413
+ for cells, _rt in raw_rows:
2414
+ for i, (text, _c) in enumerate(cells):
2415
+ data_widths[i] = max(data_widths[i], _max_line_width(text))
2416
+ col_widths = _scale_down_col_widths(
2417
+ col_widths, aligns, data_widths, available, grow_idx=1,
2418
+ )
2419
+
2253
2420
  def _split_cell(text: str) -> list[str]:
2254
2421
  return text.split("\n") if text else [""]
2255
2422
 
@@ -2290,13 +2457,15 @@ def _render_claude_session_table(
2290
2457
 
2291
2458
  out.append(_border_row(TL, T_DOWN, TR))
2292
2459
 
2293
- # Header
2460
+ # Header — labels ellipsize like data cells so a column scaled below
2461
+ # its header width stays box-aligned (issue #102 (a)).
2294
2462
  header_cells = [_split_cell(h) for h in headers]
2295
2463
  max_h = max(len(c) for c in header_cells)
2296
2464
  for li in range(max_h):
2297
2465
  parts = [_dim(V)]
2298
2466
  for i, cell in enumerate(header_cells):
2299
2467
  content = cell[li] if li < len(cell) else ""
2468
+ content = _ellipsize(content, col_widths[i], unicode_ok)
2300
2469
  parts.append(f" {_cyan(_pad_cell(content, col_widths[i], aligns[i]))} ")
2301
2470
  parts.append(_dim(V))
2302
2471
  out.append("".join(parts))
@@ -2312,7 +2481,15 @@ def _render_claude_session_table(
2312
2481
  parts = [_dim(V)]
2313
2482
  for i, (text, cfn) in enumerate(cells):
2314
2483
  content = split_cells[i][li] if li < len(split_cells[i]) else ""
2315
- padded = _pad_cell(content, col_widths[i], aligns[i])
2484
+ # Ellipsis-truncate only TEXT cells under --compact. Numeric
2485
+ # (right-aligned) cells are NEVER truncated — a wrong number
2486
+ # is worse than honest overflow (issue #102 (b)); the column
2487
+ # is floored at its full number width so this normally never
2488
+ # overflows. Mirrors _render_codex_session_table.
2489
+ w = col_widths[i]
2490
+ if aligns[i] != "right":
2491
+ content = _ellipsize(content, w, unicode_ok)
2492
+ padded = _pad_cell(content, w, aligns[i])
2316
2493
  if cfn is not None:
2317
2494
  padded = cfn(padded)
2318
2495
  parts.append(f" {padded} ")
@@ -2368,6 +2545,8 @@ def _render_project_table(
2368
2545
  weeks_missing_snapshot: int = 0,
2369
2546
  weeks_in_range: int = 1,
2370
2547
  no_color: bool = False,
2548
+ color: "bool | None" = None,
2549
+ compact: bool = False,
2371
2550
  ) -> str:
2372
2551
  """Render project rollup as a ccusage-style ANSI table.
2373
2552
 
@@ -2381,7 +2560,12 @@ def _render_project_table(
2381
2560
  first for width calc, ANSI applied at render time) and same banner /
2382
2561
  border / separator glyphs.
2383
2562
  """
2384
- color = False if no_color else _supports_color_stdout()
2563
+ # Session A (spec §7.3): caller may pass an explicit ``color`` bool
2564
+ # to override the auto-detect (so the new bool ``--color`` flag can
2565
+ # force ANSI under NO_COLOR=1 env). Legacy ``no_color`` kwarg path
2566
+ # is preserved for callers that haven't migrated.
2567
+ if color is None:
2568
+ color = False if no_color else _supports_color_stdout()
2385
2569
  unicode_ok = _supports_unicode_stdout()
2386
2570
 
2387
2571
  def _dim(s: str) -> str: return _style_ansi(s, "90", color)
@@ -2531,14 +2715,22 @@ def _render_project_table(
2531
2715
  term_width = int(os.environ.get("COLUMNS", "120"))
2532
2716
 
2533
2717
  border_overhead = 3 * num_cols + 1
2534
- if sum(col_widths) + border_overhead > term_width:
2718
+ # Issue #91 (Shape A): the ``compact`` kwarg forces this scale-down
2719
+ # branch regardless of terminal width, mirroring ``_render_blocks_table``
2720
+ # / ``_render_bucket_table``. Auto-detected width-overflow continues to
2721
+ # trigger the same path as before.
2722
+ if compact or (sum(col_widths) + border_overhead > term_width):
2535
2723
  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
2724
+ # Per-column widest DATA value (excludes the header row): numeric
2725
+ # columns are protected at their number width while header labels may
2726
+ # truncate (issue #102 (a) + (b)).
2727
+ data_widths = [0] * num_cols
2728
+ for cells, _rt in raw_rows:
2729
+ for i, (text, _c) in enumerate(cells):
2730
+ data_widths[i] = max(data_widths[i], _max_line_width(text))
2731
+ col_widths = _scale_down_col_widths(
2732
+ col_widths, aligns, data_widths, available, grow_idx=0,
2733
+ )
2542
2734
 
2543
2735
  def _split_cell(text: str) -> list[str]:
2544
2736
  return text.split("\n") if text else [""]
@@ -2585,6 +2777,7 @@ def _render_project_table(
2585
2777
  parts = [_dim(V)]
2586
2778
  for i, cell in enumerate(header_cells):
2587
2779
  content = cell[li] if li < len(cell) else ""
2780
+ content = _ellipsize(content, col_widths[i], unicode_ok)
2588
2781
  parts.append(f" {_cyan(_pad_cell(content, col_widths[i], aligns[i]))} ")
2589
2782
  parts.append(_dim(V))
2590
2783
  out.append("".join(parts))
@@ -2599,10 +2792,12 @@ def _render_project_table(
2599
2792
  parts = [_dim(V)]
2600
2793
  for i, (text, cfn) in enumerate(cells):
2601
2794
  content = split_cells[i][li] if li < len(split_cells[i]) else ""
2795
+ # Numeric (right-aligned) cells are never ellipsized \u2014 a
2796
+ # wrong number is worse than honest overflow (issue #102 (b));
2797
+ # text cells truncate so the column can shrink (a).
2602
2798
  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
2799
+ if aligns[i] != "right":
2800
+ content = _ellipsize(content, w, unicode_ok)
2606
2801
  padded = _pad_cell(content, w, aligns[i])
2607
2802
  if cfn is not None:
2608
2803
  padded = cfn(padded)
@@ -2800,7 +2995,7 @@ def _render_five_hour_blocks_table(
2800
2995
  "", "", "",
2801
2996
  ])
2802
2997
 
2803
- print(_boxed_table(headers, rows, aligns))
2998
+ print(_boxed_table(headers, rows, aligns, compact=args.compact))
2804
2999
  glyph = " · ⚡ = block crossed weekly reset" if has_crossed else ""
2805
3000
  print(f"\n{len(block_dicts)} blocks · cost: ${total_cost:.2f}{glyph}")
2806
3001
 
@@ -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