@@ -77,14 +77,24 @@ func (c *CompletionScreen) Configure(prdName string, completed, total int, branc
7777 c .prURL = ""
7878 c .prTitle = ""
7979 c .spinnerFrame = 0
80- // Initialize confetti
81- c .confetti = NewConfetti (c .width , c .height )
80+ // Initialize confetti (deferred until SetSize if dimensions aren't known yet)
81+ if c .width > 0 && c .height > 0 {
82+ c .confetti = NewConfetti (c .width , c .height )
83+ } else {
84+ c .confetti = nil
85+ }
8286}
8387
8488// SetSize sets the screen dimensions.
8589func (c * CompletionScreen ) SetSize (width , height int ) {
8690 c .width = width
8791 c .height = height
92+ if c .confetti != nil {
93+ c .confetti .SetSize (width , height )
94+ } else if c .prdName != "" && width > 0 && height > 0 {
95+ // Initialize confetti now that we have real dimensions
96+ c .confetti = NewConfetti (width , height )
97+ }
8898}
8999
90100// PRDName returns the PRD name shown on the completion screen.
@@ -459,6 +469,60 @@ func formatPRDTitle(name string) string {
459469 return strings .Join (words , " " )
460470}
461471
472+ // ansiTruncate returns the first maxWidth visual columns of an ANSI-styled string,
473+ // properly passing through escape sequences without counting them as visible width.
474+ func ansiTruncate (s string , maxWidth int ) string {
475+ var result strings.Builder
476+ width := 0
477+ inEscape := false
478+ for _ , r := range s {
479+ if r == '\033' {
480+ inEscape = true
481+ result .WriteRune (r )
482+ continue
483+ }
484+ if inEscape {
485+ result .WriteRune (r )
486+ if (r >= 'A' && r <= 'Z' ) || (r >= 'a' && r <= 'z' ) {
487+ inEscape = false
488+ }
489+ continue
490+ }
491+ if width >= maxWidth {
492+ break
493+ }
494+ result .WriteRune (r )
495+ width ++
496+ }
497+ // Reset any open ANSI styling
498+ result .WriteString ("\033 [0m" )
499+ return result .String ()
500+ }
501+
502+ // ansiSkip skips the first skipWidth visual columns of an ANSI-styled string
503+ // and returns the remainder.
504+ func ansiSkip (s string , skipWidth int ) string {
505+ width := 0
506+ inEscape := false
507+ for i , r := range s {
508+ if r == '\033' {
509+ inEscape = true
510+ continue
511+ }
512+ if inEscape {
513+ if (r >= 'A' && r <= 'Z' ) || (r >= 'a' && r <= 'z' ) {
514+ inEscape = false
515+ }
516+ continue
517+ }
518+ if width >= skipWidth {
519+ return s [i :]
520+ }
521+ width ++
522+ }
523+ return ""
524+ }
525+
462526// overlayModal composites a modal on top of a background, centering the modal.
463527func overlayModal (background , modal string , screenWidth , screenHeight int ) string {
464528 bgLines := strings .Split (background , "\n " )
@@ -501,38 +565,13 @@ func overlayModal(background, modal string, screenWidth, screenHeight int) strin
501565 continue
502566 }
503567
504- // Build the composited line: bg prefix + modal + bg suffix
505568 bgLine := bgLines [bgIdx ]
506- bgRunes := []rune (bgLine )
507-
508- // Pad bg line if needed
509- bgVisualWidth := lipgloss .Width (bgLine )
510- for bgVisualWidth < screenWidth {
511- bgRunes = append (bgRunes , ' ' )
512- bgVisualWidth ++
513- }
514-
515- var result strings.Builder
516-
517- // Write background chars before modal
518- prefixWidth := 0
519- runeIdx := 0
520- for runeIdx < len (bgRunes ) && prefixWidth < offsetX {
521- result .WriteRune (bgRunes [runeIdx ])
522- prefixWidth ++
523- runeIdx ++
524- }
525-
526- // Pad if background was shorter than offsetX
527- for prefixWidth < offsetX {
528- result .WriteByte (' ' )
529- prefixWidth ++
530- }
531569
532- // Write the modal line
533- result .WriteString (mLine )
570+ // Build: bg prefix (ANSI-aware) + modal line + bg suffix (ANSI-aware)
571+ prefix := ansiTruncate (bgLine , offsetX )
572+ suffix := ansiSkip (bgLine , offsetX + mWidth )
534573
535- bgLines [bgIdx ] = result . String ()
574+ bgLines [bgIdx ] = prefix + mLine + suffix
536575 }
537576
538577 return strings .Join (bgLines [:screenHeight ], "\n " )
0 commit comments