cctally 1.18.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.
@@ -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)