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.
- package/CHANGELOG.md +10 -0
- package/bin/_cctally_cache.py +91 -23
- package/bin/_cctally_config.py +143 -0
- package/bin/_lib_aggregators.py +68 -19
- package/bin/_lib_blocks.py +55 -1
- package/bin/_lib_doctor.py +1 -1
- package/bin/_lib_render.py +212 -53
- package/bin/_lib_statusline.py +499 -0
- package/bin/cctally +903 -20
- package/bin/cctally-statusline +3 -0
- package/package.json +3 -1
|
@@ -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)
|