diff --git a/internal/runner/runner.go b/internal/runner/runner.go index d6c0f20..83ebe55 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -12,6 +12,15 @@ import ( "sync" ) +// sanitizeLine removes control sequences that can corrupt terminal rendering +func sanitizeLine(s string) string { + // Remove carriage returns + s = strings.ReplaceAll(s, "\r", "") + // Convert tabs to spaces (tabs cause width calculation issues) + s = strings.ReplaceAll(s, "\t", " ") + return s +} + // Line represents a single line of output with its line number type Line struct { Number int @@ -141,7 +150,7 @@ func (r *Runner) Run(ctx context.Context) (Result, error) { for scanner.Scan() { lines = append(lines, Line{ Number: lineNum, - Content: scanner.Text(), + Content: sanitizeLine(scanner.Text()), }) lineNum++ } @@ -151,7 +160,7 @@ func (r *Runner) Run(ctx context.Context) (Result, error) { for stderrScanner.Scan() { lines = append(lines, Line{ Number: lineNum, - Content: stderrScanner.Text(), + Content: sanitizeLine(stderrScanner.Text()), }) lineNum++ } @@ -265,7 +274,7 @@ func (r *Runner) RunStreaming(ctx context.Context) *StreamingResult { result.mu.Lock() *result.Lines = append(*result.Lines, Line{ Number: currentLineNum, - Content: scanner.Text(), + Content: sanitizeLine(scanner.Text()), }) result.mu.Unlock() } diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index acd4de0..c1292e3 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -320,3 +320,61 @@ func TestSplitLines(t *testing.T) { }) } } + +func TestSanitizeLine(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "plain text unchanged", + input: "hello world", + want: "hello world", + }, + { + name: "tabs converted to spaces", + input: "col1\tcol2\tcol3", + want: "col1 col2 col3", + }, + { + name: "carriage returns removed", + input: "line with\r\nwindows ending", + want: "line with\nwindows ending", + }, + { + name: "carriage return only removed", + input: "progress\roverwrite", + want: "progressoverwrite", + }, + { + name: "ANSI color codes preserved", + input: "\x1b[32mgreen text\x1b[0m", + want: "\x1b[32mgreen text\x1b[0m", + }, + { + name: "mixed tabs and colors", + input: "\x1b[1m?\x1b[0m\tpackage\t[no test files]", + want: "\x1b[1m?\x1b[0m package [no test files]", + }, + { + name: "empty string", + input: "", + want: "", + }, + { + name: "multiple tabs", + input: "\t\t\t", + want: " ", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := sanitizeLine(tt.input) + if got != tt.want { + t.Errorf("sanitizeLine(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +}