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/CHANGELOG.md +29 -0
- package/bin/_cctally_cache.py +4 -2
- package/bin/_cctally_config.py +55 -9
- package/bin/_cctally_core.py +50 -2
- package/bin/_cctally_db.py +79 -0
- package/bin/_cctally_record.py +7 -1
- package/bin/_cctally_refresh.py +12 -2
- package/bin/_cctally_setup.py +80 -0
- package/bin/_lib_aggregators.py +18 -5
- package/bin/_lib_diff_kernel.py +14 -4
- package/bin/_lib_doctor.py +39 -0
- package/bin/_lib_jsonl.py +8 -1
- package/bin/_lib_render.py +236 -41
- package/bin/_lib_subscription_weeks.py +21 -3
- package/bin/cctally +1319 -90
- package/package.json +1 -1
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(
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
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
|
-
#
|
|
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
|
|
2081
|
-
|
|
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
|
-
#
|
|
2170
|
-
#
|
|
2171
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
2217
|
-
tot_tokens =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
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
|
|
2604
|
-
|
|
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
|
-
#
|
|
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
|