shelley: truncate long lines in large output summaries

Philip Zeyliger and Shelley created

Prompt: In a new worktree, reset to origin/main after fetching, and look at https://github.com/boldsoftware/shelley/issues/37. I think 0bbdde76269d93170ba27ef88f1a3d034468ac75 is buggy, because if the output is binary or has really long lines, we end up putting a lot in the context. Fix it by either reverting that change or by presneting somethign much shorter if the lines are long.

When bash output exceeds 50KB, we save to a file and show first/last
lines. However, if those lines are very long (e.g., binary data or
minified JS), we could still blow up the context.

Fix by truncating each displayed line to 200 characters.

Fixes https://github.com/boldsoftware/shelley/issues/37

Co-authored-by: Shelley <shelley@exe.dev>

Change summary

claudetool/bash.go      | 13 +++++++++++--
claudetool/bash_test.go | 33 +++++++++++++++++++++++++++++++++
2 files changed, 44 insertions(+), 2 deletions(-)

Detailed changes

claudetool/bash.go 🔗

@@ -231,6 +231,7 @@ const (
 	largeOutputThreshold = 50 * 1024 // 50KB - threshold for saving to file
 	firstLinesCount      = 2
 	lastLinesCount       = 5
+	maxLineLength        = 200 // truncate displayed lines to this length
 )
 
 func (b *BashTool) makeBashCommand(ctx context.Context, command string, out io.Writer) *exec.Cmd {
@@ -342,19 +343,27 @@ func formatForegroundBashOutput(out string) (string, error) {
 	result.WriteString("First lines:\n")
 	firstN := min(firstLinesCount, len(lines))
 	for i := 0; i < firstN; i++ {
-		result.WriteString(fmt.Sprintf("%5d: %s\n", i+1, lines[i]))
+		result.WriteString(fmt.Sprintf("%5d: %s\n", i+1, truncateLine(lines[i])))
 	}
 
 	// Last N lines
 	result.WriteString("\n...\n\nLast lines:\n")
 	startIdx := max(0, len(lines)-lastLinesCount)
 	for i := startIdx; i < len(lines); i++ {
-		result.WriteString(fmt.Sprintf("%5d: %s\n", i+1, lines[i]))
+		result.WriteString(fmt.Sprintf("%5d: %s\n", i+1, truncateLine(lines[i])))
 	}
 
 	return result.String(), nil
 }
 
+// truncateLine truncates a line to maxLineLength characters, appending "..." if truncated.
+func truncateLine(line string) string {
+	if len(line) <= maxLineLength {
+		return line
+	}
+	return line[:maxLineLength] + "..."
+}
+
 func humanizeBytes(bytes int) string {
 	switch {
 	case bytes < 4*1024:

claudetool/bash_test.go 🔗

@@ -645,6 +645,39 @@ func TestFormatForegroundBashOutput(t *testing.T) {
 
 		t.Logf("Large binary-like output result:\n%s", result)
 	})
+
+	// Test large output with very long lines (e.g., minified JS)
+	t.Run("Large Output With Long Lines", func(t *testing.T) {
+		// Generate output > 50KB with few very long lines
+		longLine := strings.Repeat("abcdefghij", 1000) // 10KB per line
+		lines := []string{longLine, longLine, longLine, longLine, longLine, longLine}
+		largeOutput := strings.Join(lines, "\n")
+		if len(largeOutput) < largeOutputThreshold {
+			t.Fatalf("Test setup error: output is only %d bytes, need > %d", len(largeOutput), largeOutputThreshold)
+		}
+
+		result, err := formatForegroundBashOutput(largeOutput)
+		if err != nil {
+			t.Fatalf("Unexpected error: %v", err)
+		}
+
+		// Result should be reasonable size (not blow up context)
+		if len(result) > 4096 {
+			t.Errorf("Expected truncated result < 4KB, got %d bytes:\n%s", len(result), result)
+		}
+
+		// Should mention the file
+		if !strings.Contains(result, "saved to:") {
+			t.Errorf("Expected result to mention saved file, got:\n%s", result)
+		}
+
+		// Lines should be truncated
+		if !strings.Contains(result, "...") {
+			t.Errorf("Expected truncated lines with '...', got:\n%s", result)
+		}
+
+		t.Logf("Large output with long lines result:\n%s", result)
+	})
 }
 
 // waitForFile waits for a file to exist and be non-empty or times out