on main help sign in
fix(bash): keep TruncateOutput from splitting UTF-8 characters

TruncateOutput slices the content at MaxOutputLength/2 bytes from each end. When the output contains multi-byte UTF-8 (CJK, emoji, ...), the cut can land in the middle of a rune and produce invalid UTF-8. Move the cut points to the nearest rune boundary before slicing. Added a test.

commita08e3329c0b540cc851f5d2e0e9405cd06fa7c2c authorZF <130743669+feizhuzheng@users.noreply.github.com> dateFri 26 Jun 2026, 21:01 (4 months ago) parent9c61c7d
2 files changed+35−11
internal/agent/tools/bash.go+6−11 internal/agent/tools/bash_test.go+29
internal/agent/tools/bash.go+6−11
@@ -12,6 +12,8 @@ import (
1212 "strings"
1313 "time"
1414
15+ "github.com/charmbracelet/x/ansi"
16+
1517 "charm.land/fantasy"
1618 "github.com/charmbracelet/crush/internal/config"
1719 "github.com/charmbracelet/crush/internal/fsext"
@@ -423,15 +425,15 @@ func formatOutput(stdout, stderr string, execErr error) string {
423425}
424426
425427func TruncateOutput(content string) string {
426− if len(content) <= MaxOutputLength {
428+ if ansi.StringWidth(content) <= MaxOutputLength {
427429 return content
428430 }
429431
430432 halfLength := MaxOutputLength / 2
431− start := content[:halfLength]
432− end := content[len(content)-halfLength:]
433+ start := ansi.Truncate(content, halfLength, "")
434+ end := ansi.TruncateLeft(content, ansi.StringWidth(content)-halfLength, "")
433435
434− truncatedLinesCount := countLines(content[halfLength : len(content)-halfLength])
436+ truncatedLinesCount := max(strings.Count(content, "\n")-strings.Count(start, "\n")-strings.Count(end, "\n"), 0)
435437 return fmt.Sprintf("%s\n\n... [%d lines truncated] ...\n\n%s", start, truncatedLinesCount, end)
436438}
437439
@@ -439,13 +441,6 @@ func truncateOutput(content string) string {
439441 return TruncateOutput(content)
440442}
441443
442−func countLines(s string) int {
443− if s == "" {
444− return 0
445− }
446− return len(strings.Split(s, "\n"))
447−}
448−
449444func normalizeWorkingDir(path string) string {
450445 if runtime.GOOS == "windows" {
451446 path = strings.ReplaceAll(path, fsext.WindowsWorkingDirDrive(), "")
internal/agent/tools/bash_test.go+29
@@ -3,7 +3,9 @@ package tools
33import (
44 "context"
55 "encoding/json"
6+ "strings"
67 "testing"
8+ "unicode/utf8"
79
810 "charm.land/fantasy"
911 "github.com/charmbracelet/crush/internal/config"
@@ -182,3 +184,30 @@ func runBashTool(t *testing.T, tool fantasy.AgentTool, ctx context.Context, para
182184 require.NoError(t, err)
183185 return resp
184186}
187+
188+func TestTruncateOutputValidUTF8(t *testing.T) {
189+ t.Parallel()
190+ // CJK characters are 2 cells wide; this string is far wider than
191+ // MaxOutputLength so TruncateOutput must truncate it.
192+ content := strings.Repeat("你好世界", MaxOutputLength)
193+
194+ out := TruncateOutput(content)
195+ require.True(t, utf8.ValidString(out), "truncated output must stay valid UTF-8")
196+ require.Contains(t, out, "lines truncated")
197+}
198+
199+func TestTruncateOutputShortContent(t *testing.T) {
200+ t.Parallel()
201+ content := "short output"
202+ require.Equal(t, content, TruncateOutput(content))
203+}
204+
205+func TestTruncateOutputEmoji(t *testing.T) {
206+ t.Parallel()
207+ // Emoji with ZWJ sequences should not be split.
208+ content := strings.Repeat("👨‍👩‍👧‍👦", MaxOutputLength)
209+
210+ out := TruncateOutput(content)
211+ require.True(t, utf8.ValidString(out), "truncated output must stay valid UTF-8")
212+ require.Contains(t, out, "lines truncated")
213+}