From 49059f8ddb8c9270484c082e1d5a20c50df1af39 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 14 Jul 2025 12:00:11 -0400 Subject: [PATCH 1/5] fix(tui): escape ANSI escape sequences and control characters in tool call content This would ensure that the content is displayed correctly in the terminal, without any unintended formatting or control characters or escape sequences. It will also style the escaped content for display in the terminal, making it more readable. --- .../tui/components/chat/messages/renderer.go | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go index 87eb2c8476655fe7d11fc8c787e73b32d4584de4..ecd095798633e62e004a165c82b5d1816797c175 100644 --- a/internal/tui/components/chat/messages/renderer.go +++ b/internal/tui/components/chat/messages/renderer.go @@ -3,6 +3,7 @@ package messages import ( "encoding/json" "fmt" + "strconv" "strings" "time" @@ -656,6 +657,7 @@ func joinHeaderBody(header, body string) string { func renderPlainContent(v *toolCallCmp, content string) string { t := styles.CurrentTheme() content = strings.TrimSpace(content) + content = escapeContent(t, content) lines := strings.Split(content, "\n") width := v.textWidth() - 2 // -2 for left padding @@ -694,6 +696,7 @@ func pad(v any, width int) string { func renderCodeContent(v *toolCallCmp, path, content string, offset int) string { t := styles.CurrentTheme() + content = escapeContent(t, content) truncated := truncateHeight(content, responseContextHeight) highlighted, _ := highlight.SyntaxHighlight(truncated, path, t.BgBase) @@ -766,3 +769,41 @@ func prettifyToolName(name string) string { return name } } + +// escapeContent escapes ANSI escape sequences and control characters in the +// content and styles it for display in the terminal. +func escapeContent(t *styles.Theme, content string) string { + lines := strings.Split(content, "\n") + for i, line := range lines { + lines[i] = escapeLine(t, line) + } + + content = strings.Join(lines, "\n") + return content +} + +// escapeLine escapes ANSI escape sequences and control characters and styles +// them for display in the terminal. +func escapeLine(t *styles.Theme, text string) string { + var ( + sb strings.Builder + state byte + seq string + n int + w int + ) + faint := ansi.NewStyle().Faint().ForegroundColor(t.FgMuted) + for len(text) > 0 { + seq, w, n, state = ansi.DecodeSequence(text, state, nil) + if w > 0 { + sb.WriteString(seq) + } else { + quote := strconv.Quote(seq) + quote = strings.TrimPrefix(quote, "\"") + quote = strings.TrimSuffix(quote, "\"") + sb.WriteString(faint.Styled(quote)) + } + text = text[n:] + } + return sb.String() +} From ee1cac6f247e3f98a2c52428bb6177a98ffb8372 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 14 Jul 2025 12:01:46 -0400 Subject: [PATCH 2/5] refactor(tools): replace custom LineScanner with bufio.Scanner --- internal/llm/tools/view.go | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/internal/llm/tools/view.go b/internal/llm/tools/view.go index 27bbc237209e64637cfefb0f4ff1097f96641c2e..ff3749dd8424639b8c8700ce46ebaeb61a3d0394 100644 --- a/internal/llm/tools/view.go +++ b/internal/llm/tools/view.go @@ -238,7 +238,7 @@ func readTextFile(filePath string, offset, limit int) (string, int, error) { lineCount := 0 - scanner := NewLineScanner(file) + scanner := bufio.NewScanner(file) if offset > 0 { for lineCount < offset && scanner.Scan() { lineCount++ @@ -298,25 +298,3 @@ func isImageFile(filePath string) (bool, string) { return false, "" } } - -type LineScanner struct { - scanner *bufio.Scanner -} - -func NewLineScanner(r io.Reader) *LineScanner { - return &LineScanner{ - scanner: bufio.NewScanner(r), - } -} - -func (s *LineScanner) Scan() bool { - return s.scanner.Scan() -} - -func (s *LineScanner) Text() string { - return s.scanner.Text() -} - -func (s *LineScanner) Err() error { - return s.scanner.Err() -} From 82eca73d65beb9b7f011bc95939efcaded7c8591 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 14 Jul 2025 16:12:49 -0400 Subject: [PATCH 3/5] feat(tui): use lipgloss on escape sequences --- internal/tui/components/chat/messages/renderer.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go index ecd095798633e62e004a165c82b5d1816797c175..e10181621628913e6fab99edfc8a9d33312b1a94 100644 --- a/internal/tui/components/chat/messages/renderer.go +++ b/internal/tui/components/chat/messages/renderer.go @@ -792,7 +792,7 @@ func escapeLine(t *styles.Theme, text string) string { n int w int ) - faint := ansi.NewStyle().Faint().ForegroundColor(t.FgMuted) + faint := t.S().Muted.Faint(true) for len(text) > 0 { seq, w, n, state = ansi.DecodeSequence(text, state, nil) if w > 0 { @@ -801,7 +801,7 @@ func escapeLine(t *styles.Theme, text string) string { quote := strconv.Quote(seq) quote = strings.TrimPrefix(quote, "\"") quote = strings.TrimSuffix(quote, "\"") - sb.WriteString(faint.Styled(quote)) + sb.WriteString(faint.Render(quote)) } text = text[n:] } From 625aa473b684ccee984437a436291064d8e88cab Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 14 Jul 2025 17:00:31 -0400 Subject: [PATCH 4/5] fix(tui): make sure we treat \r\n as \n --- internal/tui/components/chat/messages/renderer.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go index e10181621628913e6fab99edfc8a9d33312b1a94..11bb9f923456b6d16566d06951757e6be6ecbbf1 100644 --- a/internal/tui/components/chat/messages/renderer.go +++ b/internal/tui/components/chat/messages/renderer.go @@ -773,6 +773,7 @@ func prettifyToolName(name string) string { // escapeContent escapes ANSI escape sequences and control characters in the // content and styles it for display in the terminal. func escapeContent(t *styles.Theme, content string) string { + content = strings.ReplaceAll(content, "\r\n", "\n") lines := strings.Split(content, "\n") for i, line := range lines { lines[i] = escapeLine(t, line) From 7402ca82c91786f0c438e5d3bc3d2f752b635ac2 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 14 Jul 2025 17:06:54 -0400 Subject: [PATCH 5/5] chore(tui): add tests for escapeLine --- .../tui/components/chat/messages/renderer.go | 5 +- .../components/chat/messages/renderer_test.go | 52 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 internal/tui/components/chat/messages/renderer_test.go diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go index 11bb9f923456b6d16566d06951757e6be6ecbbf1..6bf3fbc3fa0c4fe6a6ba44d930bf3418696ea1d3 100644 --- a/internal/tui/components/chat/messages/renderer.go +++ b/internal/tui/components/chat/messages/renderer.go @@ -793,7 +793,10 @@ func escapeLine(t *styles.Theme, text string) string { n int w int ) - faint := t.S().Muted.Faint(true) + var faint lipgloss.Style + if t != nil { + faint = t.S().Muted.Faint(true) + } for len(text) > 0 { seq, w, n, state = ansi.DecodeSequence(text, state, nil) if w > 0 { diff --git a/internal/tui/components/chat/messages/renderer_test.go b/internal/tui/components/chat/messages/renderer_test.go new file mode 100644 index 0000000000000000000000000000000000000000..cc20ed711aa16ab1bd2ab8f462cd9ee33e58b005 --- /dev/null +++ b/internal/tui/components/chat/messages/renderer_test.go @@ -0,0 +1,52 @@ +package messages + +import ( + "testing" +) + +func TestEscapeContent(t *testing.T) { + cases := []struct { + name string + input string + expected string + }{ + { + name: "nothing to escape", + input: "Hello, World!", + expected: "Hello, World!", + }, + { + name: "escape csi sequences", + input: "\x1b[31mRed Text\x1b[0m", + expected: "\\x1b[31mRed Text\\x1b[0m", + }, + { + name: "escape control characters", + input: "Hello\x00World\x7f!", + expected: "Hello\\x00World\\x7f!", + }, + { + name: "escape csi sequences with control characters", + input: "\x1b[31mHello\x00World\x7f!\x1b[0m", + expected: "\\x1b[31mHello\\x00World\\x7f!\\x1b[0m", + }, + { + name: "just unicode", + input: "こんにちは", // "Hello" in Japanese + expected: "こんにちは", + }, + { + name: "unicode with csi sequences and control characters", + input: "\x1b[31mこんにちは\x00World\x7f!\x1b[0m", + expected: "\\x1b[31mこんにちは\\x00World\\x7f!\\x1b[0m", + }, + } + for i, c := range cases { + t.Run(c.name, func(t *testing.T) { + result := escapeContent(nil, c.input) + if result != c.expected { + t.Errorf("case %d, expected %q, got %q", i+1, c.expected, result) + } + }) + } +}