cctally 1.19.0 → 1.20.1

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/bin/cctally CHANGED
@@ -150,6 +150,7 @@ SETUP_SYMLINK_NAMES = (
150
150
  "cctally-forecast",
151
151
  "cctally-project",
152
152
  "cctally-refresh-usage",
153
+ "cctally-statusline",
153
154
  "cctally-sync-week",
154
155
  "cctally-tui",
155
156
  "cctally-update",
@@ -274,6 +275,26 @@ CODEX_FAST_MULTIPLIER_FALLBACK = _lib_pricing.CODEX_FAST_MULTIPLIER_FALLBACK
274
275
  _codex_config_requests_fast_service_tier = _lib_pricing._codex_config_requests_fast_service_tier
275
276
  _short_model_name = _lib_pricing._short_model_name
276
277
 
278
+ # Per-model context window (used by `cctally statusline` segment 4).
279
+ # Keep in sync with Anthropic's docs:
280
+ # https://docs.anthropic.com/en/docs/about-claude/models
281
+ # Unknown model id → segment renders `🧠 N/A` + one-shot stderr warn.
282
+ CLAUDE_MODEL_CONTEXT_WINDOWS = {
283
+ # 1M-token variants (explicit IDs override the family default).
284
+ "claude-opus-4-7[1m]": 1_000_000,
285
+ "claude-sonnet-4-5[1m]": 1_000_000,
286
+ # Default 200K for every other Sonnet/Opus/Haiku family member.
287
+ # The resolver does a substring match on the family token if the
288
+ # exact id is missing — see _resolve_context_window.
289
+ }
290
+
291
+ CLAUDE_MODEL_CONTEXT_WINDOW_DEFAULT_FAMILY = {
292
+ # Substring (case-insensitive) → window. Order matters; first hit wins.
293
+ "sonnet": 200_000,
294
+ "opus": 200_000,
295
+ "haiku": 200_000,
296
+ }
297
+
277
298
  _lib_display_tz = _load_sibling("_lib_display_tz")
278
299
  DISPLAY_TZ_DEFAULT = _lib_display_tz.DISPLAY_TZ_DEFAULT
279
300
  _DISPLAY_TZ_BAD_CONFIG_WARNED = _lib_display_tz._DISPLAY_TZ_BAD_CONFIG_WARNED
@@ -352,6 +373,9 @@ _blocks_to_json = _lib_blocks._blocks_to_json
352
373
  _max_completed_block_tokens = _lib_blocks._max_completed_block_tokens
353
374
  _parse_blocks_token_limit = _lib_blocks._parse_blocks_token_limit
354
375
 
376
+ _lib_statusline = _load_sibling("_lib_statusline")
377
+ STATUSLINE_BURN_RATE_BANDS = _lib_statusline.STATUSLINE_BURN_RATE_BANDS
378
+
355
379
  _lib_changelog = _load_sibling("_lib_changelog")
356
380
  # Backward-compat re-export: callers and tests reach the helper through
357
381
  # ``cctally._release_read_latest_release_version`` (incl. monkeypatch
@@ -4469,6 +4493,524 @@ def cmd_blocks(args: argparse.Namespace) -> int:
4469
4493
  return 0
4470
4494
 
4471
4495
 
4496
+ def _resolve_statusline_tz(cli_tz, cfg, warn_once):
4497
+ """Resolve the IANA tz_name for cmd_statusline using the same 3-rung
4498
+ precedence as every other reporting command:
4499
+
4500
+ CLI ``--timezone`` > ``config.display.tz`` > DISPLAY_TZ_DEFAULT ("local")
4501
+
4502
+ "local" is converted to a real IANA via ``_local_tz_name()`` before
4503
+ returning. Unknown IANA names emit a one-shot warning and fall back
4504
+ to ``"UTC"``. Returns a real IANA name (or ``"UTC"``) — never the
4505
+ literal sentinel ``"local"``.
4506
+
4507
+ Prior to #86 G follow-up, this defaulted to ``"UTC"`` when no config
4508
+ was set, so ``today`` computed on the UTC calendar day while
4509
+ ``cctally daily`` (and every other reporting command) used the local
4510
+ day — UTC-offset users saw a multi-hour lag between statusline and
4511
+ daily. Regression: tests/test_statusline.py::TestTzResolution.
4512
+ """
4513
+ tz_name = cli_tz
4514
+ if not tz_name:
4515
+ tz_name = get_display_tz_pref(cfg)
4516
+ if tz_name in ("local", "LOCAL"):
4517
+ try:
4518
+ tz_name = _local_tz_name() or "UTC"
4519
+ except Exception:
4520
+ tz_name = "UTC"
4521
+ try:
4522
+ ZoneInfo(tz_name)
4523
+ except (ZoneInfoNotFoundError, Exception):
4524
+ warn_once(
4525
+ f"cctally statusline: invalid timezone {tz_name!r}; using 'UTC'"
4526
+ )
4527
+ tz_name = "UTC"
4528
+ return tz_name
4529
+
4530
+
4531
+ def cmd_statusline(args: argparse.Namespace) -> int:
4532
+ """`cctally statusline` — one-line status summary for CC hooks.
4533
+
4534
+ See docs/superpowers/specs/2026-05-28-issue-86-session-g-statusline-design.md
4535
+ for the full design.
4536
+
4537
+ Exit codes:
4538
+ 0 success (every absent stdin field degrades gracefully)
4539
+ 1 stdin is not parseable JSON OR root is not a JSON object
4540
+ 2 argparse rejected a flag (e.g. --cost-source ccusage), OR
4541
+ --config PATH unreadable
4542
+ """
4543
+ # NOTE: `--cost-source ccusage` is rejected at argparse-time by
4544
+ # `_CostSourceAction` in `_build_statusline_parser`; it exits 2 with
4545
+ # the rename hint before we get here, so no explicit re-check is
4546
+ # needed in this function.
4547
+
4548
+ # Validate `--context-{low,medium}-threshold` BEFORE reading stdin
4549
+ # so a misconfigured invocation fails fast without consuming the
4550
+ # CC hook's stdin payload.
4551
+ low = args.context_low_threshold
4552
+ med = args.context_medium_threshold
4553
+ if not isinstance(low, int) or low < 0 or low > 100:
4554
+ eprint(
4555
+ "cctally statusline: --context-low-threshold must be in [0, 100]"
4556
+ )
4557
+ return 2
4558
+ if not isinstance(med, int) or med < 0 or med > 100:
4559
+ eprint(
4560
+ "cctally statusline: --context-medium-threshold must be in [0, 100]"
4561
+ )
4562
+ return 2
4563
+ if low >= med:
4564
+ eprint(
4565
+ "cctally statusline: --context-low-threshold must be < "
4566
+ "--context-medium-threshold"
4567
+ )
4568
+ return 2
4569
+
4570
+ # Silently clamp `--refresh-interval` to [0, 600]. The flag is a
4571
+ # no-op alias for ccusage drop-in compat; users never observe the
4572
+ # effect, but the spec mandates the clamp for forward-compat (when
4573
+ # we promote it to a real flag, the clamped value should be the one
4574
+ # propagated downstream).
4575
+ try:
4576
+ args.refresh_interval = max(0, min(600, int(args.refresh_interval)))
4577
+ except (TypeError, ValueError):
4578
+ args.refresh_interval = 1
4579
+
4580
+ # Read stdin once.
4581
+ raw = sys.stdin.buffer.read()
4582
+ parse_result = _lib_statusline.parse_statusline_stdin(raw)
4583
+ if isinstance(parse_result, _lib_statusline.ParseError):
4584
+ eprint(f"cctally statusline: {parse_result.message}")
4585
+ return 1
4586
+ inp = parse_result
4587
+
4588
+ # Resolve effective config: CLI > config.json > built-in default.
4589
+ # `_load_claude_config_for_args` honors `--config PATH` (issue #88
4590
+ # plumbing); a missing/invalid PATH raises SystemExit(2) inside
4591
+ # `_load_config_from_explicit_path` so this call already enforces
4592
+ # exit-2 on a bad --config.
4593
+ cfg = _load_claude_config_for_args(args)
4594
+ sl_cfg = (cfg.get("statusline") or {}) if isinstance(cfg, dict) else {}
4595
+ if not isinstance(sl_cfg, dict):
4596
+ sl_cfg = {}
4597
+
4598
+ # Validate config values; on invalid, one-shot stderr warn + use default.
4599
+ _warned: set = set()
4600
+
4601
+ def warn_once(msg: str) -> None:
4602
+ if msg in _warned:
4603
+ return
4604
+ _warned.add(msg)
4605
+ eprint(msg)
4606
+
4607
+ def _resolve(cli_val, cfg_key, default):
4608
+ if cli_val is not None:
4609
+ return cli_val
4610
+ cv = sl_cfg.get(cfg_key)
4611
+ if cv is None:
4612
+ return default
4613
+ return cv
4614
+
4615
+ vbr = _resolve(args.visual_burn_rate, "visual_burn_rate", "off")
4616
+ if vbr not in ("off", "emoji", "text", "emoji-text"):
4617
+ warn_once(
4618
+ f"cctally statusline: invalid statusline.visual_burn_rate={vbr!r}; "
4619
+ f"using 'off'"
4620
+ )
4621
+ vbr = "off"
4622
+
4623
+ cs = _resolve(args.cost_source, "cost_source", "auto")
4624
+ if cs not in ("auto", "cctally", "cc", "both"):
4625
+ warn_once(
4626
+ f"cctally statusline: invalid statusline.cost_source={cs!r}; "
4627
+ f"using 'auto'"
4628
+ )
4629
+ cs = "auto"
4630
+
4631
+ ext_on = _resolve(args.cctally_extensions, "cctally_extensions", True)
4632
+ if not isinstance(ext_on, bool):
4633
+ warn_once(
4634
+ f"cctally statusline: invalid statusline.cctally_extensions="
4635
+ f"{ext_on!r}; using True"
4636
+ )
4637
+ ext_on = True
4638
+
4639
+ tz_name = _resolve_statusline_tz(getattr(args, "timezone", None), cfg, warn_once)
4640
+
4641
+ # Color: explicit CLI > NO_COLOR env > TTY detect.
4642
+ if args.color is True or args.color is False:
4643
+ color = args.color
4644
+ else:
4645
+ color = (os.environ.get("NO_COLOR", "") == "") and sys.stdout.isatty()
4646
+
4647
+ sargs = _lib_statusline.StatuslineArgs(
4648
+ visual_burn_rate=vbr,
4649
+ cost_source=cs,
4650
+ context_low_threshold=int(args.context_low_threshold),
4651
+ context_medium_threshold=int(args.context_medium_threshold),
4652
+ cctally_extensions=bool(ext_on),
4653
+ color=bool(color),
4654
+ display_tz_name=tz_name,
4655
+ debug=bool(args.debug),
4656
+ )
4657
+
4658
+ # Build injections (DB + transcript file IO).
4659
+ inj = _build_statusline_injections(warn_once)
4660
+
4661
+ # `_command_as_of()` honors the `CCTALLY_AS_OF` testing hook so the
4662
+ # golden harness can pin "now" for deterministic block-remaining and
4663
+ # 5h/7d countdown numbers. Falls back to wall-clock UTC otherwise.
4664
+ now = _command_as_of()
4665
+ try:
4666
+ line = _lib_statusline.render_statusline(inp, sargs, inj, now)
4667
+ except Exception as exc: # pragma: no cover — defensive
4668
+ eprint(f"cctally statusline: render failed: {exc}")
4669
+ return 1
4670
+ print(line)
4671
+ return 0
4672
+
4673
+
4674
+ def _resolve_context_window(model_id, warn_once) -> "int | None":
4675
+ """Look up ``model_id`` in ``CLAUDE_MODEL_CONTEXT_WINDOWS``; fall back
4676
+ to a family-substring match against
4677
+ ``CLAUDE_MODEL_CONTEXT_WINDOW_DEFAULT_FAMILY``. Unknown id → ``None`` +
4678
+ one-shot stderr warning.
4679
+ """
4680
+ if not model_id:
4681
+ return None
4682
+ if model_id in CLAUDE_MODEL_CONTEXT_WINDOWS:
4683
+ return CLAUDE_MODEL_CONTEXT_WINDOWS[model_id]
4684
+ mid_lower = model_id.lower()
4685
+ for family, window in CLAUDE_MODEL_CONTEXT_WINDOW_DEFAULT_FAMILY.items():
4686
+ if family in mid_lower:
4687
+ return window
4688
+ warn_once(
4689
+ f"cctally statusline: unknown model {model_id!r}; context % unavailable"
4690
+ )
4691
+ return None
4692
+
4693
+
4694
+ def _read_last_assistant_usage(transcript_path):
4695
+ """Tail-walk the transcript JSONL backwards to the most recent
4696
+ ``type=assistant`` line carrying ``message.usage``. Returns the usage
4697
+ dict or ``None``.
4698
+
4699
+ Reads in 64 KB chunks from the end so multi-MB transcripts don't
4700
+ block the hot statusline path with a full-file parse.
4701
+ """
4702
+ if not transcript_path:
4703
+ return None
4704
+ path = pathlib.Path(transcript_path)
4705
+ if not path.exists():
4706
+ return None
4707
+ try:
4708
+ with path.open("rb") as fh:
4709
+ fh.seek(0, 2)
4710
+ size = fh.tell()
4711
+ tail = b""
4712
+ chunk = 65536
4713
+ # Read backwards in chunks until we have at least one full line
4714
+ # and the tail starts with a newline (so the first line is whole).
4715
+ while size > 0 and tail.count(b"\n") < 2:
4716
+ read_at = max(0, size - chunk)
4717
+ fh.seek(read_at)
4718
+ tail = fh.read(size - read_at) + tail
4719
+ size = read_at
4720
+ lines = tail.split(b"\n")
4721
+ except OSError:
4722
+ return None
4723
+ for line in reversed(lines):
4724
+ line = line.strip()
4725
+ if not line:
4726
+ continue
4727
+ try:
4728
+ obj = json.loads(line)
4729
+ except Exception:
4730
+ continue
4731
+ if not isinstance(obj, dict):
4732
+ continue
4733
+ if obj.get("type") != "assistant":
4734
+ continue
4735
+ msg = obj.get("message") or {}
4736
+ usage = msg.get("usage") if isinstance(msg, dict) else None
4737
+ if isinstance(usage, dict):
4738
+ return usage
4739
+ return None
4740
+
4741
+
4742
+ def _build_statusline_injections(warn_once):
4743
+ """Wire DB- and FS-backed implementations for the kernel's injection ports.
4744
+
4745
+ See ``_lib_statusline.StatuslineInjections`` for the contract. All
4746
+ callables fast-fail to "no data" on any exception — statusline must
4747
+ NEVER block the Claude Code hook tick.
4748
+ """
4749
+ def _cctally_session_cost(sid):
4750
+ if not sid:
4751
+ return None
4752
+ try:
4753
+ conn = open_cache_db()
4754
+ except Exception:
4755
+ return None
4756
+ try:
4757
+ # Walk all entries via session_files join; sum costs whose
4758
+ # session_id matches. Stays read-only — does NOT call
4759
+ # sync_cache (too heavy for the hot statusline path; the
4760
+ # record-usage + hook-tick paths keep the cache warm).
4761
+ sql = (
4762
+ "SELECT se.timestamp_utc, se.model, "
4763
+ " se.input_tokens, se.output_tokens, "
4764
+ " se.cache_create_tokens, se.cache_read_tokens, "
4765
+ " se.cost_usd_raw, se.usage_extra_json "
4766
+ "FROM session_entries se "
4767
+ "LEFT JOIN session_files sf ON sf.path = se.source_path "
4768
+ "WHERE sf.session_id = ?"
4769
+ )
4770
+ rows = list(conn.execute(sql, (sid,)))
4771
+ except Exception:
4772
+ return None
4773
+ finally:
4774
+ try:
4775
+ conn.close()
4776
+ except Exception:
4777
+ pass
4778
+ if not rows:
4779
+ return None
4780
+ total = 0.0
4781
+ for r in rows:
4782
+ usage = {
4783
+ "input_tokens": r[2] or 0,
4784
+ "output_tokens": r[3] or 0,
4785
+ "cache_creation_input_tokens": r[4] or 0,
4786
+ "cache_read_input_tokens": r[5] or 0,
4787
+ }
4788
+ try:
4789
+ if r[7]:
4790
+ extras = json.loads(r[7])
4791
+ if isinstance(extras, dict):
4792
+ usage.update(extras)
4793
+ except Exception:
4794
+ pass
4795
+ try:
4796
+ total += _calculate_entry_cost(
4797
+ r[1], usage, mode="auto", cost_usd=r[6],
4798
+ )
4799
+ except Exception:
4800
+ continue
4801
+ return total
4802
+
4803
+ def _today_cost(tz_name, now):
4804
+ try:
4805
+ tz = ZoneInfo(tz_name) if tz_name and tz_name != "UTC" else dt.timezone.utc
4806
+ except Exception:
4807
+ tz = dt.timezone.utc
4808
+ local_now = now.astimezone(tz)
4809
+ day_start_local = local_now.replace(
4810
+ hour=0, minute=0, second=0, microsecond=0
4811
+ )
4812
+ day_end_local = day_start_local + dt.timedelta(days=1)
4813
+ range_start = day_start_local.astimezone(dt.timezone.utc)
4814
+ range_end = day_end_local.astimezone(dt.timezone.utc)
4815
+ # Two-filter pattern (UTC half-open + display-tz date check):
4816
+ # the UTC range fetches the candidate window cheaply via the
4817
+ # cache's indexed `timestamp` column; the display-tz date check
4818
+ # then trims any entries that fall outside today's local
4819
+ # calendar day (the UTC window straddles two local dates when
4820
+ # the display tz has any UTC offset, so the SQL range alone is
4821
+ # slightly wider than the local day).
4822
+ try:
4823
+ entries = get_entries(range_start, range_end, skip_sync=True)
4824
+ except Exception:
4825
+ return 0.0
4826
+ total = 0.0
4827
+ for e in entries:
4828
+ try:
4829
+ # Filter to today in display tz (second-pass trim).
4830
+ ts_local = e.timestamp.astimezone(tz)
4831
+ if ts_local.date() != local_now.date():
4832
+ continue
4833
+ total += _calculate_entry_cost(
4834
+ e.model, e.usage, mode="auto", cost_usd=e.cost_usd,
4835
+ )
4836
+ except Exception:
4837
+ continue
4838
+ return total
4839
+
4840
+ def _active_block(now):
4841
+ try:
4842
+ # Look at last 24h — captures the full active 5h window.
4843
+ range_start = now - dt.timedelta(hours=24)
4844
+ entries = get_entries(range_start, now, skip_sync=True)
4845
+ except Exception:
4846
+ return None
4847
+ if not entries:
4848
+ return None
4849
+ try:
4850
+ recorded_windows, block_start_overrides, canonical_intervals = (
4851
+ _load_recorded_five_hour_windows(
4852
+ range_start - BLOCK_DURATION, now + BLOCK_DURATION,
4853
+ )
4854
+ )
4855
+ except Exception:
4856
+ recorded_windows, block_start_overrides, canonical_intervals = (
4857
+ [], {}, {},
4858
+ )
4859
+ try:
4860
+ blocks = _group_entries_into_blocks(
4861
+ entries,
4862
+ mode="auto",
4863
+ recorded_windows=recorded_windows,
4864
+ block_start_overrides=block_start_overrides,
4865
+ canonical_intervals=canonical_intervals,
4866
+ now=now,
4867
+ )
4868
+ except Exception:
4869
+ return None
4870
+ for b in blocks:
4871
+ if not b.is_gap and b.is_active:
4872
+ remaining_s = int((b.end_time - now).total_seconds())
4873
+ elapsed_s = int((now - b.start_time).total_seconds())
4874
+ return (float(b.cost_usd or 0.0), remaining_s, elapsed_s)
4875
+ return None
4876
+
4877
+ def _hwm_clamp(five_resets, seven_resets):
4878
+ five_hwm = None
4879
+ seven_hwm = None
4880
+ try:
4881
+ conn = open_db()
4882
+ except Exception:
4883
+ return (None, None)
4884
+ try:
4885
+ if five_resets is not None:
4886
+ try:
4887
+ key = _canonical_5h_window_key(int(five_resets))
4888
+ row = conn.execute(
4889
+ "SELECT MAX(five_hour_percent) "
4890
+ "FROM weekly_usage_snapshots "
4891
+ "WHERE five_hour_window_key = ?",
4892
+ (key,),
4893
+ ).fetchone()
4894
+ if row and row[0] is not None:
4895
+ five_hwm = float(row[0])
4896
+ except Exception:
4897
+ pass
4898
+ if seven_resets is not None:
4899
+ try:
4900
+ # Seven-day window: derive week_start_date from the
4901
+ # resets_at epoch (reset - 7 days, UTC date).
4902
+ week_start_date = dt.datetime.fromtimestamp(
4903
+ int(seven_resets) - 7 * 86400, tz=dt.timezone.utc,
4904
+ ).date().isoformat()
4905
+ row = conn.execute(
4906
+ "SELECT MAX(weekly_percent) "
4907
+ "FROM weekly_usage_snapshots "
4908
+ "WHERE week_start_date = ?",
4909
+ (week_start_date,),
4910
+ ).fetchone()
4911
+ if row and row[0] is not None:
4912
+ seven_hwm = float(row[0])
4913
+ except Exception:
4914
+ pass
4915
+ finally:
4916
+ try:
4917
+ conn.close()
4918
+ except Exception:
4919
+ pass
4920
+ return (five_hwm, seven_hwm)
4921
+
4922
+ def _db_latest_rate_limits():
4923
+ try:
4924
+ conn = open_db()
4925
+ except Exception:
4926
+ return None
4927
+ try:
4928
+ # Prefer `week_end_at` (ISO timestamp; sub-day precision) over
4929
+ # `week_end_date` (date-only; UTC-midnight). Older snapshots
4930
+ # may have `week_end_at` NULL — fall back to the date column
4931
+ # in that case. See the neighbor query in `pick_week_selection`
4932
+ # (bin/cctally:3849) for the precedent.
4933
+ row = conn.execute(
4934
+ "SELECT five_hour_percent, five_hour_window_key, "
4935
+ " weekly_percent, week_end_at, week_end_date "
4936
+ "FROM weekly_usage_snapshots "
4937
+ "ORDER BY captured_at_utc DESC, id DESC LIMIT 1"
4938
+ ).fetchone()
4939
+ if not row:
4940
+ return None
4941
+ five_pct = float(row[0]) if row[0] is not None else None
4942
+ five_resets = int(row[1]) if row[1] is not None else None
4943
+ seven_pct = float(row[2]) if row[2] is not None else None
4944
+ seven_resets = None
4945
+ week_end_at = row[3]
4946
+ week_end_date = row[4]
4947
+ if week_end_at:
4948
+ try:
4949
+ # `datetime.fromisoformat` accepts the trailing `Z`
4950
+ # only on Python 3.11+; normalize to `+00:00` so 3.10
4951
+ # checkouts (and any odd Z-suffixed snapshot) parse.
4952
+ raw_iso = str(week_end_at)
4953
+ if raw_iso.endswith("Z"):
4954
+ raw_iso = raw_iso[:-1] + "+00:00"
4955
+ end_dt = dt.datetime.fromisoformat(raw_iso)
4956
+ if end_dt.tzinfo is None:
4957
+ end_dt = end_dt.replace(tzinfo=dt.timezone.utc)
4958
+ seven_resets = int(end_dt.timestamp())
4959
+ except Exception:
4960
+ seven_resets = None
4961
+ if seven_resets is None and week_end_date:
4962
+ try:
4963
+ end_dt = dt.datetime.fromisoformat(str(week_end_date))
4964
+ if end_dt.tzinfo is None:
4965
+ end_dt = end_dt.replace(tzinfo=dt.timezone.utc)
4966
+ # week_end_date is exclusive — that's the reset moment.
4967
+ seven_resets = int(end_dt.timestamp())
4968
+ except Exception:
4969
+ seven_resets = None
4970
+ return (five_pct, five_resets, seven_pct, seven_resets)
4971
+ except Exception:
4972
+ return None
4973
+ finally:
4974
+ try:
4975
+ conn.close()
4976
+ except Exception:
4977
+ pass
4978
+
4979
+ def _context_pct(transcript_path, model_id):
4980
+ if not transcript_path or not model_id:
4981
+ return None
4982
+ window = _resolve_context_window(model_id, warn_once)
4983
+ if window is None:
4984
+ return None
4985
+ try:
4986
+ usage = _read_last_assistant_usage(transcript_path)
4987
+ except Exception:
4988
+ return None
4989
+ if not isinstance(usage, dict):
4990
+ return None
4991
+ try:
4992
+ ctx_tokens = (
4993
+ int(usage.get("input_tokens", 0) or 0)
4994
+ + int(usage.get("cache_read_input_tokens", 0) or 0)
4995
+ + int(usage.get("cache_creation_input_tokens", 0) or 0)
4996
+ )
4997
+ except (TypeError, ValueError):
4998
+ return None
4999
+ if window <= 0:
5000
+ return None
5001
+ return ctx_tokens / window * 100.0
5002
+
5003
+ return _lib_statusline.StatuslineInjections(
5004
+ cctally_session_cost=_cctally_session_cost,
5005
+ today_cost=_today_cost,
5006
+ active_block=_active_block,
5007
+ hwm_clamp=_hwm_clamp,
5008
+ db_latest_rate_limits=_db_latest_rate_limits,
5009
+ context_pct=_context_pct,
5010
+ warn_once=warn_once,
5011
+ )
5012
+
5013
+
4472
5014
  def _maybe_swap_active_block_to_canonical(
4473
5015
  blocks: list[Any],
4474
5016
  all_entries: list[Any],
@@ -10775,6 +11317,184 @@ def _build_blocks_parser(subparsers, name, *, help_text, xref):
10775
11317
  return p
10776
11318
 
10777
11319
 
11320
+ def _build_statusline_parser(subparsers, name, *, help_text, xref):
11321
+ """Build the `statusline` (or `claude statusline`) leaf parser.
11322
+
11323
+ Registered TWICE per the Session B build-once register-twice pattern:
11324
+ once on the flat ``cctally statusline`` subparser, once under the
11325
+ nested ``cctally claude statusline`` subgroup. Output is byte-identical
11326
+ between the two forms; only ``--help`` text differs (the ``xref``
11327
+ paragraph appended to ``description``).
11328
+ """
11329
+ p = subparsers.add_parser(
11330
+ name,
11331
+ help=help_text,
11332
+ formatter_class=CLIHelpFormatter,
11333
+ description=(
11334
+ "Display a compact one-line status for Claude Code hooks "
11335
+ "(ccusage drop-in + cctally extensions).\n\n" + xref
11336
+ ),
11337
+ )
11338
+ # ccusage-shape flags
11339
+ p.add_argument(
11340
+ "-B", "--visual-burn-rate",
11341
+ dest="visual_burn_rate",
11342
+ default=None,
11343
+ choices=["off", "emoji", "text", "emoji-text"],
11344
+ help="Burn-rate visualization (default: off; config key "
11345
+ "statusline.visual_burn_rate).",
11346
+ )
11347
+ # NOTE: `ccusage` is intentionally NOT in `choices=` so it doesn't
11348
+ # appear in `--help` advertised options. Argparse runs the `choices`
11349
+ # check BEFORE the action's `__call__`, so we cannot list `ccusage`
11350
+ # in `choices=` AND catch it in the action. Instead we omit `choices=`
11351
+ # entirely, manually validate inside the action, and re-raise the
11352
+ # spec's rename hint via `parser.error` for `ccusage` specifically.
11353
+ # Help text below hardcodes the legal set so users still see it in
11354
+ # `--help`. A typo like `ccussage` falls through to a standard
11355
+ # argparse-style "invalid choice" error from `parser.error`.
11356
+ class _CostSourceAction(argparse.Action):
11357
+ _ACCEPTED = ("auto", "cctally", "cc", "both")
11358
+ _RENAMED = "ccusage"
11359
+
11360
+ def __call__(self, parser, namespace, values, option_string=None):
11361
+ if values == self._RENAMED:
11362
+ parser.error(
11363
+ f"argument {option_string}: invalid choice: "
11364
+ f"{values!r} — cctally renamed it; try "
11365
+ f"--cost-source cctally"
11366
+ )
11367
+ if values not in self._ACCEPTED:
11368
+ parser.error(
11369
+ f"argument {option_string}: invalid choice: "
11370
+ f"{values!r} (choose from "
11371
+ + ", ".join(repr(c) for c in self._ACCEPTED)
11372
+ + ")"
11373
+ )
11374
+ setattr(namespace, self.dest, values)
11375
+
11376
+ p.add_argument(
11377
+ "--cost-source",
11378
+ dest="cost_source",
11379
+ default=None,
11380
+ action=_CostSourceAction,
11381
+ metavar="{auto,cctally,cc,both}",
11382
+ help="Session cost source (default: auto; config key "
11383
+ "statusline.cost_source). Note: 'ccusage' errors with a "
11384
+ "rename hint — use 'cctally' instead.",
11385
+ )
11386
+ p.add_argument(
11387
+ "--cache",
11388
+ dest="cache",
11389
+ action="store_true",
11390
+ default=None,
11391
+ help="Accepted for ccusage drop-in compat; cctally renders from "
11392
+ "cache.db directly without an extra output cache.",
11393
+ )
11394
+ p.add_argument(
11395
+ "--no-cache",
11396
+ dest="cache",
11397
+ action="store_false",
11398
+ help="(no-op alias)",
11399
+ )
11400
+ p.add_argument(
11401
+ "--refresh-interval",
11402
+ dest="refresh_interval",
11403
+ default=1,
11404
+ type=int,
11405
+ metavar="N",
11406
+ help="(no-op alias) Accepted for ccusage drop-in compat.",
11407
+ )
11408
+ p.add_argument(
11409
+ "--context-low-threshold",
11410
+ dest="context_low_threshold",
11411
+ default=50,
11412
+ type=int,
11413
+ metavar="N",
11414
+ help="Below this %% → segment 4 green (default: 50, 0-100).",
11415
+ )
11416
+ p.add_argument(
11417
+ "--context-medium-threshold",
11418
+ dest="context_medium_threshold",
11419
+ default=80,
11420
+ type=int,
11421
+ metavar="N",
11422
+ help="Below this %% → segment 4 yellow; else red (default: 80, 0-100).",
11423
+ )
11424
+ p.add_argument(
11425
+ "-z", "--timezone",
11426
+ dest="timezone",
11427
+ default=None,
11428
+ metavar="TZ",
11429
+ help="Display tz (IANA) for `today` calendar day. Overrides "
11430
+ "display.tz config.",
11431
+ )
11432
+ p.add_argument(
11433
+ "-O", "--offline",
11434
+ dest="offline",
11435
+ action="store_true",
11436
+ default=True,
11437
+ help="(no-op alias) cctally is always offline.",
11438
+ )
11439
+ p.add_argument(
11440
+ "--no-offline",
11441
+ dest="offline",
11442
+ action="store_false",
11443
+ help="(no-op alias)",
11444
+ )
11445
+ p.add_argument(
11446
+ "--color",
11447
+ dest="color",
11448
+ action="store_true",
11449
+ default=None,
11450
+ help="Force ANSI colors on (default: auto via NO_COLOR + TTY).",
11451
+ )
11452
+ p.add_argument(
11453
+ "--no-color",
11454
+ dest="color",
11455
+ action="store_false",
11456
+ help="Force ANSI colors off.",
11457
+ )
11458
+ p.add_argument(
11459
+ "--cctally-extensions",
11460
+ dest="cctally_extensions",
11461
+ action="store_true",
11462
+ default=None,
11463
+ help="Append cctally 5h%%/7d%% segment (default: on; config key "
11464
+ "statusline.cctally_extensions).",
11465
+ )
11466
+ p.add_argument(
11467
+ "--no-cctally-extensions",
11468
+ dest="cctally_extensions",
11469
+ action="store_false",
11470
+ help="Suppress cctally 5h%%/7d%% segment.",
11471
+ )
11472
+ p.add_argument(
11473
+ "--config",
11474
+ dest="config",
11475
+ default=None,
11476
+ metavar="PATH",
11477
+ help="Read config from PATH for this invocation only (no "
11478
+ "mutation of the default config). Missing/invalid PATH "
11479
+ "exits 2.",
11480
+ )
11481
+ p.add_argument(
11482
+ "--single-thread",
11483
+ dest="single_thread",
11484
+ action="store_true",
11485
+ help="(no-op alias) cctally is always single-threaded via the "
11486
+ "session-entry cache.",
11487
+ )
11488
+ p.add_argument(
11489
+ "-d", "--debug",
11490
+ dest="debug",
11491
+ action="store_true",
11492
+ help="Emit pricing-mismatch / config diagnostics on stderr.",
11493
+ )
11494
+ p.set_defaults(func=cmd_statusline, command=name)
11495
+ return p
11496
+
11497
+
10778
11498
  def _build_codex_daily_parser(subparsers, name, *, help_text, xref):
10779
11499
  """Build the `codex-daily` leaf parser (issue #86 Session B; routes to cmd_codex_daily)."""
10780
11500
  p = subparsers.add_parser(
@@ -11635,6 +12355,12 @@ def build_parser() -> argparse.ArgumentParser:
11635
12355
  help_text="Show usage report grouped by 5-hour session blocks",
11636
12356
  xref="Alias of `cctally claude blocks` (the canonical form).")
11637
12357
 
12358
+ # -- statusline --
12359
+ _build_statusline_parser(
12360
+ sub, "statusline",
12361
+ help_text="Compact one-line status for Claude Code hooks",
12362
+ xref="Alias of `cctally claude statusline` (the canonical form).")
12363
+
11638
12364
  # -- five-hour-blocks --
11639
12365
  fhb = sub.add_parser(
11640
12366
  "five-hour-blocks",
@@ -11890,6 +12616,10 @@ def build_parser() -> argparse.ArgumentParser:
11890
12616
  _build_blocks_parser(claude_sub, "blocks",
11891
12617
  help_text="Show usage grouped by 5-hour session blocks",
11892
12618
  xref="Drop-in for `ccusage claude blocks`. Same engine as `cctally blocks`.")
12619
+ _build_statusline_parser(claude_sub, "statusline",
12620
+ help_text="Compact one-line status for Claude Code hooks",
12621
+ xref="Canonical `cctally claude statusline` (flat alias: `cctally statusline`). "
12622
+ "Drop-in for `ccusage statusline` plus cctally extension segments.")
11893
12623
 
11894
12624
  # --- `codex` subgroup (drop-in for `ccusage codex …`); issue #86 Session B ---
11895
12625
  codex_p = sub.add_parser(