From 661b04141cb3d897b253b0d9a9d3de137dc9886c Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Fri, 3 Apr 2026 23:18:04 +0900 Subject: [PATCH] fix(plugin): improve buddy rendering alignment --- .../hooks/lib/buddy_renderer.py | 198 ++++++++++++++---- .../hooks/lib/onboarding_tour.py | 8 +- packages/claude-code-plugin/hooks/stop.py | 12 +- .../hooks/test_buddy_renderer.py | 42 +++- .../tests/test_onboarding_tour.py | 8 + .../claude-code-plugin/tests/test_stop.py | 26 +++ 6 files changed, 242 insertions(+), 52 deletions(-) diff --git a/packages/claude-code-plugin/hooks/lib/buddy_renderer.py b/packages/claude-code-plugin/hooks/lib/buddy_renderer.py index 7518b3b8..90c7f822 100644 --- a/packages/claude-code-plugin/hooks/lib/buddy_renderer.py +++ b/packages/claude-code-plugin/hooks/lib/buddy_renderer.py @@ -7,6 +7,7 @@ import re import sys import time +import unicodedata from typing import Any, Dict, List, Optional # ANSI color codes for terminal output @@ -152,6 +153,128 @@ def _detect_unicode_support() -> bool: r"^[^\x00-\x1f\x7f A-Za-z0-9]{1,%d}$" % _MAX_FACE_LENGTH ) +_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") + + +def strip_ansi(text: str) -> str: + """Remove ANSI escape codes from a string.""" + return _ANSI_RE.sub("", text) + + +def _char_display_width(char: str) -> int: + """Best-effort terminal display width for a single character.""" + if not char: + return 0 + if unicodedata.combining(char): + return 0 + + category = unicodedata.category(char) + if category in ("Cc", "Cf"): + return 0 + + codepoint = ord(char) + # Treat emoji/pictographs as double-width in common terminals. + if 0x1F300 <= codepoint <= 0x1FAFF or 0x2600 <= codepoint <= 0x27BF: + return 2 + + if unicodedata.east_asian_width(char) in ("W", "F"): + return 2 + + return 1 + + +def display_width(text: str) -> int: + """Return terminal display width, ignoring ANSI escape codes.""" + return sum(_char_display_width(ch) for ch in strip_ansi(text)) + + +def pad_to_display_width(text: str, width: int) -> str: + """Right-pad a string to a target terminal display width.""" + padding = max(0, width - display_width(text)) + return text + (" " * padding) + + +def truncate_to_display_width( + text: str, + width: int, + ellipsis: str = "\u2026", +) -> str: + """Truncate a string to a target terminal display width.""" + if width <= 0: + return "" + if display_width(text) <= width: + return text + + ellipsis_width = display_width(ellipsis) + if width <= ellipsis_width: + return ellipsis if width == ellipsis_width else "" + + result: List[str] = [] + current = 0 + limit = width - ellipsis_width + for ch in strip_ansi(text): + ch_width = _char_display_width(ch) + if current + ch_width > limit: + break + result.append(ch) + current += ch_width + + return "".join(result).rstrip() + ellipsis + + +def truncate_left_to_display_width( + text: str, + width: int, + ellipsis: str = "\u2026", +) -> str: + """Left-truncate a string while keeping the tail visible.""" + if width <= 0: + return "" + if display_width(text) <= width: + return text + + ellipsis_width = display_width(ellipsis) + if width <= ellipsis_width: + return ellipsis if width == ellipsis_width else "" + + result: List[str] = [] + current = 0 + limit = width - ellipsis_width + for ch in reversed(strip_ansi(text)): + ch_width = _char_display_width(ch) + if current + ch_width > limit: + break + result.append(ch) + current += ch_width + + return ellipsis + "".join(reversed(result)).lstrip() + + +def render_face_banner(face: str, message: str = "") -> List[str]: + """Render a small face box that stays aligned for custom/wide faces.""" + inner_width = max(3, display_width(face) + 2) + face_cell = pad_to_display_width(f" {face} ", inner_width) + rule = "\u2501" * inner_width + top = f"\u256d{rule}\u256e" + middle = f"\u2503{face_cell}\u2503" + if message: + middle += f" {message}" + bottom = f"\u2570{rule}\u256f" + return [top, middle, bottom] + + +def render_section_header(label: str, min_tail: int = 10) -> str: + """Render a section header with balanced trailing rule characters.""" + prefix = f"\u2501\u2501 {label} " + tail = "\u2501" * max(min_tail, 28 - display_width(prefix)) + return prefix + tail + + +def render_box_line(text: str, width: int) -> str: + """Render a single box row with display-width-aware padding.""" + clipped = truncate_to_display_width(text, width) + return f"\u2502{pad_to_display_width(clipped, width)}\u2502" + def type_text(text: str, speed: float = 0.03) -> None: """Write text to stderr one character at a time with flush for typing animation. @@ -524,12 +647,7 @@ def render_buddy_face( face = bc.get("face", BUDDY_FACE) custom_greeting = bc.get("greeting", "") greeting = custom_greeting if custom_greeting else _get_greeting(tone, language) - lines = [ - "\u256d\u2501\u2501\u2501\u256e", - f"\u2503 {face} \u2503 {greeting}", - "\u2570\u2501\u2501\u2501\u256f", - ] - return "\n".join(lines) + return "\n".join(render_face_banner(face, greeting)) def render_scan_results(scan: Dict[str, Any]) -> str: @@ -543,7 +661,7 @@ def render_scan_results(scan: Dict[str, Any]) -> str: Formatted scan results string. """ name = scan.get("name", "unknown") - lines = [f"\u2501\u2501 {name} \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501"] + lines = [render_section_header(name, min_tail=12)] framework = scan.get("framework") if framework: @@ -732,18 +850,16 @@ def render_session_summary( farewell = custom_farewell if custom_farewell else _get_farewell_message(tone, language) # Use custom face for wrap variant: take first char of custom face if set - face = bc.get("face", BUDDY_FACE) - if face != BUDDY_FACE: - wrap_face = face # custom face used as-is + buddy_face = bc.get("face", BUDDY_FACE) + if buddy_face != BUDDY_FACE: + wrap_face = buddy_face # custom face used as-is else: wrap_face = BUDDY_WRAP_FACE parts = [] # Buddy face with farewell greeting - parts.append("\u256d\u2501\u2501\u2501\u256e") - parts.append(f"\u2503 {wrap_face} \u2503 {greeting}") - parts.append("\u2570\u2501\u2501\u2501\u256f") + parts.extend(render_face_banner(wrap_face, greeting)) # Session stats section (only if data available) duration = stats.get("duration_minutes", 0) @@ -753,7 +869,7 @@ def render_session_summary( if duration > 0 or tool_count > 0 or files_changed > 0: header = _get_summary_header("summary", language) parts.append("") - parts.append(f"\u2501\u2501 {header} \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501") + parts.append(render_section_header(header, min_tail=18)) stat_items = [] if duration > 0: @@ -783,14 +899,14 @@ def render_session_summary( if agents: header = _get_summary_header("agents", language) parts.append("") - parts.append(f"\u2501\u2501 {header} \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501") + parts.append(render_section_header(header, min_tail=16)) for agent in agents: name = agent.get("name", "Agent") message = agent.get("message", "") eye = agent.get("eye", "\u25cf") color = agent.get("colorAnsi", "white") - face = f"{eye}\u2304{eye}" - colored_face = _colorize(face, color) + agent_face = f"{eye}\u2304{eye}" + colored_face = _colorize(agent_face, color) line = f"{colored_face} {name}" if message: line += f" {message}" @@ -798,7 +914,7 @@ def render_session_summary( # Farewell parts.append("") - parts.append(f"{face} {farewell}") + parts.append(f"{buddy_face} {farewell}") result = "\n".join(parts) @@ -873,14 +989,12 @@ def render_returning_session( parts = [] # Buddy face with welcome-back greeting - parts.append("\u256d\u2501\u2501\u2501\u256e") - parts.append(f"\u2503 {face} \u2503 {greeting}") - parts.append("\u2570\u2501\u2501\u2501\u256f") + parts.extend(render_face_banner(face, greeting)) # Last session summary header = _get_returning_header("last_session", language) parts.append("") - parts.append(f"\u2501\u2501 {header} \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501") + parts.append(render_section_header(header, min_tail=8)) stat_items = [] @@ -906,7 +1020,7 @@ def render_returning_session( if pending_context and pending_context.get("task"): pending_header = _get_returning_header("pending", language) parts.append("") - parts.append(f"\u2501\u2501 {pending_header} \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501") + parts.append(render_section_header(pending_header, min_tail=8)) mode = pending_context.get("mode", "") task = pending_context.get("task", "") @@ -1005,7 +1119,7 @@ def render_session_start( if recommendations: header = _get_header("recommendations", language) parts.append("") - parts.append(f"\u2501\u2501 {header} \u2501\u2501\u2501\u2501\u2501\u2501") + parts.append(render_section_header(header, min_tail=8)) parts.append(render_recommendations(recommendations)) # Achievement badges section (#1008) @@ -1082,13 +1196,25 @@ def render_intelligence_report( Returns: Formatted Intelligence Report string, or empty string if no data. """ - BOX_W = 41 + title = _intel_header("title", language) + activity_hdr = _intel_header("activity", language) + changes_hdr = _intel_header("changes", language) + gaps_hdr = _intel_header("gaps", language) + insights_hdr = _intel_header("insights", language) + + width_candidates = [ + f" \U0001f9e0 {title}", + f" \U0001f4ca {activity_hdr}", + f" \U0001f4dd {changes_hdr}", + f" \u26a0\ufe0f {gaps_hdr}", + f" \U0001f4a1 {insights_hdr}", + f" {change_summary}", + ] + BOX_W = max(41, min(54, max(display_width(text) for text in width_candidates) + 2)) hr = "\u2500" * BOX_W def _box(text: str) -> str: - # Truncate if too long, accounting for Unicode widths - display = text[:BOX_W] - return f"\u2502{display.ljust(BOX_W)}\u2502" + return render_box_line(text, BOX_W) def _separator() -> str: return f"\u251c{hr}\u2524" @@ -1096,13 +1222,11 @@ def _separator() -> str: lines: List[str] = [] # Title - title = _intel_header("title", language) lines.append(f"\u256d{hr}\u256e") lines.append(_box(f" \U0001f9e0 {title}")) lines.append(_separator()) # --- Activity section --- - activity_hdr = _intel_header("activity", language) lines.append(_box(f" \U0001f4ca {activity_hdr}")) duration = stats.get("duration_minutes", 0) @@ -1130,17 +1254,14 @@ def _fmt_tokens(n: int) -> str: # --- What You Did section --- if changes: lines.append(_separator()) - changes_hdr = _intel_header("changes", language) lines.append(_box(f" \U0001f4dd {changes_hdr}")) lines.append(_box(f" {change_summary}")) # Show up to 5 changed files shown = changes[:5] + file_width = max(12, BOX_W - display_width(" \u2022 ")) for c in shown: - fname = c["file"] - # Truncate long paths - if len(fname) > 33: - fname = "\u2026" + fname[-(33 - 1):] + fname = truncate_left_to_display_width(c["file"], file_width) lines.append(_box(f" \u2022 {fname}")) if len(changes) > 5: lines.append(_box(f" ... +{len(changes) - 5} more")) @@ -1148,7 +1269,6 @@ def _fmt_tokens(n: int) -> str: # --- Gaps section (untested files) --- if untested: lines.append(_separator()) - gaps_hdr = _intel_header("gaps", language) lines.append(_box(f" \u26a0\ufe0f {gaps_hdr}")) count_msg = ( f" {len(untested)} file{'s' if len(untested) != 1 else ''}" @@ -1156,10 +1276,9 @@ def _fmt_tokens(n: int) -> str: ) lines.append(_box(count_msg)) shown_untested = untested[:3] + file_width = max(12, BOX_W - display_width(" \u2022 ")) for f in shown_untested: - fname = f - if len(fname) > 33: - fname = "\u2026" + fname[-(33 - 1):] + fname = truncate_left_to_display_width(f, file_width) lines.append(_box(f" \u2022 {fname}")) if len(untested) > 3: lines.append(_box(f" ... +{len(untested) - 3} more")) @@ -1167,7 +1286,6 @@ def _fmt_tokens(n: int) -> str: # --- Insights section --- if changes: lines.append(_separator()) - insights_hdr = _intel_header("insights", language) lines.append(_box(f" \U0001f4a1 {insights_hdr}")) # Detect frequent-edit directories diff --git a/packages/claude-code-plugin/hooks/lib/onboarding_tour.py b/packages/claude-code-plugin/hooks/lib/onboarding_tour.py index eea295bd..a7aa9b28 100644 --- a/packages/claude-code-plugin/hooks/lib/onboarding_tour.py +++ b/packages/claude-code-plugin/hooks/lib/onboarding_tour.py @@ -12,6 +12,8 @@ BUDDY_FACE, DEFAULT_BUDDY_CONFIG, get_buddy_config, + render_face_banner, + render_section_header, ) # Flag file location @@ -188,11 +190,9 @@ def render_onboarding_tour( reset = ANSI_COLORS["reset"] lines = [ - f"\u256d\u2501\u2501\u2501\u256e", - f"\u2503 {face} \u2503 {cyan}{welcome}{reset}", - f"\u2570\u2501\u2501\u2501\u256f", + *render_face_banner(face, f"{cyan}{welcome}{reset}"), "", - f"\u2501\u2501 {_get_text(TOUR_HEADER, language)} \u2501\u2501\u2501\u2501\u2501\u2501", + render_section_header(_get_text(TOUR_HEADER, language), min_tail=6), ] for step_num in (1, 2, 3): diff --git a/packages/claude-code-plugin/hooks/stop.py b/packages/claude-code-plugin/hooks/stop.py index d8c3e454..b1254f6b 100644 --- a/packages/claude-code-plugin/hooks/stop.py +++ b/packages/claude-code-plugin/hooks/stop.py @@ -317,13 +317,19 @@ def _render_impact_report(session_id, project_dir): return "" # Box rendering - BOX_W = 41 + from buddy_renderer import display_width, render_box_line + + title = " \U0001f4ca Impact Report" + BOX_W = max( + 41, + min(54, max(display_width(text) for text in [title, *rows]) + 2), + ) hr = "─" * BOX_W def _box(text): - return f"│{text.ljust(BOX_W)}│" + return render_box_line(text, BOX_W) - lines = [f"╭{hr}╮", _box(" 📊 Impact Report"), f"├{hr}┤"] + lines = [f"╭{hr}╮", _box(title), f"├{hr}┤"] for row in rows: lines.append(_box(row)) lines.append(f"╰{hr}╯") diff --git a/packages/claude-code-plugin/hooks/test_buddy_renderer.py b/packages/claude-code-plugin/hooks/test_buddy_renderer.py index e799b37a..cfa34521 100644 --- a/packages/claude-code-plugin/hooks/test_buddy_renderer.py +++ b/packages/claude-code-plugin/hooks/test_buddy_renderer.py @@ -10,12 +10,14 @@ sys.path.insert(0, str(Path(__file__).parent / "lib")) from buddy_renderer import ( + display_width, render_buddy_face, render_scan_results, render_recommendations, render_session_start, render_session_summary, render_returning_session, + render_intelligence_report, render_bar_chart, get_buddy_config, type_text, @@ -58,7 +60,7 @@ def test_unknown_tone_defaults_casual(self): assert "Hey!" in result def test_unknown_language_defaults_en(self): - result = render_buddy_face("casual", "fr") + result = render_buddy_face("casual", "xx") assert "Hey!" in result def test_face_contains_box(self): @@ -295,7 +297,7 @@ def test_unknown_tone_defaults_casual(self): def test_unknown_language_defaults_en(self): stats = {"duration_minutes": 5, "tool_count": 3, "files_changed": 1} - result = render_session_summary(stats, [], "casual", "fr") + result = render_session_summary(stats, [], "casual", "xx") assert "Great work today!" in result def test_face_contains_box(self): @@ -409,6 +411,13 @@ def test_render_buddy_face_custom(self): assert "\u2606\u203f\u2606" in result assert "Hey!" in result # default greeting since custom is empty + def test_render_buddy_face_box_scales_for_longer_face(self): + face = "\u2605\u25d5\u203f\u25d5\u2605" + bc = {"face": face, "greeting": "Hi!", "name": "Buddy", "farewell": ""} + result = render_buddy_face("casual", "en", buddy_config=bc) + lines = result.splitlines() + assert lines[0].count("\u2501") == display_width(face) + 2 + def test_render_buddy_face_custom_greeting(self): bc = {"face": BUDDY_FACE, "greeting": "Yo!", "name": "B", "farewell": ""} result = render_buddy_face("casual", "en", buddy_config=bc) @@ -428,6 +437,14 @@ def test_render_session_summary_custom_farewell(self): assert "Ciao!" in result assert "See you next time!" not in result + def test_render_session_summary_farewell_keeps_buddy_face_when_agents_exist(self): + stats = {"duration_minutes": 5, "tool_count": 3, "files_changed": 1} + agents = [{"name": "Backend", "message": "JWT", "eye": "\u25d0", "colorAnsi": "yellow"}] + result = render_session_summary(stats, agents, "casual", "en") + last_line = result.splitlines()[-1] + assert BUDDY_FACE in last_line + assert "\u25d0\u2304\u25d0" not in last_line + def test_render_session_summary_default_farewell_without_config(self): stats = {"duration_minutes": 5, "tool_count": 3, "files_changed": 1} result = render_session_summary(stats, [], "casual", "en") @@ -653,7 +670,7 @@ def test_session_start_ascii_no_unicode_box(self): assert "\u256d" not in result # no ╭ assert "\u2501" not in result # no ━ assert "\u2503" not in result # no ┃ - assert "+---+" in result + assert "+-----+" in result assert "|" in result def test_session_start_ascii_face_converted(self): @@ -683,7 +700,7 @@ 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 "+-----+" in result assert "\u256d" not in result assert "[time]" in result # ⏱ → [time] assert "[tool]" in result # 🔧 → [tool] @@ -701,7 +718,7 @@ def test_typing_mode_ascii(self, capsys, monkeypatch): result = render_session_start(scan, [], "casual", "en", buddy_config=bc, typing=True) assert result == "" captured = capsys.readouterr() - assert "+---+" in captured.err + assert "+-----+" in captured.err assert "\u256d" not in captured.err @@ -758,6 +775,21 @@ def test_color_applied_in_normal_mode(self): assert ANSI_COLORS["yellow"] in result assert ANSI_COLORS["reset"] in result + +class TestRenderIntelligenceReport: + """Tests for display-width-aware report boxes.""" + + def test_box_lines_share_same_display_width_for_wide_text(self): + result = render_intelligence_report( + {"duration_minutes": 32, "tool_count": 47, "tokens_in": 12345, "tokens_out": 6789}, + [{"file": "src/\uc778\uc99d/\ub85c\uadf8\uc778/very-long-component-name.tsx"} for _ in range(6)], + ["src/\ud14c\uc2a4\ud2b8\uc5c6\uc74c/\ub9e4\uc6b0\uae34\ud30c\uc77c\uc774\ub984.ts"], + "\ud30c\uc77c 8\uac1c \uc218\uc815 \u00b7 \uba85\ub839\uc5b4 7\uac1c \uc2e4\ud589", + language="ko", + ) + widths = {display_width(line) for line in result.splitlines() if line} + assert len(widths) == 1 + def test_no_color_in_ascii_mode(self): result = render_bar_chart("Edit", 5, 10, color="yellow", ascii_mode=True) assert ANSI_COLORS["yellow"] not in result diff --git a/packages/claude-code-plugin/tests/test_onboarding_tour.py b/packages/claude-code-plugin/tests/test_onboarding_tour.py index 2aac94a4..be7b075f 100644 --- a/packages/claude-code-plugin/tests/test_onboarding_tour.py +++ b/packages/claude-code-plugin/tests/test_onboarding_tour.py @@ -16,6 +16,7 @@ sys.path.insert(0, _hooks_lib) import onboarding_tour # noqa: E402 +from buddy_renderer import display_width # noqa: E402 class TestIsFirstRun: @@ -143,6 +144,13 @@ def test_custom_buddy_face(self): result = onboarding_tour.render_onboarding_tour(buddy_config=custom) assert "\u2605\u203f\u2605" in result + def test_custom_face_banner_keeps_top_and_bottom_aligned(self): + """Longer custom faces expand the top banner cleanly.""" + custom = {"name": "TestBuddy", "face": "\u2605\u25d5\u203f\u25d5\u2605", "greeting": "", "farewell": ""} + result = onboarding_tour.render_onboarding_tour(buddy_config=custom) + top, _middle, bottom = result.splitlines()[:3] + assert display_width(top) == display_width(bottom) + def test_welcome_message_present(self): """Welcome message is in the output.""" result = onboarding_tour.render_onboarding_tour(language="en") diff --git a/packages/claude-code-plugin/tests/test_stop.py b/packages/claude-code-plugin/tests/test_stop.py index 8d2c2859..5cf606d2 100644 --- a/packages/claude-code-plugin/tests/test_stop.py +++ b/packages/claude-code-plugin/tests/test_stop.py @@ -7,6 +7,7 @@ import io from unittest.mock import patch, MagicMock import pytest +from buddy_renderer import display_width sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "hooks", "lib")) @@ -264,6 +265,31 @@ def test_renders_session_summary_to_stderr( assert call_args[0][2] == "casual" assert call_args[0][3] == "en" + +class TestImpactReportRendering: + """Tests for display-width-safe impact report rendering.""" + + def test_impact_report_box_aligns_with_wide_text(self, tmp_path): + mod = _load_module() + impact_dir = tmp_path / "docs" / "codingbuddy" + impact_dir.mkdir(parents=True) + + events = [ + {"sessionId": "session-1", "eventType": "issue_prevented", "data": {"count": 2, "domain": "\ubcf4\uc548"}}, + {"sessionId": "session-1", "eventType": "agent_dispatched", "data": {}}, + {"sessionId": "session-1", "eventType": "checklist_generated", "data": {"domain": "\uc811\uadfc\uc131"}}, + {"sessionId": "session-1", "eventType": "mode_activated", "data": {"mode": "PLAN"}}, + {"sessionId": "session-1", "eventType": "mode_activated", "data": {"mode": "ACT"}}, + ] + with open(impact_dir / "impact-events.jsonl", "w", encoding="utf-8") as f: + for event in events: + f.write(json.dumps(event, ensure_ascii=False) + "\n") + + result = mod._render_impact_report("session-1", str(tmp_path)) + assert result + widths = {display_width(line) for line in result.splitlines() if line} + assert len(widths) == 1 + @patch("buddy_renderer.render_session_summary") @patch("config.get_config") @patch("history_db.HistoryDB")