Skip to content

Commit 5d78f24

Browse files
committed
feat(hud): breathing buddy face states (Wave 2-A)
The Buddy face now reacts to HUD phase / blocker state so the status bar feels alive: Idle / Ready -> ◕‿◕ (FACE_IDLE) Thinking / Planning-> ◔‿◔ (FACE_THINKING) Active / Executing -> ◕◡◕ (FACE_ACTIVE) Error / Blocked -> ◕︵◕ (FACE_ERROR) Victory / Completed-> ◕ᴗ◕ (FACE_VICTORY) Priority resolution: 1. blocker_count > 0 always wins (error face) 2. recent_event == "victory" beats phase 3. Phase lookup (case-insensitive) 4. Fallback to FACE_IDLE hud_buddy.py extended: - FACE_* constants for each state - _PHASE_FACE_MAP for phase -> glyph lookup - get_buddy_face(phase, *, blocker_count, recent_event) pure function - select_face_from_state(hud_state) convenience dict wrapper - __all__ exports updated BUDDY_FACE is still canonically re-exported from tiny_actor_presets (identity lock preserved). 29 new tests in test_hud_buddy.py cover: - All 5 face constants distinct + non-empty - All 6 phase mappings (ready/planning/executing/evaluating/cycling/completed) - Case-insensitive phase, unknown phase, empty, None - Priority: blocker beats phase, blocker beats victory - Malformed blocker_count fall-through - Victory event recognition (case-insensitive) + unknown event - select_face_from_state (empty, None, planning, blocked, victory) - __all__ public API coverage 158/158 pass. Part of #1464 (Wave 0 statusbar refactor)
1 parent de622cc commit 5d78f24

2 files changed

Lines changed: 310 additions & 11 deletions

File tree

Lines changed: 142 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,148 @@
1-
"""Buddy face re-export for CodingBuddy statusLine (#1326).
1+
"""Buddy face state engine for CodingBuddy statusLine (#1326, Wave 2-A).
22
3-
``BUDDY_FACE`` is canonically defined in
4-
``tiny_actor_presets.BUDDY_FACE`` and already covered by
5-
``tests/test_tiny_actor_presets.py`` for value/type assertions. This
6-
module re-exports it so statusLine helpers that conceptually belong to
7-
the HUD layer can depend on a ``hud_*`` module instead of reaching into
8-
``tiny_actor_presets``.
3+
``BUDDY_FACE`` is still canonically defined in
4+
``tiny_actor_presets.BUDDY_FACE`` and re-exported here for import
5+
clarity at the HUD layer. Wave 2-A adds a **state-aware** face
6+
picker so the Buddy appears to "breathe" as the session moves
7+
through its lifecycle phases:
98
10-
Wave 0 establishes the re-export only. Wave 2-A will extend this file
11-
with breathing Buddy face state logic (e.g., ``get_buddy_face(phase)``).
9+
- **Idle / Ready** → ``◕‿◕`` — resting, calm
10+
- **Thinking / Planning** → ``◔‿◔`` — half-closed eyes, pondering
11+
- **Active / Executing** → ``◕◡◕`` — smiling, working
12+
- **Error / Blocked** → ``◕︵◕`` — concerned, something broke
13+
- **Victory / Completed**→ ``◕ᴗ◕`` — beaming, success
14+
15+
Priority rules:
16+
17+
1. ``blocker_count > 0`` always wins (error face) regardless of phase.
18+
2. ``recent_event == "victory"`` wins over phase when set.
19+
3. Otherwise the phase mapping decides.
20+
4. Unknown/empty phase falls back to the canonical idle face.
21+
22+
Primary entry points:
23+
24+
- :data:`BUDDY_FACE` — canonical idle glyph (re-exported)
25+
- :data:`FACE_*` — individual state glyphs as constants
26+
- :func:`get_buddy_face` — state-to-glyph lookup
27+
- :func:`select_face_from_state` — HUD-state-dict convenience wrapper
1228
"""
1329
from __future__ import annotations
1430

15-
from tiny_actor_presets import BUDDY_FACE # canonical SSoT
31+
from typing import Any, Dict, Optional
32+
33+
from tiny_actor_presets import BUDDY_FACE # canonical SSoT (idle face)
34+
35+
# ------------------------------------------------------------------------
36+
# Face glyph constants
37+
# ------------------------------------------------------------------------
38+
39+
#: Default / idle face — ``◕‿◕`` (U+25D5 U+203F U+25D5).
40+
FACE_IDLE: str = BUDDY_FACE
41+
42+
#: Thinking face — ``◔‿◔`` (half-closed eyes).
43+
FACE_THINKING: str = "\u25d4\u203f\u25d4"
44+
45+
#: Active face — ``◕◡◕`` (smiling, working).
46+
FACE_ACTIVE: str = "\u25d5\u25e1\u25d5"
47+
48+
#: Error / blocked face — ``◕︵◕`` (concerned).
49+
FACE_ERROR: str = "\u25d5\ufe35\u25d5"
50+
51+
#: Victory / completed face — ``◕ᴗ◕`` (beaming).
52+
FACE_VICTORY: str = "\u25d5\u1d17\u25d5"
53+
54+
55+
# ------------------------------------------------------------------------
56+
# Phase → face mapping
57+
# ------------------------------------------------------------------------
58+
59+
_PHASE_FACE_MAP: Dict[str, str] = {
60+
"ready": FACE_IDLE,
61+
"planning": FACE_THINKING,
62+
"executing": FACE_ACTIVE,
63+
"evaluating": FACE_THINKING,
64+
"cycling": FACE_ACTIVE,
65+
"completed": FACE_VICTORY,
66+
}
67+
68+
69+
# ------------------------------------------------------------------------
70+
# Public API
71+
# ------------------------------------------------------------------------
72+
73+
74+
def get_buddy_face(
75+
phase: Optional[str] = None,
76+
*,
77+
blocker_count: int = 0,
78+
recent_event: Optional[str] = None,
79+
) -> str:
80+
"""Return the buddy face glyph for the given state.
81+
82+
Priority resolution (highest wins):
83+
84+
1. ``blocker_count > 0`` → :data:`FACE_ERROR`
85+
2. ``recent_event == "victory"`` → :data:`FACE_VICTORY`
86+
3. Phase lookup against :data:`_PHASE_FACE_MAP`
87+
4. Fallback: :data:`FACE_IDLE`
88+
89+
Args:
90+
phase: HUD state phase. One of
91+
``ready``, ``planning``, ``executing``, ``evaluating``,
92+
``cycling``, ``completed`` (case-insensitive). Unknown
93+
or empty values fall back to the idle face.
94+
blocker_count: Number of blockers detected. Any positive
95+
value triggers the error face.
96+
recent_event: Optional one-off event marker. Currently only
97+
``"victory"`` is recognised.
98+
99+
Returns:
100+
A 3-character glyph string, never empty.
101+
"""
102+
# (1) Error: blockers take precedence
103+
try:
104+
if int(blocker_count) > 0:
105+
return FACE_ERROR
106+
except (TypeError, ValueError):
107+
pass # ignore malformed counter, fall through
108+
109+
# (2) Victory event beats phase
110+
if recent_event and recent_event.lower() == "victory":
111+
return FACE_VICTORY
112+
113+
# (3) Phase mapping
114+
if phase:
115+
return _PHASE_FACE_MAP.get(phase.lower(), FACE_IDLE)
116+
117+
# (4) Fallback
118+
return FACE_IDLE
119+
120+
121+
def select_face_from_state(hud_state: Dict[str, Any]) -> str:
122+
"""Convenience wrapper that extracts face inputs from a hud_state dict.
123+
124+
Reads:
125+
126+
- ``phase`` — HUD phase string
127+
- ``blockerCount`` — integer blocker count (default 0)
128+
- ``lastEvent`` — optional one-off event marker
129+
"""
130+
if not hud_state:
131+
return FACE_IDLE
132+
return get_buddy_face(
133+
phase=hud_state.get("phase"),
134+
blocker_count=hud_state.get("blockerCount", 0) or 0,
135+
recent_event=hud_state.get("lastEvent"),
136+
)
137+
16138

17-
__all__ = ["BUDDY_FACE"]
139+
__all__ = [
140+
"BUDDY_FACE",
141+
"FACE_IDLE",
142+
"FACE_THINKING",
143+
"FACE_ACTIVE",
144+
"FACE_ERROR",
145+
"FACE_VICTORY",
146+
"get_buddy_face",
147+
"select_face_from_state",
148+
]

packages/claude-code-plugin/tests/test_hud_buddy.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,171 @@ def test_reexport_identity_from_codingbuddy_hud():
2929
hud_main = importlib.import_module("codingbuddy-hud")
3030
assert hud_main.BUDDY_FACE is hud_buddy.BUDDY_FACE
3131
assert hud_main.BUDDY_FACE is CANONICAL_BUDDY_FACE
32+
33+
34+
# ========================= Wave 2-A: face state engine ======================
35+
36+
37+
def test_face_constants_defined():
38+
"""All five named faces are distinct non-empty strings."""
39+
faces = {
40+
hud_buddy.FACE_IDLE,
41+
hud_buddy.FACE_THINKING,
42+
hud_buddy.FACE_ACTIVE,
43+
hud_buddy.FACE_ERROR,
44+
hud_buddy.FACE_VICTORY,
45+
}
46+
assert len(faces) == 5
47+
for face in faces:
48+
assert face # non-empty
49+
assert len(face) >= 3 # 3-char glyphs
50+
51+
52+
def test_face_idle_matches_canonical_buddy():
53+
"""FACE_IDLE is the canonical buddy face."""
54+
assert hud_buddy.FACE_IDLE is hud_buddy.BUDDY_FACE
55+
56+
57+
# --- get_buddy_face: phase mapping ---
58+
59+
60+
def test_get_face_ready_phase_is_idle():
61+
assert hud_buddy.get_buddy_face("ready") == hud_buddy.FACE_IDLE
62+
63+
64+
def test_get_face_planning_phase_is_thinking():
65+
assert hud_buddy.get_buddy_face("planning") == hud_buddy.FACE_THINKING
66+
67+
68+
def test_get_face_executing_phase_is_active():
69+
assert hud_buddy.get_buddy_face("executing") == hud_buddy.FACE_ACTIVE
70+
71+
72+
def test_get_face_evaluating_phase_is_thinking():
73+
assert hud_buddy.get_buddy_face("evaluating") == hud_buddy.FACE_THINKING
74+
75+
76+
def test_get_face_cycling_phase_is_active():
77+
assert hud_buddy.get_buddy_face("cycling") == hud_buddy.FACE_ACTIVE
78+
79+
80+
def test_get_face_completed_phase_is_victory():
81+
assert hud_buddy.get_buddy_face("completed") == hud_buddy.FACE_VICTORY
82+
83+
84+
def test_get_face_case_insensitive():
85+
assert hud_buddy.get_buddy_face("PLANNING") == hud_buddy.FACE_THINKING
86+
assert hud_buddy.get_buddy_face("Executing") == hud_buddy.FACE_ACTIVE
87+
88+
89+
def test_get_face_unknown_phase_falls_back_to_idle():
90+
assert hud_buddy.get_buddy_face("waiting") == hud_buddy.FACE_IDLE
91+
assert hud_buddy.get_buddy_face("unknown") == hud_buddy.FACE_IDLE
92+
93+
94+
def test_get_face_empty_phase_is_idle():
95+
assert hud_buddy.get_buddy_face("") == hud_buddy.FACE_IDLE
96+
97+
98+
def test_get_face_none_phase_is_idle():
99+
assert hud_buddy.get_buddy_face(None) == hud_buddy.FACE_IDLE
100+
101+
102+
# --- get_buddy_face: priority rules ---
103+
104+
105+
def test_blocker_count_beats_phase():
106+
"""Any positive blocker_count triggers the error face."""
107+
assert (
108+
hud_buddy.get_buddy_face("executing", blocker_count=1)
109+
== hud_buddy.FACE_ERROR
110+
)
111+
112+
113+
def test_blocker_count_zero_does_not_trigger():
114+
assert (
115+
hud_buddy.get_buddy_face("executing", blocker_count=0)
116+
== hud_buddy.FACE_ACTIVE
117+
)
118+
119+
120+
def test_blocker_count_beats_victory():
121+
"""Even a victory event yields error when blockers are present."""
122+
assert (
123+
hud_buddy.get_buddy_face(
124+
"completed", blocker_count=3, recent_event="victory"
125+
)
126+
== hud_buddy.FACE_ERROR
127+
)
128+
129+
130+
def test_blocker_count_malformed_ignored():
131+
"""Non-numeric blocker_count falls through to phase mapping."""
132+
assert (
133+
hud_buddy.get_buddy_face("planning", blocker_count="abc") # type: ignore[arg-type]
134+
== hud_buddy.FACE_THINKING
135+
)
136+
137+
138+
def test_recent_event_victory_beats_phase():
139+
"""Victory event wins over phase when no blockers."""
140+
assert (
141+
hud_buddy.get_buddy_face("executing", recent_event="victory")
142+
== hud_buddy.FACE_VICTORY
143+
)
144+
145+
146+
def test_recent_event_case_insensitive():
147+
assert (
148+
hud_buddy.get_buddy_face("planning", recent_event="VICTORY")
149+
== hud_buddy.FACE_VICTORY
150+
)
151+
152+
153+
def test_recent_event_unknown_ignored():
154+
assert (
155+
hud_buddy.get_buddy_face("planning", recent_event="foobar")
156+
== hud_buddy.FACE_THINKING
157+
)
158+
159+
160+
# --- select_face_from_state ---
161+
162+
163+
def test_select_face_from_empty_state():
164+
assert hud_buddy.select_face_from_state({}) == hud_buddy.FACE_IDLE
165+
166+
167+
def test_select_face_from_none_state():
168+
assert hud_buddy.select_face_from_state(None) == hud_buddy.FACE_IDLE # type: ignore[arg-type]
169+
170+
171+
def test_select_face_from_planning_state():
172+
state = {"phase": "planning", "blockerCount": 0}
173+
assert hud_buddy.select_face_from_state(state) == hud_buddy.FACE_THINKING
174+
175+
176+
def test_select_face_from_blocked_state():
177+
state = {"phase": "executing", "blockerCount": 2}
178+
assert hud_buddy.select_face_from_state(state) == hud_buddy.FACE_ERROR
179+
180+
181+
def test_select_face_from_victory_state():
182+
state = {"phase": "completed", "blockerCount": 0, "lastEvent": "victory"}
183+
assert hud_buddy.select_face_from_state(state) == hud_buddy.FACE_VICTORY
184+
185+
186+
def test_select_face_from_completed_state_without_victory_marker():
187+
state = {"phase": "completed", "blockerCount": 0}
188+
assert hud_buddy.select_face_from_state(state) == hud_buddy.FACE_VICTORY
189+
190+
191+
# --- __all__ exports ---
192+
193+
194+
def test_public_api_exported():
195+
assert "BUDDY_FACE" in hud_buddy.__all__
196+
assert "get_buddy_face" in hud_buddy.__all__
197+
assert "select_face_from_state" in hud_buddy.__all__
198+
for name in ("FACE_IDLE", "FACE_THINKING", "FACE_ACTIVE", "FACE_ERROR", "FACE_VICTORY"):
199+
assert name in hud_buddy.__all__

0 commit comments

Comments
 (0)