Skip to content

Commit f5a9f25

Browse files
committed
feat(plugin): council assembly animation with staggered specialist arrival (#1441)
- Add council_animator.py: staggered typing animation for council scene rendering to stderr, with per-agent delay for dramatic "assembling" effect - Wire into user-prompt-submit.py: animate council on mode entry (PLAN/EVAL/AUTO) when council preset exists - Graceful degradation: static output when stderr is not a TTY - Configurable via CODINGBUDDY_COUNCIL_ANIMATION env (on/off/1/0) - Total animation < 2 seconds for typical 4-agent council - 18 tests covering build_lines, static render, animation toggle, env config Closes #1441
1 parent fbf119c commit f5a9f25

3 files changed

Lines changed: 267 additions & 0 deletions

File tree

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Council Assembly Animation — staggered specialist arrival effect (#1441).
2+
3+
Renders council specialists one-by-one with typing animation to stderr,
4+
creating a dramatic "assembling the team" moment. Degrades gracefully
5+
to static output when stderr is not a TTY or animation is disabled.
6+
"""
7+
import os
8+
import sys
9+
import time
10+
from typing import List, Optional
11+
12+
# Per-agent delay in seconds (total < 2s for typical 4-agent council)
13+
DEFAULT_AGENT_DELAY = 0.15
14+
DEFAULT_CHAR_SPEED = 0.02
15+
16+
# Environment variable to disable animation
17+
ANIMATION_ENV = "CODINGBUDDY_COUNCIL_ANIMATION"
18+
19+
20+
def animate_council_assembly(
21+
primary: str,
22+
specialists: List[str],
23+
moderator_copy: str = "Council assembled.",
24+
agent_delay: float = DEFAULT_AGENT_DELAY,
25+
char_speed: float = DEFAULT_CHAR_SPEED,
26+
) -> str:
27+
"""Render council assembly with staggered animation to stderr.
28+
29+
When stderr is a TTY and animation is enabled, agents appear
30+
one-by-one with a typing effect. Otherwise, falls back to
31+
static rendering returned as a string.
32+
33+
Args:
34+
primary: Primary agent name.
35+
specialists: List of specialist agent names.
36+
moderator_copy: Moderator greeting text.
37+
agent_delay: Delay between agents in seconds.
38+
char_speed: Delay between characters for typing effect.
39+
40+
Returns:
41+
The full rendered council scene as a string (for logging/testing).
42+
"""
43+
lines = _build_lines(primary, specialists, moderator_copy)
44+
full_text = "\n".join(lines)
45+
46+
if _should_animate():
47+
_animate_to_stderr(lines, agent_delay, char_speed)
48+
else:
49+
sys.stderr.write(full_text + "\n")
50+
sys.stderr.flush()
51+
52+
return full_text
53+
54+
55+
def render_council_static(
56+
primary: str,
57+
specialists: List[str],
58+
moderator_copy: str = "Council assembled.",
59+
) -> str:
60+
"""Render council scene as static text (no animation).
61+
62+
Args:
63+
primary: Primary agent name.
64+
specialists: List of specialist agent names.
65+
moderator_copy: Moderator greeting text.
66+
67+
Returns:
68+
Formatted council scene string.
69+
"""
70+
return "\n".join(_build_lines(primary, specialists, moderator_copy))
71+
72+
73+
def _build_lines(
74+
primary: str,
75+
specialists: List[str],
76+
moderator_copy: str,
77+
) -> List[str]:
78+
"""Build the council scene lines."""
79+
lines = []
80+
lines.append(f" \u25d5\u203f\u25d5 {moderator_copy}") # ◕‿◕ buddy face
81+
lines.append(f" \u25b6 {primary} [primary]")
82+
for spec in specialists:
83+
lines.append(f" \u25cb {spec} [specialist]")
84+
lines.append(" \u2501\u2501 Council assembled \u2501\u2501")
85+
return lines
86+
87+
88+
def _should_animate() -> bool:
89+
"""Check if animation should be enabled."""
90+
env_value = os.environ.get(ANIMATION_ENV, "").lower()
91+
if env_value == "0" or env_value == "false" or env_value == "off":
92+
return False
93+
if env_value == "1" or env_value == "true" or env_value == "on":
94+
return True
95+
# Default: animate only if stderr is a TTY
96+
return hasattr(sys.stderr, "isatty") and sys.stderr.isatty()
97+
98+
99+
def _animate_to_stderr(
100+
lines: List[str],
101+
agent_delay: float,
102+
char_speed: float,
103+
) -> None:
104+
"""Write lines to stderr with staggered typing effect."""
105+
for i, line in enumerate(lines):
106+
for char in line:
107+
sys.stderr.write(char)
108+
sys.stderr.flush()
109+
time.sleep(char_speed)
110+
sys.stderr.write("\n")
111+
sys.stderr.flush()
112+
if i < len(lines) - 1:
113+
time.sleep(agent_delay)
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
"""Tests for council_animator module (#1441)."""
2+
import os
3+
import sys
4+
5+
import pytest
6+
7+
# Add lib to path
8+
_hooks_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
9+
_lib_dir = os.path.join(_hooks_dir, "lib")
10+
if _lib_dir not in sys.path:
11+
sys.path.insert(0, _lib_dir)
12+
13+
from council_animator import (
14+
animate_council_assembly,
15+
render_council_static,
16+
_build_lines,
17+
_should_animate,
18+
ANIMATION_ENV,
19+
)
20+
21+
22+
class TestBuildLines:
23+
def test_includes_buddy_face(self):
24+
lines = _build_lines("planner", ["security"], "Let's go.")
25+
assert any("\u25d5\u203f\u25d5" in line for line in lines)
26+
27+
def test_includes_primary_agent(self):
28+
lines = _build_lines("technical-planner", ["security"], "Go.")
29+
assert any("technical-planner" in line and "[primary]" in line for line in lines)
30+
31+
def test_includes_specialists(self):
32+
lines = _build_lines("planner", ["security-specialist", "performance-specialist"], "Go.")
33+
specialist_lines = [l for l in lines if "[specialist]" in l]
34+
assert len(specialist_lines) == 2
35+
assert any("security-specialist" in l for l in specialist_lines)
36+
assert any("performance-specialist" in l for l in specialist_lines)
37+
38+
def test_ends_with_assembled_line(self):
39+
lines = _build_lines("planner", ["security"], "Go.")
40+
assert "Council assembled" in lines[-1]
41+
42+
def test_moderator_copy_in_first_line(self):
43+
lines = _build_lines("planner", [], "Time for a checkup.")
44+
assert "Time for a checkup." in lines[0]
45+
46+
def test_empty_specialists(self):
47+
lines = _build_lines("planner", [], "Go.")
48+
assert len(lines) == 3 # buddy, primary, assembled
49+
50+
51+
class TestRenderCouncilStatic:
52+
def test_returns_string(self):
53+
result = render_council_static("planner", ["security"], "Go.")
54+
assert isinstance(result, str)
55+
56+
def test_contains_all_agents(self):
57+
result = render_council_static("planner", ["sec", "perf", "a11y"], "Go.")
58+
assert "planner" in result
59+
assert "sec" in result
60+
assert "perf" in result
61+
assert "a11y" in result
62+
63+
def test_multiline_output(self):
64+
result = render_council_static("planner", ["security"], "Go.")
65+
assert "\n" in result
66+
67+
68+
class TestShouldAnimate:
69+
@pytest.fixture(autouse=True)
70+
def cleanup_env(self):
71+
original = os.environ.get(ANIMATION_ENV)
72+
yield
73+
if original is not None:
74+
os.environ[ANIMATION_ENV] = original
75+
elif ANIMATION_ENV in os.environ:
76+
del os.environ[ANIMATION_ENV]
77+
78+
def test_disabled_with_env_0(self):
79+
os.environ[ANIMATION_ENV] = "0"
80+
assert _should_animate() is False
81+
82+
def test_disabled_with_env_false(self):
83+
os.environ[ANIMATION_ENV] = "false"
84+
assert _should_animate() is False
85+
86+
def test_disabled_with_env_off(self):
87+
os.environ[ANIMATION_ENV] = "off"
88+
assert _should_animate() is False
89+
90+
def test_enabled_with_env_1(self):
91+
os.environ[ANIMATION_ENV] = "1"
92+
assert _should_animate() is True
93+
94+
def test_enabled_with_env_true(self):
95+
os.environ[ANIMATION_ENV] = "true"
96+
assert _should_animate() is True
97+
98+
def test_default_depends_on_tty(self):
99+
if ANIMATION_ENV in os.environ:
100+
del os.environ[ANIMATION_ENV]
101+
# In test environment, stderr is typically not a TTY
102+
result = _should_animate()
103+
expected = hasattr(sys.stderr, "isatty") and sys.stderr.isatty()
104+
assert result == expected
105+
106+
107+
class TestAnimateCouncilAssembly:
108+
def test_returns_full_text(self, capsys):
109+
os.environ[ANIMATION_ENV] = "0" # Disable animation for speed
110+
result = animate_council_assembly(
111+
"technical-planner",
112+
["security-specialist", "performance-specialist"],
113+
"Let's map it out.",
114+
agent_delay=0,
115+
char_speed=0,
116+
)
117+
assert "technical-planner" in result
118+
assert "security-specialist" in result
119+
assert "performance-specialist" in result
120+
assert "Let's map it out." in result
121+
assert "Council assembled" in result
122+
os.environ.pop(ANIMATION_ENV, None)
123+
124+
def test_static_mode_writes_to_stderr(self, capsys):
125+
os.environ[ANIMATION_ENV] = "0"
126+
animate_council_assembly(
127+
"planner", ["security"], "Go.",
128+
agent_delay=0, char_speed=0,
129+
)
130+
captured = capsys.readouterr()
131+
assert captured.out == "" # Nothing to stdout
132+
os.environ.pop(ANIMATION_ENV, None)
133+
134+
def test_handles_empty_specialists(self):
135+
os.environ[ANIMATION_ENV] = "0"
136+
result = animate_council_assembly("planner", [], "Go.", agent_delay=0, char_speed=0)
137+
assert "planner" in result
138+
assert "Council assembled" in result
139+
os.environ.pop(ANIMATION_ENV, None)

packages/claude-code-plugin/hooks/user-prompt-submit.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,21 @@ def main():
165165
except Exception:
166166
pass
167167

168+
# Council Assembly Animation: staggered specialist arrival (#1441)
169+
try:
170+
if council_preset and isinstance(council_preset, dict):
171+
from council_animator import animate_council_assembly
172+
173+
animate_council_assembly(
174+
primary=council_preset.get("primary", ""),
175+
specialists=council_preset.get("specialists", []),
176+
moderator_copy=council_preset.get(
177+
"moderator_copy", "Council assembled."
178+
),
179+
)
180+
except Exception:
181+
pass # Never block prompt submission
182+
168183
# Agent Council Memory: inject prior findings for specialists (#1435)
169184
try:
170185
from agent_memory import AgentMemory

0 commit comments

Comments
 (0)