fix: stop TUI truncation helpers from splitting multi-byte UTF-8 runes

truncStr, firstN, and lastN (internal/render/helpers.go), ptrunc
(proxy.go), and truncateShellLine (terminal.go) all sliced strings on
raw byte offsets. When a cut landed mid-rune, the result was invalid
UTF-8 and the terminal rendered mojibake; the same byte-vs-column
mismatch also cut CJK/emoji lines short, since those runes spend 2-4
bytes but only 1-2 display columns of the budget.

Switch all five to github.com/charmbracelet/x/ansi, already a direct
dependency and already the established pattern for exactly this in
internal/app (view.go). Truncate/TruncateLeft are grapheme-aware and
operate on display columns, not bytes, so a cut can no longer split a
rune and CJK/emoji now correctly consume 2 columns instead of 1.

- truncStr/firstN ("keep the start"): ansi.Truncate(s, n, "")
- lastN ("keep the end"): reverse via ansi.TruncateLeft(s, w-n, "")
  where w is the string's total display width
- ptrunc/truncateShellLine (truncate-with-ellipsis): ansi.Truncate
  already reserves the tail's width internally, so this is a direct
  swap of the same max/length budget, no arithmetic needed

Added helpers_test.go with the issue's own repro (asserting valid
UTF-8 out of all five paths) plus per-function behavior tests.

Fixes #1152.
This commit is contained in:
STiFLeR7 2026-08-25 12:04:20 +05:30
parent f4ef8867f6
commit dfa9433cfb
No known key found for this signature in database
4 changed files with 87 additions and 18 deletions

View file

@ -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 {

View file

@ -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
}

View file

@ -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 {

View file

@ -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).