Skip to content

Commit 4daec63

Browse files
committed
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
1 parent b9df988 commit 4daec63

2 files changed

Lines changed: 279 additions & 0 deletions

File tree

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""Feature-flagged Tiny Actor Grid preview (#1271).
2+
3+
Provides a controlled preview path for the Tiny Actor Grid.
4+
The feature is gated behind ``CODINGBUDDY_TINY_ACTORS`` env var (default: off).
5+
"""
6+
from __future__ import annotations
7+
8+
import os
9+
from typing import Optional
10+
11+
from tiny_actor_card import create_actor_card, TinyActorCard
12+
from tiny_actor_presets import get_cast_preset, BUDDY_FACE
13+
14+
# ---------------------------------------------------------------------------
15+
# Feature flag
16+
# ---------------------------------------------------------------------------
17+
18+
_FLAG_ENV = "CODINGBUDDY_TINY_ACTORS"
19+
_TRUTHY = {"1", "true", "yes"}
20+
21+
22+
def is_tiny_actors_enabled() -> bool:
23+
"""Return ``True`` when the Tiny Actor preview is explicitly enabled."""
24+
return os.environ.get(_FLAG_ENV, "").lower() in _TRUTHY
25+
26+
27+
# ---------------------------------------------------------------------------
28+
# Card rendering helpers
29+
# ---------------------------------------------------------------------------
30+
31+
_CARD_WIDTH = 14
32+
_SEPARATOR = " "
33+
34+
35+
def _render_card_lines(card: TinyActorCard, width: int) -> list[str]:
36+
"""Render a single actor card as a list of fixed-width lines."""
37+
label = card.label[:width]
38+
face = card.face
39+
lines = [
40+
label.center(width),
41+
face.center(width),
42+
]
43+
if card.quote:
44+
quote = card.quote[:width]
45+
lines.append(quote.center(width))
46+
return lines
47+
48+
49+
def _arrange_cards_horizontal(
50+
cards: list[TinyActorCard],
51+
available_width: int,
52+
) -> str:
53+
"""Arrange actor cards in a horizontal row, wrapping to fit *available_width*."""
54+
if not cards:
55+
return ""
56+
57+
card_w = min(_CARD_WIDTH, available_width)
58+
sep_w = len(_SEPARATOR)
59+
# Cards per row: at least 1
60+
cards_per_row = max(1, (available_width + sep_w) // (card_w + sep_w))
61+
62+
rows_output: list[str] = []
63+
for row_start in range(0, len(cards), cards_per_row):
64+
row_cards = cards[row_start : row_start + cards_per_row]
65+
rendered = [_render_card_lines(c, card_w) for c in row_cards]
66+
67+
# Pad all to same number of lines
68+
max_lines = max(len(r) for r in rendered)
69+
for r in rendered:
70+
while len(r) < max_lines:
71+
r.append(" " * card_w)
72+
73+
# Merge horizontally
74+
for line_idx in range(max_lines):
75+
merged = _SEPARATOR.join(r[line_idx] for r in rendered)
76+
rows_output.append(merged[:available_width])
77+
78+
return "\n".join(rows_output)
79+
80+
81+
# ---------------------------------------------------------------------------
82+
# Public API
83+
# ---------------------------------------------------------------------------
84+
85+
86+
def render_actor_preview(
87+
mode: str,
88+
available_width: int = 80,
89+
) -> Optional[str]:
90+
"""Render a Tiny Actor Grid preview for *mode*.
91+
92+
Returns ``None`` if the feature flag is disabled, the mode has no preset,
93+
or an error occurs during rendering.
94+
"""
95+
if not is_tiny_actors_enabled():
96+
return None
97+
98+
try:
99+
preset = get_cast_preset(mode)
100+
if preset is None:
101+
return None
102+
103+
cards: list[TinyActorCard] = []
104+
105+
# Buddy moderator card
106+
moderator = create_actor_card(
107+
agent_id="buddy",
108+
label="Buddy",
109+
mood="speaking",
110+
eye_glyph="\u25d5", # ◕
111+
quote=preset["moderator_copy"],
112+
is_moderator=True,
113+
)
114+
cards.append(moderator)
115+
116+
# Primary agent card
117+
primary = create_actor_card(
118+
agent_id=preset["primary"],
119+
label=_agent_id_to_label(preset["primary"]),
120+
mood="proposing",
121+
)
122+
cards.append(primary)
123+
124+
# Specialist cards
125+
for spec_id in preset["specialists"]:
126+
card = create_actor_card(
127+
agent_id=spec_id,
128+
label=_agent_id_to_label(spec_id),
129+
mood="reviewing",
130+
)
131+
cards.append(card)
132+
133+
return _arrange_cards_horizontal(cards, available_width)
134+
135+
except Exception:
136+
return None
137+
138+
139+
def _agent_id_to_label(agent_id: str) -> str:
140+
"""Convert an agent id like ``'security-specialist'`` to ``'Security'``."""
141+
parts = agent_id.split("-")
142+
if not parts:
143+
return agent_id
144+
return parts[0].capitalize()
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
"""Tests for feature-flagged Tiny Actor Grid preview (#1271)."""
2+
import os
3+
import sys
4+
5+
import pytest
6+
7+
# Ensure hooks/lib is on path
8+
_tests_dir = os.path.dirname(os.path.abspath(__file__))
9+
_lib_dir = os.path.join(os.path.dirname(_tests_dir), "hooks", "lib")
10+
if _lib_dir not in sys.path:
11+
sys.path.insert(0, _lib_dir)
12+
13+
from tiny_actor_preview import is_tiny_actors_enabled, render_actor_preview
14+
15+
# ---------------------------------------------------------------------------
16+
# is_tiny_actors_enabled
17+
# ---------------------------------------------------------------------------
18+
19+
20+
class TestIsTinyActorsEnabled:
21+
"""Feature flag respects CODINGBUDDY_TINY_ACTORS env var."""
22+
23+
def test_disabled_by_default(self, monkeypatch):
24+
monkeypatch.delenv("CODINGBUDDY_TINY_ACTORS", raising=False)
25+
assert is_tiny_actors_enabled() is False
26+
27+
def test_enabled_when_set_to_1(self, monkeypatch):
28+
monkeypatch.setenv("CODINGBUDDY_TINY_ACTORS", "1")
29+
assert is_tiny_actors_enabled() is True
30+
31+
def test_enabled_when_set_to_true(self, monkeypatch):
32+
monkeypatch.setenv("CODINGBUDDY_TINY_ACTORS", "true")
33+
assert is_tiny_actors_enabled() is True
34+
35+
def test_disabled_when_set_to_0(self, monkeypatch):
36+
monkeypatch.setenv("CODINGBUDDY_TINY_ACTORS", "0")
37+
assert is_tiny_actors_enabled() is False
38+
39+
def test_disabled_when_set_to_empty(self, monkeypatch):
40+
monkeypatch.setenv("CODINGBUDDY_TINY_ACTORS", "")
41+
assert is_tiny_actors_enabled() is False
42+
43+
44+
# ---------------------------------------------------------------------------
45+
# render_actor_preview — flag disabled
46+
# ---------------------------------------------------------------------------
47+
48+
49+
class TestRenderActorPreviewDisabled:
50+
"""When feature flag is off, render_actor_preview returns None."""
51+
52+
def test_returns_none_when_disabled(self, monkeypatch):
53+
monkeypatch.delenv("CODINGBUDDY_TINY_ACTORS", raising=False)
54+
result = render_actor_preview("PLAN")
55+
assert result is None
56+
57+
58+
# ---------------------------------------------------------------------------
59+
# render_actor_preview — flag enabled
60+
# ---------------------------------------------------------------------------
61+
62+
63+
class TestRenderActorPreviewEnabled:
64+
"""When feature flag is on, render_actor_preview returns formatted output."""
65+
66+
@pytest.fixture(autouse=True)
67+
def _enable_flag(self, monkeypatch):
68+
monkeypatch.setenv("CODINGBUDDY_TINY_ACTORS", "1")
69+
70+
def test_valid_mode_returns_string(self):
71+
result = render_actor_preview("PLAN")
72+
assert isinstance(result, str)
73+
assert len(result) > 0
74+
75+
def test_unknown_mode_returns_none(self):
76+
result = render_actor_preview("NONEXISTENT")
77+
assert result is None
78+
79+
def test_preview_contains_agent_faces(self):
80+
result = render_actor_preview("PLAN")
81+
assert result is not None
82+
# Should contain face-like patterns (eye+mouth+eye)
83+
assert "\u25cf" in result or "o" in result # default eye glyphs
84+
85+
def test_preview_contains_moderator(self):
86+
result = render_actor_preview("PLAN")
87+
assert result is not None
88+
# Buddy moderator face should appear
89+
assert "\u25d5\u203f\u25d5" in result # ◕‿◕
90+
91+
def test_preview_contains_agent_labels(self):
92+
result = render_actor_preview("PLAN")
93+
assert result is not None
94+
# Primary agent label derived from "technical-planner"
95+
assert "Technical" in result
96+
# Specialist label derived from "security-specialist"
97+
assert "Security" in result
98+
99+
def test_respects_available_width(self):
100+
result = render_actor_preview("PLAN", available_width=40)
101+
assert result is not None
102+
for line in result.split("\n"):
103+
# Each line should not exceed available_width
104+
# (stripped of ANSI codes for measurement)
105+
assert len(line) <= 40
106+
107+
def test_all_preset_modes_render(self):
108+
for mode in ("PLAN", "EVAL", "AUTO", "SHIP"):
109+
result = render_actor_preview(mode)
110+
assert result is not None, f"Mode {mode} should render"
111+
assert len(result) > 0
112+
113+
114+
# ---------------------------------------------------------------------------
115+
# render_actor_preview — error fallback
116+
# ---------------------------------------------------------------------------
117+
118+
119+
class TestRenderActorPreviewFallback:
120+
"""Errors in rendering fall back to None without raising."""
121+
122+
@pytest.fixture(autouse=True)
123+
def _enable_flag(self, monkeypatch):
124+
monkeypatch.setenv("CODINGBUDDY_TINY_ACTORS", "1")
125+
126+
def test_exception_in_preset_returns_none(self, monkeypatch):
127+
"""If get_cast_preset raises, render_actor_preview returns None."""
128+
import tiny_actor_preview
129+
130+
def _boom(mode):
131+
raise RuntimeError("boom")
132+
133+
monkeypatch.setattr(tiny_actor_preview, "get_cast_preset", _boom)
134+
result = render_actor_preview("PLAN")
135+
assert result is None

0 commit comments

Comments
 (0)