cctally 1.19.0 β 1.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/bin/_cctally_config.py +143 -0
- package/bin/_lib_statusline.py +499 -0
- package/bin/cctally +715 -0
- package/bin/cctally-statusline +3 -0
- package/package.json +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,11 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.20.0] - 2026-05-28
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **`cctally statusline` (and `cctally claude statusline`) β a one-line status string for Claude Code's `statusLine` hook, drop-in for `ccusage statusline` with cctally-only extensions appended.** Reads the Claude Code hook stdin and emits five `|`-delimited segments: `π€ <model>` (display name) Β· `π° $X.XX session / $Y.YY today / $Z.ZZ block (Hh Mm left)` cost Β· `π₯ $X.XX/hr` burn rate with optional `π’/π‘/π΄ (Normal/Moderate/High)` visual Β· `π§ N%` context-window utilization Β· `5h X% (Hh Mm) Β· 7d Y% (Dd Hh)` cctally extension sourced from stdin `rate_limits` with the existing monotonic HWM clamp and DB-latest-row fallback. Flag surface mirrors ccusage's: `-B {off,emoji,text,emoji-text}`, `--cost-source {auto,cctally,cc,both}` (the `ccusage` value name is renamed; passing it errors with a one-line rename hint), `--context-low-threshold`/`--context-medium-threshold` (defaults 50/80, with `[0, 100]` range + `low < medium` ordering validation), `-z/--timezone`, `-d/--debug`, plus `--cache`/`--no-cache`/`--refresh-interval`/`-O/--offline`/`--single-thread` as documented no-op aliases and `--config PATH` honored as a real per-invocation override (parity with the 10 sibling Claude reporting commands). cctally adds `--cctally-extensions`/`--no-cctally-extensions` (default-on) to toggle segment 5 and persists three config keys (`statusline.{visual_burn_rate,cost_source,cctally_extensions}`) with CLI > config > built-in default precedence. Cost sources: `auto` uses the cctally JSONL-derived cost when the transcript is readable and the session exists in the cache, falling back to stdin `cost.total_cost_usd` otherwise; `cctally`/`cc`/`both` force one path or render side-by-side. Today's bucket honors `display.tz`; the active block segment reuses Session F's `_lib_blocks` kernel and burn-rate bands are `<$15/hr` green, `<$30/hr` yellow, `β₯$30/hr` red. Context % is computed from a memory-safe tail-walk of the transcript JSONL (last assistant turn's usage block Γ· per-model context window β 200K default, 1M for `[1m]` variants); unknown models render `π§ N/A` with a one-shot stderr warn. Stdin contract is deliberately graceful (an intentional ccusage divergence, documented in the spec): only malformed JSON or a non-object root exits 1; every other field absence β missing `model`, `transcript_path`, `session_id`, `cost`, even `rate_limits` β produces a degraded but non-broken line at exit 0, so the status line never fails the hook on a partial payload. Architecture: pure-function render kernel at `bin/_lib_statusline.py` (no I/O β every side-effecting dep dataclass-injected); I/O glue in `bin/cctally::cmd_statusline` wires DB + transcript reader + HWM clamp callables; built once and registered twice (flat + nested) per the Session B parser pattern, so `cctally statusline` and `cctally claude statusline` produce byte-identical output (only `--help` xref differs). Regression: `bin/cctally-statusline-test` (32 fixture scenarios β every cost-source value, every `-B` variant, every context color band + the unknown-model + 1M-window paths, extension HWM clamp + DB fallback + suppression, resumed-session merge, every graceful-degradation case, bad-stdin, tz buckets, config persistence + CLI override + `--config PATH`), `bin/cctally-reconcile-test` (+6 statusline invariants binding the segments to `cctally session`/`daily`/`blocks --active`/`weekly_usage_snapshots` within 1e-9 USD tolerance), `bin/cctally-subgroup-test` (new `compare_forms_stdin` proves flatβ‘`claude statusline` over a subgroup-parity fixture), and `tests/test_statusline.py` (66 kernel units). (#86)
|
|
12
|
+
|
|
8
13
|
## [1.19.0] - 2026-05-28
|
|
9
14
|
|
|
10
15
|
### Added
|
package/bin/_cctally_config.py
CHANGED
|
@@ -298,9 +298,71 @@ ALLOWED_CONFIG_KEYS = (
|
|
|
298
298
|
"dashboard.bind",
|
|
299
299
|
"update.check.enabled",
|
|
300
300
|
"update.check.ttl_hours",
|
|
301
|
+
"statusline.visual_burn_rate",
|
|
302
|
+
"statusline.cost_source",
|
|
303
|
+
"statusline.cctally_extensions",
|
|
301
304
|
)
|
|
302
305
|
|
|
303
306
|
|
|
307
|
+
# === statusline config validators (issue #86 Session G) ===================
|
|
308
|
+
|
|
309
|
+
_STATUSLINE_VBR_VALUES = ("off", "emoji", "text", "emoji-text")
|
|
310
|
+
_STATUSLINE_COST_SOURCE_VALUES = ("auto", "cctally", "cc", "both")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _validate_statusline_visual_burn_rate(value):
|
|
314
|
+
"""Validate ``statusline.visual_burn_rate``.
|
|
315
|
+
|
|
316
|
+
Accepts any of ``off`` / ``emoji`` / ``text`` / ``emoji-text``. Other
|
|
317
|
+
strings raise ``ValueError`` with a hint listing the valid values.
|
|
318
|
+
"""
|
|
319
|
+
if isinstance(value, str) and value in _STATUSLINE_VBR_VALUES:
|
|
320
|
+
return value
|
|
321
|
+
raise ValueError(
|
|
322
|
+
f"statusline.visual_burn_rate must be one of "
|
|
323
|
+
f"{', '.join(_STATUSLINE_VBR_VALUES)} (got {value!r})"
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _validate_statusline_cost_source(value):
|
|
328
|
+
"""Validate ``statusline.cost_source``.
|
|
329
|
+
|
|
330
|
+
Accepts ``auto`` / ``cctally`` / ``cc`` / ``both``. The ``ccusage``
|
|
331
|
+
value name is rejected at config set time too β the rename hint
|
|
332
|
+
is surfaced both here AND at flag-parse time by the argparse choice
|
|
333
|
+
rejection inside ``cmd_statusline``.
|
|
334
|
+
"""
|
|
335
|
+
if isinstance(value, str) and value in _STATUSLINE_COST_SOURCE_VALUES:
|
|
336
|
+
return value
|
|
337
|
+
if value == "ccusage":
|
|
338
|
+
raise ValueError(
|
|
339
|
+
"statusline.cost_source 'ccusage' was renamed; use 'cctally'"
|
|
340
|
+
)
|
|
341
|
+
raise ValueError(
|
|
342
|
+
f"statusline.cost_source must be one of "
|
|
343
|
+
f"{', '.join(_STATUSLINE_COST_SOURCE_VALUES)} (got {value!r})"
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _validate_statusline_cctally_extensions(value):
|
|
348
|
+
"""Validate ``statusline.cctally_extensions``.
|
|
349
|
+
|
|
350
|
+
Accepts booleans (preferred) or canonical truthy/falsy strings
|
|
351
|
+
(``true``/``false``/``yes``/``no``/``on``/``off``/``1``/``0``).
|
|
352
|
+
"""
|
|
353
|
+
if isinstance(value, bool):
|
|
354
|
+
return value
|
|
355
|
+
if isinstance(value, str):
|
|
356
|
+
lo = value.strip().lower()
|
|
357
|
+
if lo in ("true", "yes", "on", "1"):
|
|
358
|
+
return True
|
|
359
|
+
if lo in ("false", "no", "off", "0"):
|
|
360
|
+
return False
|
|
361
|
+
raise ValueError(
|
|
362
|
+
f"statusline.cctally_extensions must be boolean (got {value!r})"
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
|
|
304
366
|
def cmd_config(args: argparse.Namespace) -> int:
|
|
305
367
|
"""Get/set/unset persisted user preferences in config.json.
|
|
306
368
|
|
|
@@ -374,6 +436,33 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
374
436
|
return c._validate_update_check_ttl_hours_value(stored)
|
|
375
437
|
except ValueError:
|
|
376
438
|
return c.UPDATE_DEFAULT_TTL_HOURS
|
|
439
|
+
if key in (
|
|
440
|
+
"statusline.visual_burn_rate",
|
|
441
|
+
"statusline.cost_source",
|
|
442
|
+
"statusline.cctally_extensions",
|
|
443
|
+
):
|
|
444
|
+
sl_block = config.get("statusline") if isinstance(config, dict) else None
|
|
445
|
+
if not isinstance(sl_block, dict):
|
|
446
|
+
sl_block = {}
|
|
447
|
+
inner = key.split(".", 1)[1]
|
|
448
|
+
stored = sl_block.get(inner)
|
|
449
|
+
defaults = {
|
|
450
|
+
"visual_burn_rate": "off",
|
|
451
|
+
"cost_source": "auto",
|
|
452
|
+
"cctally_extensions": True,
|
|
453
|
+
}
|
|
454
|
+
if stored is None:
|
|
455
|
+
return defaults[inner]
|
|
456
|
+
validator = {
|
|
457
|
+
"visual_burn_rate": _validate_statusline_visual_burn_rate,
|
|
458
|
+
"cost_source": _validate_statusline_cost_source,
|
|
459
|
+
"cctally_extensions": _validate_statusline_cctally_extensions,
|
|
460
|
+
}[inner]
|
|
461
|
+
try:
|
|
462
|
+
return validator(stored)
|
|
463
|
+
except ValueError:
|
|
464
|
+
# Hand-edited junk: surface the default β mirrors dashboard.bind.
|
|
465
|
+
return defaults[inner]
|
|
377
466
|
return None
|
|
378
467
|
|
|
379
468
|
|
|
@@ -509,6 +598,44 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
509
598
|
else:
|
|
510
599
|
print(f"dashboard.bind={canonical}")
|
|
511
600
|
return 0
|
|
601
|
+
if key in (
|
|
602
|
+
"statusline.visual_burn_rate",
|
|
603
|
+
"statusline.cost_source",
|
|
604
|
+
"statusline.cctally_extensions",
|
|
605
|
+
):
|
|
606
|
+
inner_key = key.split(".", 1)[1]
|
|
607
|
+
validator = {
|
|
608
|
+
"visual_burn_rate": _validate_statusline_visual_burn_rate,
|
|
609
|
+
"cost_source": _validate_statusline_cost_source,
|
|
610
|
+
"cctally_extensions": _validate_statusline_cctally_extensions,
|
|
611
|
+
}[inner_key]
|
|
612
|
+
try:
|
|
613
|
+
normalized = validator(raw)
|
|
614
|
+
except ValueError as exc:
|
|
615
|
+
print(f"cctally: {exc}", file=sys.stderr)
|
|
616
|
+
return 2
|
|
617
|
+
with config_writer_lock():
|
|
618
|
+
config = _load_config_unlocked()
|
|
619
|
+
existing = config.get("statusline")
|
|
620
|
+
if existing is not None and not isinstance(existing, dict):
|
|
621
|
+
print(
|
|
622
|
+
"cctally: statusline config error: statusline must be an object",
|
|
623
|
+
file=sys.stderr,
|
|
624
|
+
)
|
|
625
|
+
return 2
|
|
626
|
+
block = dict(existing or {})
|
|
627
|
+
block[inner_key] = normalized
|
|
628
|
+
config["statusline"] = block
|
|
629
|
+
save_config(config)
|
|
630
|
+
if getattr(args, "emit_json", False):
|
|
631
|
+
print(json.dumps({"statusline": {inner_key: normalized}}, indent=2))
|
|
632
|
+
else:
|
|
633
|
+
if isinstance(normalized, bool):
|
|
634
|
+
rendered = "true" if normalized else "false"
|
|
635
|
+
else:
|
|
636
|
+
rendered = str(normalized)
|
|
637
|
+
print(f"{key}={rendered}")
|
|
638
|
+
return 0
|
|
512
639
|
if key in ("update.check.enabled", "update.check.ttl_hours"):
|
|
513
640
|
# Validate first; rejection short-circuits before lock acquisition.
|
|
514
641
|
if key == "update.check.enabled":
|
|
@@ -607,6 +734,22 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
|
607
734
|
save_config(config)
|
|
608
735
|
# idempotent: silent on missing key
|
|
609
736
|
return 0
|
|
737
|
+
if key in (
|
|
738
|
+
"statusline.visual_burn_rate",
|
|
739
|
+
"statusline.cost_source",
|
|
740
|
+
"statusline.cctally_extensions",
|
|
741
|
+
):
|
|
742
|
+
inner_key = key.split(".", 1)[1]
|
|
743
|
+
with config_writer_lock():
|
|
744
|
+
config = _load_config_unlocked()
|
|
745
|
+
block = config.get("statusline")
|
|
746
|
+
if isinstance(block, dict) and inner_key in block:
|
|
747
|
+
del block[inner_key]
|
|
748
|
+
if not block:
|
|
749
|
+
config.pop("statusline", None)
|
|
750
|
+
save_config(config)
|
|
751
|
+
# idempotent: silent on missing key
|
|
752
|
+
return 0
|
|
610
753
|
if key in ("update.check.enabled", "update.check.ttl_hours"):
|
|
611
754
|
# Mirror the dashboard.bind branch: drop the leaf, then prune
|
|
612
755
|
# empty `check` and empty `update` so config.json stays tidy.
|
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
"""Pure-function render kernel for ``cctally statusline``.
|
|
2
|
+
|
|
3
|
+
No I/O β every side-effecting dependency is dataclass-injected (cache.db
|
|
4
|
+
query fns, HWM-clamp fn, transcript-reader fn, ``now``). Keeps unit tests
|
|
5
|
+
injection-driven and golden tests reproducible.
|
|
6
|
+
|
|
7
|
+
See docs/superpowers/specs/2026-05-28-issue-86-session-g-statusline-design.md
|
|
8
|
+
for the full design.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from typing import Callable, Optional
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---- Stdin payload (subset we care about) ----------------------------------
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class StatuslineInput:
|
|
24
|
+
"""Parsed Claude Code hook stdin. Every field is optional β see Β§3.1 of
|
|
25
|
+
the spec for the graceful-degradation contract. The two exit-1 paths
|
|
26
|
+
(parse failure, non-object root) are handled BEFORE this dataclass is
|
|
27
|
+
constructed; if you have a ``StatuslineInput`` instance, the payload
|
|
28
|
+
was at least a valid JSON object.
|
|
29
|
+
"""
|
|
30
|
+
session_id: Optional[str] = None
|
|
31
|
+
model_id: Optional[str] = None
|
|
32
|
+
model_display_name: Optional[str] = None
|
|
33
|
+
transcript_path: Optional[str] = None
|
|
34
|
+
workspace_current_dir: Optional[str] = None
|
|
35
|
+
cost_total_usd: Optional[float] = None
|
|
36
|
+
rate_limits_5h_pct: Optional[float] = None
|
|
37
|
+
rate_limits_5h_resets_at: Optional[int] = None # unix epoch
|
|
38
|
+
rate_limits_7d_pct: Optional[float] = None
|
|
39
|
+
rate_limits_7d_resets_at: Optional[int] = None # unix epoch
|
|
40
|
+
raw: dict = field(default_factory=dict) # full parsed JSON for diagnostics
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---- CLI args (post-config-resolution) -------------------------------------
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class StatuslineArgs:
|
|
48
|
+
"""Effective configuration AFTER CLI > config.json > built-in default
|
|
49
|
+
precedence has been resolved by the I/O layer. The kernel sees a fully
|
|
50
|
+
resolved view.
|
|
51
|
+
"""
|
|
52
|
+
visual_burn_rate: str # "off" | "emoji" | "text" | "emoji-text"
|
|
53
|
+
cost_source: str # "auto" | "cctally" | "cc" | "both"
|
|
54
|
+
context_low_threshold: int
|
|
55
|
+
context_medium_threshold: int
|
|
56
|
+
cctally_extensions: bool
|
|
57
|
+
color: bool # ANSI on/off after auto-detect resolved
|
|
58
|
+
display_tz_name: str # IANA name; "UTC" if config absent
|
|
59
|
+
debug: bool
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---- Injection-ports (no defaults β every field MUST be supplied) ----------
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class StatuslineInjections:
|
|
67
|
+
"""Side-effecting callables. Unit tests pass simple lambdas; the I/O
|
|
68
|
+
layer in ``cmd_statusline`` passes DB- and filesystem-backed
|
|
69
|
+
implementations.
|
|
70
|
+
"""
|
|
71
|
+
# Sum of session_entries.cost WHERE session_id = ? (merged-resumed).
|
|
72
|
+
# Returns None if session_id unknown or cache miss.
|
|
73
|
+
cctally_session_cost: Callable[[Optional[str]], Optional[float]]
|
|
74
|
+
# Sum of session_entries.cost WHERE date(timestamp, tz) == today.
|
|
75
|
+
today_cost: Callable[[str, datetime], float]
|
|
76
|
+
# Active 5h block: returns (cost_usd, time_remaining_seconds, elapsed_seconds)
|
|
77
|
+
# or None if no active block.
|
|
78
|
+
active_block: Callable[[datetime], "Optional[tuple[float, int, int]]"]
|
|
79
|
+
# Returns (5h_hwm_pct, 7d_hwm_pct), both may be None.
|
|
80
|
+
hwm_clamp: Callable[
|
|
81
|
+
[Optional[int], Optional[int]], # five_resets, seven_resets epochs
|
|
82
|
+
"tuple[Optional[float], Optional[float]]",
|
|
83
|
+
]
|
|
84
|
+
# Latest weekly_usage_snapshots row as (five_pct, five_resets, seven_pct, seven_resets)
|
|
85
|
+
# or None.
|
|
86
|
+
db_latest_rate_limits: Callable[
|
|
87
|
+
[],
|
|
88
|
+
"Optional[tuple[Optional[float], Optional[int], Optional[float], Optional[int]]]",
|
|
89
|
+
]
|
|
90
|
+
# transcript_path β context % (0.0..100.0) or None if unreadable/unknown.
|
|
91
|
+
context_pct: Callable[[Optional[str], Optional[str]], Optional[float]]
|
|
92
|
+
# Emits one-shot stderr warnings (deduped by message β caller maintains set).
|
|
93
|
+
warn_once: Callable[[str], None]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---- ParseError sentinel ---------------------------------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class ParseError:
|
|
101
|
+
"""Returned by parse_statusline_stdin on JSON parse failure or
|
|
102
|
+
non-object root. The I/O layer maps this to exit 1 with a stderr
|
|
103
|
+
message and empty stdout.
|
|
104
|
+
"""
|
|
105
|
+
message: str
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def parse_statusline_stdin(raw: "bytes | str") -> "StatuslineInput | ParseError":
|
|
109
|
+
"""Parse the Claude Code hook stdin payload.
|
|
110
|
+
|
|
111
|
+
Returns ``StatuslineInput`` on success (every field optional), or
|
|
112
|
+
``ParseError`` if stdin is not parseable JSON OR not an object root.
|
|
113
|
+
Field-level absences are NOT errors β they degrade gracefully per
|
|
114
|
+
spec Β§3.1.
|
|
115
|
+
"""
|
|
116
|
+
try:
|
|
117
|
+
text = raw.decode("utf-8") if isinstance(raw, (bytes, bytearray)) else raw
|
|
118
|
+
parsed = json.loads(text)
|
|
119
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
120
|
+
return ParseError(f"invalid JSON: {exc}")
|
|
121
|
+
if not isinstance(parsed, dict):
|
|
122
|
+
typ = type(parsed).__name__
|
|
123
|
+
return ParseError(f"expected JSON object, got {typ}")
|
|
124
|
+
|
|
125
|
+
def _get(d, *path):
|
|
126
|
+
cur = d
|
|
127
|
+
for k in path:
|
|
128
|
+
if not isinstance(cur, dict):
|
|
129
|
+
return None
|
|
130
|
+
cur = cur.get(k)
|
|
131
|
+
return cur
|
|
132
|
+
|
|
133
|
+
def _to_epoch(v) -> Optional[int]:
|
|
134
|
+
if v is None:
|
|
135
|
+
return None
|
|
136
|
+
if isinstance(v, bool): # bool is an int subclass β exclude
|
|
137
|
+
return None
|
|
138
|
+
if isinstance(v, (int, float)):
|
|
139
|
+
return int(v)
|
|
140
|
+
if isinstance(v, str):
|
|
141
|
+
try:
|
|
142
|
+
# iso8601 with Z or offset
|
|
143
|
+
s = v.replace("Z", "+00:00")
|
|
144
|
+
dt_obj = datetime.fromisoformat(s)
|
|
145
|
+
if dt_obj.tzinfo is None:
|
|
146
|
+
dt_obj = dt_obj.replace(tzinfo=timezone.utc)
|
|
147
|
+
return int(dt_obj.timestamp())
|
|
148
|
+
except ValueError:
|
|
149
|
+
return None
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
def _to_float(v) -> Optional[float]:
|
|
153
|
+
if v is None:
|
|
154
|
+
return None
|
|
155
|
+
if isinstance(v, bool):
|
|
156
|
+
return None
|
|
157
|
+
try:
|
|
158
|
+
return float(v)
|
|
159
|
+
except (TypeError, ValueError):
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
def _to_str(v) -> Optional[str]:
|
|
163
|
+
return v if isinstance(v, str) and v else None
|
|
164
|
+
|
|
165
|
+
return StatuslineInput(
|
|
166
|
+
session_id=_to_str(parsed.get("session_id")),
|
|
167
|
+
model_id=_to_str(_get(parsed, "model", "id")),
|
|
168
|
+
model_display_name=_to_str(_get(parsed, "model", "display_name")),
|
|
169
|
+
transcript_path=_to_str(parsed.get("transcript_path")),
|
|
170
|
+
workspace_current_dir=_to_str(_get(parsed, "workspace", "current_dir")),
|
|
171
|
+
cost_total_usd=_to_float(_get(parsed, "cost", "total_cost_usd")),
|
|
172
|
+
rate_limits_5h_pct=_to_float(
|
|
173
|
+
_get(parsed, "rate_limits", "five_hour", "used_percentage")
|
|
174
|
+
),
|
|
175
|
+
rate_limits_5h_resets_at=_to_epoch(
|
|
176
|
+
_get(parsed, "rate_limits", "five_hour", "resets_at")
|
|
177
|
+
),
|
|
178
|
+
rate_limits_7d_pct=_to_float(
|
|
179
|
+
_get(parsed, "rate_limits", "seven_day", "used_percentage")
|
|
180
|
+
),
|
|
181
|
+
rate_limits_7d_resets_at=_to_epoch(
|
|
182
|
+
_get(parsed, "rate_limits", "seven_day", "resets_at")
|
|
183
|
+
),
|
|
184
|
+
raw=parsed,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ---- Segment 1: model -----------------------------------------------------
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def resolve_model_segment(inp: StatuslineInput) -> str:
|
|
192
|
+
"""Segment 1: `π€ <model>`. display_name > id > 'Unknown model'."""
|
|
193
|
+
name = inp.model_display_name or inp.model_id or "Unknown model"
|
|
194
|
+
return f"π€ {name}"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ---- Segment 2 components -------------------------------------------------
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _fmt_usd(v: float) -> str:
|
|
201
|
+
return f"${v:.2f}"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def resolve_session_cost(
|
|
205
|
+
inp: StatuslineInput,
|
|
206
|
+
cost_source: str,
|
|
207
|
+
inj: StatuslineInjections,
|
|
208
|
+
) -> str:
|
|
209
|
+
"""Segment 2 prefix β the `session` slot.
|
|
210
|
+
|
|
211
|
+
`cctally`/`auto` (when transcript+session_id available and cache hit):
|
|
212
|
+
sum session_entries WHERE session_id = ?
|
|
213
|
+
`auto` falls through to `cc` when:
|
|
214
|
+
- session_id absent, OR
|
|
215
|
+
- transcript_path absent, OR
|
|
216
|
+
- cache miss (cctally_session_cost returns None)
|
|
217
|
+
`cc`: stdin cost.total_cost_usd (absent β $0.00)
|
|
218
|
+
`both`: side-by-side `($X cc / $Y cctally) session`
|
|
219
|
+
"""
|
|
220
|
+
def _cctally_usable() -> Optional[float]:
|
|
221
|
+
# We require BOTH session_id (for the cache lookup key) AND
|
|
222
|
+
# transcript_path (proxy for "we trust the local cache" β its
|
|
223
|
+
# presence means CC believes a local transcript exists, so the
|
|
224
|
+
# session-entry cache should have ingested it). Future readers:
|
|
225
|
+
# don't drop the transcript guard without re-thinking that
|
|
226
|
+
# invariant.
|
|
227
|
+
if not inp.session_id or not inp.transcript_path:
|
|
228
|
+
return None
|
|
229
|
+
return inj.cctally_session_cost(inp.session_id)
|
|
230
|
+
|
|
231
|
+
cc = float(inp.cost_total_usd) if inp.cost_total_usd is not None else 0.0
|
|
232
|
+
|
|
233
|
+
if cost_source == "cctally":
|
|
234
|
+
v = _cctally_usable()
|
|
235
|
+
return f"{_fmt_usd(v if v is not None else 0.0)} session"
|
|
236
|
+
if cost_source == "cc":
|
|
237
|
+
return f"{_fmt_usd(cc)} session"
|
|
238
|
+
if cost_source == "both":
|
|
239
|
+
cct = _cctally_usable()
|
|
240
|
+
cct_val = cct if cct is not None else 0.0
|
|
241
|
+
return f"({_fmt_usd(cc)} cc / {_fmt_usd(cct_val)} cctally) session"
|
|
242
|
+
# auto (and any other value falls into auto behavior)
|
|
243
|
+
cct = _cctally_usable()
|
|
244
|
+
if cct is not None:
|
|
245
|
+
return f"{_fmt_usd(cct)} session"
|
|
246
|
+
return f"{_fmt_usd(cc)} session"
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def resolve_today_cost(
|
|
250
|
+
inp: StatuslineInput,
|
|
251
|
+
display_tz_name: str,
|
|
252
|
+
now: datetime,
|
|
253
|
+
inj: StatuslineInjections,
|
|
254
|
+
) -> str:
|
|
255
|
+
"""Segment 2 middle slot β `today`. Always cctally-source."""
|
|
256
|
+
cost = inj.today_cost(display_tz_name, now)
|
|
257
|
+
return f"{_fmt_usd(cost)} today"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _fmt_block_remaining(seconds: int) -> str:
|
|
261
|
+
s = max(seconds, 0)
|
|
262
|
+
h = s // 3600
|
|
263
|
+
m = (s % 3600) // 60
|
|
264
|
+
return f"{h}h {m}m left"
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def resolve_block_segment(
|
|
268
|
+
inp: StatuslineInput,
|
|
269
|
+
now: datetime,
|
|
270
|
+
inj: StatuslineInjections,
|
|
271
|
+
) -> "tuple[str, tuple[float, int]]":
|
|
272
|
+
"""Segment 2 tail slot β `block (Xh Ym left)`.
|
|
273
|
+
|
|
274
|
+
Returns the formatted segment string AND a tuple
|
|
275
|
+
``(block_cost, elapsed_seconds)`` for the downstream burn-rate
|
|
276
|
+
resolver.
|
|
277
|
+
"""
|
|
278
|
+
blk = inj.active_block(now)
|
|
279
|
+
if blk is None:
|
|
280
|
+
# No active block β clamp to 5h0m left, $0.00.
|
|
281
|
+
return ("$0.00 block (5h 0m left)", (0.0, 1))
|
|
282
|
+
cost, remaining_s, elapsed_s = blk
|
|
283
|
+
seg = f"{_fmt_usd(cost)} block ({_fmt_block_remaining(remaining_s)})"
|
|
284
|
+
return (seg, (cost, max(elapsed_s, 1)))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ---- Segment 3: burn rate -------------------------------------------------
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# Burn rate bands (mirrors ccusage at the time of writing). A future
|
|
291
|
+
# bump-to-match-ccusage PR is a one-tuple edit.
|
|
292
|
+
STATUSLINE_BURN_RATE_BANDS = (
|
|
293
|
+
# (upper_bound_exclusive_usd_per_hr, emoji, text)
|
|
294
|
+
(15.00, "π’", "Normal"),
|
|
295
|
+
(30.00, "π‘", "Moderate"),
|
|
296
|
+
(float("inf"), "π΄", "High"),
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def resolve_burn_rate(
|
|
301
|
+
block_cost: float,
|
|
302
|
+
elapsed_seconds: int,
|
|
303
|
+
visual: str,
|
|
304
|
+
color: bool, # color injection deferred to render_statusline; passthrough here
|
|
305
|
+
) -> str:
|
|
306
|
+
"""Segment 3 β `π₯ $X.XX/hr [visual]`.
|
|
307
|
+
|
|
308
|
+
``visual`` β {off, emoji, text, emoji-text}.
|
|
309
|
+
"""
|
|
310
|
+
rate = block_cost / max(elapsed_seconds, 1) * 3600.0
|
|
311
|
+
base = f"π₯ {_fmt_usd(rate)}/hr"
|
|
312
|
+
if visual == "off":
|
|
313
|
+
return base
|
|
314
|
+
# Find band.
|
|
315
|
+
emoji = text = ""
|
|
316
|
+
for upper, e, t in STATUSLINE_BURN_RATE_BANDS:
|
|
317
|
+
if rate < upper:
|
|
318
|
+
emoji, text = e, t
|
|
319
|
+
break
|
|
320
|
+
if visual == "emoji":
|
|
321
|
+
return f"{base} {emoji}"
|
|
322
|
+
if visual == "text":
|
|
323
|
+
return f"{base} ({text})"
|
|
324
|
+
# emoji-text
|
|
325
|
+
return f"{base} {emoji} ({text})"
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# ---- Segment 4: context % -------------------------------------------------
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def resolve_context_pct(
|
|
332
|
+
inp: StatuslineInput,
|
|
333
|
+
args: StatuslineArgs,
|
|
334
|
+
inj: StatuslineInjections,
|
|
335
|
+
) -> str:
|
|
336
|
+
"""Segment 4 β `π§ X%` or `π§ N/A`.
|
|
337
|
+
|
|
338
|
+
Color band selection is the render kernel's job, not this resolver β
|
|
339
|
+
this function only returns the plain `π§ X%` form. ``render_statusline``
|
|
340
|
+
wraps the result in ANSI color codes per ``args.color``.
|
|
341
|
+
"""
|
|
342
|
+
pct = inj.context_pct(inp.transcript_path, inp.model_id)
|
|
343
|
+
if pct is None:
|
|
344
|
+
return "π§ N/A"
|
|
345
|
+
return f"π§ {int(round(pct))}%"
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# ---- Segment 5: cctally extensions ----------------------------------------
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _fmt_countdown(seconds: int) -> str:
|
|
352
|
+
"""Human-friendly countdown β same shape as the user's bash
|
|
353
|
+
statusline-command.sh: `Xd Yh`, `Xh Ym`, or `Xm`.
|
|
354
|
+
"""
|
|
355
|
+
s = max(seconds, 0)
|
|
356
|
+
days = s // 86400
|
|
357
|
+
hours = (s % 86400) // 3600
|
|
358
|
+
minutes = (s % 3600) // 60
|
|
359
|
+
if days > 0:
|
|
360
|
+
return f"{days}d {hours}h"
|
|
361
|
+
if hours > 0:
|
|
362
|
+
return f"{hours}h {minutes}m"
|
|
363
|
+
return f"{minutes}m"
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def resolve_cctally_extensions(
|
|
367
|
+
inp: StatuslineInput,
|
|
368
|
+
now: datetime,
|
|
369
|
+
inj: StatuslineInjections,
|
|
370
|
+
) -> Optional[str]:
|
|
371
|
+
"""Segment 5 β cctally-only `5h X% (...) Β· 7d Y% (...)`.
|
|
372
|
+
|
|
373
|
+
Source priority chain (spec Β§3.5):
|
|
374
|
+
1. stdin rate_limits (preferred β freshest)
|
|
375
|
+
2. DB latest weekly_usage_snapshots row (if stdin EMPTY)
|
|
376
|
+
3. HWM monotonic clamp (within window only)
|
|
377
|
+
4. If all empty β return None (segment 5 suppressed)
|
|
378
|
+
"""
|
|
379
|
+
five_pct = inp.rate_limits_5h_pct
|
|
380
|
+
five_resets = inp.rate_limits_5h_resets_at
|
|
381
|
+
seven_pct = inp.rate_limits_7d_pct
|
|
382
|
+
seven_resets = inp.rate_limits_7d_resets_at
|
|
383
|
+
|
|
384
|
+
# If stdin entirely empty, try DB fallback.
|
|
385
|
+
stdin_empty = (
|
|
386
|
+
five_pct is None and five_resets is None
|
|
387
|
+
and seven_pct is None and seven_resets is None
|
|
388
|
+
)
|
|
389
|
+
if stdin_empty:
|
|
390
|
+
db = inj.db_latest_rate_limits()
|
|
391
|
+
if db is not None:
|
|
392
|
+
five_pct, five_resets, seven_pct, seven_resets = db
|
|
393
|
+
|
|
394
|
+
# HWM clamp β monotonic UP only.
|
|
395
|
+
hwm_5h, hwm_7d = inj.hwm_clamp(five_resets, seven_resets)
|
|
396
|
+
if five_pct is not None and hwm_5h is not None and hwm_5h > five_pct:
|
|
397
|
+
five_pct = hwm_5h
|
|
398
|
+
if seven_pct is not None and hwm_7d is not None and hwm_7d > seven_pct:
|
|
399
|
+
seven_pct = hwm_7d
|
|
400
|
+
|
|
401
|
+
# Suppress segment 5 if nothing to render.
|
|
402
|
+
if five_pct is None and seven_pct is None:
|
|
403
|
+
return None
|
|
404
|
+
|
|
405
|
+
now_epoch = int(now.timestamp())
|
|
406
|
+
parts = []
|
|
407
|
+
if five_pct is not None:
|
|
408
|
+
s = f"5h {int(round(five_pct))}%"
|
|
409
|
+
if five_resets is not None:
|
|
410
|
+
s += f" ({_fmt_countdown(five_resets - now_epoch)})"
|
|
411
|
+
parts.append(s)
|
|
412
|
+
if seven_pct is not None:
|
|
413
|
+
s = f"7d {int(round(seven_pct))}%"
|
|
414
|
+
if seven_resets is not None:
|
|
415
|
+
s += f" ({_fmt_countdown(seven_resets - now_epoch)})"
|
|
416
|
+
parts.append(s)
|
|
417
|
+
return " Β· ".join(parts)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
# ---- Top-level render -----------------------------------------------------
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
# ANSI color codes (only emitted when args.color is True).
|
|
424
|
+
_ANSI = {
|
|
425
|
+
"green": "\033[32m",
|
|
426
|
+
"yellow": "\033[33m",
|
|
427
|
+
"red": "\033[31m",
|
|
428
|
+
"reset": "\033[0m",
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _wrap_color(text: str, color: Optional[str], enable: bool) -> str:
|
|
433
|
+
if not enable or color is None:
|
|
434
|
+
return text
|
|
435
|
+
return f"{_ANSI[color]}{text}{_ANSI['reset']}"
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
_PERCENT_INT_RE = re.compile(r"(\d+)%")
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def render_statusline(
|
|
442
|
+
inp: StatuslineInput,
|
|
443
|
+
args: StatuslineArgs,
|
|
444
|
+
inj: StatuslineInjections,
|
|
445
|
+
now: datetime,
|
|
446
|
+
) -> str:
|
|
447
|
+
"""Top-level render chokepoint. Joins segments with ` | `; suppresses
|
|
448
|
+
None segments (currently only segment 5). See spec Β§1 for the exact
|
|
449
|
+
layout and Β§3 for the data flow.
|
|
450
|
+
"""
|
|
451
|
+
seg1 = resolve_model_segment(inp)
|
|
452
|
+
|
|
453
|
+
# Segment 2: π° ... session / ... today / ... block (Xh Ym left)
|
|
454
|
+
session = resolve_session_cost(inp, args.cost_source, inj)
|
|
455
|
+
today = resolve_today_cost(inp, args.display_tz_name, now, inj)
|
|
456
|
+
block, burn_kwargs = resolve_block_segment(inp, now, inj)
|
|
457
|
+
seg2 = f"π° {session} / {today} / {block}"
|
|
458
|
+
|
|
459
|
+
# Segment 3: π₯ $X.XX/hr [visual]
|
|
460
|
+
seg3 = resolve_burn_rate(
|
|
461
|
+
burn_kwargs[0], burn_kwargs[1], args.visual_burn_rate, args.color
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
# Segment 4: π§ X% with color band
|
|
465
|
+
pct_text = resolve_context_pct(inp, args, inj)
|
|
466
|
+
if pct_text == "π§ N/A":
|
|
467
|
+
seg4 = pct_text
|
|
468
|
+
else:
|
|
469
|
+
m = _PERCENT_INT_RE.search(pct_text)
|
|
470
|
+
n = int(m.group(1)) if m else 0
|
|
471
|
+
if n < args.context_low_threshold:
|
|
472
|
+
color = "green"
|
|
473
|
+
elif n < args.context_medium_threshold:
|
|
474
|
+
color = "yellow"
|
|
475
|
+
else:
|
|
476
|
+
color = "red"
|
|
477
|
+
seg4 = _wrap_color(pct_text, color, args.color)
|
|
478
|
+
|
|
479
|
+
# Segment 5: cctally extension (may be None)
|
|
480
|
+
seg5 = None
|
|
481
|
+
if args.cctally_extensions:
|
|
482
|
+
ext = resolve_cctally_extensions(inp, now, inj)
|
|
483
|
+
if ext is not None:
|
|
484
|
+
# Color by the higher of (5h%, 7d%) using cctally bands:
|
|
485
|
+
# <60 green, <85 yellow, >=85 red.
|
|
486
|
+
nums = [int(x) for x in _PERCENT_INT_RE.findall(ext)]
|
|
487
|
+
mx = max(nums) if nums else 0
|
|
488
|
+
if mx < 60:
|
|
489
|
+
color = "green"
|
|
490
|
+
elif mx < 85:
|
|
491
|
+
color = "yellow"
|
|
492
|
+
else:
|
|
493
|
+
color = "red"
|
|
494
|
+
seg5 = _wrap_color(ext, color, args.color)
|
|
495
|
+
|
|
496
|
+
segs = [seg1, seg2, seg3, seg4]
|
|
497
|
+
if seg5 is not None:
|
|
498
|
+
segs.append(seg5)
|
|
499
|
+
return " | ".join(segs)
|