Skip to content

Commit 39916bc

Browse files
committed
fix: remove extra brackets to text containing ANSI escape seqences
addresses issue #449
1 parent ad31ae8 commit 39916bc

4 files changed

Lines changed: 119 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
processes:
2+
repro:
3+
command: ./repro.py
4+
but:
5+
command: "echo this will print [butThisWont] and this also should: [with spaces]"

issues/issue_449/repro.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
#!/usr/bin/env python3
2+
3+
print(
4+
"\033[2m2026-01-01T00:00:00Z\033[0m"
5+
" [\033[32m\033[1minfo \033[0m]"
6+
" \033[1msome log message\033[0m"
7+
" [\033[34mmyapp\033[0m]"
8+
" \033[36mkey\033[0m=\033[35mvalue\033[0m"
9+
)

src/tui/log-viewer.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ var (
1919
// ANSI escape sequence patterns
2020
// Clear entire screen sequences
2121
clearScreenPattern = regexp.MustCompile(`\x1b\[2J|\x1bc|\x1b\[H\x1b\[2J`)
22+
// tviewTagPattern matches patterns that tview's parseTag actually interprets
23+
// as style/region tags. Unlike tview.Escape's regex, this excludes spaces,
24+
// underscores, and other characters that parseTag rejects.
25+
tviewTagPattern = regexp.MustCompile(`(\[[a-zA-Z0-9#:\-"]+\[*)\]`)
2226
)
2327

2428
type truncator interface {
@@ -72,7 +76,7 @@ func (l *LogView) WriteString(line string) (n int, err error) {
7276
// Remove the clear sequence and process remaining text
7377
line = clearScreenPattern.ReplaceAllString(line, "")
7478
}
75-
return l.buffer.WriteString(tview.Escape(line + "\n"))
79+
return l.buffer.WriteString(escapeForAnsiWriter(line) + "\n")
7680
}
7781
if strings.Contains(strings.ToLower(line), "error") {
7882
return fmt.Fprintf(l.buffer, "[deeppink]%s[-:-:-]\n", tview.Escape(line))
@@ -81,6 +85,23 @@ func (l *LogView) WriteString(line string) (n int, err error) {
8185
}
8286
}
8387

88+
89+
// escapeForAnsiWriter escapes tview-style tags in text that also contains ANSI
90+
// escape sequences. tview.Escape cannot be used here for two reasons:
91+
// 1. Its regex matches across ANSI/literal boundaries (e.g. "\x1b[0m]" contains
92+
// "[0m]" which tview.Escape corrupts to "[0m[]").
93+
// 2. Its regex is broader than tview's actual parser — it escapes patterns with
94+
// spaces and underscores that parseTag would never interpret as tags.
95+
//
96+
// This function protects ANSI CSI introducers (\x1b[) with a placeholder, then
97+
// applies a tighter escape regex that only matches what tview actually parses.
98+
func escapeForAnsiWriter(line string) string {
99+
const placeholder = "\x1a" // SUB control char, not expected in log output
100+
protected := strings.ReplaceAll(line, "\x1b[", placeholder)
101+
protected = tviewTagPattern.ReplaceAllString(protected, "$1[]")
102+
return strings.ReplaceAll(protected, placeholder, "\x1b[")
103+
}
104+
84105
func (l *LogView) tryPrettyPrintJson(line string) string {
85106
trimmed := strings.TrimSpace(line)
86107
if len(trimmed) < 2 || (trimmed[0] != '{' && trimmed[0] != '[') {

src/tui/log-viewer_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package tui
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestEscapeForAnsiWriter(t *testing.T) {
8+
tests := []struct {
9+
name string
10+
input string
11+
want string
12+
}{
13+
{
14+
name: "plain text unchanged",
15+
input: "hello world",
16+
want: "hello world",
17+
},
18+
{
19+
name: "ANSI reset before literal bracket not corrupted",
20+
input: "\x1b[0m] after",
21+
want: "\x1b[0m] after",
22+
},
23+
{
24+
name: "literal tview-like tag is escaped",
25+
input: "[ButThisWont] visible",
26+
want: "[ButThisWont[] visible",
27+
},
28+
{
29+
name: "structured log with ANSI colors and brackets",
30+
input: "\x1b[2m2026-01-01T00:00:00Z\x1b[0m [\x1b[32m\x1b[1minfo \x1b[0m] \x1b[1msome log message\x1b[0m [\x1b[34mmyapp\x1b[0m] \x1b[36mkey\x1b[0m=\x1b[35mvalue\x1b[0m",
31+
want: "\x1b[2m2026-01-01T00:00:00Z\x1b[0m [\x1b[32m\x1b[1minfo \x1b[0m] \x1b[1msome log message\x1b[0m [\x1b[34mmyapp\x1b[0m] \x1b[36mkey\x1b[0m=\x1b[35mvalue\x1b[0m",
32+
},
33+
{
34+
name: "ANSI sequences preserved",
35+
input: "\x1b[31mred\x1b[0m",
36+
want: "\x1b[31mred\x1b[0m",
37+
},
38+
{
39+
name: "multiple literal tags escaped",
40+
input: "[red] and [blue]",
41+
want: "[red[] and [blue[]",
42+
},
43+
{
44+
name: "brackets with spaces not escaped",
45+
input: "[not a tag because of spaces]",
46+
want: "[not a tag because of spaces]",
47+
},
48+
{
49+
name: "ANSI color followed immediately by literal tag",
50+
input: "\x1b[0m[SomeTag]",
51+
want: "\x1b[0m[SomeTag[]",
52+
},
53+
{
54+
name: "hex color tag escaped",
55+
input: "[#ff0000]text",
56+
want: "[#ff0000[]text",
57+
},
58+
{
59+
name: "reset tag escaped",
60+
input: "[-]reset",
61+
want: "[-[]reset",
62+
},
63+
{
64+
name: "compound style tag escaped",
65+
input: "[red:-:b]bold red",
66+
want: "[red:-:b[]bold red",
67+
},
68+
{
69+
name: "brackets with underscores not escaped",
70+
input: "[some_var]",
71+
want: "[some_var]",
72+
},
73+
}
74+
75+
for _, tt := range tests {
76+
t.Run(tt.name, func(t *testing.T) {
77+
got := escapeForAnsiWriter(tt.input)
78+
if got != tt.want {
79+
t.Errorf("escapeForAnsiWriter(%q)\n got %q\n want %q", tt.input, got, tt.want)
80+
}
81+
})
82+
}
83+
}

0 commit comments

Comments
 (0)