diff --git a/strix/interface/tui/internal/render/helpers.go b/strix/interface/tui/internal/render/helpers.go index 80c6dfa8..52b6812e 100644 --- a/strix/interface/tui/internal/render/helpers.go +++ b/strix/interface/tui/internal/render/helpers.go @@ -5,6 +5,8 @@ import ( "fmt" "sort" "strings" + + "github.com/charmbracelet/x/ansi" ) // --------------------------------------------------------------------------- @@ -37,25 +39,23 @@ func NumericValue(v any) (float64, bool) { return 0, false } +// truncStr and firstN keep the first n display columns of s, without splitting +// a multi-byte rune. Kept as two names since callers use both spellings for the +// same "keep the start" truncation. func truncStr(s string, n int) string { - if len(s) > n { - return s[:n] - } - return s + return ansi.Truncate(s, n, "") } +// lastN keeps the last n display columns of s, without splitting a multi-byte rune. func lastN(s string, n int) string { - if len(s) > n { - return s[len(s)-n:] + if w := ansi.StringWidth(s); w > n { + return ansi.TruncateLeft(s, w-n, "") } return s } func firstN(s string, n int) string { - if len(s) > n { - return s[:n] - } - return s + return ansi.Truncate(s, n, "") } func joinTrunc(items []any, max, limit int) string { diff --git a/strix/interface/tui/internal/render/helpers_test.go b/strix/interface/tui/internal/render/helpers_test.go new file mode 100644 index 00000000..940468ef --- /dev/null +++ b/strix/interface/tui/internal/render/helpers_test.go @@ -0,0 +1,73 @@ +package render + +import ( + "testing" + "unicode/utf8" +) + +// Regression test for https://github.com/usestrix/strix/issues/1152 +func TestTruncSplitsRunes(t *testing.T) { + cases := []struct { + name string + out string + }{ + {"firstN", firstN("中文字ABC", 4)}, + {"lastN", lastN("ABC中文字", 4)}, + {"truncStr", truncStr("中文字ABC", 4)}, + {"ptrunc", ptrunc("中文字ABCDEF", 5)}, + {"truncateShellLine", truncateShellLine(repeatStr("中", maxLineLength))}, + } + for _, c := range cases { + if !utf8.ValidString(c.out) { + t.Errorf("%s produced invalid UTF-8: %q", c.name, c.out) + } + } +} + +func TestFirstNTruncStrKeepStart(t *testing.T) { + if got := firstN("中文字ABC", 4); got != "中文" { + t.Errorf("firstN(%q, 4) = %q, want %q", "中文字ABC", got, "中文") + } + if got := truncStr("中文字ABC", 4); got != "中文" { + t.Errorf("truncStr(%q, 4) = %q, want %q", "中文字ABC", got, "中文") + } + if got := firstN("hello", 3); got != "hel" { + t.Errorf("firstN(%q, 3) = %q, want %q", "hello", got, "hel") + } + if got := firstN("hi", 10); got != "hi" { + t.Errorf("firstN(%q, 10) = %q, want unchanged", "hi", got) + } +} + +func TestLastNKeepsEnd(t *testing.T) { + // Widths: A=1 B=1 C=1 中=2 文=2 字=2. Keeping the last 4 *columns* (not + // characters) lands on 文+字 (2+2=4), matching the issue's "count columns, + // not bytes" requirement. + if got := lastN("ABC中文字", 4); got != "文字" { + t.Errorf("lastN(%q, 4) = %q, want %q", "ABC中文字", got, "文字") + } + if got := lastN("hello", 3); got != "llo" { + t.Errorf("lastN(%q, 3) = %q, want %q", "hello", got, "llo") + } + if got := lastN("hi", 10); got != "hi" { + t.Errorf("lastN(%q, 10) = %q, want unchanged", "hi", got) + } +} + +func TestPtruncAppendsEllipsisWithinBudget(t *testing.T) { + got := ptrunc("abcdefgh", 5) + if got != "ab..." { + t.Errorf("ptrunc(%q, 5) = %q, want %q", "abcdefgh", got, "ab...") + } + if got := ptrunc("hi", 5); got != "hi" { + t.Errorf("ptrunc(%q, 5) = %q, want unchanged", "hi", got) + } +} + +func repeatStr(s string, n int) string { + out := "" + for i := 0; i < n; i++ { + out += s + } + return out +} diff --git a/strix/interface/tui/internal/render/proxy.go b/strix/interface/tui/internal/render/proxy.go index a8f9fb75..bc80d2eb 100644 --- a/strix/interface/tui/internal/render/proxy.go +++ b/strix/interface/tui/internal/render/proxy.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" ) // --------------------------------------------------------------------------- @@ -28,10 +29,7 @@ func proxyStatusStyle(code int) lipgloss.Style { } func ptrunc(s string, max int) string { - if len(s) > max { - return s[:max-3] + "..." - } - return s + return ansi.Truncate(s, max, "...") } func psanitize(s string, max int) string { diff --git a/strix/interface/tui/internal/render/terminal.go b/strix/interface/tui/internal/render/terminal.go index 059e1936..c7c7e60d 100644 --- a/strix/interface/tui/internal/render/terminal.go +++ b/strix/interface/tui/internal/render/terminal.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" ) // --------------------------------------------------------------------------- @@ -83,10 +84,7 @@ func cleanShellOutput(output string) string { } func truncateShellLine(line string) string { - if len(line) > maxLineLength { - return line[:maxLineLength-3] + "..." - } - return line + return ansi.Truncate(line, maxLineLength, "...") } // formatShellOutput ports _format_output (head/tail truncation with a middle marker).