Skip to content

Commit 3946da4

Browse files
committed
feat(plugin): add ASCII fallback mode for buddy character rendering (#1040)
- Add buddy.asciiMode config option (true/false/"auto") - Unicode→ASCII mapping: box drawing (╭→+, ━→-, ┃→|), faces (◕→:, ‿→_), emojis (⚡→*, 🔧→[tool]) - Auto-detect terminal unicode support via LANG/LC_ALL/TERM - Apply conversion in render_session_start and render_session_summary - Add 21 new tests (conversion, config, rendering, auto-detect)
1 parent 7e5281b commit 3946da4

2 files changed

Lines changed: 236 additions & 8 deletions

File tree

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

Lines changed: 86 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,71 @@
7777
BUDDY_WRAP_FACE = "\u25d5\u2304\u25d5" # ◕⌄◕
7878
BUDDY_WINK_FACE = "\u25d5\u2040\u25d5" # ◕⁀◕
7979

80+
# Unicode → ASCII fallback mapping (#1040)
81+
_UNICODE_TO_ASCII: Dict[str, str] = {
82+
# Box drawing
83+
"\u256d": "+", "\u256e": "+", "\u2570": "+", "\u256f": "+",
84+
"\u2501": "-", "\u2503": "|", "\u2502": "|",
85+
# Face characters
86+
"\u25d5": ":", "\u203f": "_", "\u2304": "v", "\u2040": "^",
87+
# Emoji → text
88+
"\u26a1": "*",
89+
"\U0001f9f2": "[cov]",
90+
"\U0001f4c1": "[file]",
91+
"\U0001f517": "[api]",
92+
"\u23f1": "[time]",
93+
"\U0001f527": "[tool]",
94+
"\U0001f4dd": "[edit]",
95+
"\U0001f4ac": "[msg]",
96+
"\u2705": "[ok]",
97+
"\u26a0\ufe0f": "[warn]",
98+
"\u26a0": "[warn]",
99+
"\U0001f916": "[bot]",
100+
# Surrogate pair variants (used in some source strings)
101+
"\ud83e\uddf2": "[cov]",
102+
"\ud83d\udcc1": "[file]",
103+
"\ud83d\udd17": "[api]",
104+
"\U0001f534": "(R)",
105+
"\U0001f7e2": "(G)",
106+
"\U0001f535": "(B)",
107+
"\U0001f7e1": "(Y)",
108+
"\U0001f7e3": "(P)",
109+
"\u26aa": "(W)",
110+
"\u2728": "(*)",
111+
}
112+
113+
114+
def _to_ascii(text: str) -> str:
115+
"""Replace Unicode/emoji characters with ASCII equivalents (#1040)."""
116+
for uc, asc in _UNICODE_TO_ASCII.items():
117+
text = text.replace(uc, asc)
118+
return text
119+
120+
121+
def _detect_unicode_support() -> bool:
122+
"""Check if the terminal likely supports Unicode.
123+
124+
Returns True if Unicode is likely supported, False otherwise.
125+
"""
126+
lang = os.environ.get("LANG", "")
127+
lc_all = os.environ.get("LC_ALL", "")
128+
term = os.environ.get("TERM", "")
129+
130+
if "utf" in lang.lower() or "utf" in lc_all.lower():
131+
return True
132+
if term == "dumb":
133+
return False
134+
# Default: assume Unicode support (most modern terminals)
135+
return True
136+
137+
80138
# Default buddy character configuration
81-
DEFAULT_BUDDY_CONFIG: Dict[str, str] = {
139+
DEFAULT_BUDDY_CONFIG: Dict[str, Any] = {
82140
"name": "Buddy",
83141
"face": BUDDY_FACE,
84142
"greeting": "",
85143
"farewell": "",
144+
"asciiMode": False,
86145
}
87146

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

109168

110-
def get_buddy_config(config: Optional[Dict[str, Any]] = None) -> Dict[str, str]:
169+
def get_buddy_config(config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
111170
"""Extract and validate buddy customization from config dict.
112171
113172
Args:
@@ -143,6 +202,15 @@ def get_buddy_config(config: Optional[Dict[str, Any]] = None) -> Dict[str, str]:
143202
if isinstance(farewell, str) and 0 < len(farewell.strip()) <= 100:
144203
result["farewell"] = farewell.strip()
145204

205+
# asciiMode: bool or "auto" (#1040)
206+
ascii_val = buddy.get("asciiMode")
207+
if ascii_val is True:
208+
result["asciiMode"] = True
209+
elif ascii_val == "auto":
210+
result["asciiMode"] = not _detect_unicode_support()
211+
else:
212+
result["asciiMode"] = False
213+
146214
return result
147215

148216
# Returning session greetings by tone and language
@@ -396,7 +464,7 @@ def _colorize(text: str, color: str) -> str:
396464
def render_buddy_face(
397465
tone: str,
398466
language: str,
399-
buddy_config: Optional[Dict[str, str]] = None,
467+
buddy_config: Optional[Dict[str, Any]] = None,
400468
) -> str:
401469
"""Render the buddy character face with greeting.
402470
@@ -502,7 +570,7 @@ def render_session_summary(
502570
agents: List[Dict[str, Any]],
503571
tone: str,
504572
language: str,
505-
buddy_config: Optional[Dict[str, str]] = None,
573+
buddy_config: Optional[Dict[str, Any]] = None,
506574
tool_names: Optional[Dict[str, int]] = None,
507575
) -> str:
508576
"""Render session summary with buddy character for stop hook.
@@ -591,7 +659,13 @@ def render_session_summary(
591659
parts.append("")
592660
parts.append(f"{face} {farewell}")
593661

594-
return "\n".join(parts)
662+
result = "\n".join(parts)
663+
664+
# ASCII fallback mode (#1040)
665+
if bc.get("asciiMode"):
666+
result = _to_ascii(result)
667+
668+
return result
595669

596670

597671
def _get_returning_greeting(tone: str, language: str) -> str:
@@ -627,7 +701,7 @@ def render_returning_session(
627701
pending_context: "Dict[str, Any] | None",
628702
tone: str,
629703
language: str,
630-
buddy_config: Optional[Dict[str, str]] = None,
704+
buddy_config: Optional[Dict[str, Any]] = None,
631705
) -> str:
632706
"""Render returning session welcome-back display.
633707
@@ -744,7 +818,7 @@ def render_session_start(
744818
language: str,
745819
previous_session: "Dict[str, Any] | None" = None,
746820
pending_context: "Dict[str, Any] | None" = None,
747-
buddy_config: Optional[Dict[str, str]] = None,
821+
buddy_config: Optional[Dict[str, Any]] = None,
748822
typing: bool = False,
749823
) -> str:
750824
"""Render complete session-start output.
@@ -801,11 +875,15 @@ def render_session_start(
801875

802876
full_output = "\n".join(parts)
803877

878+
# ASCII fallback mode (#1040)
879+
bc = buddy_config or DEFAULT_BUDDY_CONFIG
880+
if bc.get("asciiMode"):
881+
full_output = _to_ascii(full_output)
882+
804883
if not typing:
805884
return full_output
806885

807886
# Typing mode: animate the greeting text, print rest instantly
808-
bc = buddy_config or DEFAULT_BUDDY_CONFIG
809887
custom_greeting = bc.get("greeting", "")
810888
if previous_session:
811889
greeting = custom_greeting if custom_greeting else _get_returning_greeting(tone, language)

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

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
render_returning_session,
1919
get_buddy_config,
2020
type_text,
21+
_to_ascii,
22+
_detect_unicode_support,
2123
GREETINGS,
2224
FAREWELL_GREETINGS,
2325
FAREWELL_MESSAGES,
@@ -554,6 +556,154 @@ def test_typing_with_custom_greeting(self, capsys, monkeypatch):
554556
assert "Custom hello!" in captured.err
555557

556558

559+
class TestToAscii:
560+
"""Tests for _to_ascii Unicode→ASCII conversion (#1040)."""
561+
562+
def test_box_drawing_converted(self):
563+
assert _to_ascii("\u256d\u2501\u2501\u2501\u256e") == "+---+"
564+
565+
def test_pipe_converted(self):
566+
assert _to_ascii("\u2503") == "|"
567+
568+
def test_buddy_face_converted(self):
569+
assert _to_ascii("\u25d5\u203f\u25d5") == ":_:"
570+
571+
def test_emoji_converted(self):
572+
assert _to_ascii("\u26a1") == "*"
573+
assert _to_ascii("\U0001f527") == "[tool]"
574+
assert _to_ascii("\U0001f4c1") == "[file]"
575+
576+
def test_plain_ascii_unchanged(self):
577+
assert _to_ascii("Hello world") == "Hello world"
578+
579+
def test_mixed_content(self):
580+
result = _to_ascii("\u2503 \u25d5\u203f\u25d5 \u2503 Hey!")
581+
assert result == "| :_: | Hey!"
582+
583+
584+
class TestDetectUnicodeSupport:
585+
"""Tests for _detect_unicode_support (#1040)."""
586+
587+
def test_utf8_lang_returns_true(self, monkeypatch):
588+
monkeypatch.setenv("LANG", "en_US.UTF-8")
589+
monkeypatch.delenv("LC_ALL", raising=False)
590+
assert _detect_unicode_support() is True
591+
592+
def test_dumb_term_returns_false(self, monkeypatch):
593+
monkeypatch.setenv("TERM", "dumb")
594+
monkeypatch.delenv("LANG", raising=False)
595+
monkeypatch.delenv("LC_ALL", raising=False)
596+
assert _detect_unicode_support() is False
597+
598+
def test_default_returns_true(self, monkeypatch):
599+
monkeypatch.delenv("LANG", raising=False)
600+
monkeypatch.delenv("LC_ALL", raising=False)
601+
monkeypatch.delenv("TERM", raising=False)
602+
assert _detect_unicode_support() is True
603+
604+
605+
class TestAsciiModeConfig:
606+
"""Tests for asciiMode in get_buddy_config (#1040)."""
607+
608+
def test_ascii_mode_true(self):
609+
config = {"buddy": {"asciiMode": True}}
610+
result = get_buddy_config(config)
611+
assert result["asciiMode"] is True
612+
613+
def test_ascii_mode_false(self):
614+
config = {"buddy": {"asciiMode": False}}
615+
result = get_buddy_config(config)
616+
assert result["asciiMode"] is False
617+
618+
def test_ascii_mode_default(self):
619+
config = {"buddy": {"name": "Bot"}}
620+
result = get_buddy_config(config)
621+
assert result["asciiMode"] is False
622+
623+
def test_ascii_mode_auto_with_utf8(self, monkeypatch):
624+
monkeypatch.setenv("LANG", "en_US.UTF-8")
625+
config = {"buddy": {"asciiMode": "auto"}}
626+
result = get_buddy_config(config)
627+
assert result["asciiMode"] is False # Unicode supported → no ASCII
628+
629+
def test_ascii_mode_auto_dumb_term(self, monkeypatch):
630+
monkeypatch.setenv("TERM", "dumb")
631+
monkeypatch.delenv("LANG", raising=False)
632+
monkeypatch.delenv("LC_ALL", raising=False)
633+
config = {"buddy": {"asciiMode": "auto"}}
634+
result = get_buddy_config(config)
635+
assert result["asciiMode"] is True # No Unicode → ASCII
636+
637+
638+
class TestAsciiModeRendering:
639+
"""Tests for ASCII mode in render functions (#1040)."""
640+
641+
def _ascii_buddy_config(self):
642+
return {
643+
"name": "Buddy", "face": BUDDY_FACE,
644+
"greeting": "", "farewell": "", "asciiMode": True,
645+
}
646+
647+
def test_session_start_ascii_no_unicode_box(self):
648+
bc = self._ascii_buddy_config()
649+
scan = {"name": "app"}
650+
result = render_session_start(scan, [], "casual", "en", buddy_config=bc)
651+
# Box drawing replaced
652+
assert "\u256d" not in result # no ╭
653+
assert "\u2501" not in result # no ━
654+
assert "\u2503" not in result # no ┃
655+
assert "+---+" in result
656+
assert "|" in result
657+
658+
def test_session_start_ascii_face_converted(self):
659+
bc = self._ascii_buddy_config()
660+
scan = {"name": "app"}
661+
result = render_session_start(scan, [], "casual", "en", buddy_config=bc)
662+
assert ":_:" in result
663+
assert "\u25d5" not in result # no ◕
664+
665+
def test_session_start_ascii_emoji_converted(self):
666+
bc = self._ascii_buddy_config()
667+
scan = {"name": "app", "framework": "Next.js 15", "file_count": 42}
668+
result = render_session_start(scan, [], "casual", "en", buddy_config=bc)
669+
assert "*" in result # ⚡ → *
670+
assert "[file]" in result # 📁 → [file]
671+
assert "\u26a1" not in result
672+
assert "\U0001f4c1" not in result
673+
674+
def test_session_start_unicode_mode_unchanged(self):
675+
"""Default (no asciiMode) should keep Unicode characters."""
676+
scan = {"name": "app"}
677+
result = render_session_start(scan, [], "casual", "en")
678+
assert "\u256d" in result # ╭ still present
679+
assert "\u25d5" in result # ◕ still present
680+
681+
def test_session_summary_ascii_mode(self):
682+
bc = self._ascii_buddy_config()
683+
stats = {"duration_minutes": 10, "tool_count": 5, "files_changed": 2}
684+
result = render_session_summary(stats, [], "casual", "en", buddy_config=bc)
685+
assert "+---+" in result
686+
assert "\u256d" not in result
687+
assert "[time]" in result # ⏱ → [time]
688+
assert "[tool]" in result # 🔧 → [tool]
689+
690+
def test_session_summary_unicode_mode_unchanged(self):
691+
stats = {"duration_minutes": 10, "tool_count": 5, "files_changed": 2}
692+
result = render_session_summary(stats, [], "casual", "en")
693+
assert "\u256d" in result
694+
assert "\U0001f527" in result # 🔧
695+
696+
def test_typing_mode_ascii(self, capsys, monkeypatch):
697+
monkeypatch.setattr("buddy_renderer.time.sleep", lambda _s: None)
698+
bc = self._ascii_buddy_config()
699+
scan = {"name": "app"}
700+
result = render_session_start(scan, [], "casual", "en", buddy_config=bc, typing=True)
701+
assert result == ""
702+
captured = capsys.readouterr()
703+
assert "+---+" in captured.err
704+
assert "\u256d" not in captured.err
705+
706+
557707
if __name__ == "__main__":
558708
import pytest
559709
pytest.main([__file__, "-v"])

0 commit comments

Comments
 (0)