on main help sign in
fix(bash): keep TruncateOutput from splitting UTF-8 characters
a08e332ZF
internal/agent/tools/bash.go+6−11
@@ -12,6 +12,8 @@ import (
12 "strings"
12 "strings"
13 "time"
13 "time"
14
14
15+ "github.com/charmbracelet/x/ansi"
16+
15 "charm.land/fantasy"
17 "charm.land/fantasy"
16 "github.com/charmbracelet/crush/internal/config"
18 "github.com/charmbracelet/crush/internal/config"
17 "github.com/charmbracelet/crush/internal/fsext"
19 "github.com/charmbracelet/crush/internal/fsext"
@@ -423,15 +425,15 @@ func formatOutput(stdout, stderr string, execErr error) string {
423}
425}
424
426
425func TruncateOutput(content string) string {
427func TruncateOutput(content string) string {
426− if len(content) <= MaxOutputLength {
428+ if ansi.StringWidth(content) <= MaxOutputLength {
427 return content
429 return content
428 }
430 }
429
431
430 halfLength := MaxOutputLength / 2
432 halfLength := MaxOutputLength / 2
431− start := content[:halfLength]
433+ start := ansi.Truncate(content, halfLength, "")
432− end := content[len(content)-halfLength:]
434+ end := ansi.TruncateLeft(content, ansi.StringWidth(content)-halfLength, "")
433
435
434− truncatedLinesCount := countLines(content[halfLength : len(content)-halfLength])
436+ truncatedLinesCount := max(strings.Count(content, "\n")-strings.Count(start, "\n")-strings.Count(end, "\n"), 0)
435 return fmt.Sprintf("%s\n\n... [%d lines truncated] ...\n\n%s", start, truncatedLinesCount, end)
437 return fmt.Sprintf("%s\n\n... [%d lines truncated] ...\n\n%s", start, truncatedLinesCount, end)
436}
438}
437
439
@@ -439,13 +441,6 @@ func truncateOutput(content string) string {
439 return TruncateOutput(content)
441 return TruncateOutput(content)
440}
442}
441
443
442−func countLines(s string) int {
443− if s == "" {
444− return 0
445− }
446− return len(strings.Split(s, "\n"))
447−}
448−
449func normalizeWorkingDir(path string) string {
444func normalizeWorkingDir(path string) string {
450 if runtime.GOOS == "windows" {
445 if runtime.GOOS == "windows" {
451 path = strings.ReplaceAll(path, fsext.WindowsWorkingDirDrive(), "")
446 path = strings.ReplaceAll(path, fsext.WindowsWorkingDirDrive(), "")
internal/agent/tools/bash_test.go+29
@@ -3,7 +3,9 @@ package tools
3import (
3import (
4 "context"
4 "context"
5 "encoding/json"
5 "encoding/json"
6+ "strings"
6 "testing"
7 "testing"
8+ "unicode/utf8"
7
9
8 "charm.land/fantasy"
10 "charm.land/fantasy"
9 "github.com/charmbracelet/crush/internal/config"
11 "github.com/charmbracelet/crush/internal/config"
@@ -182,3 +184,30 @@ func runBashTool(t *testing.T, tool fantasy.AgentTool, ctx context.Context, para
182 require.NoError(t, err)
184 require.NoError(t, err)
183 return resp
185 return resp
184}
186}
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+}