Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 51 additions & 2 deletions packages/claude-code-plugin/hooks/lib/buddy_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -585,13 +601,17 @@ 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.

Assembles buddy face, scan results, and recommendations into
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.
Expand All @@ -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:
Expand Down Expand Up @@ -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 ""
9 changes: 8 additions & 1 deletion packages/claude-code-plugin/hooks/session-start.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
Expand Down Expand Up @@ -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)
Expand Down
90 changes: 90 additions & 0 deletions packages/claude-code-plugin/hooks/test_buddy_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
render_session_summary,
render_returning_session,
get_buddy_config,
type_text,
GREETINGS,
FAREWELL_GREETINGS,
FAREWELL_MESSAGES,
Expand Down Expand Up @@ -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"])
Loading