diff --git a/internal/core/truncate.go b/internal/core/truncate.go index 53798eee..5867f7fd 100644 --- a/internal/core/truncate.go +++ b/internal/core/truncate.go @@ -6,14 +6,17 @@ import ( ) const ( - defaultMaxLines = 2000 - defaultMaxBytes = 50 * 1024 // 50KB - grepMaxLineLen = 500 + defaultMaxLines = 2000 + defaultMaxBytes = 50 * 1024 // 50KB + defaultMaxLineLen = 2000 // max characters per line before truncation + grepMaxLineLen = 500 // DefaultMaxLines is the exported default line limit for truncation. DefaultMaxLines = defaultMaxLines // DefaultMaxBytes is the exported default byte limit for truncation. DefaultMaxBytes = defaultMaxBytes + // DefaultMaxLineLen is the exported default per-line character limit. + DefaultMaxLineLen = defaultMaxLineLen ) // TruncationResult describes how output was truncated. @@ -26,6 +29,8 @@ type TruncationResult struct { } // TruncateTail keeps the last maxLines lines and at most maxBytes bytes. +// Individual lines longer than defaultMaxLineLen are truncated to prevent +// extremely long single lines from blowing up the TUI when wrapped. // Used for bash output where the tail is most relevant. func TruncateTail(content string, maxLines, maxBytes int) TruncationResult { if maxLines <= 0 { @@ -38,11 +43,11 @@ func TruncateTail(content string, maxLines, maxBytes int) TruncationResult { lines := strings.Split(content, "\n") total := len(lines) - if len(content) <= maxBytes && total <= maxLines { - return TruncationResult{Content: content, Total: total, Kept: total} - } + // Truncate individual long lines first to prevent single lines from + // wrapping into hundreds of visual lines in the TUI. + lines = truncateLongLines(lines, defaultMaxLineLen) - // Truncate by lines first (keep tail) + // Truncate by lines (keep tail) truncBy := "" if total > maxLines { lines = lines[total-maxLines:] @@ -78,6 +83,7 @@ func TruncateTail(content string, maxLines, maxBytes int) TruncationResult { } // truncateHead keeps the first maxLines lines and at most maxBytes bytes. +// Individual lines longer than defaultMaxLineLen are truncated. // Used for read, grep, find, ls output where the head is most relevant. func truncateHead(content string, maxLines, maxBytes int) TruncationResult { if maxLines <= 0 { @@ -90,9 +96,8 @@ func truncateHead(content string, maxLines, maxBytes int) TruncationResult { lines := strings.Split(content, "\n") total := len(lines) - if len(content) <= maxBytes && total <= maxLines { - return TruncationResult{Content: content, Total: total, Kept: total} - } + // Truncate individual long lines first. + lines = truncateLongLines(lines, defaultMaxLineLen) truncBy := "" if total > maxLines { @@ -125,6 +130,19 @@ func truncateHead(content string, maxLines, maxBytes int) TruncationResult { } } +// truncateLongLines caps each line to maxLen characters, appending a +// "[...N chars truncated]" marker to any line that exceeds the limit. +// This prevents a single very long line (e.g. minified JSON/JS) from +// wrapping into hundreds of visual rows and blowing up the TUI. +func truncateLongLines(lines []string, maxLen int) []string { + for i, line := range lines { + if len(line) > maxLen { + lines[i] = line[:maxLen] + fmt.Sprintf("... [%d chars truncated]", len(line)-maxLen) + } + } + return lines +} + // truncateLine truncates a single line to maxChars, appending "..." if cut. func truncateLine(line string, maxChars int) string { if maxChars <= 0 { diff --git a/internal/core/truncate_test.go b/internal/core/truncate_test.go new file mode 100644 index 00000000..2ee38ab9 --- /dev/null +++ b/internal/core/truncate_test.go @@ -0,0 +1,163 @@ +package core + +import ( + "strings" + "testing" +) + +func TestTruncateTail_LongLines(t *testing.T) { + // A single line of 5000 chars should be truncated to defaultMaxLineLen. + longLine := strings.Repeat("x", 5000) + tr := TruncateTail(longLine, 2000, 50*1024) + + if len(tr.Content) > defaultMaxLineLen+100 { // +100 for the "[...N chars truncated]" suffix + t.Errorf("single long line not truncated: got %d chars, want <= %d", len(tr.Content), defaultMaxLineLen+100) + } + if !strings.Contains(tr.Content, "chars truncated]") { + t.Error("truncated line should contain truncation marker") + } +} + +func TestTruncateTail_NormalLines(t *testing.T) { + // Lines within the limit should pass through unchanged. + content := "line1\nline2\nline3" + tr := TruncateTail(content, 2000, 50*1024) + if tr.Content != content { + t.Errorf("got %q, want %q", tr.Content, content) + } + if tr.Truncated { + t.Error("should not be marked as truncated") + } +} + +func TestTruncateTail_LineCount(t *testing.T) { + lines := make([]string, 100) + for i := range lines { + lines[i] = "line" + } + content := strings.Join(lines, "\n") + tr := TruncateTail(content, 10, 50*1024) + + if !tr.Truncated { + t.Error("should be marked as truncated") + } + if tr.Total != 100 { + t.Errorf("total = %d, want 100", tr.Total) + } + if tr.Kept != 10 { + t.Errorf("kept = %d, want 10", tr.Kept) + } +} + +func TestTruncateHead_LongLines(t *testing.T) { + longLine := strings.Repeat("y", 5000) + tr := truncateHead(longLine, 2000, 50*1024) + + if len(tr.Content) > defaultMaxLineLen+100 { + t.Errorf("single long line not truncated: got %d chars, want <= %d", len(tr.Content), defaultMaxLineLen+100) + } + if !strings.Contains(tr.Content, "chars truncated]") { + t.Error("truncated line should contain truncation marker") + } +} + +func TestTruncateHead_NormalLines(t *testing.T) { + content := "line1\nline2\nline3" + tr := truncateHead(content, 2000, 50*1024) + if tr.Content != content { + t.Errorf("got %q, want %q", tr.Content, content) + } + if tr.Truncated { + t.Error("should not be marked as truncated") + } +} + +func TestTruncateHead_LineCount(t *testing.T) { + lines := make([]string, 100) + for i := range lines { + lines[i] = "line" + } + content := strings.Join(lines, "\n") + tr := truncateHead(content, 10, 50*1024) + + if !tr.Truncated { + t.Error("should be marked as truncated") + } + if tr.Total != 100 { + t.Errorf("total = %d, want 100", tr.Total) + } + if tr.Kept != 10 { + t.Errorf("kept = %d, want 10", tr.Kept) + } +} + +func TestTruncateLongLines(t *testing.T) { + lines := []string{ + "short", + strings.Repeat("a", 3000), + "also short", + } + result := truncateLongLines(lines, 100) + + if result[0] != "short" { + t.Error("short line should be unchanged") + } + if len(result[1]) > 200 { // 100 chars + marker + t.Errorf("long line not truncated: len=%d", len(result[1])) + } + if !strings.Contains(result[1], "chars truncated]") { + t.Error("should contain truncation marker") + } + if result[2] != "also short" { + t.Error("short line should be unchanged") + } +} + +func TestTruncateTail_MixedLongAndManyLines(t *testing.T) { + // 50 lines, each 3000 chars — tests both per-line and total truncation. + lines := make([]string, 50) + for i := range lines { + lines[i] = strings.Repeat("z", 3000) + } + content := strings.Join(lines, "\n") + + tr := TruncateTail(content, 10, 50*1024) + + // Should keep 10 lines. + if tr.Kept != 10 { + t.Errorf("kept = %d, want 10", tr.Kept) + } + // Each line should be capped at ~defaultMaxLineLen. + resultLines := strings.Split(tr.Content, "\n") + for i, line := range resultLines { + if len(line) > defaultMaxLineLen+100 { + t.Errorf("line %d too long: %d chars", i, len(line)) + } + } +} + +func TestTruncateLine(t *testing.T) { + short := "hello" + if truncateLine(short, 10) != short { + t.Error("short line should be unchanged") + } + + long := strings.Repeat("x", 100) + result := truncateLine(long, 10) + if len(result) != 13 { // 10 + "..." + t.Errorf("got len %d, want 13", len(result)) + } + + // Default max for 0 — input shorter than default, so unchanged + result2 := truncateLine(long, 0) + if result2 != long { + t.Errorf("100-char line should be unchanged when maxChars defaults to %d", grepMaxLineLen) + } + + // Longer input with default + veryLong := strings.Repeat("x", 1000) + result3 := truncateLine(veryLong, 0) + if len(result3) != grepMaxLineLen+3 { + t.Errorf("got len %d, want %d", len(result3), grepMaxLineLen+3) + } +} diff --git a/internal/ui/model.go b/internal/ui/model.go index 077f3c2b..f270e422 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -3343,9 +3343,22 @@ func (m *AppModel) handleShellCommandResult(msg shellCommandResultMsg) tea.Cmd { var displayHiddenCount int if displayOutput != "" { lines := strings.Split(displayOutput, "\n") + // Cap individual line length to prevent long lines from wrapping + // into excessive visual rows. + maxLineChars := m.width * 3 + if maxLineChars < 200 { + maxLineChars = 200 + } + for i, line := range lines { + if len(line) > maxLineChars { + lines[i] = line[:maxLineChars] + "…" + } + } if len(lines) > maxShellDisplayLines { displayHiddenCount = len(lines) - maxShellDisplayLines displayOutput = strings.Join(lines[:maxShellDisplayLines], "\n") + } else { + displayOutput = strings.Join(lines, "\n") } } diff --git a/internal/ui/tool_renderers.go b/internal/ui/tool_renderers.go index c2c10347..72feeabe 100644 --- a/internal/ui/tool_renderers.go +++ b/internal/ui/tool_renderers.go @@ -578,9 +578,17 @@ func renderBashBody(toolResult string, width int) string { } const lineIndent = " " + // Cap individual line length to prevent long lines (e.g. minified + // JSON) from wrapping into hundreds of visual rows. + maxLineChars := (width - len(lineIndent)) * 3 // allow some wrapping but not unbounded + if maxLineChars < 200 { + maxLineChars = 200 + } + var rendered []string inStderr := false for _, line := range lines { + line = truncateLine(line, maxLineChars) // Detect the STDERR: label that Kit's bash tool emits if strings.TrimSpace(line) == "STDERR:" { inStderr = true