cctally 1.8.0 → 1.8.2
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 +16 -0
- package/bin/_cctally_dashboard.py +158 -98
- package/bin/_cctally_tui.py +156 -31
- package/bin/_lib_view_models.py +784 -0
- package/bin/cctally +152 -39
- package/dashboard/static/assets/{index-CfXu9Fx_.js → index-cWE5HB8O.js} +2 -2
- package/dashboard/static/dashboard.html +1 -1
- package/package.json +1 -1
package/bin/cctally
CHANGED
|
@@ -80,6 +80,7 @@ import textwrap
|
|
|
80
80
|
import threading
|
|
81
81
|
import time
|
|
82
82
|
import traceback
|
|
83
|
+
import unicodedata
|
|
83
84
|
import urllib.error
|
|
84
85
|
import urllib.request
|
|
85
86
|
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
|
|
@@ -358,6 +359,19 @@ TrendView = _lib_view_models.TrendView
|
|
|
358
359
|
build_trend_view = _lib_view_models.build_trend_view
|
|
359
360
|
SessionsView = _lib_view_models.SessionsView
|
|
360
361
|
build_sessions_view = _lib_view_models.build_sessions_view
|
|
362
|
+
BlocksView = _lib_view_models.BlocksView
|
|
363
|
+
build_blocks_view = _lib_view_models.build_blocks_view
|
|
364
|
+
build_blocks_view_from_table_rows = _lib_view_models.build_blocks_view_from_table_rows
|
|
365
|
+
ForecastView = _lib_view_models.ForecastView
|
|
366
|
+
build_forecast_view = _lib_view_models.build_forecast_view
|
|
367
|
+
CodexDailyView = _lib_view_models.CodexDailyView
|
|
368
|
+
build_codex_daily_view = _lib_view_models.build_codex_daily_view
|
|
369
|
+
CodexMonthlyView = _lib_view_models.CodexMonthlyView
|
|
370
|
+
build_codex_monthly_view = _lib_view_models.build_codex_monthly_view
|
|
371
|
+
CodexWeeklyView = _lib_view_models.CodexWeeklyView
|
|
372
|
+
build_codex_weekly_view = _lib_view_models.build_codex_weekly_view
|
|
373
|
+
CodexSessionView = _lib_view_models.CodexSessionView
|
|
374
|
+
build_codex_session_view = _lib_view_models.build_codex_session_view
|
|
361
375
|
|
|
362
376
|
_lib_render = _load_sibling("_lib_render")
|
|
363
377
|
_CODEX_MONTHS = _lib_render._CODEX_MONTHS
|
|
@@ -776,6 +790,7 @@ _dashboard_build_monthly_periods = _cctally_dashboard._dashboard_build_monthly_p
|
|
|
776
790
|
_dashboard_build_weekly_periods = _cctally_dashboard._dashboard_build_weekly_periods
|
|
777
791
|
_build_block_detail = _cctally_dashboard._build_block_detail
|
|
778
792
|
_dashboard_build_blocks_panel = _cctally_dashboard._dashboard_build_blocks_panel
|
|
793
|
+
_dashboard_build_blocks_view = _cctally_dashboard._dashboard_build_blocks_view
|
|
779
794
|
_dashboard_build_daily_panel = _cctally_dashboard._dashboard_build_daily_panel
|
|
780
795
|
_empty_dashboard_snapshot = _cctally_dashboard._empty_dashboard_snapshot
|
|
781
796
|
_iso_z = _cctally_dashboard._iso_z
|
|
@@ -2691,6 +2706,29 @@ def _supports_unicode_stdout() -> bool:
|
|
|
2691
2706
|
return "UTF" in encoding
|
|
2692
2707
|
|
|
2693
2708
|
|
|
2709
|
+
def _display_width(s: str) -> int:
|
|
2710
|
+
"""Terminal cells consumed by ``s``.
|
|
2711
|
+
|
|
2712
|
+
Counts each codepoint by its East Asian Width: ``W`` / ``F`` (Wide
|
|
2713
|
+
/ Fullwidth) → 2 cells; combining marks → 0; everything else → 1.
|
|
2714
|
+
Ambiguous (``A``) defaults to 1, matching every non-CJK terminal
|
|
2715
|
+
locale — cctally has no CJK content in cell data, and `→` / `—` /
|
|
2716
|
+
`·` (all `A`) are intentionally rendered narrow.
|
|
2717
|
+
|
|
2718
|
+
Used by `_boxed_table` so cells containing wide glyphs (notably
|
|
2719
|
+
`⚡` U+26A1 on credit-row annotations) pad to the right cell count
|
|
2720
|
+
rather than the right codepoint count. Without this, `len()`-based
|
|
2721
|
+
padding under-pads by one cell per wide glyph and the right border
|
|
2722
|
+
drifts off-column on those rows only.
|
|
2723
|
+
"""
|
|
2724
|
+
width = 0
|
|
2725
|
+
for ch in s:
|
|
2726
|
+
if unicodedata.combining(ch):
|
|
2727
|
+
continue
|
|
2728
|
+
width += 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
|
|
2729
|
+
return width
|
|
2730
|
+
|
|
2731
|
+
|
|
2694
2732
|
def _boxed_table(
|
|
2695
2733
|
headers: list[str],
|
|
2696
2734
|
rows: list[list[str]],
|
|
@@ -2714,15 +2752,20 @@ def _boxed_table(
|
|
|
2714
2752
|
|
|
2715
2753
|
widths: list[int] = []
|
|
2716
2754
|
for idx, header in enumerate(headers):
|
|
2717
|
-
max_cell = max((
|
|
2718
|
-
widths.append(max(
|
|
2755
|
+
max_cell = max((_display_width(r[idx]) for r in sanitized_rows), default=0)
|
|
2756
|
+
widths.append(max(_display_width(header), max_cell))
|
|
2719
2757
|
|
|
2720
2758
|
def _pad(text: str, width: int, align: str) -> str:
|
|
2759
|
+
deficit = width - _display_width(text)
|
|
2760
|
+
if deficit <= 0:
|
|
2761
|
+
return text
|
|
2762
|
+
pad = " " * deficit
|
|
2721
2763
|
if align == "right":
|
|
2722
|
-
return text
|
|
2764
|
+
return pad + text
|
|
2723
2765
|
if align == "center":
|
|
2724
|
-
|
|
2725
|
-
|
|
2766
|
+
left = deficit // 2
|
|
2767
|
+
return (" " * left) + text + (" " * (deficit - left))
|
|
2768
|
+
return text + pad
|
|
2726
2769
|
|
|
2727
2770
|
if _supports_unicode_stdout():
|
|
2728
2771
|
chars = {
|
|
@@ -4987,13 +5030,30 @@ def cmd_blocks(args: argparse.Namespace) -> int:
|
|
|
4987
5030
|
range_start - BLOCK_DURATION, range_end + BLOCK_DURATION,
|
|
4988
5031
|
)
|
|
4989
5032
|
|
|
4990
|
-
# Group into blocks
|
|
4991
|
-
|
|
4992
|
-
|
|
5033
|
+
# Group into blocks via the view-model kernel (issue #56). The
|
|
5034
|
+
# heuristic-aware ``aggregated`` tuple holds the full Block list
|
|
5035
|
+
# (gaps included, oldest-first) — same shape the JSON / table
|
|
5036
|
+
# renderers expect. We materialize back to a list because
|
|
5037
|
+
# ``_maybe_swap_active_block_to_canonical`` mutates in-place.
|
|
5038
|
+
#
|
|
5039
|
+
# ``skip_rows=True`` (issue #60 review fix) opts out of the
|
|
5040
|
+
# dashboard-row construction inside ``build_blocks_view`` — the
|
|
5041
|
+
# per-block per-model enrichment that scans every entry per
|
|
5042
|
+
# non-gap block (O(B × N)). The CLI never reads ``view.rows``
|
|
5043
|
+
# (only ``view.aggregated`` here), so on large all-history blocks
|
|
5044
|
+
# runs we avoid quadratic-ish work we'd discard.
|
|
5045
|
+
view = build_blocks_view(
|
|
5046
|
+
all_entries,
|
|
5047
|
+
now_utc=now_utc,
|
|
4993
5048
|
recorded_windows=recorded_windows,
|
|
4994
5049
|
block_start_overrides=block_start_overrides,
|
|
4995
|
-
|
|
5050
|
+
range_start=range_start,
|
|
5051
|
+
range_end=range_end,
|
|
5052
|
+
display_tz=tz,
|
|
5053
|
+
mode="auto",
|
|
5054
|
+
skip_rows=True,
|
|
4996
5055
|
)
|
|
5056
|
+
blocks = list(view.aggregated)
|
|
4997
5057
|
|
|
4998
5058
|
# Bug E (v1.7.2 round-4): when the ACTIVE block is heuristic-anchored
|
|
4999
5059
|
# but a canonical ``five_hour_blocks`` row exists for the current 5h
|
|
@@ -5508,7 +5568,14 @@ def cmd_codex_daily(args: argparse.Namespace) -> int:
|
|
|
5508
5568
|
range_start, range_end = range
|
|
5509
5569
|
|
|
5510
5570
|
entries = get_codex_entries(range_start, range_end)
|
|
5511
|
-
|
|
5571
|
+
# Route through ``build_codex_daily_view`` (issue #58). The View
|
|
5572
|
+
# wraps ``_aggregate_codex_daily`` without changing it — preserves
|
|
5573
|
+
# LiteLLM token semantics, intentional dedup vs upstream, and
|
|
5574
|
+
# ``CODEX_LEGACY_FALLBACK_MODEL`` warning end-to-end.
|
|
5575
|
+
view = build_codex_daily_view(
|
|
5576
|
+
entries, now_utc=_command_as_of(), tz_name=tz_name,
|
|
5577
|
+
)
|
|
5578
|
+
days = list(view.rows) # asc — matches aggregator default
|
|
5512
5579
|
if args.order == "desc":
|
|
5513
5580
|
days = list(reversed(days))
|
|
5514
5581
|
|
|
@@ -5530,7 +5597,7 @@ def cmd_codex_daily(args: argparse.Namespace) -> int:
|
|
|
5530
5597
|
y, m, d = bucket.split("-")
|
|
5531
5598
|
return f"{_CODEX_MONTHS[int(m) - 1]} {int(d):02d},\n{y}"
|
|
5532
5599
|
|
|
5533
|
-
tz_label =
|
|
5600
|
+
tz_label = view.display_tz_label
|
|
5534
5601
|
title = f"Codex Token Usage Report - Daily (Timezone: {tz_label})"
|
|
5535
5602
|
print(_render_codex_bucket_table(
|
|
5536
5603
|
days,
|
|
@@ -5560,7 +5627,11 @@ def cmd_codex_monthly(args: argparse.Namespace) -> int:
|
|
|
5560
5627
|
range_start, range_end = range
|
|
5561
5628
|
|
|
5562
5629
|
entries = get_codex_entries(range_start, range_end)
|
|
5563
|
-
|
|
5630
|
+
# Route through ``build_codex_monthly_view`` (issue #58).
|
|
5631
|
+
view = build_codex_monthly_view(
|
|
5632
|
+
entries, now_utc=_command_as_of(), tz_name=tz_name,
|
|
5633
|
+
)
|
|
5634
|
+
months = list(view.rows)
|
|
5564
5635
|
if args.order == "desc":
|
|
5565
5636
|
months = list(reversed(months))
|
|
5566
5637
|
|
|
@@ -5582,7 +5653,7 @@ def cmd_codex_monthly(args: argparse.Namespace) -> int:
|
|
|
5582
5653
|
y, m = bucket.split("-")
|
|
5583
5654
|
return f"{_CODEX_MONTHS[int(m) - 1]}\n{y}"
|
|
5584
5655
|
|
|
5585
|
-
tz_label =
|
|
5656
|
+
tz_label = view.display_tz_label
|
|
5586
5657
|
title = f"Codex Token Usage Report - Monthly (Timezone: {tz_label})"
|
|
5587
5658
|
print(_render_codex_bucket_table(
|
|
5588
5659
|
months,
|
|
@@ -5615,7 +5686,12 @@ def cmd_codex_weekly(args: argparse.Namespace) -> int:
|
|
|
5615
5686
|
week_start_idx = WEEKDAY_MAP[week_start_name]
|
|
5616
5687
|
|
|
5617
5688
|
entries = get_codex_entries(range_start, range_end)
|
|
5618
|
-
|
|
5689
|
+
# Route through ``build_codex_weekly_view`` (issue #58).
|
|
5690
|
+
view = build_codex_weekly_view(
|
|
5691
|
+
entries, now_utc=now_utc, tz_name=tz_name,
|
|
5692
|
+
week_start_idx=week_start_idx,
|
|
5693
|
+
)
|
|
5694
|
+
weeks = list(view.rows)
|
|
5619
5695
|
if args.order == "desc":
|
|
5620
5696
|
weeks = list(reversed(weeks))
|
|
5621
5697
|
|
|
@@ -5640,7 +5716,7 @@ def cmd_codex_weekly(args: argparse.Namespace) -> int:
|
|
|
5640
5716
|
y, m, d = bucket.split("-")
|
|
5641
5717
|
return f"{_CODEX_MONTHS[int(m) - 1]} {int(d):02d},\n{y}"
|
|
5642
5718
|
|
|
5643
|
-
tz_label =
|
|
5719
|
+
tz_label = view.display_tz_label
|
|
5644
5720
|
title = f"Codex Token Usage Report - Weekly (Timezone: {tz_label})"
|
|
5645
5721
|
print(_render_codex_bucket_table(
|
|
5646
5722
|
weeks,
|
|
@@ -5670,8 +5746,13 @@ def cmd_codex_session(args: argparse.Namespace) -> int:
|
|
|
5670
5746
|
range_start, range_end = range
|
|
5671
5747
|
|
|
5672
5748
|
entries = get_codex_entries(range_start, range_end)
|
|
5673
|
-
|
|
5674
|
-
#
|
|
5749
|
+
# Route through ``build_codex_session_view`` (issue #58). View rows
|
|
5750
|
+
# come descending by last_activity (aggregator default + upstream
|
|
5751
|
+
# parity); --order asc reverses.
|
|
5752
|
+
view = build_codex_session_view(
|
|
5753
|
+
entries, now_utc=_command_as_of(), tz_name=tz_name,
|
|
5754
|
+
)
|
|
5755
|
+
sessions = list(view.rows)
|
|
5675
5756
|
if args.order == "asc":
|
|
5676
5757
|
sessions = list(reversed(sessions))
|
|
5677
5758
|
|
|
@@ -5685,7 +5766,7 @@ def cmd_codex_session(args: argparse.Namespace) -> int:
|
|
|
5685
5766
|
print(_codex_sessions_to_json(sessions))
|
|
5686
5767
|
return 0
|
|
5687
5768
|
|
|
5688
|
-
tz_label =
|
|
5769
|
+
tz_label = view.display_tz_label
|
|
5689
5770
|
# Upstream uses "Sessions" (plural) in the session banner title.
|
|
5690
5771
|
title = f"Codex Token Usage Report - Sessions (Timezone: {tz_label})"
|
|
5691
5772
|
print(_render_codex_session_table(
|
|
@@ -8241,7 +8322,16 @@ def cmd_forecast(args: argparse.Namespace) -> int:
|
|
|
8241
8322
|
# sync_cache(conn) here — that prior call ran on the stats DB connection
|
|
8242
8323
|
# (wrong conn) and was a no-op for the real cache anyway.
|
|
8243
8324
|
|
|
8244
|
-
|
|
8325
|
+
# Route through ``build_forecast_view`` (issue #57). The View is the
|
|
8326
|
+
# kernel-pattern wrapper; ``view.output`` carries the existing
|
|
8327
|
+
# ``ForecastOutput`` math result so every downstream renderer here
|
|
8328
|
+
# (text / JSON / status-line / share) reuses the same projection,
|
|
8329
|
+
# verdict, budgets, and per-method rate fields without recomputing.
|
|
8330
|
+
view = build_forecast_view(
|
|
8331
|
+
conn, now_utc=now_utc, targets=tuple(targets),
|
|
8332
|
+
skip_sync=args.no_sync, display_tz=args._resolved_tz,
|
|
8333
|
+
)
|
|
8334
|
+
inputs = view.output.inputs if view.output is not None else None
|
|
8245
8335
|
if inputs is None:
|
|
8246
8336
|
# No snapshot for the current week.
|
|
8247
8337
|
if getattr(args, "format", None):
|
|
@@ -8307,12 +8397,12 @@ def cmd_forecast(args: argparse.Namespace) -> int:
|
|
|
8307
8397
|
print("forecast: no data for current week yet")
|
|
8308
8398
|
return 0
|
|
8309
8399
|
|
|
8310
|
-
output =
|
|
8400
|
+
output = view.output
|
|
8311
8401
|
|
|
8312
8402
|
# Shareable-reports gate: --format short-circuits the JSON / status-line /
|
|
8313
8403
|
# terminal dispatch via `_share_render_and_emit`. The mutex in
|
|
8314
8404
|
# `_add_share_args(has_status_line=True)` keeps `--format`, `--json`, and
|
|
8315
|
-
# `--status-line` from coexisting. The gate fires AFTER
|
|
8405
|
+
# `--status-line` from coexisting. The gate fires AFTER ``build_forecast_view``
|
|
8316
8406
|
# so the snapshot reuses the same projection math as the terminal/JSON
|
|
8317
8407
|
# paths — no parallel computation.
|
|
8318
8408
|
if getattr(args, "format", None):
|
|
@@ -8857,10 +8947,21 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
|
|
|
8857
8947
|
)
|
|
8858
8948
|
else:
|
|
8859
8949
|
period_end = _share_now_utc()
|
|
8860
|
-
|
|
8950
|
+
# Build a BlocksView from the API-anchored table rows
|
|
8951
|
+
# (issue #56). Reset-aware totals come from the table's
|
|
8952
|
+
# per-block columns (CLAUDE.md 5-hour gotcha block) so the
|
|
8953
|
+
# share snapshot's footer reads from the single typed
|
|
8954
|
+
# source rather than re-summing inline.
|
|
8955
|
+
view = build_blocks_view_from_table_rows(
|
|
8861
8956
|
block_dicts,
|
|
8862
8957
|
period_start=period_start,
|
|
8863
8958
|
period_end=period_end,
|
|
8959
|
+
display_tz=args._resolved_tz,
|
|
8960
|
+
)
|
|
8961
|
+
snap = _build_five_hour_blocks_snapshot(
|
|
8962
|
+
view,
|
|
8963
|
+
period_start=period_start,
|
|
8964
|
+
period_end=period_end,
|
|
8864
8965
|
display_tz=display_tz_str,
|
|
8865
8966
|
version=_share_resolve_version(),
|
|
8866
8967
|
theme=args.theme,
|
|
@@ -13182,7 +13283,7 @@ def _build_project_snapshot(
|
|
|
13182
13283
|
|
|
13183
13284
|
|
|
13184
13285
|
def _build_five_hour_blocks_snapshot(
|
|
13185
|
-
|
|
13286
|
+
view: "BlocksView",
|
|
13186
13287
|
*,
|
|
13187
13288
|
period_start: dt.datetime,
|
|
13188
13289
|
period_end: dt.datetime,
|
|
@@ -13194,13 +13295,18 @@ def _build_five_hour_blocks_snapshot(
|
|
|
13194
13295
|
) -> "ShareSnapshot":
|
|
13195
13296
|
"""Build a ShareSnapshot for `cctally five-hour-blocks`.
|
|
13196
13297
|
|
|
13197
|
-
`
|
|
13198
|
-
|
|
13199
|
-
|
|
13200
|
-
|
|
13201
|
-
|
|
13202
|
-
|
|
13203
|
-
|
|
13298
|
+
`view` is the ``BlocksView`` produced by
|
|
13299
|
+
``build_blocks_view_from_table_rows`` (issue #56). The
|
|
13300
|
+
API-anchored block dicts (sqlite Row → dict with the
|
|
13301
|
+
``__is_active`` / ``__credits`` side-channels attached) live on
|
|
13302
|
+
``view.aggregated``; reset-aware totals come from
|
|
13303
|
+
``view.total_cost_usd`` so the share footer reads from the typed
|
|
13304
|
+
single source rather than re-summing inline. Schema fields used
|
|
13305
|
+
from each dict: ``block_start_at`` (ISO timestamp),
|
|
13306
|
+
``total_cost_usd``, ``final_five_hour_percent``,
|
|
13307
|
+
``crossed_seven_day_reset`` (0/1 int),
|
|
13308
|
+
``seven_day_pct_at_block_start``, ``seven_day_pct_at_block_end``,
|
|
13309
|
+
plus the synthetic ``__is_active`` flag.
|
|
13204
13310
|
|
|
13205
13311
|
Deviations from the plan sketch (which assumed dict rows with keys
|
|
13206
13312
|
`block_start` / `cost_usd` / `used_pct_5h` / `top_model` /
|
|
@@ -13232,11 +13338,12 @@ def _build_five_hour_blocks_snapshot(
|
|
|
13232
13338
|
the builder owns the canonical subtitle shape — no post-build
|
|
13233
13339
|
re-stamp at the gate site.
|
|
13234
13340
|
|
|
13235
|
-
Caller MUST pass
|
|
13236
|
-
|
|
13237
|
-
BarChart bars line up
|
|
13238
|
-
|
|
13239
|
-
|
|
13341
|
+
Caller MUST pass a view whose ``aggregated`` block dicts are
|
|
13342
|
+
already in the desired chronological order (cmd_five_hour_blocks
|
|
13343
|
+
pulls newest-first; we reverse here so the BarChart bars line up
|
|
13344
|
+
oldest→newest left-to-right). Tabular row order in the snapshot is
|
|
13345
|
+
irrelevant because the snapshot is what gets rendered (the gate
|
|
13346
|
+
site short-circuits the table renderer).
|
|
13240
13347
|
"""
|
|
13241
13348
|
_lib_share = _share_load_lib()
|
|
13242
13349
|
columns = (
|
|
@@ -13249,9 +13356,11 @@ def _build_five_hour_blocks_snapshot(
|
|
|
13249
13356
|
_lib_share.ColumnSpec(key="cross_reset", label="Reset",
|
|
13250
13357
|
align="left"),
|
|
13251
13358
|
)
|
|
13252
|
-
#
|
|
13253
|
-
#
|
|
13254
|
-
# chart order so consumer
|
|
13359
|
+
# `view.aggregated` carries the newest-first DESC block dicts the
|
|
13360
|
+
# caller built from the SELECT. Reverse so BarChart x-axis runs
|
|
13361
|
+
# oldest→newest; table-row order tracks chart order so consumer
|
|
13362
|
+
# expectations align.
|
|
13363
|
+
rows = list(view.aggregated)
|
|
13255
13364
|
chrono_rows = list(reversed(rows))
|
|
13256
13365
|
snap_rows: list = []
|
|
13257
13366
|
chart_pts: list = []
|
|
@@ -13307,7 +13416,11 @@ def _build_five_hour_blocks_snapshot(
|
|
|
13307
13416
|
_lib_share.BarChart(points=tuple(chart_pts), y_label="$")
|
|
13308
13417
|
if chart_pts else None
|
|
13309
13418
|
)
|
|
13310
|
-
|
|
13419
|
+
# Reset-aware total comes from the BlocksView (issue #56); avg
|
|
13420
|
+
# divides by `chart_pts` count so the share footer "Sum" totalled
|
|
13421
|
+
# and the per-block `chart_pts` cost values share a single source-
|
|
13422
|
+
# of-truth at `view.total_cost_usd`.
|
|
13423
|
+
sum_cost = view.total_cost_usd
|
|
13311
13424
|
avg_cost = (sum_cost / len(chart_pts)) if chart_pts else 0.0
|
|
13312
13425
|
crossed_count = sum(
|
|
13313
13426
|
1 for r in chrono_rows if bool(r.get("crossed_seven_day_reset"))
|