|
1 | | -"""Cost velocity indicator for CodingBuddy statusLine (#1326). |
| 1 | +"""Cost velocity indicator for CodingBuddy statusLine (#1326, Wave 2-B). |
2 | 2 |
|
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). |
4 | 7 |
|
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:: |
10 | 9 |
|
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 |
15 | 29 | """ |
| 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