on main help sign in
fix(tui): escape control characters in tool call content
acf354eAyman Bagabas
internal/tui/components/chat/messages/renderer.go+30−2
@@ -655,6 +655,7 @@ func joinHeaderBody(header, body string) string {
655
655
656func renderPlainContent(v *toolCallCmp, content string) string {
656func renderPlainContent(v *toolCallCmp, content string) string {
657 t := styles.CurrentTheme()
657 t := styles.CurrentTheme()
658+ content = strings.ReplaceAll(content, "\r\n", "\n") // Normalize line endings
658 content = strings.TrimSpace(content)
659 content = strings.TrimSpace(content)
659 lines := strings.Split(content, "\n")
660 lines := strings.Split(content, "\n")
660
661
@@ -664,6 +665,7 @@ func renderPlainContent(v *toolCallCmp, content string) string {
664 if i >= responseContextHeight {
665 if i >= responseContextHeight {
665 break
666 break
666 }
667 }
668+ ln = escapeContent(ln)
667 ln = " " + ln // left padding
669 ln = " " + ln // left padding
668 if len(ln) > width {
670 if len(ln) > width {
669 ln = v.fit(ln, width)
671 ln = v.fit(ln, width)
@@ -680,6 +682,7 @@ func renderPlainContent(v *toolCallCmp, content string) string {
680 Width(width).
682 Width(width).
681 Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight)))
683 Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight)))
682 }
684 }
685+
683 return strings.Join(out, "\n")
686 return strings.Join(out, "\n")
684}
687}
685
688
@@ -694,10 +697,17 @@ func pad(v any, width int) string {
694
697
695func renderCodeContent(v *toolCallCmp, path, content string, offset int) string {
698func renderCodeContent(v *toolCallCmp, path, content string, offset int) string {
696 t := styles.CurrentTheme()
699 t := styles.CurrentTheme()
700+ content = strings.ReplaceAll(content, "\r\n", "\n") // Normalize line endings
701+ content = strings.ReplaceAll(content, "\t", " ") // Replace tabs with spaces
697 truncated := truncateHeight(content, responseContextHeight)
702 truncated := truncateHeight(content, responseContextHeight)
698
703
704+ lines := strings.Split(truncated, "\n")
705+ for i, ln := range lines {
706+ lines[i] = escapeContent(ln)
707+ }
708+
699− highlighted, _ := highlight.SyntaxHighlight(truncated, path, t.BgBase)
709+ highlighted, _ := highlight.SyntaxHighlight(strings.Join(lines, "\n"), path, t.BgBase)
700− lines := strings.Split(highlighted, "\n")
710+ lines = strings.Split(highlighted, "\n")
701
711
702 if len(strings.Split(content, "\n")) > responseContextHeight {
712 if len(strings.Split(content, "\n")) > responseContextHeight {
703 lines = append(lines, t.S().Muted.
713 lines = append(lines, t.S().Muted.
@@ -721,6 +731,7 @@ func renderCodeContent(v *toolCallCmp, path, content string, offset int) string
721 PaddingLeft(1).
731 PaddingLeft(1).
722 Render(v.fit(ln, w-1)))
732 Render(v.fit(ln, w-1)))
723 }
733 }
734+
724 return lipgloss.JoinVertical(lipgloss.Left, lines...)
735 return lipgloss.JoinVertical(lipgloss.Left, lines...)
725}
736}
726
737
@@ -766,3 +777,20 @@ func prettifyToolName(name string) string {
766 return name
777 return name
767 }
778 }
768}
779}
780+
781+// escapeContent replaces control characters with their Unicode Control Picture
782+// representations to ensure they are displayed correctly in the UI.
783+func escapeContent(content string) string {
784+ var sb strings.Builder
785+ for _, r := range content {
786+ switch {
787+ case r >= 0 && r <= 0x1f: // Control characters 0x00-0x1F
788+ sb.WriteRune('\u2400' + r)
789+ case r == ansi.DEL:
790+ sb.WriteRune('\u2421')
791+ default:
792+ sb.WriteRune(r)
793+ }
794+ }
795+ return sb.String()
796+}