diff --git a/cmd/display/tty.go b/cmd/display/tty.go index 7285b2be9..609096925 100644 --- a/cmd/display/tty.go +++ b/cmd/display/tty.go @@ -449,7 +449,7 @@ func maxBeforeStatusWidth(lines []lineData) int { var maxWidth int for i := range lines { l := &lines[i] - width := 3 + lenAnsi(l.prefix) + len(l.taskID) + lenAnsi(l.progress) + width := 3 + lenAnsi(l.prefix) + utf8.RuneCountInString(l.taskID) + lenAnsi(l.progress) if width > maxWidth { maxWidth = width } @@ -478,18 +478,29 @@ func computeOverflow(lines []lineData, maxBeforeStatus, maxStatusLen, timerLen, } // truncateProgressSize drops the trailing "X.XMB / Y.YMB" size info from the -// first line whose progress still carries it. Returns true if any line was -// modified. Used to recover from overflow without abbreviating the taskID. +// line currently driving maxBeforeStatusWidth — only that line's shrink can +// reduce overflow. Returns true if any line was modified. func truncateProgressSize(lines []lineData) bool { + maxIdx := -1 + var maxWidth int for i := range lines { l := &lines[i] - if l.progressSizeBytes > 0 { - l.progress = l.progress[:len(l.progress)-l.progressSizeBytes] - l.progressSizeBytes = 0 - return true + if l.progressSizeBytes == 0 { + continue + } + w := lenAnsi(l.prefix) + utf8.RuneCountInString(l.taskID) + lenAnsi(l.progress) + if maxIdx < 0 || w > maxWidth { + maxWidth = w + maxIdx = i } } - return false + if maxIdx < 0 { + return false + } + l := &lines[maxIdx] + l.progress = l.progress[:len(l.progress)-l.progressSizeBytes] + l.progressSizeBytes = 0 + return true } // truncateDetails tries to truncate the first line's details to reduce overflow. @@ -510,13 +521,14 @@ func truncateDetails(lines []lineData, overflow int) bool { } // truncateLongestTaskID truncates the longest taskID to reduce overflow. -// Returns true if truncation was performed. +// Returns true if truncation was performed. Lengths and slicing are in runes +// to avoid emitting invalid UTF-8 when taskID contains multi-byte chars. func truncateLongestTaskID(lines []lineData, overflow, minIDLen int) bool { longestIdx := -1 longestLen := minIDLen for i := range lines { - if len(lines[i].taskID) > longestLen { - longestLen = len(lines[i].taskID) + if utf8.RuneCountInString(lines[i].taskID) > longestLen { + longestLen = utf8.RuneCountInString(lines[i].taskID) longestIdx = i } } @@ -527,10 +539,9 @@ func truncateLongestTaskID(lines []lineData, overflow, minIDLen int) bool { l := &lines[longestIdx] reduction := overflow + 3 // account for "..." - newLen := max(len(l.taskID)-reduction, minIDLen-3) - if newLen > 0 { - l.taskID = l.taskID[:newLen] + "..." - } + newLen := max(longestLen-reduction, minIDLen-3) + runes := []rune(l.taskID) + l.taskID = string(runes[:newLen]) + "..." return true } diff --git a/cmd/display/tty_test.go b/cmd/display/tty_test.go index f5dd67442..c6d6165c3 100644 --- a/cmd/display/tty_test.go +++ b/cmd/display/tty_test.go @@ -270,6 +270,69 @@ func TestAdjustLineWidth_TaskIDCorrectlyTruncated(t *testing.T) { assert.Assert(t, strings.HasSuffix(lines[0].taskID, "..."), "truncated taskID should end with ...") } +// TestAdjustLineWidth_MultiByteTaskIDFits guards against drift between +// applyPadding (rune-based) and maxBeforeStatusWidth (formerly byte-based): +// a byte-based measurement falsely flags overflow for multi-byte taskIDs. +func TestAdjustLineWidth_MultiByteTaskIDFits(t *testing.T) { + w := &ttyWriter{} + taskID := "Image 测试测试" // 10 runes, 18 bytes + lines := []lineData{{ + taskID: taskID, + status: "Pulling", + }} + + // terminalWidth=30 fits in runes (3+10+1+7+1+4 = 26) but not in bytes + // (3+18+1+7+1+4 = 34), so a byte-based measurement would truncate. + w.adjustLineWidth(lines, 4, 30) + + assert.Equal(t, taskID, lines[0].taskID, + "taskID should not be modified when it fits terminal width in runes") +} + +// TestTruncateLongestTaskID_PreservesValidUTF8 verifies that when truncation +// of a multi-byte UTF-8 taskID is genuinely required, the resulting string +// remains valid UTF-8. Byte-indexed slicing can land mid-rune and emit +// replacement characters (�) into the rendered output. +func TestTruncateLongestTaskID_PreservesValidUTF8(t *testing.T) { + taskID := "Image 测试测试测试测试" // 14 runes, 30 bytes + lines := []lineData{{taskID: taskID}} + + truncateLongestTaskID(lines, 8, 10) + + assert.Assert(t, utf8.ValidString(lines[0].taskID), + "truncated taskID must remain valid UTF-8, got %q", lines[0].taskID) + assert.Assert(t, strings.HasSuffix(lines[0].taskID, "..."), + "truncated taskID should end with ..., got %q", lines[0].taskID) +} + +// TestTruncateProgressSize_PicksWidestLine verifies that dropping the size +// suffix targets the line currently driving maxBeforeStatusWidth (the only +// line whose shrink can reduce overflow), preserving size info on narrower +// lines that are not the bottleneck. +func TestTruncateProgressSize_PicksWidestLine(t *testing.T) { + narrowSuffix := " 5MB / 10MB" + wideSuffix := " 50MB / 100MB" + lines := []lineData{ + { + taskID: "Image short", + progress: " [⣿⣿]" + narrowSuffix, + progressSizeBytes: len(narrowSuffix), + }, + { + taskID: "Image very-long-named-task", + progress: " [⣿⣿⣿⣿⣿⣿⣿⣿]" + wideSuffix, + progressSizeBytes: len(wideSuffix), + }, + } + + truncateProgressSize(lines) + + assert.Equal(t, 0, lines[1].progressSizeBytes, + "widest line should lose its size suffix first") + assert.Equal(t, len(narrowSuffix), lines[0].progressSizeBytes, + "narrower line should retain its size suffix") +} + func TestAdjustLineWidth_NoTruncationNeeded(t *testing.T) { w := &ttyWriter{} originalDetails := "short"