diff --git a/packages/claude-code-plugin/hooks/lib/buddy_renderer.py b/packages/claude-code-plugin/hooks/lib/buddy_renderer.py index 5bb60001..e116398f 100644 --- a/packages/claude-code-plugin/hooks/lib/buddy_renderer.py +++ b/packages/claude-code-plugin/hooks/lib/buddy_renderer.py @@ -3,7 +3,10 @@ Renders buddy face greeting, project scan results, agent recommendations, and session summary with tone/language support and ANSI color output. """ +import os import re +import sys +import time from typing import Any, Dict, List, Optional # ANSI color codes for terminal output @@ -91,6 +94,19 @@ ) +def type_text(text: str, speed: float = 0.03) -> None: + """Write text to stderr one character at a time with flush for typing animation. + + Args: + text: The text to animate. + speed: Delay in seconds between each character (default 0.03s). + """ + for char in text: + sys.stderr.write(char) + sys.stderr.flush() + time.sleep(speed) + + def get_buddy_config(config: Optional[Dict[str, Any]] = None) -> Dict[str, str]: """Extract and validate buddy customization from config dict. @@ -585,6 +601,7 @@ def render_session_start( previous_session: "Dict[str, Any] | None" = None, pending_context: "Dict[str, Any] | None" = None, buddy_config: Optional[Dict[str, str]] = None, + typing: bool = False, ) -> str: """Render complete session-start output. @@ -592,6 +609,9 @@ def render_session_start( a single formatted output string. If previous_session is provided, renders a returning session greeting instead of the default. + When typing=True, writes the output to stderr with a typing animation + effect on the greeting text and returns an empty string. + Args: scan: Project scan data dict. recommendations: Agent recommendation list. @@ -600,9 +620,10 @@ def render_session_start( previous_session: Optional previous session data for returning users. pending_context: Optional pending work context from context.md. buddy_config: Optional buddy customization from get_buddy_config(). + typing: If True, output to stderr with typing animation on greeting. Returns: - Complete formatted session-start output. + Complete formatted session-start output, or empty string if typing=True. """ # Returning session path if previous_session: @@ -634,4 +655,32 @@ def render_session_start( parts.append("") parts.append(badges) - return "\n".join(parts) + full_output = "\n".join(parts) + + if not typing: + return full_output + + # Typing mode: animate the greeting text, print rest instantly + bc = buddy_config or DEFAULT_BUDDY_CONFIG + custom_greeting = bc.get("greeting", "") + if previous_session: + greeting = custom_greeting if custom_greeting else _get_returning_greeting(tone, language) + else: + greeting = custom_greeting if custom_greeting else _get_greeting(tone, language) + + idx = full_output.find(greeting) + if idx == -1 or not greeting: + # Fallback: print everything instantly + sys.stderr.write(full_output + "\n") + sys.stderr.flush() + return "" + + before = full_output[:idx] + after = full_output[idx + len(greeting):] + + sys.stderr.write(before) + sys.stderr.flush() + type_text(greeting) + sys.stderr.write(after + "\n") + sys.stderr.flush() + return "" diff --git a/packages/claude-code-plugin/hooks/session-start.py b/packages/claude-code-plugin/hooks/session-start.py index 0568c68d..423502e0 100644 --- a/packages/claude-code-plugin/hooks/session-start.py +++ b/packages/claude-code-plugin/hooks/session-start.py @@ -547,7 +547,7 @@ def main(): _ensure_lib_path() from config import get_config as _get_config - from buddy_renderer import render_session_start + from buddy_renderer import render_session_start, get_buddy_config from adaptive_perf import get_monitor, format_lightweight_notice cwd = os.environ.get("CLAUDE_PROJECT_DIR", str(Path.cwd())) @@ -593,11 +593,18 @@ def main(): except Exception: pass # Never block for returning session detection + # Buddy config and typing animation (#1033) + buddy_cfg = get_buddy_config(cfg) + buddy_section = cfg.get("buddy") if isinstance(cfg.get("buddy"), dict) else {} + typing_enabled = buddy_section.get("typingEffect", True) and not os.environ.get("CI") + # Render and output output = render_session_start( scan_data, recommendations, tone, language, previous_session=previous_session, pending_context=pending_context, + buddy_config=buddy_cfg, + typing=bool(typing_enabled), ) if output: print(output) diff --git a/packages/claude-code-plugin/hooks/test_buddy_renderer.py b/packages/claude-code-plugin/hooks/test_buddy_renderer.py index 01205dd4..edac1842 100644 --- a/packages/claude-code-plugin/hooks/test_buddy_renderer.py +++ b/packages/claude-code-plugin/hooks/test_buddy_renderer.py @@ -17,6 +17,7 @@ render_session_summary, render_returning_session, get_buddy_config, + type_text, GREETINGS, FAREWELL_GREETINGS, FAREWELL_MESSAGES, @@ -464,6 +465,95 @@ def test_no_buddy_config_keeps_defaults(self): assert BUDDY_FACE in result +class TestTypeText: + """Tests for type_text — typing animation (#1033).""" + + def test_writes_all_chars_to_stderr(self, capsys, monkeypatch): + """type_text writes every character to stderr.""" + monkeypatch.setattr("buddy_renderer.time.sleep", lambda _s: None) + type_text("hello") + captured = capsys.readouterr() + assert captured.err == "hello" + assert captured.out == "" + + def test_empty_string_writes_nothing(self, capsys, monkeypatch): + monkeypatch.setattr("buddy_renderer.time.sleep", lambda _s: None) + type_text("") + captured = capsys.readouterr() + assert captured.err == "" + + def test_unicode_text(self, capsys, monkeypatch): + monkeypatch.setattr("buddy_renderer.time.sleep", lambda _s: None) + type_text("\uc548\ub155!") + captured = capsys.readouterr() + assert captured.err == "\uc548\ub155!" + + def test_calls_sleep_per_char(self, monkeypatch): + sleep_calls = [] + monkeypatch.setattr("buddy_renderer.time.sleep", lambda s: sleep_calls.append(s)) + type_text("abc", speed=0.05) + assert len(sleep_calls) == 3 + assert all(s == 0.05 for s in sleep_calls) + + def test_default_speed(self, monkeypatch): + sleep_calls = [] + monkeypatch.setattr("buddy_renderer.time.sleep", lambda s: sleep_calls.append(s)) + type_text("ab") + assert all(s == 0.03 for s in sleep_calls) + + +class TestTypingMode: + """Tests for render_session_start typing=True (#1033).""" + + def test_typing_returns_empty_string(self, monkeypatch): + monkeypatch.setattr("buddy_renderer.time.sleep", lambda _s: None) + scan = {"name": "app"} + result = render_session_start(scan, [], "casual", "en", typing=True) + assert result == "" + + def test_typing_false_returns_full_output(self): + scan = {"name": "app"} + result = render_session_start(scan, [], "casual", "en", typing=False) + assert "app" in result + assert BUDDY_FACE in result + + def test_typing_writes_to_stderr(self, capsys, monkeypatch): + monkeypatch.setattr("buddy_renderer.time.sleep", lambda _s: None) + scan = {"name": "app"} + render_session_start(scan, [], "casual", "en", typing=True) + captured = capsys.readouterr() + assert BUDDY_FACE in captured.err + assert "Hey!" in captured.err + assert "app" in captured.err + + def test_typing_greeting_appears_in_stderr(self, capsys, monkeypatch): + monkeypatch.setattr("buddy_renderer.time.sleep", lambda _s: None) + scan = {"name": "app"} + render_session_start(scan, [], "formal", "ko", typing=True) + captured = capsys.readouterr() + assert "\ud504\ub85c\uc81d\ud2b8" in captured.err + + def test_typing_with_returning_session(self, capsys, monkeypatch): + monkeypatch.setattr("buddy_renderer.time.sleep", lambda _s: None) + scan = {"name": "app"} + prev = {"started_at": 1000, "ended_at": 2000, "tool_call_count": 5, "error_count": 0} + result = render_session_start( + scan, [], "casual", "en", + previous_session=prev, typing=True, + ) + assert result == "" + captured = capsys.readouterr() + assert "back" in captured.err.lower() + + def test_typing_with_custom_greeting(self, capsys, monkeypatch): + monkeypatch.setattr("buddy_renderer.time.sleep", lambda _s: None) + bc = {"face": BUDDY_FACE, "greeting": "Custom hello!", "name": "B", "farewell": ""} + scan = {"name": "app"} + render_session_start(scan, [], "casual", "en", buddy_config=bc, typing=True) + captured = capsys.readouterr() + assert "Custom hello!" in captured.err + + if __name__ == "__main__": import pytest pytest.main([__file__, "-v"])