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
94 changes: 86 additions & 8 deletions packages/claude-code-plugin/hooks/lib/buddy_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,71 @@
BUDDY_WRAP_FACE = "\u25d5\u2304\u25d5" # ◕⌄◕
BUDDY_WINK_FACE = "\u25d5\u2040\u25d5" # ◕⁀◕

# Unicode → ASCII fallback mapping (#1040)
_UNICODE_TO_ASCII: Dict[str, str] = {
# Box drawing
"\u256d": "+", "\u256e": "+", "\u2570": "+", "\u256f": "+",
"\u2501": "-", "\u2503": "|", "\u2502": "|",
# Face characters
"\u25d5": ":", "\u203f": "_", "\u2304": "v", "\u2040": "^",
# Emoji → text
"\u26a1": "*",
"\U0001f9f2": "[cov]",
"\U0001f4c1": "[file]",
"\U0001f517": "[api]",
"\u23f1": "[time]",
"\U0001f527": "[tool]",
"\U0001f4dd": "[edit]",
"\U0001f4ac": "[msg]",
"\u2705": "[ok]",
"\u26a0\ufe0f": "[warn]",
"\u26a0": "[warn]",
"\U0001f916": "[bot]",
# Surrogate pair variants (used in some source strings)
"\ud83e\uddf2": "[cov]",
"\ud83d\udcc1": "[file]",
"\ud83d\udd17": "[api]",
"\U0001f534": "(R)",
"\U0001f7e2": "(G)",
"\U0001f535": "(B)",
"\U0001f7e1": "(Y)",
"\U0001f7e3": "(P)",
"\u26aa": "(W)",
"\u2728": "(*)",
}


def _to_ascii(text: str) -> str:
"""Replace Unicode/emoji characters with ASCII equivalents (#1040)."""
for uc, asc in _UNICODE_TO_ASCII.items():
text = text.replace(uc, asc)
return text


def _detect_unicode_support() -> bool:
"""Check if the terminal likely supports Unicode.

Returns True if Unicode is likely supported, False otherwise.
"""
lang = os.environ.get("LANG", "")
lc_all = os.environ.get("LC_ALL", "")
term = os.environ.get("TERM", "")

if "utf" in lang.lower() or "utf" in lc_all.lower():
return True
if term == "dumb":
return False
# Default: assume Unicode support (most modern terminals)
return True


# Default buddy character configuration
DEFAULT_BUDDY_CONFIG: Dict[str, str] = {
DEFAULT_BUDDY_CONFIG: Dict[str, Any] = {
"name": "Buddy",
"face": BUDDY_FACE,
"greeting": "",
"farewell": "",
"asciiMode": False,
}

# Max length for custom face field (unicode characters)
Expand All @@ -107,7 +166,7 @@ def type_text(text: str, speed: float = 0.03) -> None:
time.sleep(speed)


def get_buddy_config(config: Optional[Dict[str, Any]] = None) -> Dict[str, str]:
def get_buddy_config(config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Extract and validate buddy customization from config dict.

Args:
Expand Down Expand Up @@ -143,6 +202,15 @@ def get_buddy_config(config: Optional[Dict[str, Any]] = None) -> Dict[str, str]:
if isinstance(farewell, str) and 0 < len(farewell.strip()) <= 100:
result["farewell"] = farewell.strip()

# asciiMode: bool or "auto" (#1040)
ascii_val = buddy.get("asciiMode")
if ascii_val is True:
result["asciiMode"] = True
elif ascii_val == "auto":
result["asciiMode"] = not _detect_unicode_support()
else:
result["asciiMode"] = False

return result

# Returning session greetings by tone and language
Expand Down Expand Up @@ -396,7 +464,7 @@ def _colorize(text: str, color: str) -> str:
def render_buddy_face(
tone: str,
language: str,
buddy_config: Optional[Dict[str, str]] = None,
buddy_config: Optional[Dict[str, Any]] = None,
) -> str:
"""Render the buddy character face with greeting.

Expand Down Expand Up @@ -502,7 +570,7 @@ def render_session_summary(
agents: List[Dict[str, Any]],
tone: str,
language: str,
buddy_config: Optional[Dict[str, str]] = None,
buddy_config: Optional[Dict[str, Any]] = None,
tool_names: Optional[Dict[str, int]] = None,
) -> str:
"""Render session summary with buddy character for stop hook.
Expand Down Expand Up @@ -591,7 +659,13 @@ def render_session_summary(
parts.append("")
parts.append(f"{face} {farewell}")

return "\n".join(parts)
result = "\n".join(parts)

# ASCII fallback mode (#1040)
if bc.get("asciiMode"):
result = _to_ascii(result)

return result


def _get_returning_greeting(tone: str, language: str) -> str:
Expand Down Expand Up @@ -627,7 +701,7 @@ def render_returning_session(
pending_context: "Dict[str, Any] | None",
tone: str,
language: str,
buddy_config: Optional[Dict[str, str]] = None,
buddy_config: Optional[Dict[str, Any]] = None,
) -> str:
"""Render returning session welcome-back display.

Expand Down Expand Up @@ -744,7 +818,7 @@ def render_session_start(
language: str,
previous_session: "Dict[str, Any] | None" = None,
pending_context: "Dict[str, Any] | None" = None,
buddy_config: Optional[Dict[str, str]] = None,
buddy_config: Optional[Dict[str, Any]] = None,
typing: bool = False,
) -> str:
"""Render complete session-start output.
Expand Down Expand Up @@ -801,11 +875,15 @@ def render_session_start(

full_output = "\n".join(parts)

# ASCII fallback mode (#1040)
bc = buddy_config or DEFAULT_BUDDY_CONFIG
if bc.get("asciiMode"):
full_output = _to_ascii(full_output)

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)
Expand Down
150 changes: 150 additions & 0 deletions packages/claude-code-plugin/hooks/test_buddy_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
render_returning_session,
get_buddy_config,
type_text,
_to_ascii,
_detect_unicode_support,
GREETINGS,
FAREWELL_GREETINGS,
FAREWELL_MESSAGES,
Expand Down Expand Up @@ -554,6 +556,154 @@ def test_typing_with_custom_greeting(self, capsys, monkeypatch):
assert "Custom hello!" in captured.err


class TestToAscii:
"""Tests for _to_ascii Unicode→ASCII conversion (#1040)."""

def test_box_drawing_converted(self):
assert _to_ascii("\u256d\u2501\u2501\u2501\u256e") == "+---+"

def test_pipe_converted(self):
assert _to_ascii("\u2503") == "|"

def test_buddy_face_converted(self):
assert _to_ascii("\u25d5\u203f\u25d5") == ":_:"

def test_emoji_converted(self):
assert _to_ascii("\u26a1") == "*"
assert _to_ascii("\U0001f527") == "[tool]"
assert _to_ascii("\U0001f4c1") == "[file]"

def test_plain_ascii_unchanged(self):
assert _to_ascii("Hello world") == "Hello world"

def test_mixed_content(self):
result = _to_ascii("\u2503 \u25d5\u203f\u25d5 \u2503 Hey!")
assert result == "| :_: | Hey!"


class TestDetectUnicodeSupport:
"""Tests for _detect_unicode_support (#1040)."""

def test_utf8_lang_returns_true(self, monkeypatch):
monkeypatch.setenv("LANG", "en_US.UTF-8")
monkeypatch.delenv("LC_ALL", raising=False)
assert _detect_unicode_support() is True

def test_dumb_term_returns_false(self, monkeypatch):
monkeypatch.setenv("TERM", "dumb")
monkeypatch.delenv("LANG", raising=False)
monkeypatch.delenv("LC_ALL", raising=False)
assert _detect_unicode_support() is False

def test_default_returns_true(self, monkeypatch):
monkeypatch.delenv("LANG", raising=False)
monkeypatch.delenv("LC_ALL", raising=False)
monkeypatch.delenv("TERM", raising=False)
assert _detect_unicode_support() is True


class TestAsciiModeConfig:
"""Tests for asciiMode in get_buddy_config (#1040)."""

def test_ascii_mode_true(self):
config = {"buddy": {"asciiMode": True}}
result = get_buddy_config(config)
assert result["asciiMode"] is True

def test_ascii_mode_false(self):
config = {"buddy": {"asciiMode": False}}
result = get_buddy_config(config)
assert result["asciiMode"] is False

def test_ascii_mode_default(self):
config = {"buddy": {"name": "Bot"}}
result = get_buddy_config(config)
assert result["asciiMode"] is False

def test_ascii_mode_auto_with_utf8(self, monkeypatch):
monkeypatch.setenv("LANG", "en_US.UTF-8")
config = {"buddy": {"asciiMode": "auto"}}
result = get_buddy_config(config)
assert result["asciiMode"] is False # Unicode supported → no ASCII

def test_ascii_mode_auto_dumb_term(self, monkeypatch):
monkeypatch.setenv("TERM", "dumb")
monkeypatch.delenv("LANG", raising=False)
monkeypatch.delenv("LC_ALL", raising=False)
config = {"buddy": {"asciiMode": "auto"}}
result = get_buddy_config(config)
assert result["asciiMode"] is True # No Unicode → ASCII


class TestAsciiModeRendering:
"""Tests for ASCII mode in render functions (#1040)."""

def _ascii_buddy_config(self):
return {
"name": "Buddy", "face": BUDDY_FACE,
"greeting": "", "farewell": "", "asciiMode": True,
}

def test_session_start_ascii_no_unicode_box(self):
bc = self._ascii_buddy_config()
scan = {"name": "app"}
result = render_session_start(scan, [], "casual", "en", buddy_config=bc)
# Box drawing replaced
assert "\u256d" not in result # no ╭
assert "\u2501" not in result # no ━
assert "\u2503" not in result # no ┃
assert "+---+" in result
assert "|" in result

def test_session_start_ascii_face_converted(self):
bc = self._ascii_buddy_config()
scan = {"name": "app"}
result = render_session_start(scan, [], "casual", "en", buddy_config=bc)
assert ":_:" in result
assert "\u25d5" not in result # no ◕

def test_session_start_ascii_emoji_converted(self):
bc = self._ascii_buddy_config()
scan = {"name": "app", "framework": "Next.js 15", "file_count": 42}
result = render_session_start(scan, [], "casual", "en", buddy_config=bc)
assert "*" in result # ⚡ → *
assert "[file]" in result # 📁 → [file]
assert "\u26a1" not in result
assert "\U0001f4c1" not in result

def test_session_start_unicode_mode_unchanged(self):
"""Default (no asciiMode) should keep Unicode characters."""
scan = {"name": "app"}
result = render_session_start(scan, [], "casual", "en")
assert "\u256d" in result # ╭ still present
assert "\u25d5" in result # ◕ still present

def test_session_summary_ascii_mode(self):
bc = self._ascii_buddy_config()
stats = {"duration_minutes": 10, "tool_count": 5, "files_changed": 2}
result = render_session_summary(stats, [], "casual", "en", buddy_config=bc)
assert "+---+" in result
assert "\u256d" not in result
assert "[time]" in result # ⏱ → [time]
assert "[tool]" in result # 🔧 → [tool]

def test_session_summary_unicode_mode_unchanged(self):
stats = {"duration_minutes": 10, "tool_count": 5, "files_changed": 2}
result = render_session_summary(stats, [], "casual", "en")
assert "\u256d" in result
assert "\U0001f527" in result # 🔧

def test_typing_mode_ascii(self, capsys, monkeypatch):
monkeypatch.setattr("buddy_renderer.time.sleep", lambda _s: None)
bc = self._ascii_buddy_config()
scan = {"name": "app"}
result = render_session_start(scan, [], "casual", "en", buddy_config=bc, typing=True)
assert result == ""
captured = capsys.readouterr()
assert "+---+" in captured.err
assert "\u256d" not in captured.err


if __name__ == "__main__":
import pytest
pytest.main([__file__, "-v"])
Loading