Skip to content

Commit 74cbfa9

Browse files
committed
feat(plugin): add typing animation effect for buddy greeting (#1033)
Add type_text() function that writes to stderr char-by-char with flush for a typing animation effect on session start greeting. The buddy face renders instantly while the greeting text animates character by character. - Add type_text(text, speed=0.03) utility in buddy_renderer.py - Add typing= param to render_session_start() - Read buddy.typingEffect config, auto-disable in CI environments - Pass buddy_config to render_session_start in session-start.py - Add 11 tests covering type_text and typing mode
1 parent 761a406 commit 74cbfa9

3 files changed

Lines changed: 149 additions & 3 deletions

File tree

packages/claude-code-plugin/hooks/lib/buddy_renderer.py

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
Renders buddy face greeting, project scan results, agent recommendations,
44
and session summary with tone/language support and ANSI color output.
55
"""
6+
import os
67
import re
8+
import sys
9+
import time
710
from typing import Any, Dict, List, Optional
811

912
# ANSI color codes for terminal output
@@ -91,6 +94,19 @@
9194
)
9295

9396

97+
def type_text(text: str, speed: float = 0.03) -> None:
98+
"""Write text to stderr one character at a time with flush for typing animation.
99+
100+
Args:
101+
text: The text to animate.
102+
speed: Delay in seconds between each character (default 0.03s).
103+
"""
104+
for char in text:
105+
sys.stderr.write(char)
106+
sys.stderr.flush()
107+
time.sleep(speed)
108+
109+
94110
def get_buddy_config(config: Optional[Dict[str, Any]] = None) -> Dict[str, str]:
95111
"""Extract and validate buddy customization from config dict.
96112
@@ -585,13 +601,17 @@ def render_session_start(
585601
previous_session: "Dict[str, Any] | None" = None,
586602
pending_context: "Dict[str, Any] | None" = None,
587603
buddy_config: Optional[Dict[str, str]] = None,
604+
typing: bool = False,
588605
) -> str:
589606
"""Render complete session-start output.
590607
591608
Assembles buddy face, scan results, and recommendations into
592609
a single formatted output string. If previous_session is provided,
593610
renders a returning session greeting instead of the default.
594611
612+
When typing=True, writes the output to stderr with a typing animation
613+
effect on the greeting text and returns an empty string.
614+
595615
Args:
596616
scan: Project scan data dict.
597617
recommendations: Agent recommendation list.
@@ -600,9 +620,10 @@ def render_session_start(
600620
previous_session: Optional previous session data for returning users.
601621
pending_context: Optional pending work context from context.md.
602622
buddy_config: Optional buddy customization from get_buddy_config().
623+
typing: If True, output to stderr with typing animation on greeting.
603624
604625
Returns:
605-
Complete formatted session-start output.
626+
Complete formatted session-start output, or empty string if typing=True.
606627
"""
607628
# Returning session path
608629
if previous_session:
@@ -634,4 +655,32 @@ def render_session_start(
634655
parts.append("")
635656
parts.append(badges)
636657

637-
return "\n".join(parts)
658+
full_output = "\n".join(parts)
659+
660+
if not typing:
661+
return full_output
662+
663+
# Typing mode: animate the greeting text, print rest instantly
664+
bc = buddy_config or DEFAULT_BUDDY_CONFIG
665+
custom_greeting = bc.get("greeting", "")
666+
if previous_session:
667+
greeting = custom_greeting if custom_greeting else _get_returning_greeting(tone, language)
668+
else:
669+
greeting = custom_greeting if custom_greeting else _get_greeting(tone, language)
670+
671+
idx = full_output.find(greeting)
672+
if idx == -1 or not greeting:
673+
# Fallback: print everything instantly
674+
sys.stderr.write(full_output + "\n")
675+
sys.stderr.flush()
676+
return ""
677+
678+
before = full_output[:idx]
679+
after = full_output[idx + len(greeting):]
680+
681+
sys.stderr.write(before)
682+
sys.stderr.flush()
683+
type_text(greeting)
684+
sys.stderr.write(after + "\n")
685+
sys.stderr.flush()
686+
return ""

packages/claude-code-plugin/hooks/session-start.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -547,7 +547,7 @@ def main():
547547
_ensure_lib_path()
548548

549549
from config import get_config as _get_config
550-
from buddy_renderer import render_session_start
550+
from buddy_renderer import render_session_start, get_buddy_config
551551
from adaptive_perf import get_monitor, format_lightweight_notice
552552

553553
cwd = os.environ.get("CLAUDE_PROJECT_DIR", str(Path.cwd()))
@@ -593,11 +593,18 @@ def main():
593593
except Exception:
594594
pass # Never block for returning session detection
595595

596+
# Buddy config and typing animation (#1033)
597+
buddy_cfg = get_buddy_config(cfg)
598+
buddy_section = cfg.get("buddy") if isinstance(cfg.get("buddy"), dict) else {}
599+
typing_enabled = buddy_section.get("typingEffect", True) and not os.environ.get("CI")
600+
596601
# Render and output
597602
output = render_session_start(
598603
scan_data, recommendations, tone, language,
599604
previous_session=previous_session,
600605
pending_context=pending_context,
606+
buddy_config=buddy_cfg,
607+
typing=bool(typing_enabled),
601608
)
602609
if output:
603610
print(output)

packages/claude-code-plugin/hooks/test_buddy_renderer.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
render_session_summary,
1818
render_returning_session,
1919
get_buddy_config,
20+
type_text,
2021
GREETINGS,
2122
FAREWELL_GREETINGS,
2223
FAREWELL_MESSAGES,
@@ -464,6 +465,95 @@ def test_no_buddy_config_keeps_defaults(self):
464465
assert BUDDY_FACE in result
465466

466467

468+
class TestTypeText:
469+
"""Tests for type_text — typing animation (#1033)."""
470+
471+
def test_writes_all_chars_to_stderr(self, capsys, monkeypatch):
472+
"""type_text writes every character to stderr."""
473+
monkeypatch.setattr("buddy_renderer.time.sleep", lambda _s: None)
474+
type_text("hello")
475+
captured = capsys.readouterr()
476+
assert captured.err == "hello"
477+
assert captured.out == ""
478+
479+
def test_empty_string_writes_nothing(self, capsys, monkeypatch):
480+
monkeypatch.setattr("buddy_renderer.time.sleep", lambda _s: None)
481+
type_text("")
482+
captured = capsys.readouterr()
483+
assert captured.err == ""
484+
485+
def test_unicode_text(self, capsys, monkeypatch):
486+
monkeypatch.setattr("buddy_renderer.time.sleep", lambda _s: None)
487+
type_text("\uc548\ub155!")
488+
captured = capsys.readouterr()
489+
assert captured.err == "\uc548\ub155!"
490+
491+
def test_calls_sleep_per_char(self, monkeypatch):
492+
sleep_calls = []
493+
monkeypatch.setattr("buddy_renderer.time.sleep", lambda s: sleep_calls.append(s))
494+
type_text("abc", speed=0.05)
495+
assert len(sleep_calls) == 3
496+
assert all(s == 0.05 for s in sleep_calls)
497+
498+
def test_default_speed(self, monkeypatch):
499+
sleep_calls = []
500+
monkeypatch.setattr("buddy_renderer.time.sleep", lambda s: sleep_calls.append(s))
501+
type_text("ab")
502+
assert all(s == 0.03 for s in sleep_calls)
503+
504+
505+
class TestTypingMode:
506+
"""Tests for render_session_start typing=True (#1033)."""
507+
508+
def test_typing_returns_empty_string(self, monkeypatch):
509+
monkeypatch.setattr("buddy_renderer.time.sleep", lambda _s: None)
510+
scan = {"name": "app"}
511+
result = render_session_start(scan, [], "casual", "en", typing=True)
512+
assert result == ""
513+
514+
def test_typing_false_returns_full_output(self):
515+
scan = {"name": "app"}
516+
result = render_session_start(scan, [], "casual", "en", typing=False)
517+
assert "app" in result
518+
assert BUDDY_FACE in result
519+
520+
def test_typing_writes_to_stderr(self, capsys, monkeypatch):
521+
monkeypatch.setattr("buddy_renderer.time.sleep", lambda _s: None)
522+
scan = {"name": "app"}
523+
render_session_start(scan, [], "casual", "en", typing=True)
524+
captured = capsys.readouterr()
525+
assert BUDDY_FACE in captured.err
526+
assert "Hey!" in captured.err
527+
assert "app" in captured.err
528+
529+
def test_typing_greeting_appears_in_stderr(self, capsys, monkeypatch):
530+
monkeypatch.setattr("buddy_renderer.time.sleep", lambda _s: None)
531+
scan = {"name": "app"}
532+
render_session_start(scan, [], "formal", "ko", typing=True)
533+
captured = capsys.readouterr()
534+
assert "\ud504\ub85c\uc81d\ud2b8" in captured.err
535+
536+
def test_typing_with_returning_session(self, capsys, monkeypatch):
537+
monkeypatch.setattr("buddy_renderer.time.sleep", lambda _s: None)
538+
scan = {"name": "app"}
539+
prev = {"started_at": 1000, "ended_at": 2000, "tool_call_count": 5, "error_count": 0}
540+
result = render_session_start(
541+
scan, [], "casual", "en",
542+
previous_session=prev, typing=True,
543+
)
544+
assert result == ""
545+
captured = capsys.readouterr()
546+
assert "back" in captured.err.lower()
547+
548+
def test_typing_with_custom_greeting(self, capsys, monkeypatch):
549+
monkeypatch.setattr("buddy_renderer.time.sleep", lambda _s: None)
550+
bc = {"face": BUDDY_FACE, "greeting": "Custom hello!", "name": "B", "farewell": ""}
551+
scan = {"name": "app"}
552+
render_session_start(scan, [], "casual", "en", buddy_config=bc, typing=True)
553+
captured = capsys.readouterr()
554+
assert "Custom hello!" in captured.err
555+
556+
467557
if __name__ == "__main__":
468558
import pytest
469559
pytest.main([__file__, "-v"])

0 commit comments

Comments
 (0)