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 +8 -0
- package/bin/_cctally_record.py +3 -2
- package/bin/_cctally_weekrefs.py +36 -2
- package/bin/_lib_diff_kernel.py +5 -8
- package/bin/_lib_fmt.py +325 -0
- package/bin/_lib_render.py +9 -24
- package/bin/cctally +21 -273
- package/package.json +2 -1
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
|
package/bin/_cctally_record.py
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 (
|
|
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 (
|
|
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
|
package/bin/_cctally_weekrefs.py
CHANGED
|
@@ -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 (
|
|
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 (
|
|
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
|
package/bin/_lib_diff_kernel.py
CHANGED
|
@@ -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
|
package/bin/_lib_fmt.py
ADDED
|
@@ -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"
|
package/bin/_lib_render.py
CHANGED
|
@@ -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
|
-
|
|
1924
|
-
|
|
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
|
+
"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",
|