diff --git a/packages/claude-code-plugin/hooks/lib/tiny_actor_card.py b/packages/claude-code-plugin/hooks/lib/tiny_actor_card.py new file mode 100644 index 00000000..3b674e2e --- /dev/null +++ b/packages/claude-code-plugin/hooks/lib/tiny_actor_card.py @@ -0,0 +1,153 @@ +"""TinyActorCard contract and mood-to-face mapping for ASCII actor scenes (#1270). + +Provides a stable, reusable data contract for the Tiny Actor Grid. +Face generation is deterministic from ``eye + mood``. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Optional + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +DEFAULT_EYE: str = "\u25cf" # ● +DEFAULT_EYE_ASCII: str = "o" + +# Unicode mouth glyphs keyed by mood +_MOOD_MOUTHS: dict[str, str] = { + "speaking": "\u203f", # ‿ + "reviewing": "\u2304", # ⌄ + "proposing": "\u2040", # ⁀ + "unsure": "_", + "blocked": "x", +} + +# ASCII mouth glyphs keyed by mood +_MOOD_MOUTHS_ASCII: dict[str, str] = { + "speaking": "_", + "reviewing": ".", + "proposing": "~", + "unsure": "_", + "blocked": "x", +} + +_DEFAULT_MOOD = "unsure" + + +# --------------------------------------------------------------------------- +# Dataclass +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class TinyActorCard: + """Immutable card representing a single actor in the Tiny Actor Grid.""" + + agent_id: str + label: str + face: str + eye: str + mood: str + quote: Optional[str] = None + color_ansi: Optional[str] = None + is_moderator: bool = False + + +# --------------------------------------------------------------------------- +# Face builder +# --------------------------------------------------------------------------- + + +def build_face( + mood: str, + eye_glyph: Optional[str] = None, + *, + eye_fallback: Optional[str] = None, + ascii_mode: bool = False, +) -> str: + """Build a deterministic face string from *mood* and optional eye glyph. + + Parameters + ---------- + mood: + Mood key (``speaking``, ``reviewing``, ``proposing``, ``unsure``, + ``blocked``). Unknown moods fall back to ``unsure``. + eye_glyph: + Unicode eye character (e.g. ``★``). Ignored when *ascii_mode* is + ``True``. + eye_fallback: + ASCII single-char eye used when *ascii_mode* is ``True``. + Defaults to ``o``. + ascii_mode: + When ``True``, produce a pure-ASCII face. + """ + if ascii_mode: + eye = eye_fallback or DEFAULT_EYE_ASCII + mouth = _MOOD_MOUTHS_ASCII.get(mood, _MOOD_MOUTHS_ASCII[_DEFAULT_MOOD]) + return f"{eye}{mouth}{eye}" + + eye = eye_glyph or DEFAULT_EYE + mouth = _MOOD_MOUTHS.get(mood, _MOOD_MOUTHS[_DEFAULT_MOOD]) + return f"{eye}{mouth}{eye}" + + +# --------------------------------------------------------------------------- +# Factory +# --------------------------------------------------------------------------- + + +def create_actor_card( + agent_id: str, + label: str, + *, + mood: str = _DEFAULT_MOOD, + eye_glyph: Optional[str] = None, + eye_fallback: Optional[str] = None, + quote: Optional[str] = None, + color_ansi: Optional[str] = None, + is_moderator: bool = False, + ascii_mode: bool = False, +) -> TinyActorCard: + """Create a :class:`TinyActorCard` with a deterministic face. + + Parameters + ---------- + agent_id: + Agent identifier (e.g. ``"security-specialist"``). + label: + Short display name (e.g. ``"Security"``). + mood: + Mood key — defaults to ``"unsure"``. + eye_glyph: + Unicode eye glyph from agent JSON ``visual.eye``. + eye_fallback: + ASCII eye character for fallback rendering. + quote: + Optional status text or quote. + color_ansi: + ANSI colour name (``red``, ``green``, …). + is_moderator: + ``True`` for Buddy / moderator cards. + ascii_mode: + Produce a pure-ASCII face when ``True``. + """ + face = build_face( + mood, + eye_glyph=eye_glyph, + eye_fallback=eye_fallback, + ascii_mode=ascii_mode, + ) + eye = (eye_fallback or DEFAULT_EYE_ASCII) if ascii_mode else (eye_glyph or DEFAULT_EYE) + + return TinyActorCard( + agent_id=agent_id, + label=label, + face=face, + eye=eye, + mood=mood, + quote=quote, + color_ansi=color_ansi, + is_moderator=is_moderator, + ) diff --git a/packages/claude-code-plugin/tests/test_tiny_actor_card.py b/packages/claude-code-plugin/tests/test_tiny_actor_card.py new file mode 100644 index 00000000..249ad4f6 --- /dev/null +++ b/packages/claude-code-plugin/tests/test_tiny_actor_card.py @@ -0,0 +1,197 @@ +"""Tests for TinyActorCard contract and mood-to-face mapping (#1270).""" +import os +import sys + +import pytest + +# Ensure hooks/lib is on path +_tests_dir = os.path.dirname(os.path.abspath(__file__)) +_lib_dir = os.path.join(os.path.dirname(_tests_dir), "hooks", "lib") +if _lib_dir not in sys.path: + sys.path.insert(0, _lib_dir) + +from tiny_actor_card import TinyActorCard, build_face, create_actor_card + +# --------------------------------------------------------------------------- +# build_face — mood-to-face mapping +# --------------------------------------------------------------------------- + +DEFAULT_EYE = "\u25cf" # ● + + +class TestBuildFaceMoods: + """build_face returns the correct face for each mood.""" + + def test_speaking_mood(self): + assert build_face("speaking") == f"{DEFAULT_EYE}\u203f{DEFAULT_EYE}" + + def test_reviewing_mood(self): + assert build_face("reviewing") == f"{DEFAULT_EYE}\u2304{DEFAULT_EYE}" + + def test_proposing_mood(self): + assert build_face("proposing") == f"{DEFAULT_EYE}\u2040{DEFAULT_EYE}" + + def test_unsure_mood(self): + assert build_face("unsure") == f"{DEFAULT_EYE}_{DEFAULT_EYE}" + + def test_blocked_mood(self): + assert build_face("blocked") == f"{DEFAULT_EYE}x{DEFAULT_EYE}" + + def test_unknown_mood_falls_back_to_unsure(self): + assert build_face("nonexistent") == f"{DEFAULT_EYE}_{DEFAULT_EYE}" + + +class TestBuildFaceCustomEye: + """build_face with custom eye_glyph replaces the default eye.""" + + def test_star_eye(self): + assert build_face("speaking", eye_glyph="\u2605") == "\u2605\u203f\u2605" + + def test_diamond_eye(self): + assert build_face("reviewing", eye_glyph="\u25c6") == "\u25c6\u2304\u25c6" + + def test_buddy_eye(self): + # Buddy uses ◕ (U+25D5) + assert build_face("proposing", eye_glyph="\u25d5") == "\u25d5\u2040\u25d5" + + +class TestBuildFaceAsciiFallback: + """build_face ASCII fallback mode produces safe ASCII faces.""" + + def test_speaking_ascii(self): + assert build_face("speaking", ascii_mode=True) == "o_o" + + def test_reviewing_ascii(self): + assert build_face("reviewing", ascii_mode=True) == "o.o" + + def test_proposing_ascii(self): + assert build_face("proposing", ascii_mode=True) == "o~o" + + def test_unsure_ascii(self): + assert build_face("unsure", ascii_mode=True) == "o_o" + + def test_blocked_ascii(self): + assert build_face("blocked", ascii_mode=True) == "oxo" + + def test_ascii_with_eye_fallback(self): + assert build_face("speaking", eye_fallback="O", ascii_mode=True) == "O_O" + + def test_ascii_unknown_mood(self): + assert build_face("whatever", ascii_mode=True) == "o_o" + + +# --------------------------------------------------------------------------- +# TinyActorCard — dataclass contract +# --------------------------------------------------------------------------- + + +class TestTinyActorCard: + """TinyActorCard dataclass holds the expected fields.""" + + def test_all_fields_present(self): + card = TinyActorCard( + agent_id="security-specialist", + label="Security", + face="\u2605\u203f\u2605", + eye="\u2605", + mood="speaking", + quote="analyzing", + color_ansi="yellow", + is_moderator=False, + ) + assert card.agent_id == "security-specialist" + assert card.label == "Security" + assert card.face == "\u2605\u203f\u2605" + assert card.eye == "\u2605" + assert card.mood == "speaking" + assert card.quote == "analyzing" + assert card.color_ansi == "yellow" + assert card.is_moderator is False + + def test_optional_fields_default(self): + card = TinyActorCard( + agent_id="test", + label="Test", + face="o_o", + eye=DEFAULT_EYE, + mood="unsure", + ) + assert card.quote is None + assert card.color_ansi is None + assert card.is_moderator is False + + +# --------------------------------------------------------------------------- +# create_actor_card — factory function +# --------------------------------------------------------------------------- + + +class TestCreateActorCard: + """create_actor_card factory builds valid cards.""" + + def test_creates_card_with_defaults(self): + card = create_actor_card("frontend-developer", "Frontend") + assert card.agent_id == "frontend-developer" + assert card.label == "Frontend" + assert card.mood == "unsure" # default mood + assert card.eye == DEFAULT_EYE + assert card.face == f"{DEFAULT_EYE}_{DEFAULT_EYE}" + assert card.quote is None + assert card.color_ansi is None + assert card.is_moderator is False + + def test_creates_card_with_custom_mood(self): + card = create_actor_card("backend-developer", "Backend", mood="speaking") + assert card.mood == "speaking" + assert card.face == f"{DEFAULT_EYE}\u203f{DEFAULT_EYE}" + + def test_creates_card_with_eye_glyph(self): + card = create_actor_card( + "security-specialist", "Security", eye_glyph="\u2605" + ) + assert card.eye == "\u2605" + assert card.face == "\u2605_\u2605" + + def test_creates_card_with_all_options(self): + card = create_actor_card( + "test-engineer", + "Test", + mood="proposing", + eye_glyph="\u25c6", + quote="running tests", + color_ansi="green", + is_moderator=False, + ) + assert card.agent_id == "test-engineer" + assert card.face == "\u25c6\u2040\u25c6" + assert card.quote == "running tests" + assert card.color_ansi == "green" + + def test_buddy_moderator_card(self): + card = create_actor_card( + "buddy", + "Buddy", + mood="speaking", + eye_glyph="\u25d5", + is_moderator=True, + ) + assert card.is_moderator is True + assert card.eye == "\u25d5" + assert card.face == "\u25d5\u203f\u25d5" + + def test_ascii_mode_card(self): + card = create_actor_card( + "test", "Test", mood="speaking", ascii_mode=True + ) + assert card.face == "o_o" + + def test_ascii_mode_with_eye_fallback(self): + card = create_actor_card( + "test", + "Test", + mood="reviewing", + eye_fallback="O", + ascii_mode=True, + ) + assert card.face == "O.O" + assert card.eye == "O"