From cf9c068650a2b15664e54f04d7fc4f1f910be505 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 05:09:09 +0000 Subject: [PATCH] fix(cli): make width helpers rune-safe --- go.mod | 4 +-- go.sum | 12 +++++++++ pkg/cli/styles.go | 55 ++++++++++++++++++++++++++++++++++------- pkg/cli/styles_test.go | 2 ++ pkg/cli/tracker.go | 8 +++--- pkg/cli/tracker_test.go | 11 +++++++++ 6 files changed, 77 insertions(+), 15 deletions(-) diff --git a/go.mod b/go.mod index 453b1a1..8f4108b 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,8 @@ require ( forge.lthn.ai/core/go-log v0.0.4 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 + github.com/charmbracelet/x/ansi v0.11.6 + github.com/mattn/go-runewidth v0.0.21 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 golang.org/x/term v0.41.0 @@ -19,7 +21,6 @@ require ( forge.lthn.ai/core/go-inference v0.1.7 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect @@ -30,7 +31,6 @@ require ( github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.21 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect diff --git a/go.sum b/go.sum index 7085683..c4c9fe2 100644 --- a/go.sum +++ b/go.sum @@ -6,10 +6,13 @@ forge.lthn.ai/core/go-i18n v0.1.7 h1:aHkAoc3W8fw3RPNvw/UszQbjyFWXHszzbZgty3SwyAA forge.lthn.ai/core/go-i18n v0.1.7/go.mod h1:0VDjwtY99NSj2iqwrI09h5GUsJeM9s48MLkr+/Dn4G8= forge.lthn.ai/core/go-inference v0.1.7 h1:9Dy6v03jX5ZRH3n5iTzlYyGtucuBIgSe+S7GWvBzx9Q= forge.lthn.ai/core/go-inference v0.1.7/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw= +forge.lthn.ai/core/go-io v0.1.6/go.mod h1:3MSuQZuzhCi6aefECQ/LxhM8ooVLam1KgEvgeEjYZVc= forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0= forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= @@ -20,10 +23,12 @@ github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -31,6 +36,7 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -63,6 +69,7 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= @@ -70,6 +77,8 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJu go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= @@ -78,6 +87,9 @@ golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/pkg/cli/styles.go b/pkg/cli/styles.go index 3813b1a..9ee9880 100644 --- a/pkg/cli/styles.go +++ b/pkg/cli/styles.go @@ -5,6 +5,9 @@ import ( "fmt" "strings" "time" + + "github.com/charmbracelet/x/ansi" + "github.com/mattn/go-runewidth" ) // Tailwind colour palette (hex strings) @@ -69,21 +72,53 @@ var ( // Truncate shortens a string to max length with ellipsis. func Truncate(s string, max int) string { - if len(s) <= max { + if max <= 0 || s == "" { + return "" + } + if displayWidth(s) <= max { return s } if max <= 3 { - return s[:max] + return truncateByWidth(s, max) } - return s[:max-3] + "..." + return truncateByWidth(s, max-3) + "..." } // Pad right-pads a string to width. func Pad(s string, width int) string { - if len(s) >= width { + if displayWidth(s) >= width { return s } - return s + strings.Repeat(" ", width-len(s)) + return s + strings.Repeat(" ", width-displayWidth(s)) +} + +func displayWidth(s string) int { + return runewidth.StringWidth(ansi.Strip(s)) +} + +func truncateByWidth(s string, max int) string { + if max <= 0 || s == "" { + return "" + } + + plain := ansi.Strip(s) + if displayWidth(plain) <= max { + return plain + } + + var ( + width int + out strings.Builder + ) + for _, r := range plain { + rw := runewidth.RuneWidth(r) + if width+rw > max { + break + } + out.WriteRune(r) + width += rw + } + return out.String() } // FormatAge formats a time as human-readable age (e.g., "2h ago", "3d ago"). @@ -249,14 +284,16 @@ func (t *Table) columnWidths() []int { widths := make([]int, cols) for i, h := range t.Headers { - if len(h) > widths[i] { - widths[i] = len(h) + if w := displayWidth(h); w > widths[i] { + widths[i] = w } } for _, row := range t.Rows { for i, cell := range row { - if i < cols && len(cell) > widths[i] { - widths[i] = len(cell) + if i < cols { + if w := displayWidth(cell); w > widths[i] { + widths[i] = w + } } } } diff --git a/pkg/cli/styles_test.go b/pkg/cli/styles_test.go index 0ac02bc..8adae78 100644 --- a/pkg/cli/styles_test.go +++ b/pkg/cli/styles_test.go @@ -198,9 +198,11 @@ func TestTruncate_Good(t *testing.T) { assert.Equal(t, "hel...", Truncate("hello world", 6)) assert.Equal(t, "hi", Truncate("hi", 6)) assert.Equal(t, "he", Truncate("hello", 2)) + assert.Equal(t, "東", Truncate("東京", 3)) } func TestPad_Good(t *testing.T) { assert.Equal(t, "hi ", Pad("hi", 5)) assert.Equal(t, "hello", Pad("hello", 3)) + assert.Equal(t, "東京 ", Pad("東京", 6)) } diff --git a/pkg/cli/tracker.go b/pkg/cli/tracker.go index c64c2e7..c89a17f 100644 --- a/pkg/cli/tracker.go +++ b/pkg/cli/tracker.go @@ -244,7 +244,7 @@ func (tr *TaskTracker) renderLine(idx, frame int) { styledStatus = DimStyle.Render(status) } - fmt.Fprintf(tr.out, "\033[2K%s %-*s %s\n", icon, nameW, name, styledStatus) + fmt.Fprintf(tr.out, "\033[2K%s %s %s\n", icon, Pad(name, nameW), styledStatus) } func (tr *TaskTracker) nameWidth() int { @@ -252,8 +252,8 @@ func (tr *TaskTracker) nameWidth() int { defer tr.mu.Unlock() w := 0 for _, t := range tr.tasks { - if len(t.name) > w { - w = len(t.name) + if nameW := displayWidth(t.name); nameW > w { + w = nameW } } return w @@ -313,7 +313,7 @@ func (tr *TaskTracker) String() string { case taskRunning: icon = "⠋" } - fmt.Fprintf(&sb, "%s %-*s %s\n", icon, nameW, name, status) + fmt.Fprintf(&sb, "%s %s %s\n", icon, Pad(name, nameW), status) } return sb.String() } diff --git a/pkg/cli/tracker_test.go b/pkg/cli/tracker_test.go index df16a8b..6f4a813 100644 --- a/pkg/cli/tracker_test.go +++ b/pkg/cli/tracker_test.go @@ -135,6 +135,17 @@ func TestTaskTracker_Good(t *testing.T) { assert.Equal(t, 19, w) }) + t.Run("name width counts visible width", func(t *testing.T) { + tr := NewTaskTracker() + tr.out = &bytes.Buffer{} + + tr.Add("東京") + tr.Add("repo") + + w := tr.nameWidth() + assert.Equal(t, 4, w) + }) + t.Run("String output format", func(t *testing.T) { tr := NewTaskTracker() tr.out = &bytes.Buffer{}