fix(ui): fix selection of code blocks with tabs inside markdown (#2039)

Andrey Nering created

Yes, this is very specific. You need a code block, inside markdown,
that uses tabs instead of spaces for indentation, like Go code.

This affected both how the code is present on the TUI as well as the text
copied to clipboard.

We need to convert tabs into 4 spaces on the highlighter to match how
it's shown in the TUI.

Centralized this into a function to ensure we're doing the exact same
thing everywhere.

Change summary

internal/stringext/string.go  | 12 ++++++++++++
internal/ui/chat/tools.go     | 12 ++++--------
internal/ui/list/highlight.go |  3 +++
3 files changed, 19 insertions(+), 8 deletions(-)

Detailed changes

internal/stringext/string.go 🔗

@@ -1,6 +1,8 @@
 package stringext
 
 import (
+	"strings"
+
 	"golang.org/x/text/cases"
 	"golang.org/x/text/language"
 )
@@ -8,3 +10,13 @@ import (
 func Capitalize(text string) string {
 	return cases.Title(language.English, cases.Compact).String(text)
 }
+
+// NormalizeSpace normalizes whitespace in the given content string.
+// It replaces Windows-style line endings with Unix-style line endings,
+// converts tabs to four spaces, and trims leading and trailing whitespace.
+func NormalizeSpace(content string) string {
+	content = strings.ReplaceAll(content, "\r\n", "\n")
+	content = strings.ReplaceAll(content, "\t", "    ")
+	content = strings.TrimSpace(content)
+	return content
+}

internal/ui/chat/tools.go 🔗

@@ -15,6 +15,7 @@ import (
 	"github.com/charmbracelet/crush/internal/diff"
 	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/stringext"
 	"github.com/charmbracelet/crush/internal/ui/anim"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/styles"
@@ -531,9 +532,7 @@ func toolHeader(sty *styles.Styles, status ToolStatus, name string, width int, n
 
 // toolOutputPlainContent renders plain text with optional expansion support.
 func toolOutputPlainContent(sty *styles.Styles, content string, width int, expanded bool) string {
-	content = strings.ReplaceAll(content, "\r\n", "\n")
-	content = strings.ReplaceAll(content, "\t", "    ")
-	content = strings.TrimSpace(content)
+	content = stringext.NormalizeSpace(content)
 	lines := strings.Split(content, "\n")
 
 	maxLines := responseContextHeight
@@ -566,8 +565,7 @@ func toolOutputPlainContent(sty *styles.Styles, content string, width int, expan
 
 // toolOutputCodeContent renders code with syntax highlighting and line numbers.
 func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, width int, expanded bool) string {
-	content = strings.ReplaceAll(content, "\r\n", "\n")
-	content = strings.ReplaceAll(content, "\t", "    ")
+	content = stringext.NormalizeSpace(content)
 
 	lines := strings.Split(content, "\n")
 	maxLines := responseContextHeight
@@ -776,9 +774,7 @@ func roundedEnumerator(lPadding, width int) tree.Enumerator {
 
 // toolOutputMarkdownContent renders markdown content with optional truncation.
 func toolOutputMarkdownContent(sty *styles.Styles, content string, width int, expanded bool) string {
-	content = strings.ReplaceAll(content, "\r\n", "\n")
-	content = strings.ReplaceAll(content, "\t", "    ")
-	content = strings.TrimSpace(content)
+	content = stringext.NormalizeSpace(content)
 
 	// Cap width for readability.
 	if width > maxTextWidth {

internal/ui/list/highlight.go 🔗

@@ -5,6 +5,7 @@ import (
 	"strings"
 
 	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/stringext"
 	uv "github.com/charmbracelet/ultraviolet"
 )
 
@@ -53,6 +54,8 @@ func Highlight(content string, area image.Rectangle, startLine, startCol, endLin
 // HighlightBuffer highlights a region of text within the given content and
 // region, returning a [uv.ScreenBuffer].
 func HighlightBuffer(content string, area image.Rectangle, startLine, startCol, endLine, endCol int, highlighter Highlighter) *uv.ScreenBuffer {
+	content = stringext.NormalizeSpace(content)
+
 	if startLine < 0 || startCol < 0 {
 		return nil
 	}