cctally 1.22.3 → 1.22.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,14 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.22.4] - 2026-06-01
9
+
10
+ ### Fixed
11
+ - **A surprise mid-week Anthropic usage reset is now reflected in the 7d percentage even when you were below ~25% usage.** When Anthropic zeroes the weekly counter mid-window (same reset timestamp, usage drops to ~0 — e.g. the 2026-06-01 incident), the reset detector previously only fired when the drop was at least 25 percentage points, so any account reset from a lower base (the observed 14% → 0%) slipped through: no `week_reset_events` row was written, the monotonic high-water-mark clamp kept reporting the stale pre-reset percentage across the statusline, `weekly`/`report`, and the dashboard, and post-reset 0% reads were silently dropped at the write site. The detector now ALSO fires on a reset-to-zero — when the post-reset value collapses to ~0 (≤ 1%) with at least a 3pp drop — independently of the 25pp magnitude gate, since a clean drop to zero is an unambiguous reset regardless of size (a lagging API replica reports a slightly-lower number, never a clean 0 against real usage), while the 3pp floor rejects 1%→0% replica jitter that would otherwise spuriously segment the week. The new discriminator is a single shared `_is_reset_drop()` helper wired into all four 7d detection sites (live + backfill, boundary-advance + in-place-credit branches) so they stay byte-identical; the 25pp partial-credit path and the separate 5h detector are unchanged. Regression: three new cases in `tests/test_in_place_credit_detection.py` (live reset-to-zero fires below 25pp, the 3pp floor rejects 1%→0% jitter, and backfill parity).
12
+
13
+ ### Changed
14
+ - **Internal refactor (no user-facing change): extracted the shared formatting/color/table render primitives — the boxed-table renderer, the color/unicode capability detectors, the ANSI styling + display-width helpers, the integer/width-budget number formatters, the compact timestamp/week-window formatters, and the `_ANSI_ESC_RE` regex (plus the small `_parse_iso_datetime_optional` helper they depend on) — out of the `bin/cctally` single-file program into a new stdlib-only pure-function sibling module, `bin/_lib_fmt.py` (#126, the final tranche of the `bin/cctally` split).** This trims the main script by ~250 lines (2,969 → 2,715), and additionally converts the eight back-references these primitives had in `bin/_lib_render.py` and `bin/_lib_diff_kernel.py` from `cctally`-namespace shims into honest direct imports of the new kernel — with no change to any command's behavior, flags, output, or exit codes: every reporting/`diff`/`project`/`cache-report`/`forecast`/`blocks`/`percent-breakdown`/`five-hour-blocks` golden test is byte-identical and the full harness + pytest suite (1,329 checks) passes, a new `tests/test_lib_fmt_extraction_invariants.py` locks the extraction's import discipline, and the new module ships in the npm/brew/public packages (promoted to the mirror allowlist). Purely a maintainability / code-organization change; nothing to do on upgrade.
15
+
8
16
  ## [1.22.3] - 2026-06-01
9
17
 
10
18
  ### Changed
@@ -384,6 +384,7 @@ _logged_window_key_coerce_failure = False
384
384
  # _cctally_core.HOOK_TICK_THROTTLE_PATH / _LOCK_PATH
385
385
  # c._FIVE_HOUR_JITTER_FLOOR_SECONDS — _lib_five_hour.* re-export
386
386
  # c._RESET_PCT_DROP_THRESHOLD — bin/_cctally_weekrefs.py constant (re-exported on cctally ns)
387
+ # c._is_reset_drop — bin/_cctally_weekrefs.py helper (re-exported on cctally ns)
387
388
  # c.HOOK_TICK_DEFAULT_THROTTLE_SECONDS
388
389
 
389
390
 
@@ -1639,7 +1640,7 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
1639
1640
  if (
1640
1641
  prior_end_dt > now_utc
1641
1642
  and prior_pct is not None
1642
- and (float(prior_pct) - float(weekly_percent)) >= c._RESET_PCT_DROP_THRESHOLD
1643
+ and c._is_reset_drop(prior_pct, weekly_percent)
1643
1644
  ):
1644
1645
  # See _backfill_week_reset_events for why we floor
1645
1646
  # the reset moment to the hour (natural display
@@ -1667,7 +1668,7 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
1667
1668
  if (
1668
1669
  prior_end_dt > now_utc
1669
1670
  and prior_pct is not None
1670
- and (float(prior_pct) - float(weekly_percent)) >= c._RESET_PCT_DROP_THRESHOLD
1671
+ and c._is_reset_drop(prior_pct, weekly_percent)
1671
1672
  ):
1672
1673
  # Pre-check (Q5 belt-and-suspenders): suppress duplicate
1673
1674
  # event rows for the same new_week_end_at across
@@ -278,7 +278,7 @@ def _backfill_week_reset_events(conn: sqlite3.Connection) -> None:
278
278
  if (
279
279
  captured_dt < prior_end_dt
280
280
  and prior_pct is not None and cur_pct is not None
281
- and (float(prior_pct) - float(cur_pct)) >= _RESET_PCT_DROP_THRESHOLD
281
+ and _is_reset_drop(prior_pct, cur_pct)
282
282
  ):
283
283
  # Floor to the hour so the display boundary lands on the
284
284
  # natural hour mark (Anthropic's reset times are always
@@ -309,7 +309,7 @@ def _backfill_week_reset_events(conn: sqlite3.Connection) -> None:
309
309
  if (
310
310
  captured_dt < prior_end_dt
311
311
  and prior_pct is not None and cur_pct is not None
312
- and (float(prior_pct) - float(cur_pct)) >= _RESET_PCT_DROP_THRESHOLD
312
+ and _is_reset_drop(prior_pct, cur_pct)
313
313
  ):
314
314
  # Pre-check on ``new_week_end_at`` (mirrors the live
315
315
  # detection path's pre-check). Necessary because the
@@ -379,6 +379,40 @@ _RESET_PCT_DROP_THRESHOLD = 25.0
379
379
  # §2.1 (Q1) for rationale.
380
380
  _FIVE_HOUR_RESET_PCT_DROP_THRESHOLD = 5.0
381
381
 
382
+ # Reset-to-zero discriminator (2026-06-01 surprise-reset fix). Anthropic's
383
+ # weekly reset zeroes the counter mid-window, but the 25pp magnitude gate
384
+ # above silently masks it for any user below ~25% usage (e.g. the observed
385
+ # 14→0). A reset-to-zero is unambiguous REGARDLESS of magnitude: a lagging
386
+ # API replica reports a slightly-lower number, never a clean 0 against real
387
+ # usage. So the detector ALSO fires when the post value collapses to ~0
388
+ # (<= _RESET_ZERO_FLOOR_PCT) with a drop clearing a small min-drop floor.
389
+ # The floor rejects 1%→0% stale-replica jitter, which would otherwise write
390
+ # a spurious week_reset_events row and segment the week.
391
+ _RESET_ZERO_FLOOR_PCT = 1.0
392
+ _RESET_ZERO_MIN_DROP_PCT = 3.0
393
+
394
+
395
+ def _is_reset_drop(prior_pct: float, cur_pct: float) -> bool:
396
+ """True when ``prior_pct → cur_pct`` is a genuine weekly reset/credit.
397
+
398
+ Two independent percent-shape signals (OR):
399
+
400
+ * **Partial credit** — drop ``>= _RESET_PCT_DROP_THRESHOLD`` (25pp).
401
+ * **Reset-to-zero** — ``cur_pct`` collapses to ~0
402
+ (``<= _RESET_ZERO_FLOOR_PCT``) with a drop clearing
403
+ ``_RESET_ZERO_MIN_DROP_PCT``.
404
+
405
+ Callers retain the boundary predicates (same/advanced ``week_end_at``
406
+ AND ``prior_end_dt > now``); this helper owns ONLY the percent-shape
407
+ discrimination so all four 7d detection sites (live advance, live
408
+ in-place, backfill advance, backfill in-place) stay byte-identical.
409
+ """
410
+ cur = float(cur_pct)
411
+ drop = float(prior_pct) - cur
412
+ if drop >= _RESET_PCT_DROP_THRESHOLD:
413
+ return True
414
+ return cur <= _RESET_ZERO_FLOOR_PCT and drop >= _RESET_ZERO_MIN_DROP_PCT
415
+
382
416
 
383
417
  def _week_ref_has_reset_event(
384
418
  conn: sqlite3.Connection, ref: WeekRef
@@ -115,6 +115,11 @@ _lib_display_tz = _load_lib("_lib_display_tz")
115
115
  _resolve_tz = _lib_display_tz._resolve_tz
116
116
  format_display_dt = _lib_display_tz.format_display_dt
117
117
 
118
+ # fmt/color/table primitives honest-imported from _lib_fmt (#126 C11)
119
+ _lib_fmt = _load_lib("_lib_fmt")
120
+ _style_ansi = _lib_fmt._style_ansi
121
+ _supports_unicode_stdout = _lib_fmt._supports_unicode_stdout
122
+
118
123
 
119
124
  # === Honest imports from extracted homes ===================================
120
125
  # Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3: kernel symbols
@@ -147,14 +152,6 @@ def _iso_z(*args, **kwargs):
147
152
  return sys.modules["cctally"]._iso_z(*args, **kwargs)
148
153
 
149
154
 
150
- def _supports_unicode_stdout(*args, **kwargs):
151
- return sys.modules["cctally"]._supports_unicode_stdout(*args, **kwargs)
152
-
153
-
154
- def _style_ansi(*args, **kwargs):
155
- return sys.modules["cctally"]._style_ansi(*args, **kwargs)
156
-
157
-
158
155
  # Private eprint shim per spec §5.3 (pure layer does not back-import
159
156
  # cctally for ubiquitous helpers; eprint isn't actually called by the
160
157
  # moved code, but kept here as the canonical pure-layer pattern so
@@ -0,0 +1,325 @@
1
+ """Shared fmt / color / table render primitives (pure-fn kernel).
2
+
3
+ Holds the low-level formatting primitives extracted from bin/cctally
4
+ (#126, C11): timestamp/week-window compaction, color/unicode capability
5
+ detection, ANSI styling, display-width-aware boxed tables, number
6
+ formatting + width-budget truncation. Pure: the only environment reads
7
+ (os.environ, sys.stdout.isatty(), sys.stdout.encoding) happen INSIDE the
8
+ functions at call time, never at import.
9
+
10
+ Imported honestly by _lib_render.py and _lib_diff_kernel.py (via their
11
+ _load_lib helpers); re-exported on the bin/cctally namespace for the
12
+ _cctally_* command siblings and _lib_view_models.py (c.X accessor) and the
13
+ test monkeypatch surfaces.
14
+
15
+ Spec: docs/superpowers/specs/2026-06-01-extract-fmt-color-table-primitives-design.md
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import datetime as dt
20
+ import os
21
+ import pathlib
22
+ import re
23
+ import sys
24
+ import unicodedata
25
+ from typing import Any
26
+
27
+ # parse_iso_datetime: bare import matches the established _lib_* convention
28
+ # (_lib_aggregators.py:86, _lib_diff_kernel.py:123 do the same).
29
+ from _cctally_core import parse_iso_datetime
30
+
31
+
32
+ # format_display_dt: loaded via the file-path _load_lib helper, NOT a bare
33
+ # `from _lib_display_tz import …`. The repo's sibling loading deliberately
34
+ # bypasses sys.path (test/loader contexts may lack bin/ on the path); every
35
+ # _lib_* consumer of _lib_display_tz uses _load_lib (_lib_render.py:100,
36
+ # _lib_diff_kernel.py:114, _lib_aggregators.py:75). _lib_fmt matches them.
37
+ def _load_lib(name: str):
38
+ cached = sys.modules.get(name)
39
+ if cached is not None:
40
+ return cached
41
+ import importlib.util as _ilu
42
+ p = pathlib.Path(__file__).resolve().parent / f"{name}.py"
43
+ spec = _ilu.spec_from_file_location(name, p)
44
+ mod = _ilu.module_from_spec(spec)
45
+ sys.modules[name] = mod
46
+ spec.loader.exec_module(mod)
47
+ return mod
48
+
49
+
50
+ _lib_display_tz = _load_lib("_lib_display_tz")
51
+ format_display_dt = _lib_display_tz.format_display_dt
52
+
53
+
54
+ def _parse_iso_datetime_optional(value: Any) -> dt.datetime | None:
55
+ if not isinstance(value, str) or not value.strip():
56
+ return None
57
+ try:
58
+ return parse_iso_datetime(value, "timestamp")
59
+ except ValueError:
60
+ return None
61
+
62
+
63
+ def _format_ts_compact(
64
+ value: str | None,
65
+ tz: "ZoneInfo | None" = None,
66
+ ) -> str:
67
+ """Compact ISO-instant -> "YYYY-MM-DD HH:MM" line.
68
+
69
+ F5 fix: optional ``tz`` localizes the parsed instant before strftime
70
+ AND appends the offset suffix via ``display_tz_label`` so the column
71
+ becomes unambiguous (mirrors ``format_display_dt``'s pattern). When
72
+ ``tz`` is None, returns the legacy UTC-clock string with no suffix
73
+ so existing callers keep their byte-stable output.
74
+ """
75
+ parsed = _parse_iso_datetime_optional(value)
76
+ if parsed is None:
77
+ return "n/a"
78
+ if tz is None:
79
+ # Host-local fallback / default-config path: preserve the original
80
+ # byte-stable host-naive strftime output (UTC-aware datetimes render
81
+ # UTC clock, no suffix). This branch is reachable in production for
82
+ # users whose ``display.tz`` resolves to None — NOT a legacy or
83
+ # back-compat path. The non-None branch routes through
84
+ # ``format_display_dt`` for tz-aware rendering.
85
+ return parsed.strftime("%Y-%m-%d %H:%M")
86
+ return format_display_dt(parsed, tz, fmt="%Y-%m-%d %H:%M", suffix=True)
87
+
88
+
89
+ def _format_week_window(
90
+ week_start_date: str | None,
91
+ week_end_date: str | None,
92
+ week_start_at: str | None,
93
+ week_end_at: str | None,
94
+ tz: "ZoneInfo | None" = None,
95
+ ) -> str:
96
+ """Render a "<start> -> <end>" week-window column. F5 adds tz-aware
97
+ rendering for ISO-timestamp-bearing rows; legacy date-only rows pass
98
+ through unchanged. ``tz=None`` preserves byte-stable callers."""
99
+ if week_start_at and week_end_at:
100
+ return (
101
+ f"{_format_ts_compact(week_start_at, tz=tz)} -> "
102
+ f"{_format_ts_compact(week_end_at, tz=tz)}"
103
+ )
104
+ return f"{week_start_date or 'n/a'} -> {week_end_date or 'n/a'}"
105
+
106
+
107
+ def _supports_color_stdout() -> bool:
108
+ # Matches ccusage's picocolors behavior exactly.
109
+ # FORCE_COLOR always enables (any value, including empty)
110
+ if "FORCE_COLOR" in os.environ:
111
+ return True
112
+ # NO_COLOR always disables (any value, including empty)
113
+ if "NO_COLOR" in os.environ:
114
+ return False
115
+ # CI environments get color
116
+ if "CI" in os.environ:
117
+ return True
118
+ # TTY check on stdout or stderr
119
+ if sys.stdout.isatty() or sys.stderr.isatty():
120
+ term = os.environ.get("TERM", "")
121
+ return term.lower() != "dumb"
122
+ return False
123
+
124
+
125
+ def _style_ansi(text: str, code: str, enabled: bool) -> str:
126
+ if not enabled:
127
+ return text
128
+ return f"\033[{code}m{text}\033[0m"
129
+
130
+
131
+ def _supports_unicode_stdout() -> bool:
132
+ encoding = (sys.stdout.encoding or "").upper()
133
+ return "UTF" in encoding
134
+
135
+
136
+ def _display_width(s: str) -> int:
137
+ """Terminal cells consumed by ``s``.
138
+
139
+ Counts each codepoint by its East Asian Width: ``W`` / ``F`` (Wide
140
+ / Fullwidth) → 2 cells; combining marks → 0; everything else → 1.
141
+ Ambiguous (``A``) defaults to 1, matching every non-CJK terminal
142
+ locale — cctally has no CJK content in cell data, and `→` / `—` /
143
+ `·` (all `A`) are intentionally rendered narrow.
144
+
145
+ Used by `_boxed_table` so cells containing wide glyphs (notably
146
+ `⚡` U+26A1 on credit-row annotations) pad to the right cell count
147
+ rather than the right codepoint count. Without this, `len()`-based
148
+ padding under-pads by one cell per wide glyph and the right border
149
+ drifts off-column on those rows only.
150
+ """
151
+ width = 0
152
+ for ch in s:
153
+ if unicodedata.combining(ch):
154
+ continue
155
+ width += 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
156
+ return width
157
+
158
+
159
+ def _boxed_table(
160
+ headers: list[str],
161
+ rows: list[list[str]],
162
+ aligns: list[str] | None = None,
163
+ *,
164
+ color_header: bool = True,
165
+ compact: bool = False,
166
+ ) -> str:
167
+ if not headers:
168
+ return ""
169
+ col_count = len(headers)
170
+ aligns = aligns or ["left"] * col_count
171
+ if len(aligns) != col_count:
172
+ raise ValueError("aligns length must match headers length")
173
+
174
+ sanitized_rows: list[list[str]] = []
175
+ for row in rows:
176
+ normalized = [str(cell).replace("\n", " ") for cell in row]
177
+ if len(normalized) != col_count:
178
+ raise ValueError("row length must match headers length")
179
+ sanitized_rows.append(normalized)
180
+
181
+ widths: list[int] = []
182
+ for idx, header in enumerate(headers):
183
+ max_cell = max((_display_width(r[idx]) for r in sanitized_rows), default=0)
184
+ widths.append(max(_display_width(header), max_cell))
185
+
186
+ def _pad(text: str, width: int, align: str) -> str:
187
+ deficit = width - _display_width(text)
188
+ if deficit <= 0:
189
+ return text
190
+ pad = " " * deficit
191
+ if align == "right":
192
+ return pad + text
193
+ if align == "center":
194
+ left = deficit // 2
195
+ return (" " * left) + text + (" " * (deficit - left))
196
+ return text + pad
197
+
198
+ if _supports_unicode_stdout():
199
+ chars = {
200
+ "top_left": "┌",
201
+ "top_mid": "┬",
202
+ "top_right": "┐",
203
+ "mid_left": "├",
204
+ "mid_mid": "┼",
205
+ "mid_right": "┤",
206
+ "bottom_left": "└",
207
+ "bottom_mid": "┴",
208
+ "bottom_right": "┘",
209
+ "h": "─",
210
+ "v": "│",
211
+ }
212
+ else:
213
+ chars = {
214
+ "top_left": "+",
215
+ "top_mid": "+",
216
+ "top_right": "+",
217
+ "mid_left": "+",
218
+ "mid_mid": "+",
219
+ "mid_right": "+",
220
+ "bottom_left": "+",
221
+ "bottom_mid": "+",
222
+ "bottom_right": "+",
223
+ "h": "-",
224
+ "v": "|",
225
+ }
226
+
227
+ color_enabled = _supports_color_stdout()
228
+
229
+ def _dim(s: str) -> str:
230
+ return _style_ansi(s, "90", color_enabled)
231
+
232
+ # Issue #91 (Shape B): ``compact`` drops the 1-space cell padding to
233
+ # 0 on this content-sized table (which has no proportional-width path
234
+ # to force). Borders and rows both key off ``pad`` so the default
235
+ # (``pad == 1``) reproduces the prior output byte-for-byte.
236
+ pad = 0 if compact else 1
237
+ pad_s = " " * pad
238
+
239
+ def make_border(left: str, mid: str, right: str) -> str:
240
+ return _dim(
241
+ left
242
+ + mid.join(chars["h"] * (w + 2 * pad) for w in widths)
243
+ + right
244
+ )
245
+
246
+ def make_row(cells: list[str], *, header: bool = False) -> str:
247
+ is_total = not header and cells and cells[0].strip() == "Total"
248
+ styled_cells: list[str] = []
249
+ for i, raw in enumerate(cells):
250
+ text = _pad(raw, widths[i], aligns[i])
251
+ if header and color_header:
252
+ text = _style_ansi(text, "36", color_enabled) # cyan text, like ccusage table head
253
+ elif is_total:
254
+ text = _style_ansi(text, "32", color_enabled) # green text for totals
255
+ styled_cells.append(text)
256
+ v = _dim(chars["v"])
257
+ return (
258
+ v
259
+ + pad_s
260
+ + f"{pad_s}{v}{pad_s}".join(styled_cells)
261
+ + pad_s
262
+ + v
263
+ )
264
+
265
+ top = make_border(chars["top_left"], chars["top_mid"], chars["top_right"])
266
+ mid = make_border(chars["mid_left"], chars["mid_mid"], chars["mid_right"])
267
+ bottom = make_border(chars["bottom_left"], chars["bottom_mid"], chars["bottom_right"])
268
+
269
+ out_lines = [top, make_row(headers, header=True), mid]
270
+ for idx, row in enumerate(sanitized_rows):
271
+ out_lines.append(make_row(row, header=False))
272
+ if idx < len(sanitized_rows) - 1:
273
+ out_lines.append(mid)
274
+ out_lines.append(bottom)
275
+ return "\n".join(out_lines)
276
+
277
+
278
+ def _fmt_num(n: int) -> str:
279
+ """Format integer with comma separators: 1234567 -> '1,234,567'."""
280
+ return f"{n:,}"
281
+
282
+
283
+ def _truncate_num(formatted: str, width: int) -> str:
284
+ """Truncate a formatted number to fit width, replacing tail with '…'."""
285
+ if len(formatted) <= width:
286
+ return formatted
287
+ return formatted[: width - 1] + "\u2026"
288
+
289
+
290
+ _ANSI_ESC_RE = re.compile(r"\033\[[0-9;]*m")
291
+
292
+
293
+ def _truncate_display(text: str, width: int) -> str:
294
+ """Truncate to `width` visible chars, preserving ANSI escape sequences.
295
+
296
+ Unlike `_truncate_num`, which slices raw string indices, this walks
297
+ the text treating `\\033[...m` sequences as zero-width and counts
298
+ printable chars toward the width budget. Used for left-aligned
299
+ cells that may carry a styled anomaly-glyph prefix — slicing those
300
+ with `_truncate_num` can cut through an ANSI escape and bleed
301
+ color into adjacent cells.
302
+ """
303
+ # Fast path: no ANSI codes, fall back to raw-slice truncation.
304
+ if "\033" not in text:
305
+ return _truncate_num(text, width)
306
+ stripped_len = len(_ANSI_ESC_RE.sub("", text))
307
+ if stripped_len <= width:
308
+ return text
309
+ # Walk chars until we've emitted (width - 1) visible chars, copying
310
+ # ANSI sequences verbatim. Append reset + ellipsis to close any open
311
+ # style and preserve the fit-to-width contract.
312
+ out: list[str] = []
313
+ visible = 0
314
+ i = 0
315
+ target = width - 1
316
+ while i < len(text) and visible < target:
317
+ m = _ANSI_ESC_RE.match(text, i)
318
+ if m:
319
+ out.append(m.group(0))
320
+ i = m.end()
321
+ continue
322
+ out.append(text[i])
323
+ visible += 1
324
+ i += 1
325
+ return "".join(out) + "\033[0m\u2026"
@@ -100,36 +100,21 @@ _short_model_name = _lib_pricing._short_model_name
100
100
  _lib_display_tz = _load_lib("_lib_display_tz")
101
101
  _resolve_tz = _lib_display_tz._resolve_tz
102
102
 
103
+ # fmt/color/table primitives honest-imported from _lib_fmt (#126 C11)
104
+ _lib_fmt = _load_lib("_lib_fmt")
105
+ _supports_color_stdout = _lib_fmt._supports_color_stdout
106
+ _supports_unicode_stdout = _lib_fmt._supports_unicode_stdout
107
+ _style_ansi = _lib_fmt._style_ansi
108
+ _fmt_num = _lib_fmt._fmt_num
109
+ _truncate_num = _lib_fmt._truncate_num
110
+ _boxed_table = _lib_fmt._boxed_table
111
+
103
112
 
104
113
  # Module-level back-ref shims. Each shim resolves
105
114
  # ``sys.modules['cctally'].X`` at CALL TIME (not bind time), so
106
115
  # monkeypatches on cctally's namespace propagate into the moved code
107
116
  # unchanged. Mirrors the precedent established in
108
117
  # ``bin/_cctally_record.py`` / ``bin/_cctally_cache.py``.
109
- def _supports_color_stdout(*args, **kwargs):
110
- return sys.modules["cctally"]._supports_color_stdout(*args, **kwargs)
111
-
112
-
113
- def _supports_unicode_stdout(*args, **kwargs):
114
- return sys.modules["cctally"]._supports_unicode_stdout(*args, **kwargs)
115
-
116
-
117
- def _style_ansi(*args, **kwargs):
118
- return sys.modules["cctally"]._style_ansi(*args, **kwargs)
119
-
120
-
121
- def _fmt_num(*args, **kwargs):
122
- return sys.modules["cctally"]._fmt_num(*args, **kwargs)
123
-
124
-
125
- def _truncate_num(*args, **kwargs):
126
- return sys.modules["cctally"]._truncate_num(*args, **kwargs)
127
-
128
-
129
- def _boxed_table(*args, **kwargs):
130
- return sys.modules["cctally"]._boxed_table(*args, **kwargs)
131
-
132
-
133
118
  def _format_block_start(*args, **kwargs):
134
119
  return sys.modules["cctally"]._format_block_start(*args, **kwargs)
135
120
 
package/bin/cctally CHANGED
@@ -66,7 +66,6 @@ import textwrap
66
66
  import threading
67
67
  import time
68
68
  import traceback
69
- import unicodedata
70
69
  import urllib.error
71
70
  import urllib.request
72
71
  from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
@@ -329,6 +328,22 @@ format_display_dt = _lib_display_tz.format_display_dt
329
328
  _argparse_tz = _lib_display_tz._argparse_tz
330
329
 
331
330
 
331
+ # fmt/color/table primitives moved to _lib_fmt.py (#126 C11)
332
+ _lib_fmt = _load_sibling("_lib_fmt")
333
+ _parse_iso_datetime_optional = _lib_fmt._parse_iso_datetime_optional
334
+ _format_ts_compact = _lib_fmt._format_ts_compact
335
+ _format_week_window = _lib_fmt._format_week_window
336
+ _supports_color_stdout = _lib_fmt._supports_color_stdout
337
+ _style_ansi = _lib_fmt._style_ansi
338
+ _supports_unicode_stdout = _lib_fmt._supports_unicode_stdout
339
+ _display_width = _lib_fmt._display_width
340
+ _boxed_table = _lib_fmt._boxed_table
341
+ _fmt_num = _lib_fmt._fmt_num
342
+ _truncate_num = _lib_fmt._truncate_num
343
+ _ANSI_ESC_RE = _lib_fmt._ANSI_ESC_RE
344
+ _truncate_display = _lib_fmt._truncate_display
345
+
346
+
332
347
  _cctally_parser = _load_sibling("_cctally_parser")
333
348
  build_parser = _cctally_parser.build_parser
334
349
  _nonneg_int = _cctally_parser._nonneg_int
@@ -1920,75 +1935,8 @@ ORIGINAL_ENTRYPOINT: "str | None" = None
1920
1935
  _UPDATE_WORKER: "UpdateWorker | None" = None
1921
1936
 
1922
1937
 
1923
- def _parse_iso_datetime_optional(value: Any) -> dt.datetime | None:
1924
- if not isinstance(value, str) or not value.strip():
1925
- return None
1926
- try:
1927
- return parse_iso_datetime(value, "timestamp")
1928
- except ValueError:
1929
- return None
1930
-
1931
-
1932
- def _format_ts_compact(
1933
- value: str | None,
1934
- tz: "ZoneInfo | None" = None,
1935
- ) -> str:
1936
- """Compact ISO-instant -> "YYYY-MM-DD HH:MM" line.
1937
-
1938
- F5 fix: optional ``tz`` localizes the parsed instant before strftime
1939
- AND appends the offset suffix via ``display_tz_label`` so the column
1940
- becomes unambiguous (mirrors ``format_display_dt``'s pattern). When
1941
- ``tz`` is None, returns the legacy UTC-clock string with no suffix
1942
- so existing callers keep their byte-stable output.
1943
- """
1944
- parsed = _parse_iso_datetime_optional(value)
1945
- if parsed is None:
1946
- return "n/a"
1947
- if tz is None:
1948
- # Host-local fallback / default-config path: preserve the original
1949
- # byte-stable host-naive strftime output (UTC-aware datetimes render
1950
- # UTC clock, no suffix). This branch is reachable in production for
1951
- # users whose ``display.tz`` resolves to None — NOT a legacy or
1952
- # back-compat path. The non-None branch routes through
1953
- # ``format_display_dt`` for tz-aware rendering.
1954
- return parsed.strftime("%Y-%m-%d %H:%M")
1955
- return format_display_dt(parsed, tz, fmt="%Y-%m-%d %H:%M", suffix=True)
1956
-
1957
-
1958
- def _format_week_window(
1959
- week_start_date: str | None,
1960
- week_end_date: str | None,
1961
- week_start_at: str | None,
1962
- week_end_at: str | None,
1963
- tz: "ZoneInfo | None" = None,
1964
- ) -> str:
1965
- """Render a "<start> -> <end>" week-window column. F5 adds tz-aware
1966
- rendering for ISO-timestamp-bearing rows; legacy date-only rows pass
1967
- through unchanged. ``tz=None`` preserves byte-stable callers."""
1968
- if week_start_at and week_end_at:
1969
- return (
1970
- f"{_format_ts_compact(week_start_at, tz=tz)} -> "
1971
- f"{_format_ts_compact(week_end_at, tz=tz)}"
1972
- )
1973
- return f"{week_start_date or 'n/a'} -> {week_end_date or 'n/a'}"
1974
-
1975
-
1976
- def _supports_color_stdout() -> bool:
1977
- # Matches ccusage's picocolors behavior exactly.
1978
- # FORCE_COLOR always enables (any value, including empty)
1979
- if "FORCE_COLOR" in os.environ:
1980
- return True
1981
- # NO_COLOR always disables (any value, including empty)
1982
- if "NO_COLOR" in os.environ:
1983
- return False
1984
- # CI environments get color
1985
- if "CI" in os.environ:
1986
- return True
1987
- # TTY check on stdout or stderr
1988
- if sys.stdout.isatty() or sys.stderr.isatty():
1989
- term = os.environ.get("TERM", "")
1990
- return term.lower() != "dumb"
1991
- return False
1938
+ # fmt/color/table primitives + _parse_iso_datetime_optional now live in
1939
+ # _lib_fmt.py (re-exported above) #126 C11.
1992
1940
 
1993
1941
 
1994
1942
  def _resolve_color_enabled(args: argparse.Namespace) -> bool:
@@ -2049,209 +1997,6 @@ def _resolve_color_enabled(args: argparse.Namespace) -> bool:
2049
1997
  return False
2050
1998
 
2051
1999
 
2052
- def _style_ansi(text: str, code: str, enabled: bool) -> str:
2053
- if not enabled:
2054
- return text
2055
- return f"\033[{code}m{text}\033[0m"
2056
-
2057
-
2058
- def _supports_unicode_stdout() -> bool:
2059
- encoding = (sys.stdout.encoding or "").upper()
2060
- return "UTF" in encoding
2061
-
2062
-
2063
- def _display_width(s: str) -> int:
2064
- """Terminal cells consumed by ``s``.
2065
-
2066
- Counts each codepoint by its East Asian Width: ``W`` / ``F`` (Wide
2067
- / Fullwidth) → 2 cells; combining marks → 0; everything else → 1.
2068
- Ambiguous (``A``) defaults to 1, matching every non-CJK terminal
2069
- locale — cctally has no CJK content in cell data, and `→` / `—` /
2070
- `·` (all `A`) are intentionally rendered narrow.
2071
-
2072
- Used by `_boxed_table` so cells containing wide glyphs (notably
2073
- `⚡` U+26A1 on credit-row annotations) pad to the right cell count
2074
- rather than the right codepoint count. Without this, `len()`-based
2075
- padding under-pads by one cell per wide glyph and the right border
2076
- drifts off-column on those rows only.
2077
- """
2078
- width = 0
2079
- for ch in s:
2080
- if unicodedata.combining(ch):
2081
- continue
2082
- width += 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
2083
- return width
2084
-
2085
-
2086
- def _boxed_table(
2087
- headers: list[str],
2088
- rows: list[list[str]],
2089
- aligns: list[str] | None = None,
2090
- *,
2091
- color_header: bool = True,
2092
- compact: bool = False,
2093
- ) -> str:
2094
- if not headers:
2095
- return ""
2096
- col_count = len(headers)
2097
- aligns = aligns or ["left"] * col_count
2098
- if len(aligns) != col_count:
2099
- raise ValueError("aligns length must match headers length")
2100
-
2101
- sanitized_rows: list[list[str]] = []
2102
- for row in rows:
2103
- normalized = [str(cell).replace("\n", " ") for cell in row]
2104
- if len(normalized) != col_count:
2105
- raise ValueError("row length must match headers length")
2106
- sanitized_rows.append(normalized)
2107
-
2108
- widths: list[int] = []
2109
- for idx, header in enumerate(headers):
2110
- max_cell = max((_display_width(r[idx]) for r in sanitized_rows), default=0)
2111
- widths.append(max(_display_width(header), max_cell))
2112
-
2113
- def _pad(text: str, width: int, align: str) -> str:
2114
- deficit = width - _display_width(text)
2115
- if deficit <= 0:
2116
- return text
2117
- pad = " " * deficit
2118
- if align == "right":
2119
- return pad + text
2120
- if align == "center":
2121
- left = deficit // 2
2122
- return (" " * left) + text + (" " * (deficit - left))
2123
- return text + pad
2124
-
2125
- if _supports_unicode_stdout():
2126
- chars = {
2127
- "top_left": "┌",
2128
- "top_mid": "┬",
2129
- "top_right": "┐",
2130
- "mid_left": "├",
2131
- "mid_mid": "┼",
2132
- "mid_right": "┤",
2133
- "bottom_left": "└",
2134
- "bottom_mid": "┴",
2135
- "bottom_right": "┘",
2136
- "h": "─",
2137
- "v": "│",
2138
- }
2139
- else:
2140
- chars = {
2141
- "top_left": "+",
2142
- "top_mid": "+",
2143
- "top_right": "+",
2144
- "mid_left": "+",
2145
- "mid_mid": "+",
2146
- "mid_right": "+",
2147
- "bottom_left": "+",
2148
- "bottom_mid": "+",
2149
- "bottom_right": "+",
2150
- "h": "-",
2151
- "v": "|",
2152
- }
2153
-
2154
- color_enabled = _supports_color_stdout()
2155
-
2156
- def _dim(s: str) -> str:
2157
- return _style_ansi(s, "90", color_enabled)
2158
-
2159
- # Issue #91 (Shape B): ``compact`` drops the 1-space cell padding to
2160
- # 0 on this content-sized table (which has no proportional-width path
2161
- # to force). Borders and rows both key off ``pad`` so the default
2162
- # (``pad == 1``) reproduces the prior output byte-for-byte.
2163
- pad = 0 if compact else 1
2164
- pad_s = " " * pad
2165
-
2166
- def make_border(left: str, mid: str, right: str) -> str:
2167
- return _dim(
2168
- left
2169
- + mid.join(chars["h"] * (w + 2 * pad) for w in widths)
2170
- + right
2171
- )
2172
-
2173
- def make_row(cells: list[str], *, header: bool = False) -> str:
2174
- is_total = not header and cells and cells[0].strip() == "Total"
2175
- styled_cells: list[str] = []
2176
- for i, raw in enumerate(cells):
2177
- text = _pad(raw, widths[i], aligns[i])
2178
- if header and color_header:
2179
- text = _style_ansi(text, "36", color_enabled) # cyan text, like ccusage table head
2180
- elif is_total:
2181
- text = _style_ansi(text, "32", color_enabled) # green text for totals
2182
- styled_cells.append(text)
2183
- v = _dim(chars["v"])
2184
- return (
2185
- v
2186
- + pad_s
2187
- + f"{pad_s}{v}{pad_s}".join(styled_cells)
2188
- + pad_s
2189
- + v
2190
- )
2191
-
2192
- top = make_border(chars["top_left"], chars["top_mid"], chars["top_right"])
2193
- mid = make_border(chars["mid_left"], chars["mid_mid"], chars["mid_right"])
2194
- bottom = make_border(chars["bottom_left"], chars["bottom_mid"], chars["bottom_right"])
2195
-
2196
- out_lines = [top, make_row(headers, header=True), mid]
2197
- for idx, row in enumerate(sanitized_rows):
2198
- out_lines.append(make_row(row, header=False))
2199
- if idx < len(sanitized_rows) - 1:
2200
- out_lines.append(mid)
2201
- out_lines.append(bottom)
2202
- return "\n".join(out_lines)
2203
-
2204
-
2205
- def _fmt_num(n: int) -> str:
2206
- """Format integer with comma separators: 1234567 -> '1,234,567'."""
2207
- return f"{n:,}"
2208
-
2209
-
2210
- def _truncate_num(formatted: str, width: int) -> str:
2211
- """Truncate a formatted number to fit width, replacing tail with '…'."""
2212
- if len(formatted) <= width:
2213
- return formatted
2214
- return formatted[: width - 1] + "\u2026"
2215
-
2216
-
2217
- _ANSI_ESC_RE = re.compile(r"\033\[[0-9;]*m")
2218
-
2219
-
2220
- def _truncate_display(text: str, width: int) -> str:
2221
- """Truncate to `width` visible chars, preserving ANSI escape sequences.
2222
-
2223
- Unlike `_truncate_num`, which slices raw string indices, this walks
2224
- the text treating `\\033[...m` sequences as zero-width and counts
2225
- printable chars toward the width budget. Used for left-aligned
2226
- cells that may carry a styled anomaly-glyph prefix — slicing those
2227
- with `_truncate_num` can cut through an ANSI escape and bleed
2228
- color into adjacent cells.
2229
- """
2230
- # Fast path: no ANSI codes, fall back to raw-slice truncation.
2231
- if "\033" not in text:
2232
- return _truncate_num(text, width)
2233
- stripped_len = len(_ANSI_ESC_RE.sub("", text))
2234
- if stripped_len <= width:
2235
- return text
2236
- # Walk chars until we've emitted (width - 1) visible chars, copying
2237
- # ANSI sequences verbatim. Append reset + ellipsis to close any open
2238
- # style and preserve the fit-to-width contract.
2239
- out: list[str] = []
2240
- visible = 0
2241
- i = 0
2242
- target = width - 1
2243
- while i < len(text) and visible < target:
2244
- m = _ANSI_ESC_RE.match(text, i)
2245
- if m:
2246
- out.append(m.group(0))
2247
- i = m.end()
2248
- continue
2249
- out.append(text[i])
2250
- visible += 1
2251
- i += 1
2252
- return "".join(out) + "\033[0m\u2026"
2253
-
2254
-
2255
2000
  def _trend_row_recency_seconds(row: dict[str, Any]) -> float:
2256
2001
  for key in ("asOf", "costCapturedAt", "usageCapturedAt", "weekStartAt"):
2257
2002
  parsed = _parse_iso_datetime_optional(row.get(key))
@@ -2650,6 +2395,9 @@ _compute_cost_for_weekref = _cctally_weekrefs._compute_cost_for_weekre
2650
2395
  _apply_overlap_clamp_to_weekrefs = _cctally_weekrefs._apply_overlap_clamp_to_weekrefs
2651
2396
  _RESET_PCT_DROP_THRESHOLD = _cctally_weekrefs._RESET_PCT_DROP_THRESHOLD
2652
2397
  _FIVE_HOUR_RESET_PCT_DROP_THRESHOLD = _cctally_weekrefs._FIVE_HOUR_RESET_PCT_DROP_THRESHOLD
2398
+ _RESET_ZERO_FLOOR_PCT = _cctally_weekrefs._RESET_ZERO_FLOOR_PCT
2399
+ _RESET_ZERO_MIN_DROP_PCT = _cctally_weekrefs._RESET_ZERO_MIN_DROP_PCT
2400
+ _is_reset_drop = _cctally_weekrefs._is_reset_drop
2653
2401
 
2654
2402
 
2655
2403
  # Eager re-export of bin/_cctally_percent_breakdown.py — the percent-breakdown
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cctally",
3
- "version": "1.22.3",
3
+ "version": "1.22.4",
4
4
  "description": "Claude Code usage tracker and local dashboard for Pro/Max subscription limits - weekly cost-per-percent trend, quota forecasts, threshold alerts. ccusage-compatible.",
5
5
  "homepage": "https://github.com/omrikais/cctally",
6
6
  "repository": {
@@ -54,6 +54,7 @@
54
54
  "bin/_lib_display_tz.py",
55
55
  "bin/_lib_doctor.py",
56
56
  "bin/_lib_five_hour.py",
57
+ "bin/_lib_fmt.py",
57
58
  "bin/_lib_jsonl.py",
58
59
  "bin/_lib_pricing.py",
59
60
  "bin/_lib_pricing_check.py",