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 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
@@ -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)