77import re
88import sys
99import time
10+ import unicodedata
1011from 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
156279def 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
535653def 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
0 commit comments