Skip to content

Commit bba7e38

Browse files
committed
feat(plugin): add statusLine script and mode-detect HUD update
- Create codingbuddy-hud.py statusLine with model pricing, cost estimation, cache hit rate, health indicator, and session duration formatting - Update user-prompt-submit.py to write detected mode to hud-state.json - Add 33 tests for statusLine (8 test classes) and 4 tests for mode detect - Add Wave 2 implementation plan document Closes #1088 Closes #1090
1 parent c556c1d commit bba7e38

5 files changed

Lines changed: 880 additions & 0 deletions

File tree

Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
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

Comments
 (0)