|
| 1 | +# Wave 2: StatusLine Script + Mode Detect Update |
| 2 | + |
| 3 | +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. |
| 4 | +
|
| 5 | +**Goal:** Create codingbuddy statusLine script (#1088) and update mode-detect hook to write HUD state (#1090). These two issues are independent and can be shipped as separate PRs. |
| 6 | + |
| 7 | +**Architecture:** StatusLine is a standalone Python script invoked by Claude Code via stdin/stdout. Mode detect update is a 5-line addition to an existing hook. |
| 8 | + |
| 9 | +**Tech Stack:** Python 3, json, sys, os, datetime |
| 10 | + |
| 11 | +**Issues:** #1088, #1090 |
| 12 | + |
| 13 | +## Alternatives |
| 14 | + |
| 15 | +### Decision: StatusLine — Single file vs Module |
| 16 | + |
| 17 | +| Criteria | Single file (standalone) | Module (import from lib/) | |
| 18 | +|---|---|---| |
| 19 | +| Deployment | Copy one file to ~/.claude/hud/ | Need to copy file + ensure lib/ path | |
| 20 | +| Testability | Must mock sys.stdin | Same | |
| 21 | +| Maintenance | All logic in one place | Split across files | |
| 22 | + |
| 23 | +**Decision:** Single file — The script is copied to `~/.claude/hud/` by session-start. It must work standalone without lib/ imports at runtime. For `hud_state`, we inline the read function (3 lines) rather than importing. |
| 24 | + |
| 25 | +--- |
| 26 | + |
| 27 | +## Part A: #1090 — Mode Detect HUD Update (5 min) |
| 28 | + |
| 29 | +### Step A1: Write failing test |
| 30 | + |
| 31 | +**File:** `packages/claude-code-plugin/tests/test_mode_detect_hud.py` |
| 32 | + |
| 33 | +```python |
| 34 | +"""Test that mode detection updates HUD state (#1090).""" |
| 35 | + |
| 36 | +def test_mode_detect_updates_hud_state(tmp_path, monkeypatch): |
| 37 | + """When a mode is detected, hud-state.json should be updated.""" |
| 38 | + import json, subprocess, sys |
| 39 | + |
| 40 | + # Create initial hud-state.json |
| 41 | + state_file = tmp_path / "hud-state.json" |
| 42 | + state_file.write_text(json.dumps({ |
| 43 | + "sessionId": "test", "version": "5.1.1", |
| 44 | + "currentMode": None, "updatedAt": "2026-01-01T00:00:00" |
| 45 | + })) |
| 46 | + |
| 47 | + # Run user-prompt-submit.py with PLAN prompt |
| 48 | + hook_path = os.path.join(os.path.dirname(__file__), "..", "hooks", "user-prompt-submit.py") |
| 49 | + env = {**os.environ, "CODINGBUDDY_HUD_STATE_FILE": str(state_file)} |
| 50 | + result = subprocess.run( |
| 51 | + [sys.executable, hook_path], |
| 52 | + input=json.dumps({"prompt": "PLAN: design auth"}), |
| 53 | + capture_output=True, text=True, env=env |
| 54 | + ) |
| 55 | + assert result.returncode == 0 |
| 56 | + |
| 57 | + data = json.loads(state_file.read_text()) |
| 58 | + assert data["currentMode"] == "PLAN" |
| 59 | +``` |
| 60 | + |
| 61 | +### Step A2: Modify `user-prompt-submit.py` |
| 62 | + |
| 63 | +After line 69 (`print(CONTEXT_TEMPLATE.format(mode=detected_mode))`), add: |
| 64 | + |
| 65 | +```python |
| 66 | + # Update HUD state with detected mode (#1090) |
| 67 | + try: |
| 68 | + _hooks_dir = os.path.dirname(os.path.abspath(__file__)) |
| 69 | + _lib_dir = os.path.join(_hooks_dir, "lib") |
| 70 | + if _lib_dir not in sys.path: |
| 71 | + sys.path.insert(0, _lib_dir) |
| 72 | + from hud_state import update_hud_state |
| 73 | + state_file = os.environ.get("CODINGBUDDY_HUD_STATE_FILE") |
| 74 | + if state_file: |
| 75 | + update_hud_state(state_file=state_file, currentMode=detected_mode) |
| 76 | + else: |
| 77 | + update_hud_state(currentMode=detected_mode) |
| 78 | + except Exception: |
| 79 | + pass |
| 80 | +``` |
| 81 | + |
| 82 | +Also add `import os` at the top (already has `import sys`). |
| 83 | + |
| 84 | +### Step A3: Run test + commit |
| 85 | + |
| 86 | +--- |
| 87 | + |
| 88 | +## Part B: #1088 — StatusLine Script |
| 89 | + |
| 90 | +### Step B1: Write failing test — parse_stdin |
| 91 | + |
| 92 | +**File:** `packages/claude-code-plugin/tests/test_hud.py` |
| 93 | + |
| 94 | +```python |
| 95 | +"""Tests for codingbuddy statusLine script (#1088).""" |
| 96 | + |
| 97 | +class TestParseStdin: |
| 98 | + def test_valid_json(self): |
| 99 | + from codingbuddy_hud import parse_stdin |
| 100 | + data = parse_stdin('{"model":{"id":"opus"},"cwd":"/tmp"}') |
| 101 | + assert data["model"]["id"] == "opus" |
| 102 | + |
| 103 | + def test_empty_input(self): |
| 104 | + from codingbuddy_hud import parse_stdin |
| 105 | + assert parse_stdin("") == {} |
| 106 | + |
| 107 | + def test_invalid_json(self): |
| 108 | + from codingbuddy_hud import parse_stdin |
| 109 | + assert parse_stdin("{bad") == {} |
| 110 | +``` |
| 111 | + |
| 112 | +### Step B2: Create `codingbuddy-hud.py` — parse_stdin |
| 113 | + |
| 114 | +**File:** `packages/claude-code-plugin/hooks/codingbuddy-hud.py` |
| 115 | + |
| 116 | +```python |
| 117 | +#!/usr/bin/env python3 |
| 118 | +"""CodingBuddy statusLine script (#1088). |
| 119 | +
|
| 120 | +Claude Code invokes this via settings.json statusLine.command. |
| 121 | +Reads session data from stdin JSON, outputs formatted status to stdout. |
| 122 | +""" |
| 123 | +import json |
| 124 | +import os |
| 125 | +import sys |
| 126 | +from datetime import datetime, timezone |
| 127 | + |
| 128 | +def parse_stdin(raw: str = "") -> dict: |
| 129 | + """Parse stdin JSON. Returns {} on any error.""" |
| 130 | + if not raw: |
| 131 | + try: |
| 132 | + if not sys.stdin.isatty(): |
| 133 | + raw = sys.stdin.read() |
| 134 | + except Exception: |
| 135 | + return {} |
| 136 | + if not raw or not raw.strip(): |
| 137 | + return {} |
| 138 | + try: |
| 139 | + return json.loads(raw) |
| 140 | + except (json.JSONDecodeError, ValueError): |
| 141 | + return {} |
| 142 | +``` |
| 143 | + |
| 144 | +### Step B3: Write test — get_model_pricing + estimate_cost |
| 145 | + |
| 146 | +```python |
| 147 | +class TestModelPricing: |
| 148 | + def test_haiku(self): |
| 149 | + from codingbuddy_hud import get_model_pricing |
| 150 | + inp, out = get_model_pricing("claude-haiku-4-5-20251001") |
| 151 | + assert inp == 0.80 |
| 152 | + assert out == 4.00 |
| 153 | + |
| 154 | + def test_sonnet(self): |
| 155 | + inp, out = get_model_pricing("claude-sonnet-4-5-20250929") |
| 156 | + assert inp == 3.00 |
| 157 | + |
| 158 | + def test_opus(self): |
| 159 | + inp, out = get_model_pricing("claude-opus-4-6-20260205") |
| 160 | + assert inp == 15.00 |
| 161 | + |
| 162 | + def test_unknown_defaults_to_sonnet(self): |
| 163 | + inp, out = get_model_pricing("unknown-model") |
| 164 | + assert inp == 3.00 |
| 165 | + |
| 166 | +class TestEstimateCost: |
| 167 | + def test_basic_cost(self): |
| 168 | + from codingbuddy_hud import estimate_cost |
| 169 | + ctx = {"current_usage": { |
| 170 | + "input_tokens": 10000, |
| 171 | + "cache_creation_input_tokens": 0, |
| 172 | + "cache_read_input_tokens": 0, |
| 173 | + }} |
| 174 | + cost = estimate_cost("claude-sonnet-4-5", ctx) |
| 175 | + # input: 10000 * 3/1M = 0.03, output est: 4000 * 15/1M = 0.06 |
| 176 | + assert 0.05 < cost < 0.15 |
| 177 | + |
| 178 | + def test_zero_tokens(self): |
| 179 | + cost = estimate_cost("claude-sonnet-4-5", {}) |
| 180 | + assert cost == 0.0 |
| 181 | +``` |
| 182 | + |
| 183 | +### Step B4: Implement pricing + cost |
| 184 | + |
| 185 | +```python |
| 186 | +MODEL_PRICING = { |
| 187 | + "haiku": (0.80, 4.00), |
| 188 | + "sonnet": (3.00, 15.00), |
| 189 | + "opus": (15.00, 75.00), |
| 190 | +} |
| 191 | + |
| 192 | +def get_model_pricing(model_id: str) -> tuple: |
| 193 | + """Return (input_per_million, output_per_million) for model.""" |
| 194 | + model_lower = model_id.lower() |
| 195 | + for key, prices in MODEL_PRICING.items(): |
| 196 | + if key in model_lower: |
| 197 | + return prices |
| 198 | + return MODEL_PRICING["sonnet"] # default |
| 199 | + |
| 200 | +OUTPUT_RATIOS = {"haiku": 0.30, "sonnet": 0.40, "opus": 0.50} |
| 201 | + |
| 202 | +def estimate_cost(model_id: str, context_window: dict) -> float: |
| 203 | + """Estimate session cost from token usage.""" |
| 204 | + usage = context_window.get("current_usage", {}) |
| 205 | + if not usage: |
| 206 | + return 0.0 |
| 207 | + input_tokens = usage.get("input_tokens", 0) |
| 208 | + cache_write = usage.get("cache_creation_input_tokens", 0) |
| 209 | + cache_read = usage.get("cache_read_input_tokens", 0) |
| 210 | + inp_price, out_price = get_model_pricing(model_id) |
| 211 | + # Output estimation |
| 212 | + model_lower = model_id.lower() |
| 213 | + ratio = next((r for k, r in OUTPUT_RATIOS.items() if k in model_lower), 0.40) |
| 214 | + est_output = input_tokens * ratio |
| 215 | + # Cost |
| 216 | + input_cost = (input_tokens / 1_000_000) * inp_price |
| 217 | + cache_write_cost = (cache_write / 1_000_000) * inp_price * 1.25 |
| 218 | + cache_read_cost = (cache_read / 1_000_000) * inp_price * 0.10 |
| 219 | + output_cost = (est_output / 1_000_000) * out_price |
| 220 | + return input_cost + cache_write_cost + cache_read_cost + output_cost |
| 221 | +``` |
| 222 | + |
| 223 | +### Step B5: Write test — cache_hit_rate, health, duration |
| 224 | + |
| 225 | +```python |
| 226 | +class TestCacheHitRate: |
| 227 | + def test_no_cache(self): |
| 228 | + from codingbuddy_hud import compute_cache_hit_rate |
| 229 | + assert compute_cache_hit_rate({}) == 0.0 |
| 230 | + |
| 231 | + def test_partial_cache(self): |
| 232 | + ctx = {"current_usage": { |
| 233 | + "input_tokens": 500, |
| 234 | + "cache_creation_input_tokens": 200, |
| 235 | + "cache_read_input_tokens": 800, |
| 236 | + }} |
| 237 | + rate = compute_cache_hit_rate(ctx) |
| 238 | + # 800 / (500 + 200 + 800) = 53.3% |
| 239 | + assert 53 < rate < 54 |
| 240 | + |
| 241 | +class TestHealth: |
| 242 | + def test_green(self): |
| 243 | + from codingbuddy_hud import get_health |
| 244 | + assert "🟢" in get_health(45) |
| 245 | + |
| 246 | + def test_yellow(self): |
| 247 | + assert "🟡" in get_health(70) |
| 248 | + |
| 249 | + def test_red(self): |
| 250 | + assert "🔴" in get_health(90) |
| 251 | + |
| 252 | +class TestFormatDuration: |
| 253 | + def test_minutes(self): |
| 254 | + from codingbuddy_hud import format_duration |
| 255 | + from datetime import datetime, timezone, timedelta |
| 256 | + ts = (datetime.now(timezone.utc) - timedelta(minutes=12)).isoformat() |
| 257 | + result = format_duration(ts) |
| 258 | + assert "12m" in result or "11m" in result |
| 259 | + |
| 260 | + def test_hours(self): |
| 261 | + ts = (datetime.now(timezone.utc) - timedelta(hours=1, minutes=23)).isoformat() |
| 262 | + result = format_duration(ts) |
| 263 | + assert "1h" in result |
| 264 | +``` |
| 265 | + |
| 266 | +### Step B6: Implement cache, health, duration |
| 267 | + |
| 268 | +### Step B7: Write test — format_status_line (integration) |
| 269 | + |
| 270 | +```python |
| 271 | +class TestFormatStatusLine: |
| 272 | + def test_full_output_with_mode(self): |
| 273 | + from codingbuddy_hud import format_status_line |
| 274 | + stdin = { |
| 275 | + "model": {"id": "claude-opus-4-6", "display_name": "Opus"}, |
| 276 | + "context_window": { |
| 277 | + "context_window_size": 200000, |
| 278 | + "used_percentage": 45, |
| 279 | + "current_usage": { |
| 280 | + "input_tokens": 1000, |
| 281 | + "cache_creation_input_tokens": 500, |
| 282 | + "cache_read_input_tokens": 2000, |
| 283 | + } |
| 284 | + } |
| 285 | + } |
| 286 | + hud_state = { |
| 287 | + "version": "5.1.1", |
| 288 | + "sessionStartTimestamp": datetime.now(timezone.utc).isoformat(), |
| 289 | + "currentMode": "PLAN", |
| 290 | + } |
| 291 | + result = format_status_line(stdin, hud_state) |
| 292 | + assert "◕‿◕" in result |
| 293 | + assert "PLAN" in result |
| 294 | + assert "🟢" in result |
| 295 | + assert "5.1.1" in result |
| 296 | + |
| 297 | + def test_no_mode_shows_ready(self): |
| 298 | + result = format_status_line({}, {"version": "5.1.1"}) |
| 299 | + assert "Ready" in result |
| 300 | + |
| 301 | + def test_agent_line(self): |
| 302 | + result = format_status_line({}, { |
| 303 | + "version": "5.1.1", |
| 304 | + "currentMode": "ACT", |
| 305 | + }, active_agent="architect") |
| 306 | + lines = result.strip().split("\n") |
| 307 | + assert len(lines) == 2 |
| 308 | + assert "architect" in lines[1] |
| 309 | +``` |
| 310 | + |
| 311 | +### Step B8: Implement format_status_line + main |
| 312 | + |
| 313 | +### Step B9: Full integration test — pipe stdin |
| 314 | + |
| 315 | +```bash |
| 316 | +echo '{"transcript_path":"/tmp/t","cwd":"/tmp","model":{"id":"claude-opus-4-6","display_name":"Opus"},"context_window":{"context_window_size":200000,"used_percentage":45,"current_usage":{"input_tokens":1000,"cache_creation_input_tokens":500,"cache_read_input_tokens":2000}}}' | python3 packages/claude-code-plugin/hooks/codingbuddy-hud.py |
| 317 | +``` |
| 318 | + |
| 319 | +### Step B10: Run all tests + commit |
| 320 | + |
| 321 | +--- |
| 322 | + |
| 323 | +## Execution Order |
| 324 | + |
| 325 | +``` |
| 326 | +#1090 (3 steps, ~5 min) → ship PR |
| 327 | +#1088 (10 steps, ~20 min) → ship PR |
| 328 | +``` |
| 329 | + |
| 330 | +Both can be on separate branches from master. |
| 331 | + |
| 332 | +## Verification |
| 333 | + |
| 334 | +1. `python3 -m pytest tests/test_mode_detect_hud.py -v` |
| 335 | +2. `python3 -m pytest tests/test_hud.py -v` |
| 336 | +3. `python3 -m pytest tests/ -q` (full regression) |
| 337 | +4. Manual: `echo '{...}' | python3 hooks/codingbuddy-hud.py` |
0 commit comments