on main help sign in
Merge pull request #176 from charmbracelet/escape-sequences

fix(tui): escape ANSI escape sequences and control characters in tool

commit5200f812090ef3ab9f0eacdf336e8afa8ba33d69 authorKujtim Hoxha <kujtimii.h@gmail.com> dateTue 15 Jul 2025, 13:04 (1 year ago) parent836ff587402ca8
3 files changed+98−23
internal/llm/tools/view.go+1−23 internal/tui/components/chat/messages/renderer.go+45 internal/tui/components/chat/messages/renderer_test.go+52
internal/llm/tools/view.go+1−23
@@ -238,7 +238,7 @@ func readTextFile(filePath string, offset, limit int) (string, int, error) {
238238
239239 lineCount := 0
240240
241− scanner := NewLineScanner(file)
241+ scanner := bufio.NewScanner(file)
242242 if offset > 0 {
243243 for lineCount < offset && scanner.Scan() {
244244 lineCount++
@@ -298,25 +298,3 @@ func isImageFile(filePath string) (bool, string) {
298298 return false, ""
299299 }
300300}
301−
302−type LineScanner struct {
303− scanner *bufio.Scanner
304−}
305−
306−func NewLineScanner(r io.Reader) *LineScanner {
307− return &LineScanner{
308− scanner: bufio.NewScanner(r),
309− }
310−}
311−
312−func (s *LineScanner) Scan() bool {
313− return s.scanner.Scan()
314−}
315−
316−func (s *LineScanner) Text() string {
317− return s.scanner.Text()
318−}
319−
320−func (s *LineScanner) Err() error {
321− return s.scanner.Err()
322−}
internal/tui/components/chat/messages/renderer.go+45
@@ -3,6 +3,7 @@ package messages
33import (
44 "encoding/json"
55 "fmt"
6+ "strconv"
67 "strings"
78 "time"
89
@@ -656,6 +657,7 @@ func joinHeaderBody(header, body string) string {
656657func renderPlainContent(v *toolCallCmp, content string) string {
657658 t := styles.CurrentTheme()
658659 content = strings.TrimSpace(content)
660+ content = escapeContent(t, content)
659661 lines := strings.Split(content, "\n")
660662
661663 width := v.textWidth() - 2 // -2 for left padding
@@ -694,6 +696,7 @@ func pad(v any, width int) string {
694696
695697func renderCodeContent(v *toolCallCmp, path, content string, offset int) string {
696698 t := styles.CurrentTheme()
699+ content = escapeContent(t, content)
697700 truncated := truncateHeight(content, responseContextHeight)
698701
699702 highlighted, _ := highlight.SyntaxHighlight(truncated, path, t.BgBase)
@@ -766,3 +769,45 @@ func prettifyToolName(name string) string {
766769 return name
767770 }
768771}
772+
773+// escapeContent escapes ANSI escape sequences and control characters in the
774+// content and styles it for display in the terminal.
775+func escapeContent(t *styles.Theme, content string) string {
776+ content = strings.ReplaceAll(content, "\r\n", "\n")
777+ lines := strings.Split(content, "\n")
778+ for i, line := range lines {
779+ lines[i] = escapeLine(t, line)
780+ }
781+
782+ content = strings.Join(lines, "\n")
783+ return content
784+}
785+
786+// escapeLine escapes ANSI escape sequences and control characters and styles
787+// them for display in the terminal.
788+func escapeLine(t *styles.Theme, text string) string {
789+ var (
790+ sb strings.Builder
791+ state byte
792+ seq string
793+ n int
794+ w int
795+ )
796+ var faint lipgloss.Style
797+ if t != nil {
798+ faint = t.S().Muted.Faint(true)
799+ }
800+ for len(text) > 0 {
801+ seq, w, n, state = ansi.DecodeSequence(text, state, nil)
802+ if w > 0 {
803+ sb.WriteString(seq)
804+ } else {
805+ quote := strconv.Quote(seq)
806+ quote = strings.TrimPrefix(quote, "\"")
807+ quote = strings.TrimSuffix(quote, "\"")
808+ sb.WriteString(faint.Render(quote))
809+ }
810+ text = text[n:]
811+ }
812+ return sb.String()
813+}
internal/tui/components/chat/messages/renderer_test.gonew file+52
@@ -0,0 +1,52 @@
1+package messages
2+
3+import (
4+ "testing"
5+)
6+
7+func TestEscapeContent(t *testing.T) {
8+ cases := []struct {
9+ name string
10+ input string
11+ expected string
12+ }{
13+ {
14+ name: "nothing to escape",
15+ input: "Hello, World!",
16+ expected: "Hello, World!",
17+ },
18+ {
19+ name: "escape csi sequences",
20+ input: "\x1b[31mRed Text\x1b[0m",
21+ expected: "\\x1b[31mRed Text\\x1b[0m",
22+ },
23+ {
24+ name: "escape control characters",
25+ input: "Hello\x00World\x7f!",
26+ expected: "Hello\\x00World\\x7f!",
27+ },
28+ {
29+ name: "escape csi sequences with control characters",
30+ input: "\x1b[31mHello\x00World\x7f!\x1b[0m",
31+ expected: "\\x1b[31mHello\\x00World\\x7f!\\x1b[0m",
32+ },
33+ {
34+ name: "just unicode",
35+ input: "こんにちは", // "Hello" in Japanese
36+ expected: "こんにちは",
37+ },
38+ {
39+ name: "unicode with csi sequences and control characters",
40+ input: "\x1b[31mこんにちは\x00World\x7f!\x1b[0m",
41+ expected: "\\x1b[31mこんにちは\\x00World\\x7f!\\x1b[0m",
42+ },
43+ }
44+ for i, c := range cases {
45+ t.Run(c.name, func(t *testing.T) {
46+ result := escapeContent(nil, c.input)
47+ if result != c.expected {
48+ t.Errorf("case %d, expected %q, got %q", i+1, c.expected, result)
49+ }
50+ })
51+ }
52+}