From eb36468583a065309f4155baced5a806aa2cb641 Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Sat, 4 Apr 2026 11:24:04 +0900 Subject: [PATCH] feat(plugin): add feature-flagged Tiny Actor Grid preview (#1271) Add render_actor_preview() gated behind CODINGBUDDY_TINY_ACTORS env var. Integrates get_cast_preset and create_actor_card to produce a horizontal actor grid preview. Feature is off by default; errors fall back to None. Closes #1271 --- .../hooks/lib/tiny_actor_preview.py | 144 ++++++++++++++++++ .../tests/test_tiny_actor_preview.py | 135 ++++++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 packages/claude-code-plugin/hooks/lib/tiny_actor_preview.py create mode 100644 packages/claude-code-plugin/tests/test_tiny_actor_preview.py diff --git a/packages/claude-code-plugin/hooks/lib/tiny_actor_preview.py b/packages/claude-code-plugin/hooks/lib/tiny_actor_preview.py new file mode 100644 index 00000000..53151e7b --- /dev/null +++ b/packages/claude-code-plugin/hooks/lib/tiny_actor_preview.py @@ -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() diff --git a/packages/claude-code-plugin/tests/test_tiny_actor_preview.py b/packages/claude-code-plugin/tests/test_tiny_actor_preview.py new file mode 100644 index 00000000..068f7aa2 --- /dev/null +++ b/packages/claude-code-plugin/tests/test_tiny_actor_preview.py @@ -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