Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions packages/claude-code-plugin/hooks/lib/tiny_actor_card.py
Original file line number Diff line number Diff line change
@@ -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,
)
197 changes: 197 additions & 0 deletions packages/claude-code-plugin/tests/test_tiny_actor_card.py
Original file line number Diff line number Diff line change
@@ -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"
Loading