Skip to content

Commit 5835915

Browse files
committed
feat(hud): cost velocity indicator (Wave 2-B)
Shows session spend rate next to absolute cost so users can see whether they're on a slow planning pass or a hot refactor burn: $1.23↗$0.08/m Trend glyphs: - 🔥 rate >= $0.20/min (hot burn) - ↗ $0.01 <= rate < $0.20/min (normal/rising) - → 0 < rate < $0.01/min (steady, very low) - 💤 rate <= 0 (idle) Stateless implementation: rate = cost_usd / (duration_ms / 60k). Uses Claude Code stdin.cost.total_cost_usd and total_duration_ms directly, with hud_state.sessionStartTimestamp fallback for the elapsed-time component. Deliberately does NOT touch hud_state.py schema (costHistory was deferred per Wave 0 review). New lib/hud_velocity.py: - TREND_IDLE_MAX=0.01, TREND_HOT_MIN=0.20 constants - compute_spend_rate(cost_usd, duration_ms) -> float - trend_glyph(rate) -> str - format_velocity_segment(stdin_data, hud_state) -> str - format_cost_with_velocity(cost_usd, stdin, hud_state, *, is_exact) - _duration_ms_from_state helper for stdin fallback 33 new tests cover: - compute_spend_rate: zero/negative/non-numeric/numeric-string, known rates ($1/min, $0.50/30s, $6/10min) - trend_glyph: all 4 tiers + boundary values + non-numeric - format_velocity_segment: empty, missing cost, missing duration, zero rate, normal hot, rising tier, state fallback, no state - format_cost_with_velocity: exact/estimate prefix, no-velocity fallback, non-numeric cost, two decimals - Constant ordering 162/162 pass. Part of #1464 (Wave 0 statusbar refactor)
1 parent de622cc commit 5835915

2 files changed

Lines changed: 389 additions & 15 deletions

File tree

Lines changed: 185 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,189 @@
1-
"""Cost velocity indicator for CodingBuddy statusLine (#1326).
1+
"""Cost velocity indicator for CodingBuddy statusLine (#1326, Wave 2-B).
22
3-
Wave 0 skeleton — reserved for **Wave 2-B**.
3+
Shows how fast the session is burning dollars by rendering a
4+
per-minute spend rate next to the absolute cost. Users can see at
5+
a glance whether they're on a slow planning pass ($0.01/min) or a
6+
heavy refactor burn ($0.50/min).
47
5-
Planned contents (Wave 2-B owner fills):
6-
* ``record_cost_sample(state_file: str, cost_usd: float, *, now=None) -> None``
7-
* ``compute_velocity(history: list[dict]) -> float`` — $/hour
8-
* ``format_velocity_badge(velocity_usd_per_hour: float) -> str``
9-
* ``MAX_COST_HISTORY_ENTRIES`` constant
8+
Output format::
109
11-
Wave 2-B will ALSO extend ``lib/hud_state.py`` with a
12-
``"costHistory": []`` entry in both ``_EXTENDED_DEFAULTS`` and
13-
``init_hud_state()`` (this is deliberately NOT done in Wave 0 — schema
14-
design belongs with the feature owner).
10+
$1.23↗$0.08/m
11+
12+
- ``$1.23`` — absolute cost so far (passed through unchanged)
13+
- ``↗/🔥/💤`` — trend glyph reflecting the burn rate
14+
- ``$0.08/m`` — computed spend rate (USD per minute)
15+
16+
Wave 2-B ships a **stateless** session-average rate: ``rate = cost /
17+
elapsed_minutes`` derived from Claude Code's own stdin ``cost`` and
18+
``duration_ms`` fields. A richer windowed/ring-buffer implementation
19+
can be added later without changing the public API; the stateless
20+
version is sufficient for UX purposes and avoids touching the
21+
``hud_state.py`` schema.
22+
23+
Primary entry points:
24+
25+
- :func:`compute_spend_rate` — pure arithmetic helper
26+
- :func:`trend_glyph` — map rate → visual tier
27+
- :func:`format_velocity_segment` — end-to-end renderer
28+
- :func:`format_cost_with_velocity` — cost prefix + velocity suffix
1529
"""
30+
from __future__ import annotations
31+
32+
from typing import Any, Dict, Optional
33+
34+
# ------------------------------------------------------------------------
35+
# Trend tier thresholds (USD per minute)
36+
# ------------------------------------------------------------------------
37+
38+
#: Below this rate the session is considered idle / coasting.
39+
TREND_IDLE_MAX: float = 0.01
40+
41+
#: Above this rate the session is a hot burn ("🔥").
42+
TREND_HOT_MIN: float = 0.20
43+
44+
# ------------------------------------------------------------------------
45+
# Glyphs
46+
# ------------------------------------------------------------------------
47+
48+
_GLYPH_HOT = "\U0001f525" # 🔥
49+
_GLYPH_RISING = "\u2197" # ↗
50+
_GLYPH_STEADY = "\u2192" # → (sideways arrow for low steady rate)
51+
_GLYPH_IDLE = "\U0001f4a4" # 💤 (zzz face)
52+
53+
54+
def compute_spend_rate(cost_usd: Any, duration_ms: Any) -> float:
55+
"""Return the session spend rate in USD per minute.
56+
57+
Formula::
58+
59+
rate = cost_usd / (duration_ms / 60_000)
60+
61+
Defensive coercion:
62+
63+
* Non-numeric or negative inputs return ``0.0``.
64+
* ``duration_ms <= 0`` returns ``0.0`` (avoids divide-by-zero).
65+
* ``cost_usd == 0`` returns ``0.0`` regardless of duration.
66+
"""
67+
try:
68+
cost = float(cost_usd)
69+
duration = float(duration_ms)
70+
except (TypeError, ValueError):
71+
return 0.0
72+
if cost <= 0 or duration <= 0:
73+
return 0.0
74+
minutes = duration / 60_000.0
75+
if minutes <= 0:
76+
return 0.0
77+
return cost / minutes
78+
79+
80+
def trend_glyph(rate_usd_per_min: float) -> str:
81+
"""Return a glyph reflecting the spend tier.
82+
83+
Tiers:
84+
85+
* ``rate >= TREND_HOT_MIN`` → 🔥 (hot burn)
86+
* ``rate >= TREND_IDLE_MAX`` → ↗ (rising / normal)
87+
* ``rate > 0`` → → (steady, very low)
88+
* ``rate <= 0`` → 💤 (idle, no meaningful rate)
89+
"""
90+
try:
91+
rate = float(rate_usd_per_min)
92+
except (TypeError, ValueError):
93+
return _GLYPH_IDLE
94+
if rate >= TREND_HOT_MIN:
95+
return _GLYPH_HOT
96+
if rate >= TREND_IDLE_MAX:
97+
return _GLYPH_RISING
98+
if rate > 0:
99+
return _GLYPH_STEADY
100+
return _GLYPH_IDLE
101+
102+
103+
def format_velocity_segment(
104+
stdin_data: Dict[str, Any],
105+
hud_state: Optional[Dict[str, Any]] = None,
106+
) -> str:
107+
"""Render the velocity suffix like ``↗$0.08/m``.
108+
109+
Reads ``stdin_data.cost.total_cost_usd`` and
110+
``stdin_data.cost.total_duration_ms`` — both optional. Falls
111+
back to ``hud_state.sessionStartTimestamp`` when stdin does not
112+
supply the duration (useful when Claude Code omits the cost
113+
payload early in a session).
114+
115+
Returns an empty string when insufficient data is available to
116+
compute a meaningful rate so callers can conditionally append
117+
without extra guards.
118+
"""
119+
if not stdin_data:
120+
return ""
121+
122+
cost_info = stdin_data.get("cost") or {}
123+
cost_usd = cost_info.get("total_cost_usd")
124+
duration_ms = cost_info.get("total_duration_ms")
125+
126+
if cost_usd is None:
127+
return ""
128+
129+
# When stdin doesn't carry duration, try computing from hud_state.
130+
if duration_ms is None and hud_state:
131+
duration_ms = _duration_ms_from_state(hud_state)
132+
133+
if duration_ms is None:
134+
return ""
135+
136+
rate = compute_spend_rate(cost_usd, duration_ms)
137+
if rate <= 0:
138+
return ""
139+
140+
glyph = trend_glyph(rate)
141+
return f"{glyph}${rate:.2f}/m"
142+
143+
144+
def format_cost_with_velocity(
145+
cost_usd: Any,
146+
stdin_data: Dict[str, Any],
147+
hud_state: Optional[Dict[str, Any]] = None,
148+
*,
149+
is_exact: bool = True,
150+
) -> str:
151+
"""Render the full cost segment with velocity appended.
152+
153+
Output format:
154+
155+
``$1.23↗$0.08/m`` (when velocity is available)
156+
``$1.23`` (when velocity cannot be computed)
157+
``~$1.23↗$0.08/m`` (when cost is an estimate)
158+
159+
Args:
160+
cost_usd: Absolute session cost. Non-numeric → ``$0.00``.
161+
stdin_data: Claude Code stdin payload (drives velocity).
162+
hud_state: Optional HUD state for duration fallback.
163+
is_exact: When ``False``, prefix with ``~`` to signal estimation.
164+
"""
165+
try:
166+
cost = float(cost_usd)
167+
except (TypeError, ValueError):
168+
cost = 0.0
169+
prefix = "$" if is_exact else "~$"
170+
velocity = format_velocity_segment(stdin_data, hud_state)
171+
return f"{prefix}{cost:.2f}{velocity}"
172+
173+
174+
def _duration_ms_from_state(hud_state: Dict[str, Any]) -> Optional[float]:
175+
"""Compute elapsed milliseconds from hud_state.sessionStartTimestamp."""
176+
ts = hud_state.get("sessionStartTimestamp", "")
177+
if not ts:
178+
return None
179+
try:
180+
from datetime import datetime, timezone
181+
182+
start = datetime.fromisoformat(ts)
183+
if start.tzinfo is None:
184+
start = start.replace(tzinfo=timezone.utc)
185+
now = datetime.now(timezone.utc)
186+
delta = (now - start).total_seconds() * 1000.0
187+
return delta if delta > 0 else None
188+
except (ValueError, TypeError):
189+
return None

0 commit comments

Comments
 (0)