diff --git a/packages/claude-code-plugin/hooks/lib/buddy_renderer.py b/packages/claude-code-plugin/hooks/lib/buddy_renderer.py index 4b53236b..d6610e28 100644 --- a/packages/claude-code-plugin/hooks/lib/buddy_renderer.py +++ b/packages/claude-code-plugin/hooks/lib/buddy_renderer.py @@ -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) @@ -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: @@ -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 @@ -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. @@ -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. @@ -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: @@ -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. @@ -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. @@ -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) diff --git a/packages/claude-code-plugin/hooks/test_buddy_renderer.py b/packages/claude-code-plugin/hooks/test_buddy_renderer.py index edac1842..03edd860 100644 --- a/packages/claude-code-plugin/hooks/test_buddy_renderer.py +++ b/packages/claude-code-plugin/hooks/test_buddy_renderer.py @@ -18,6 +18,8 @@ render_returning_session, get_buddy_config, type_text, + _to_ascii, + _detect_unicode_support, GREETINGS, FAREWELL_GREETINGS, FAREWELL_MESSAGES, @@ -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"])