Skip to content

Commit c1dd7f1

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 c1dd7f1

3 files changed

Lines changed: 322 additions & 0 deletions

File tree

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
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
11+
12+
# Timing defaults — tuned so total animation stays under 1.5s
13+
# for a typical 4-agent council (~7 lines, ~200 total chars).
14+
DEFAULT_AGENT_DELAY = 0.05
15+
DEFAULT_CHAR_SPEED = 0.005
16+
MAX_TOTAL_TIME = 1.5 # Hard cap in seconds
17+
18+
# Environment variable to disable animation
19+
ANIMATION_ENV = "CODINGBUDDY_COUNCIL_ANIMATION"
20+
21+
22+
def animate_council_assembly(
23+
primary: str,
24+
specialists: List[str],
25+
moderator_copy: str = "Council assembled.",
26+
agent_delay: float = DEFAULT_AGENT_DELAY,
27+
char_speed: float = DEFAULT_CHAR_SPEED,
28+
) -> str:
29+
"""Render council assembly with staggered animation to stderr.
30+
31+
When stderr is a TTY and animation is enabled, agents appear
32+
one-by-one with a typing effect. Otherwise, falls back to
33+
static rendering returned as a string.
34+
35+
Total animation is capped at MAX_TOTAL_TIME to avoid blocking
36+
the hook for too long.
37+
38+
Args:
39+
primary: Primary agent name.
40+
specialists: List of specialist agent names.
41+
moderator_copy: Moderator greeting text.
42+
agent_delay: Delay between agents in seconds.
43+
char_speed: Delay between characters for typing effect.
44+
45+
Returns:
46+
The full rendered council scene as a string (for logging/testing).
47+
"""
48+
lines = _build_lines(primary, specialists, moderator_copy)
49+
full_text = "\n".join(lines)
50+
51+
if _should_animate():
52+
# Auto-adjust speed to stay within time cap
53+
total_chars = sum(len(line) for line in lines)
54+
total_delays = len(lines) - 1
55+
estimated_time = (total_chars * char_speed) + (total_delays * agent_delay)
56+
if estimated_time > MAX_TOTAL_TIME and total_chars > 0:
57+
ratio = MAX_TOTAL_TIME / estimated_time
58+
char_speed *= ratio
59+
agent_delay *= ratio
60+
_animate_to_stderr(lines, agent_delay, char_speed)
61+
else:
62+
sys.stderr.write(full_text + "\n")
63+
sys.stderr.flush()
64+
65+
return full_text
66+
67+
68+
def _build_lines(
69+
primary: str,
70+
specialists: List[str],
71+
moderator_copy: str,
72+
) -> List[str]:
73+
"""Build the council scene lines."""
74+
lines = []
75+
lines.append(f" \u25d5\u203f\u25d5 {moderator_copy}") # ◕‿◕ buddy face
76+
lines.append(f" \u25b6 {primary} [primary]")
77+
for spec in specialists:
78+
lines.append(f" \u25cb {spec} [specialist]")
79+
lines.append(" \u2501\u2501 Council assembled \u2501\u2501")
80+
return lines
81+
82+
83+
def _should_animate() -> bool:
84+
"""Check if animation should be enabled."""
85+
env_value = os.environ.get(ANIMATION_ENV, "").lower()
86+
if env_value == "0" or env_value == "false" or env_value == "off":
87+
return False
88+
if env_value == "1" or env_value == "true" or env_value == "on":
89+
return True
90+
# Default: animate only if stderr is a TTY
91+
return hasattr(sys.stderr, "isatty") and sys.stderr.isatty()
92+
93+
94+
def _animate_to_stderr(
95+
lines: List[str],
96+
agent_delay: float,
97+
char_speed: float,
98+
) -> None:
99+
"""Write lines to stderr with staggered typing effect."""
100+
for i, line in enumerate(lines):
101+
for char in line:
102+
sys.stderr.write(char)
103+
sys.stderr.flush()
104+
time.sleep(char_speed)
105+
sys.stderr.write("\n")
106+
sys.stderr.flush()
107+
if i < len(lines) - 1:
108+
time.sleep(agent_delay)
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
"""Tests for council_animator module (#1441)."""
2+
import io
3+
import os
4+
import sys
5+
6+
import pytest
7+
8+
# Add lib to path
9+
_hooks_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
10+
_lib_dir = os.path.join(_hooks_dir, "lib")
11+
if _lib_dir not in sys.path:
12+
sys.path.insert(0, _lib_dir)
13+
14+
from council_animator import (
15+
animate_council_assembly,
16+
_build_lines,
17+
_should_animate,
18+
_animate_to_stderr,
19+
ANIMATION_ENV,
20+
MAX_TOTAL_TIME,
21+
)
22+
23+
24+
@pytest.fixture(autouse=True)
25+
def cleanup_animation_env():
26+
"""Save and restore ANIMATION_ENV for all tests."""
27+
original = os.environ.get(ANIMATION_ENV)
28+
yield
29+
if original is not None:
30+
os.environ[ANIMATION_ENV] = original
31+
elif ANIMATION_ENV in os.environ:
32+
del os.environ[ANIMATION_ENV]
33+
34+
35+
class TestBuildLines:
36+
def test_includes_buddy_face(self):
37+
lines = _build_lines("planner", ["security"], "Let's go.")
38+
assert any("\u25d5\u203f\u25d5" in line for line in lines)
39+
40+
def test_includes_primary_agent(self):
41+
lines = _build_lines("technical-planner", ["security"], "Go.")
42+
assert any("technical-planner" in line and "[primary]" in line for line in lines)
43+
44+
def test_includes_specialists(self):
45+
lines = _build_lines("planner", ["security-specialist", "performance-specialist"], "Go.")
46+
specialist_lines = [l for l in lines if "[specialist]" in l]
47+
assert len(specialist_lines) == 2
48+
assert any("security-specialist" in l for l in specialist_lines)
49+
assert any("performance-specialist" in l for l in specialist_lines)
50+
51+
def test_ends_with_assembled_line(self):
52+
lines = _build_lines("planner", ["security"], "Go.")
53+
assert "Council assembled" in lines[-1]
54+
55+
def test_moderator_copy_in_first_line(self):
56+
lines = _build_lines("planner", [], "Time for a checkup.")
57+
assert "Time for a checkup." in lines[0]
58+
59+
def test_empty_specialists(self):
60+
lines = _build_lines("planner", [], "Go.")
61+
assert len(lines) == 3 # buddy, primary, assembled
62+
63+
64+
class TestShouldAnimate:
65+
def test_disabled_with_env_0(self):
66+
os.environ[ANIMATION_ENV] = "0"
67+
assert _should_animate() is False
68+
69+
def test_disabled_with_env_false(self):
70+
os.environ[ANIMATION_ENV] = "false"
71+
assert _should_animate() is False
72+
73+
def test_disabled_with_env_off(self):
74+
os.environ[ANIMATION_ENV] = "off"
75+
assert _should_animate() is False
76+
77+
def test_enabled_with_env_1(self):
78+
os.environ[ANIMATION_ENV] = "1"
79+
assert _should_animate() is True
80+
81+
def test_enabled_with_env_true(self):
82+
os.environ[ANIMATION_ENV] = "true"
83+
assert _should_animate() is True
84+
85+
def test_enabled_with_env_on(self):
86+
os.environ[ANIMATION_ENV] = "on"
87+
assert _should_animate() is True
88+
89+
def test_default_depends_on_tty(self):
90+
if ANIMATION_ENV in os.environ:
91+
del os.environ[ANIMATION_ENV]
92+
result = _should_animate()
93+
expected = hasattr(sys.stderr, "isatty") and sys.stderr.isatty()
94+
assert result == expected
95+
96+
97+
class TestAnimateToStderr:
98+
def test_writes_all_characters(self):
99+
"""Animated path writes each character to stderr."""
100+
buf = io.StringIO()
101+
original = sys.stderr
102+
sys.stderr = buf
103+
try:
104+
_animate_to_stderr(["hello", "world"], agent_delay=0, char_speed=0)
105+
finally:
106+
sys.stderr = original
107+
output = buf.getvalue()
108+
assert "hello" in output
109+
assert "world" in output
110+
111+
def test_includes_newlines(self):
112+
buf = io.StringIO()
113+
original = sys.stderr
114+
sys.stderr = buf
115+
try:
116+
_animate_to_stderr(["line1", "line2"], agent_delay=0, char_speed=0)
117+
finally:
118+
sys.stderr = original
119+
assert buf.getvalue().count("\n") == 2
120+
121+
def test_single_line(self):
122+
buf = io.StringIO()
123+
original = sys.stderr
124+
sys.stderr = buf
125+
try:
126+
_animate_to_stderr(["only line"], agent_delay=0, char_speed=0)
127+
finally:
128+
sys.stderr = original
129+
assert "only line" in buf.getvalue()
130+
131+
132+
class TestAnimateCouncilAssembly:
133+
def test_returns_full_text(self):
134+
os.environ[ANIMATION_ENV] = "0"
135+
result = animate_council_assembly(
136+
"technical-planner",
137+
["security-specialist", "performance-specialist"],
138+
"Let's map it out.",
139+
agent_delay=0,
140+
char_speed=0,
141+
)
142+
assert "technical-planner" in result
143+
assert "security-specialist" in result
144+
assert "performance-specialist" in result
145+
assert "Let's map it out." in result
146+
assert "Council assembled" in result
147+
148+
def test_static_mode_writes_to_stderr(self, capsys):
149+
os.environ[ANIMATION_ENV] = "0"
150+
animate_council_assembly(
151+
"planner", ["security"], "Go.",
152+
agent_delay=0, char_speed=0,
153+
)
154+
captured = capsys.readouterr()
155+
assert captured.out == "" # Nothing to stdout
156+
157+
def test_animated_mode_writes_to_stderr(self):
158+
os.environ[ANIMATION_ENV] = "1"
159+
buf = io.StringIO()
160+
original = sys.stderr
161+
sys.stderr = buf
162+
try:
163+
result = animate_council_assembly(
164+
"planner", ["security"], "Go.",
165+
agent_delay=0, char_speed=0,
166+
)
167+
finally:
168+
sys.stderr = original
169+
output = buf.getvalue()
170+
assert "planner" in output
171+
assert "security" in output
172+
assert "planner" in result
173+
174+
def test_handles_empty_specialists(self):
175+
os.environ[ANIMATION_ENV] = "0"
176+
result = animate_council_assembly("planner", [], "Go.", agent_delay=0, char_speed=0)
177+
assert "planner" in result
178+
assert "Council assembled" in result
179+
180+
def test_time_cap_reduces_speed(self):
181+
"""When estimated time exceeds MAX_TOTAL_TIME, speeds are reduced."""
182+
os.environ[ANIMATION_ENV] = "1"
183+
# 10 specialists with high delays would exceed cap
184+
specialists = [f"specialist-{i}" for i in range(10)]
185+
buf = io.StringIO()
186+
original = sys.stderr
187+
sys.stderr = buf
188+
try:
189+
# Use high delays that would normally take >10s
190+
result = animate_council_assembly(
191+
"planner", specialists, "Go.",
192+
agent_delay=1.0, char_speed=0.1,
193+
)
194+
finally:
195+
sys.stderr = original
196+
# Should still produce full output (time cap just reduces speed)
197+
assert "planner" in result
198+
for i in range(10):
199+
assert f"specialist-{i}" in result

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)