Skip to content

Commit cc6c6f7

Browse files
committed
fix(plugin): improve buddy rendering alignment
1 parent 0c28aed commit cc6c6f7

6 files changed

Lines changed: 242 additions & 52 deletions

File tree

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

Lines changed: 158 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import re
88
import sys
99
import time
10+
import unicodedata
1011
from typing import Any, Dict, List, Optional
1112

1213
# ANSI color codes for terminal output
@@ -152,6 +153,128 @@ def _detect_unicode_support() -> bool:
152153
r"^[^\x00-\x1f\x7f A-Za-z0-9]{1,%d}$" % _MAX_FACE_LENGTH
153154
)
154155

156+
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
157+
158+
159+
def strip_ansi(text: str) -> str:
160+
"""Remove ANSI escape codes from a string."""
161+
return _ANSI_RE.sub("", text)
162+
163+
164+
def _char_display_width(char: str) -> int:
165+
"""Best-effort terminal display width for a single character."""
166+
if not char:
167+
return 0
168+
if unicodedata.combining(char):
169+
return 0
170+
171+
category = unicodedata.category(char)
172+
if category in ("Cc", "Cf"):
173+
return 0
174+
175+
codepoint = ord(char)
176+
# Treat emoji/pictographs as double-width in common terminals.
177+
if 0x1F300 <= codepoint <= 0x1FAFF or 0x2600 <= codepoint <= 0x27BF:
178+
return 2
179+
180+
if unicodedata.east_asian_width(char) in ("W", "F"):
181+
return 2
182+
183+
return 1
184+
185+
186+
def display_width(text: str) -> int:
187+
"""Return terminal display width, ignoring ANSI escape codes."""
188+
return sum(_char_display_width(ch) for ch in strip_ansi(text))
189+
190+
191+
def pad_to_display_width(text: str, width: int) -> str:
192+
"""Right-pad a string to a target terminal display width."""
193+
padding = max(0, width - display_width(text))
194+
return text + (" " * padding)
195+
196+
197+
def truncate_to_display_width(
198+
text: str,
199+
width: int,
200+
ellipsis: str = "\u2026",
201+
) -> str:
202+
"""Truncate a string to a target terminal display width."""
203+
if width <= 0:
204+
return ""
205+
if display_width(text) <= width:
206+
return text
207+
208+
ellipsis_width = display_width(ellipsis)
209+
if width <= ellipsis_width:
210+
return ellipsis if width == ellipsis_width else ""
211+
212+
result: List[str] = []
213+
current = 0
214+
limit = width - ellipsis_width
215+
for ch in strip_ansi(text):
216+
ch_width = _char_display_width(ch)
217+
if current + ch_width > limit:
218+
break
219+
result.append(ch)
220+
current += ch_width
221+
222+
return "".join(result).rstrip() + ellipsis
223+
224+
225+
def truncate_left_to_display_width(
226+
text: str,
227+
width: int,
228+
ellipsis: str = "\u2026",
229+
) -> str:
230+
"""Left-truncate a string while keeping the tail visible."""
231+
if width <= 0:
232+
return ""
233+
if display_width(text) <= width:
234+
return text
235+
236+
ellipsis_width = display_width(ellipsis)
237+
if width <= ellipsis_width:
238+
return ellipsis if width == ellipsis_width else ""
239+
240+
result: List[str] = []
241+
current = 0
242+
limit = width - ellipsis_width
243+
for ch in reversed(strip_ansi(text)):
244+
ch_width = _char_display_width(ch)
245+
if current + ch_width > limit:
246+
break
247+
result.append(ch)
248+
current += ch_width
249+
250+
return ellipsis + "".join(reversed(result)).lstrip()
251+
252+
253+
def render_face_banner(face: str, message: str = "") -> List[str]:
254+
"""Render a small face box that stays aligned for custom/wide faces."""
255+
inner_width = max(3, display_width(face) + 2)
256+
face_cell = pad_to_display_width(f" {face} ", inner_width)
257+
rule = "\u2501" * inner_width
258+
top = f"\u256d{rule}\u256e"
259+
middle = f"\u2503{face_cell}\u2503"
260+
if message:
261+
middle += f" {message}"
262+
bottom = f"\u2570{rule}\u256f"
263+
return [top, middle, bottom]
264+
265+
266+
def render_section_header(label: str, min_tail: int = 10) -> str:
267+
"""Render a section header with balanced trailing rule characters."""
268+
prefix = f"\u2501\u2501 {label} "
269+
tail = "\u2501" * max(min_tail, 28 - display_width(prefix))
270+
return prefix + tail
271+
272+
273+
def render_box_line(text: str, width: int) -> str:
274+
"""Render a single box row with display-width-aware padding."""
275+
clipped = truncate_to_display_width(text, width)
276+
return f"\u2502{pad_to_display_width(clipped, width)}\u2502"
277+
155278

156279
def type_text(text: str, speed: float = 0.03) -> None:
157280
"""Write text to stderr one character at a time with flush for typing animation.
@@ -524,12 +647,7 @@ def render_buddy_face(
524647
face = bc.get("face", BUDDY_FACE)
525648
custom_greeting = bc.get("greeting", "")
526649
greeting = custom_greeting if custom_greeting else _get_greeting(tone, language)
527-
lines = [
528-
"\u256d\u2501\u2501\u2501\u256e",
529-
f"\u2503 {face} \u2503 {greeting}",
530-
"\u2570\u2501\u2501\u2501\u256f",
531-
]
532-
return "\n".join(lines)
650+
return "\n".join(render_face_banner(face, greeting))
533651

534652

535653
def render_scan_results(scan: Dict[str, Any]) -> str:
@@ -543,7 +661,7 @@ def render_scan_results(scan: Dict[str, Any]) -> str:
543661
Formatted scan results string.
544662
"""
545663
name = scan.get("name", "unknown")
546-
lines = [f"\u2501\u2501 {name} \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501"]
664+
lines = [render_section_header(name, min_tail=12)]
547665

548666
framework = scan.get("framework")
549667
if framework:
@@ -732,18 +850,16 @@ def render_session_summary(
732850
farewell = custom_farewell if custom_farewell else _get_farewell_message(tone, language)
733851

734852
# Use custom face for wrap variant: take first char of custom face if set
735-
face = bc.get("face", BUDDY_FACE)
736-
if face != BUDDY_FACE:
737-
wrap_face = face # custom face used as-is
853+
buddy_face = bc.get("face", BUDDY_FACE)
854+
if buddy_face != BUDDY_FACE:
855+
wrap_face = buddy_face # custom face used as-is
738856
else:
739857
wrap_face = BUDDY_WRAP_FACE
740858

741859
parts = []
742860

743861
# Buddy face with farewell greeting
744-
parts.append("\u256d\u2501\u2501\u2501\u256e")
745-
parts.append(f"\u2503 {wrap_face} \u2503 {greeting}")
746-
parts.append("\u2570\u2501\u2501\u2501\u256f")
862+
parts.extend(render_face_banner(wrap_face, greeting))
747863

748864
# Session stats section (only if data available)
749865
duration = stats.get("duration_minutes", 0)
@@ -753,7 +869,7 @@ def render_session_summary(
753869
if duration > 0 or tool_count > 0 or files_changed > 0:
754870
header = _get_summary_header("summary", language)
755871
parts.append("")
756-
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")
872+
parts.append(render_section_header(header, min_tail=18))
757873

758874
stat_items = []
759875
if duration > 0:
@@ -783,22 +899,22 @@ def render_session_summary(
783899
if agents:
784900
header = _get_summary_header("agents", language)
785901
parts.append("")
786-
parts.append(f"\u2501\u2501 {header} \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501")
902+
parts.append(render_section_header(header, min_tail=16))
787903
for agent in agents:
788904
name = agent.get("name", "Agent")
789905
message = agent.get("message", "")
790906
eye = agent.get("eye", "\u25cf")
791907
color = agent.get("colorAnsi", "white")
792-
face = f"{eye}\u2304{eye}"
793-
colored_face = _colorize(face, color)
908+
agent_face = f"{eye}\u2304{eye}"
909+
colored_face = _colorize(agent_face, color)
794910
line = f"{colored_face} {name}"
795911
if message:
796912
line += f" {message}"
797913
parts.append(line)
798914

799915
# Farewell
800916
parts.append("")
801-
parts.append(f"{face} {farewell}")
917+
parts.append(f"{buddy_face} {farewell}")
802918

803919
result = "\n".join(parts)
804920

@@ -873,14 +989,12 @@ def render_returning_session(
873989
parts = []
874990

875991
# Buddy face with welcome-back greeting
876-
parts.append("\u256d\u2501\u2501\u2501\u256e")
877-
parts.append(f"\u2503 {face} \u2503 {greeting}")
878-
parts.append("\u2570\u2501\u2501\u2501\u256f")
992+
parts.extend(render_face_banner(face, greeting))
879993

880994
# Last session summary
881995
header = _get_returning_header("last_session", language)
882996
parts.append("")
883-
parts.append(f"\u2501\u2501 {header} \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501")
997+
parts.append(render_section_header(header, min_tail=8))
884998

885999
stat_items = []
8861000

@@ -906,7 +1020,7 @@ def render_returning_session(
9061020
if pending_context and pending_context.get("task"):
9071021
pending_header = _get_returning_header("pending", language)
9081022
parts.append("")
909-
parts.append(f"\u2501\u2501 {pending_header} \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501")
1023+
parts.append(render_section_header(pending_header, min_tail=8))
9101024

9111025
mode = pending_context.get("mode", "")
9121026
task = pending_context.get("task", "")
@@ -1005,7 +1119,7 @@ def render_session_start(
10051119
if recommendations:
10061120
header = _get_header("recommendations", language)
10071121
parts.append("")
1008-
parts.append(f"\u2501\u2501 {header} \u2501\u2501\u2501\u2501\u2501\u2501")
1122+
parts.append(render_section_header(header, min_tail=8))
10091123
parts.append(render_recommendations(recommendations))
10101124

10111125
# Achievement badges section (#1008)
@@ -1082,27 +1196,37 @@ def render_intelligence_report(
10821196
Returns:
10831197
Formatted Intelligence Report string, or empty string if no data.
10841198
"""
1085-
BOX_W = 41
1199+
title = _intel_header("title", language)
1200+
activity_hdr = _intel_header("activity", language)
1201+
changes_hdr = _intel_header("changes", language)
1202+
gaps_hdr = _intel_header("gaps", language)
1203+
insights_hdr = _intel_header("insights", language)
1204+
1205+
width_candidates = [
1206+
f" \U0001f9e0 {title}",
1207+
f" \U0001f4ca {activity_hdr}",
1208+
f" \U0001f4dd {changes_hdr}",
1209+
f" \u26a0\ufe0f {gaps_hdr}",
1210+
f" \U0001f4a1 {insights_hdr}",
1211+
f" {change_summary}",
1212+
]
1213+
BOX_W = max(41, min(54, max(display_width(text) for text in width_candidates) + 2))
10861214
hr = "\u2500" * BOX_W
10871215

10881216
def _box(text: str) -> str:
1089-
# Truncate if too long, accounting for Unicode widths
1090-
display = text[:BOX_W]
1091-
return f"\u2502{display.ljust(BOX_W)}\u2502"
1217+
return render_box_line(text, BOX_W)
10921218

10931219
def _separator() -> str:
10941220
return f"\u251c{hr}\u2524"
10951221

10961222
lines: List[str] = []
10971223

10981224
# Title
1099-
title = _intel_header("title", language)
11001225
lines.append(f"\u256d{hr}\u256e")
11011226
lines.append(_box(f" \U0001f9e0 {title}"))
11021227
lines.append(_separator())
11031228

11041229
# --- Activity section ---
1105-
activity_hdr = _intel_header("activity", language)
11061230
lines.append(_box(f" \U0001f4ca {activity_hdr}"))
11071231

11081232
duration = stats.get("duration_minutes", 0)
@@ -1130,44 +1254,38 @@ def _fmt_tokens(n: int) -> str:
11301254
# --- What You Did section ---
11311255
if changes:
11321256
lines.append(_separator())
1133-
changes_hdr = _intel_header("changes", language)
11341257
lines.append(_box(f" \U0001f4dd {changes_hdr}"))
11351258
lines.append(_box(f" {change_summary}"))
11361259

11371260
# Show up to 5 changed files
11381261
shown = changes[:5]
1262+
file_width = max(12, BOX_W - display_width(" \u2022 "))
11391263
for c in shown:
1140-
fname = c["file"]
1141-
# Truncate long paths
1142-
if len(fname) > 33:
1143-
fname = "\u2026" + fname[-(33 - 1):]
1264+
fname = truncate_left_to_display_width(c["file"], file_width)
11441265
lines.append(_box(f" \u2022 {fname}"))
11451266
if len(changes) > 5:
11461267
lines.append(_box(f" ... +{len(changes) - 5} more"))
11471268

11481269
# --- Gaps section (untested files) ---
11491270
if untested:
11501271
lines.append(_separator())
1151-
gaps_hdr = _intel_header("gaps", language)
11521272
lines.append(_box(f" \u26a0\ufe0f {gaps_hdr}"))
11531273
count_msg = (
11541274
f" {len(untested)} file{'s' if len(untested) != 1 else ''}"
11551275
" without tests:"
11561276
)
11571277
lines.append(_box(count_msg))
11581278
shown_untested = untested[:3]
1279+
file_width = max(12, BOX_W - display_width(" \u2022 "))
11591280
for f in shown_untested:
1160-
fname = f
1161-
if len(fname) > 33:
1162-
fname = "\u2026" + fname[-(33 - 1):]
1281+
fname = truncate_left_to_display_width(f, file_width)
11631282
lines.append(_box(f" \u2022 {fname}"))
11641283
if len(untested) > 3:
11651284
lines.append(_box(f" ... +{len(untested) - 3} more"))
11661285

11671286
# --- Insights section ---
11681287
if changes:
11691288
lines.append(_separator())
1170-
insights_hdr = _intel_header("insights", language)
11711289
lines.append(_box(f" \U0001f4a1 {insights_hdr}"))
11721290

11731291
# Detect frequent-edit directories

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
BUDDY_FACE,
1313
DEFAULT_BUDDY_CONFIG,
1414
get_buddy_config,
15+
render_face_banner,
16+
render_section_header,
1517
)
1618

1719
# Flag file location
@@ -188,11 +190,9 @@ def render_onboarding_tour(
188190
reset = ANSI_COLORS["reset"]
189191

190192
lines = [
191-
f"\u256d\u2501\u2501\u2501\u256e",
192-
f"\u2503 {face} \u2503 {cyan}{welcome}{reset}",
193-
f"\u2570\u2501\u2501\u2501\u256f",
193+
*render_face_banner(face, f"{cyan}{welcome}{reset}"),
194194
"",
195-
f"\u2501\u2501 {_get_text(TOUR_HEADER, language)} \u2501\u2501\u2501\u2501\u2501\u2501",
195+
render_section_header(_get_text(TOUR_HEADER, language), min_tail=6),
196196
]
197197

198198
for step_num in (1, 2, 3):

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -317,13 +317,19 @@ def _render_impact_report(session_id, project_dir):
317317
return ""
318318

319319
# Box rendering
320-
BOX_W = 41
320+
from buddy_renderer import display_width, render_box_line
321+
322+
title = " \U0001f4ca Impact Report"
323+
BOX_W = max(
324+
41,
325+
min(54, max(display_width(text) for text in [title, *rows]) + 2),
326+
)
321327
hr = "─" * BOX_W
322328

323329
def _box(text):
324-
return f"│{text.ljust(BOX_W)}│"
330+
return render_box_line(text, BOX_W)
325331

326-
lines = [f"╭{hr}╮", _box(" 📊 Impact Report"), f"├{hr}┤"]
332+
lines = [f"╭{hr}╮", _box(title), f"├{hr}┤"]
327333
for row in rows:
328334
lines.append(_box(row))
329335
lines.append(f"╰{hr}╯")

0 commit comments

Comments
 (0)