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 +15 -0
- package/bin/_cctally_cache.py +4 -2
- package/bin/_cctally_config.py +55 -9
- package/bin/_lib_diff_kernel.py +14 -4
- package/bin/_lib_jsonl.py +8 -1
- package/bin/_lib_render.py +193 -22
- package/bin/_lib_subscription_weeks.py +21 -3
- package/bin/cctally +1278 -85
- package/package.json +1 -1
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
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -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,
|
|
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
|
|
package/bin/_cctally_config.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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()
|
package/bin/_lib_diff_kernel.py
CHANGED
|
@@ -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"
|
|
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,
|
package/bin/_lib_render.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
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
|
|
2604
|
-
|
|
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
|
-
#
|
|
470
|
-
#
|
|
471
|
-
|
|
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
|