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
144 changes: 144 additions & 0 deletions packages/claude-code-plugin/hooks/lib/tiny_actor_preview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""Feature-flagged Tiny Actor Grid preview (#1271).

Provides a controlled preview path for the Tiny Actor Grid.
The feature is gated behind ``CODINGBUDDY_TINY_ACTORS`` env var (default: off).
"""
from __future__ import annotations

import os
from typing import Optional

from tiny_actor_card import create_actor_card, TinyActorCard
from tiny_actor_presets import get_cast_preset, BUDDY_FACE

# ---------------------------------------------------------------------------
# Feature flag
# ---------------------------------------------------------------------------

_FLAG_ENV = "CODINGBUDDY_TINY_ACTORS"
_TRUTHY = {"1", "true", "yes"}


def is_tiny_actors_enabled() -> bool:
"""Return ``True`` when the Tiny Actor preview is explicitly enabled."""
return os.environ.get(_FLAG_ENV, "").lower() in _TRUTHY


# ---------------------------------------------------------------------------
# Card rendering helpers
# ---------------------------------------------------------------------------

_CARD_WIDTH = 14
_SEPARATOR = " "


def _render_card_lines(card: TinyActorCard, width: int) -> list[str]:
"""Render a single actor card as a list of fixed-width lines."""
label = card.label[:width]
face = card.face
lines = [
label.center(width),
face.center(width),
]
if card.quote:
quote = card.quote[:width]
lines.append(quote.center(width))
return lines


def _arrange_cards_horizontal(
cards: list[TinyActorCard],
available_width: int,
) -> str:
"""Arrange actor cards in a horizontal row, wrapping to fit *available_width*."""
if not cards:
return ""

card_w = min(_CARD_WIDTH, available_width)
sep_w = len(_SEPARATOR)
# Cards per row: at least 1
cards_per_row = max(1, (available_width + sep_w) // (card_w + sep_w))

rows_output: list[str] = []
for row_start in range(0, len(cards), cards_per_row):
row_cards = cards[row_start : row_start + cards_per_row]
rendered = [_render_card_lines(c, card_w) for c in row_cards]

# Pad all to same number of lines
max_lines = max(len(r) for r in rendered)
for r in rendered:
while len(r) < max_lines:
r.append(" " * card_w)

# Merge horizontally
for line_idx in range(max_lines):
merged = _SEPARATOR.join(r[line_idx] for r in rendered)
rows_output.append(merged[:available_width])

return "\n".join(rows_output)


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


def render_actor_preview(
mode: str,
available_width: int = 80,
) -> Optional[str]:
"""Render a Tiny Actor Grid preview for *mode*.

Returns ``None`` if the feature flag is disabled, the mode has no preset,
or an error occurs during rendering.
"""
if not is_tiny_actors_enabled():
return None

try:
preset = get_cast_preset(mode)
if preset is None:
return None

cards: list[TinyActorCard] = []

# Buddy moderator card
moderator = create_actor_card(
agent_id="buddy",
label="Buddy",
mood="speaking",
eye_glyph="\u25d5", # ◕
quote=preset["moderator_copy"],
is_moderator=True,
)
cards.append(moderator)

# Primary agent card
primary = create_actor_card(
agent_id=preset["primary"],
label=_agent_id_to_label(preset["primary"]),
mood="proposing",
)
cards.append(primary)

# Specialist cards
for spec_id in preset["specialists"]:
card = create_actor_card(
agent_id=spec_id,
label=_agent_id_to_label(spec_id),
mood="reviewing",
)
cards.append(card)

return _arrange_cards_horizontal(cards, available_width)

except Exception:
return None


def _agent_id_to_label(agent_id: str) -> str:
"""Convert an agent id like ``'security-specialist'`` to ``'Security'``."""
parts = agent_id.split("-")
if not parts:
return agent_id
return parts[0].capitalize()
135 changes: 135 additions & 0 deletions packages/claude-code-plugin/tests/test_tiny_actor_preview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""Tests for feature-flagged Tiny Actor Grid preview (#1271)."""
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_preview import is_tiny_actors_enabled, render_actor_preview

# ---------------------------------------------------------------------------
# is_tiny_actors_enabled
# ---------------------------------------------------------------------------


class TestIsTinyActorsEnabled:
"""Feature flag respects CODINGBUDDY_TINY_ACTORS env var."""

def test_disabled_by_default(self, monkeypatch):
monkeypatch.delenv("CODINGBUDDY_TINY_ACTORS", raising=False)
assert is_tiny_actors_enabled() is False

def test_enabled_when_set_to_1(self, monkeypatch):
monkeypatch.setenv("CODINGBUDDY_TINY_ACTORS", "1")
assert is_tiny_actors_enabled() is True

def test_enabled_when_set_to_true(self, monkeypatch):
monkeypatch.setenv("CODINGBUDDY_TINY_ACTORS", "true")
assert is_tiny_actors_enabled() is True

def test_disabled_when_set_to_0(self, monkeypatch):
monkeypatch.setenv("CODINGBUDDY_TINY_ACTORS", "0")
assert is_tiny_actors_enabled() is False

def test_disabled_when_set_to_empty(self, monkeypatch):
monkeypatch.setenv("CODINGBUDDY_TINY_ACTORS", "")
assert is_tiny_actors_enabled() is False


# ---------------------------------------------------------------------------
# render_actor_preview — flag disabled
# ---------------------------------------------------------------------------


class TestRenderActorPreviewDisabled:
"""When feature flag is off, render_actor_preview returns None."""

def test_returns_none_when_disabled(self, monkeypatch):
monkeypatch.delenv("CODINGBUDDY_TINY_ACTORS", raising=False)
result = render_actor_preview("PLAN")
assert result is None


# ---------------------------------------------------------------------------
# render_actor_preview — flag enabled
# ---------------------------------------------------------------------------


class TestRenderActorPreviewEnabled:
"""When feature flag is on, render_actor_preview returns formatted output."""

@pytest.fixture(autouse=True)
def _enable_flag(self, monkeypatch):
monkeypatch.setenv("CODINGBUDDY_TINY_ACTORS", "1")

def test_valid_mode_returns_string(self):
result = render_actor_preview("PLAN")
assert isinstance(result, str)
assert len(result) > 0

def test_unknown_mode_returns_none(self):
result = render_actor_preview("NONEXISTENT")
assert result is None

def test_preview_contains_agent_faces(self):
result = render_actor_preview("PLAN")
assert result is not None
# Should contain face-like patterns (eye+mouth+eye)
assert "\u25cf" in result or "o" in result # default eye glyphs

def test_preview_contains_moderator(self):
result = render_actor_preview("PLAN")
assert result is not None
# Buddy moderator face should appear
assert "\u25d5\u203f\u25d5" in result # ◕‿◕

def test_preview_contains_agent_labels(self):
result = render_actor_preview("PLAN")
assert result is not None
# Primary agent label derived from "technical-planner"
assert "Technical" in result
# Specialist label derived from "security-specialist"
assert "Security" in result

def test_respects_available_width(self):
result = render_actor_preview("PLAN", available_width=40)
assert result is not None
for line in result.split("\n"):
# Each line should not exceed available_width
# (stripped of ANSI codes for measurement)
assert len(line) <= 40

def test_all_preset_modes_render(self):
for mode in ("PLAN", "EVAL", "AUTO", "SHIP"):
result = render_actor_preview(mode)
assert result is not None, f"Mode {mode} should render"
assert len(result) > 0


# ---------------------------------------------------------------------------
# render_actor_preview — error fallback
# ---------------------------------------------------------------------------


class TestRenderActorPreviewFallback:
"""Errors in rendering fall back to None without raising."""

@pytest.fixture(autouse=True)
def _enable_flag(self, monkeypatch):
monkeypatch.setenv("CODINGBUDDY_TINY_ACTORS", "1")

def test_exception_in_preset_returns_none(self, monkeypatch):
"""If get_cast_preset raises, render_actor_preview returns None."""
import tiny_actor_preview

def _boom(mode):
raise RuntimeError("boom")

monkeypatch.setattr(tiny_actor_preview, "get_cast_preset", _boom)
result = render_actor_preview("PLAN")
assert result is None
Loading