cctally 1.11.1 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +62 -0
- package/bin/_cctally_cache.py +342 -113
- package/bin/_cctally_config.py +55 -9
- package/bin/_cctally_core.py +51 -0
- package/bin/_cctally_db.py +1654 -5
- package/bin/_cctally_record.py +1 -1
- package/bin/_cctally_setup.py +11 -1
- package/bin/_lib_diff_kernel.py +14 -4
- package/bin/_lib_jsonl.py +88 -17
- 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/bin/_cctally_record.py
CHANGED
|
@@ -2350,7 +2350,7 @@ def cmd_hook_tick(args: argparse.Namespace) -> int:
|
|
|
2350
2350
|
cache_conn = open_cache_db()
|
|
2351
2351
|
try:
|
|
2352
2352
|
stats = sync_cache(cache_conn)
|
|
2353
|
-
ingested = int(stats.
|
|
2353
|
+
ingested = int(stats.rows_changed)
|
|
2354
2354
|
finally:
|
|
2355
2355
|
try:
|
|
2356
2356
|
cache_conn.close()
|
package/bin/_cctally_setup.py
CHANGED
|
@@ -1716,13 +1716,23 @@ def _setup_install(args: argparse.Namespace) -> int:
|
|
|
1716
1716
|
cache_conn = c.open_cache_db()
|
|
1717
1717
|
try:
|
|
1718
1718
|
stats = c.sync_cache(cache_conn)
|
|
1719
|
-
rows = int(stats.
|
|
1719
|
+
rows = int(stats.rows_changed)
|
|
1720
1720
|
finally:
|
|
1721
1721
|
try:
|
|
1722
1722
|
cache_conn.close()
|
|
1723
1723
|
except Exception:
|
|
1724
1724
|
pass
|
|
1725
1725
|
bootstrap_rows = rows
|
|
1726
|
+
# `rows` counts both genuine INSERTs and ccusage-parity DO UPDATE
|
|
1727
|
+
# replacements (see IngestStats.rows_changed). On first install
|
|
1728
|
+
# this is always 0-vs-N pure inserts (cache is empty), so "N new
|
|
1729
|
+
# entries" is exactly accurate. On a re-install / upgrade path
|
|
1730
|
+
# with active sessions, `rows` also counts UPSERT replacements
|
|
1731
|
+
# (streaming-vs-final tiebreaker swaps), so the count is more
|
|
1732
|
+
# accurately "ingest activity" than "rows newly added" — but
|
|
1733
|
+
# we keep "new entries" because (a) it's still a useful signal
|
|
1734
|
+
# to the operator that the cache is alive, and (b) the dominant
|
|
1735
|
+
# case (first install) reads literally.
|
|
1726
1736
|
out.append(f"✓ Synced session cache ({rows} new entries)")
|
|
1727
1737
|
except Exception as exc:
|
|
1728
1738
|
out.append(f"⚠ sync_cache during bootstrap failed: {exc}")
|
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
|
|
@@ -59,14 +64,64 @@ class CodexEntry:
|
|
|
59
64
|
source_path: str
|
|
60
65
|
|
|
61
66
|
|
|
67
|
+
def _entry_token_total(entry: "UsageEntry") -> int:
|
|
68
|
+
"""Sum of the four billed token fields. Mirrors ccusage's
|
|
69
|
+
`usage_token_total` in rust/crates/ccusage/src/claude_loader.rs:516."""
|
|
70
|
+
u = entry.usage
|
|
71
|
+
return (
|
|
72
|
+
int(u.get("input_tokens", 0) or 0)
|
|
73
|
+
+ int(u.get("output_tokens", 0) or 0)
|
|
74
|
+
+ int(u.get("cache_creation_input_tokens", 0) or 0)
|
|
75
|
+
+ int(u.get("cache_read_input_tokens", 0) or 0)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _should_replace(
|
|
80
|
+
candidate: "UsageEntry", existing: "UsageEntry"
|
|
81
|
+
) -> bool:
|
|
82
|
+
"""Port of ccusage's `should_replace_deduped_entry` in
|
|
83
|
+
rust/crates/ccusage/src/claude_loader.rs:531. Higher token total wins;
|
|
84
|
+
on equal totals, the row with `speed` set (non-null) wins (the post-stream
|
|
85
|
+
finalization row carries `speed`; streaming intermediates don't).
|
|
86
|
+
|
|
87
|
+
The `usage.get("speed") is not None` check matches the SQL UPDATE WHERE
|
|
88
|
+
clause's `json_extract(..., '$.speed') IS NOT NULL` in `sync_cache`'s
|
|
89
|
+
INSERT … ON CONFLICT … DO UPDATE, keeping the direct-parse fallback and
|
|
90
|
+
cache-ingest paths in lockstep on the rare-but-possible "explicit JSON
|
|
91
|
+
null" payload.
|
|
92
|
+
"""
|
|
93
|
+
c_total = _entry_token_total(candidate)
|
|
94
|
+
e_total = _entry_token_total(existing)
|
|
95
|
+
if c_total != e_total:
|
|
96
|
+
return c_total > e_total
|
|
97
|
+
return (candidate.usage.get("speed") is not None
|
|
98
|
+
and existing.usage.get("speed") is None)
|
|
99
|
+
|
|
100
|
+
|
|
62
101
|
def _parse_usage_entries(
|
|
63
102
|
jsonl_path: pathlib.Path,
|
|
64
103
|
range_start: dt.datetime,
|
|
65
104
|
range_end: dt.datetime,
|
|
66
|
-
|
|
105
|
+
*,
|
|
106
|
+
dedupe_map: "dict[str, UsageEntry]",
|
|
67
107
|
) -> list[UsageEntry]:
|
|
68
|
-
"""Parse
|
|
69
|
-
|
|
108
|
+
"""Parse one JSONL file's assistant entries within [range_start, range_end].
|
|
109
|
+
|
|
110
|
+
Dedup contract (matches ccusage's `push_deduped_entry`):
|
|
111
|
+
- Entries with non-null (msg_id, req_id) go into `dedupe_map`; if a key
|
|
112
|
+
already maps to an entry, replace iff `_should_replace(candidate, existing)`.
|
|
113
|
+
- Entries with null msg_id or null req_id (rare in modern Claude Code,
|
|
114
|
+
but possible on synthetic / legacy emissions) skip the dedup map and
|
|
115
|
+
land in a separate list — partial UNIQUE index on the cache mirrors
|
|
116
|
+
this behavior.
|
|
117
|
+
- `<synthetic>` model rows are dropped entirely (matches ccusage's
|
|
118
|
+
claude_loader.rs:454).
|
|
119
|
+
|
|
120
|
+
Caller is responsible for sorting the returned list by timestamp if
|
|
121
|
+
needed; `_collect_entries_direct` does this once across all files
|
|
122
|
+
after flattening `dedupe_map.values()`.
|
|
123
|
+
"""
|
|
124
|
+
no_key_entries: list[UsageEntry] = []
|
|
70
125
|
try:
|
|
71
126
|
with open(jsonl_path, "r", encoding="utf-8", errors="replace") as fh:
|
|
72
127
|
for line in fh:
|
|
@@ -96,6 +151,11 @@ def _parse_usage_entries(
|
|
|
96
151
|
model = msg.get("model") or obj.get("model")
|
|
97
152
|
if not isinstance(model, str) or not model.strip():
|
|
98
153
|
continue
|
|
154
|
+
model = model.strip()
|
|
155
|
+
if model == "<synthetic>":
|
|
156
|
+
# Matches ccusage's claude_loader.rs:454 — synthetic
|
|
157
|
+
# placeholder rows carry no billable usage.
|
|
158
|
+
continue
|
|
99
159
|
|
|
100
160
|
try:
|
|
101
161
|
ts = dt.datetime.fromisoformat(
|
|
@@ -109,16 +169,8 @@ def _parse_usage_entries(
|
|
|
109
169
|
if ts < range_start or ts > range_end:
|
|
110
170
|
continue
|
|
111
171
|
|
|
112
|
-
# Deduplicate by message.id + requestId (same as ccusage)
|
|
113
172
|
msg_id = msg.get("id")
|
|
114
173
|
req_id = obj.get("requestId")
|
|
115
|
-
if msg_id is not None and req_id is not None:
|
|
116
|
-
entry_hash = f"{msg_id}:{req_id}"
|
|
117
|
-
if seen_hashes is not None:
|
|
118
|
-
if entry_hash in seen_hashes:
|
|
119
|
-
continue
|
|
120
|
-
seen_hashes.add(entry_hash)
|
|
121
|
-
|
|
122
174
|
cost_usd_raw = obj.get("costUSD")
|
|
123
175
|
cost_usd = (
|
|
124
176
|
float(cost_usd_raw)
|
|
@@ -126,19 +178,30 @@ def _parse_usage_entries(
|
|
|
126
178
|
else None
|
|
127
179
|
)
|
|
128
180
|
|
|
129
|
-
|
|
181
|
+
entry = UsageEntry(
|
|
130
182
|
timestamp=ts,
|
|
131
|
-
model=model
|
|
183
|
+
model=model,
|
|
132
184
|
usage=usage,
|
|
133
185
|
cost_usd=cost_usd,
|
|
134
|
-
|
|
186
|
+
source_path=str(jsonl_path),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
if msg_id is None or req_id is None:
|
|
190
|
+
no_key_entries.append(entry)
|
|
191
|
+
continue
|
|
192
|
+
key = f"{msg_id}:{req_id}"
|
|
193
|
+
existing = dedupe_map.get(key)
|
|
194
|
+
if existing is None or _should_replace(entry, existing):
|
|
195
|
+
dedupe_map[key] = entry
|
|
135
196
|
except OSError as exc:
|
|
136
197
|
_eprint(f"[cost] could not read {jsonl_path}: {exc}")
|
|
137
198
|
|
|
138
|
-
|
|
199
|
+
# The function returns ONLY this file's no-key entries; the caller
|
|
200
|
+
# flattens `dedupe_map.values()` once at the end across all files.
|
|
201
|
+
return no_key_entries
|
|
139
202
|
|
|
140
203
|
|
|
141
|
-
def _iter_jsonl_entries_with_offsets(fh):
|
|
204
|
+
def _iter_jsonl_entries_with_offsets(fh, path_str: str):
|
|
142
205
|
"""Yield (byte_offset, UsageEntry, msg_id, req_id) for each assistant
|
|
143
206
|
entry starting from fh's current position.
|
|
144
207
|
|
|
@@ -185,6 +248,13 @@ def _iter_jsonl_entries_with_offsets(fh):
|
|
|
185
248
|
model = msg.get("model") or obj.get("model")
|
|
186
249
|
if not isinstance(model, str) or not model.strip():
|
|
187
250
|
continue
|
|
251
|
+
model = model.strip()
|
|
252
|
+
if model == "<synthetic>":
|
|
253
|
+
# Matches ccusage's claude_loader.rs:454. Filtered at the
|
|
254
|
+
# iterator level so the cache ingest path can't accidentally
|
|
255
|
+
# store these rows even if a downstream loop forgets to
|
|
256
|
+
# double-check (see `sync_cache` in _cctally_cache.py).
|
|
257
|
+
continue
|
|
188
258
|
|
|
189
259
|
try:
|
|
190
260
|
ts = dt.datetime.fromisoformat(ts_raw.strip().replace("Z", "+00:00"))
|
|
@@ -202,9 +272,10 @@ def _iter_jsonl_entries_with_offsets(fh):
|
|
|
202
272
|
offset,
|
|
203
273
|
UsageEntry(
|
|
204
274
|
timestamp=ts,
|
|
205
|
-
model=model
|
|
275
|
+
model=model,
|
|
206
276
|
usage=usage,
|
|
207
277
|
cost_usd=cost_usd,
|
|
278
|
+
source_path=path_str,
|
|
208
279
|
),
|
|
209
280
|
msg_id,
|
|
210
281
|
req_id,
|
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
|
|