소스 검색

Fixed progress UI to adapt to terminal width

Signed-off-by: Nicolas De Loof <[email protected]>
Nicolas De Loof 1 주 전
부모
커밋
c8d687599a
2개의 변경된 파일682개의 추가작업 그리고 73개의 파일을 삭제
  1. 258 73
      cmd/display/tty.go
  2. 424 0
      cmd/display/tty_test.go

+ 258 - 73
cmd/display/tty.go

@@ -21,9 +21,11 @@ import (
 	"fmt"
 	"io"
 	"iter"
+	"slices"
 	"strings"
 	"sync"
 	"time"
+	"unicode/utf8"
 
 	"github.com/buger/goterm"
 	"github.com/docker/go-units"
@@ -258,13 +260,39 @@ func (w *ttyWriter) childrenTasks(parent string) iter.Seq[*task] {
 	}
 }
 
+// lineData holds pre-computed formatting for a task line
+type lineData struct {
+	spinner     string // rendered spinner with color
+	prefix      string // dry-run prefix if any
+	taskID      string // possibly abbreviated
+	progress    string // progress bar and size info
+	status      string // rendered status with color
+	details     string // possibly abbreviated
+	timer       string // rendered timer with color
+	statusPad   int    // padding before status to align
+	timerPad    int    // padding before timer to align
+	statusColor colorFunc
+}
+
 func (w *ttyWriter) print() {
+	terminalWidth := goterm.Width()
+	terminalHeight := goterm.Height()
+	if terminalWidth <= 0 {
+		terminalWidth = 80
+	}
+	if terminalHeight <= 0 {
+		terminalHeight = 24
+	}
+	w.printWithDimensions(terminalWidth, terminalHeight)
+}
+
+func (w *ttyWriter) printWithDimensions(terminalWidth, terminalHeight int) {
 	w.mtx.Lock()
 	defer w.mtx.Unlock()
 	if len(w.tasks) == 0 {
 		return
 	}
-	terminalWidth := goterm.Width()
+
 	up := w.numLines + 1
 	if !w.repeated {
 		up--
@@ -283,39 +311,208 @@ func (w *ttyWriter) print() {
 	firstLine := fmt.Sprintf("[+] %s %d/%d", w.operation, numDone(w.tasks), len(w.tasks))
 	_, _ = fmt.Fprintln(w.out, firstLine)
 
-	var statusPadding int
-	for _, t := range w.tasks {
-		l := len(t.ID)
-		if len(t.parents) == 0 && statusPadding < l {
-			statusPadding = l
+	// Collect parent tasks in original order
+	allTasks := slices.Collect(w.parentTasks())
+
+	// Available lines: terminal height - 2 (header line + potential "more" line)
+	maxLines := terminalHeight - 2
+	if maxLines < 1 {
+		maxLines = 1
+	}
+
+	showMore := len(allTasks) > maxLines
+	tasksToShow := allTasks
+	if showMore {
+		tasksToShow = allTasks[:maxLines-1] // Reserve one line for "more" message
+	}
+
+	// collect line data and compute timerLen
+	lines := make([]lineData, len(tasksToShow))
+	var timerLen int
+	for i, t := range tasksToShow {
+		lines[i] = w.prepareLineData(t)
+		if len(lines[i].timer) > timerLen {
+			timerLen = len(lines[i].timer)
 		}
 	}
 
-	skipChildEvents := len(w.tasks) > goterm.Height()-2
+	// shorten details/taskID to fit terminal width
+	w.adjustLineWidth(lines, timerLen, terminalWidth)
+
+	// compute padding
+	w.applyPadding(lines, terminalWidth, timerLen)
+
+	// Render lines
 	numLines := 0
-	for t := range w.parentTasks() {
-		line := w.lineText(t, "", terminalWidth, statusPadding, w.dryRun)
-		_, _ = fmt.Fprint(w.out, line)
+	for _, l := range lines {
+		_, _ = fmt.Fprint(w.out, lineText(l))
 		numLines++
-		if skipChildEvents {
-			continue
-		}
-		for child := range w.childrenTasks(t.ID) {
-			line := w.lineText(child, "  ", terminalWidth, statusPadding-2, w.dryRun)
-			_, _ = fmt.Fprint(w.out, line)
-			numLines++
+	}
+
+	if showMore {
+		moreCount := len(allTasks) - len(tasksToShow)
+		moreText := fmt.Sprintf(" ... %d more", moreCount)
+		pad := terminalWidth - len(moreText)
+		if pad < 0 {
+			pad = 0
 		}
+		_, _ = fmt.Fprintf(w.out, "%s%s\n", moreText, strings.Repeat(" ", pad))
+		numLines++
 	}
+
+	// Clear any remaining lines from previous render
 	for i := numLines; i < w.numLines; i++ {
-		if numLines < goterm.Height()-2 {
-			_, _ = fmt.Fprintln(w.out, strings.Repeat(" ", terminalWidth))
-			numLines++
-		}
+		_, _ = fmt.Fprintln(w.out, strings.Repeat(" ", terminalWidth))
+		numLines++
 	}
 	w.numLines = numLines
 }
 
-func (w *ttyWriter) lineText(t *task, pad string, terminalWidth, statusPadding int, dryRun bool) string {
+func (w *ttyWriter) applyPadding(lines []lineData, terminalWidth int, timerLen int) {
+	var maxBeforeStatus int
+	for i := range lines {
+		l := &lines[i]
+		// Width before statusPad: space(1) + spinner(1) + prefix + space(1) + taskID + progress
+		beforeStatus := 3 + lenAnsi(l.prefix) + utf8.RuneCountInString(l.taskID) + lenAnsi(l.progress)
+		if beforeStatus > maxBeforeStatus {
+			maxBeforeStatus = beforeStatus
+		}
+	}
+
+	for i, l := range lines {
+		// Position before statusPad: space(1) + spinner(1) + prefix + space(1) + taskID + progress
+		beforeStatus := 3 + lenAnsi(l.prefix) + utf8.RuneCountInString(l.taskID) + lenAnsi(l.progress)
+		// statusPad aligns status; lineText adds 1 more space after statusPad
+		l.statusPad = maxBeforeStatus - beforeStatus
+
+		// Format: beforeStatus + statusPad + space(1) + status
+		lineLen := beforeStatus + l.statusPad + 1 + utf8.RuneCountInString(l.status)
+		if l.details != "" {
+			lineLen += 1 + utf8.RuneCountInString(l.details)
+		}
+		l.timerPad = terminalWidth - lineLen - timerLen
+		if l.timerPad < 1 {
+			l.timerPad = 1
+		}
+		lines[i] = l
+
+	}
+}
+
+func (w *ttyWriter) adjustLineWidth(lines []lineData, timerLen int, terminalWidth int) {
+	const minIDLen = 10
+	maxStatusLen := maxStatusLength(lines)
+
+	// Iteratively truncate until all lines fit
+	for range 100 { // safety limit
+		maxBeforeStatus := maxBeforeStatusWidth(lines)
+		overflow := computeOverflow(lines, maxBeforeStatus, maxStatusLen, timerLen, terminalWidth)
+
+		if overflow <= 0 {
+			break
+		}
+
+		// First try to truncate details, then taskID
+		if !truncateDetails(lines, overflow) && !truncateLongestTaskID(lines, overflow, minIDLen) {
+			break // Can't truncate further
+		}
+	}
+}
+
+// maxStatusLength returns the maximum status text length across all lines.
+func maxStatusLength(lines []lineData) int {
+	var maxLen int
+	for i := range lines {
+		if len(lines[i].status) > maxLen {
+			maxLen = len(lines[i].status)
+		}
+	}
+	return maxLen
+}
+
+// maxBeforeStatusWidth computes the maximum width before statusPad across all lines.
+// This is: space(1) + spinner(1) + prefix + space(1) + taskID + progress
+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)
+		if width > maxWidth {
+			maxWidth = width
+		}
+	}
+	return maxWidth
+}
+
+// computeOverflow calculates how many characters the widest line exceeds the terminal width.
+// Returns 0 or negative if all lines fit.
+func computeOverflow(lines []lineData, maxBeforeStatus, maxStatusLen, timerLen, terminalWidth int) int {
+	var maxOverflow int
+	for i := range lines {
+		l := &lines[i]
+		detailsLen := len(l.details)
+		if detailsLen > 0 {
+			detailsLen++ // space before details
+		}
+		// Line width: maxBeforeStatus + space(1) + status + details + minTimerPad(1) + timer
+		lineWidth := maxBeforeStatus + 1 + maxStatusLen + detailsLen + 1 + timerLen
+		overflow := lineWidth - terminalWidth
+		if overflow > maxOverflow {
+			maxOverflow = overflow
+		}
+	}
+	return maxOverflow
+}
+
+// truncateDetails tries to truncate the first line's details to reduce overflow.
+// Returns true if any truncation was performed.
+func truncateDetails(lines []lineData, overflow int) bool {
+	for i := range lines {
+		l := &lines[i]
+		if len(l.details) > 3 {
+			reduction := overflow
+			if reduction > len(l.details)-3 {
+				reduction = len(l.details) - 3
+			}
+			l.details = l.details[:len(l.details)-reduction-3] + "..."
+			return true
+		} else if l.details != "" {
+			l.details = ""
+			return true
+		}
+	}
+	return false
+}
+
+// truncateLongestTaskID truncates the longest taskID to reduce overflow.
+// Returns true if truncation was performed.
+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)
+			longestIdx = i
+		}
+	}
+
+	if longestIdx < 0 {
+		return false
+	}
+
+	l := &lines[longestIdx]
+	reduction := overflow + 3 // account for "..."
+	newLen := len(l.taskID) - reduction
+	if newLen < minIDLen-3 {
+		newLen = minIDLen - 3
+	}
+	if newLen > 0 {
+		l.taskID = l.taskID[:newLen] + "..."
+	}
+	return true
+}
+
+func (w *ttyWriter) prepareLineData(t *task) lineData {
 	endTime := time.Now()
 	if t.status != api.Working {
 		endTime = t.startTime
@@ -323,8 +520,9 @@ func (w *ttyWriter) lineText(t *task, pad string, terminalWidth, statusPadding i
 			endTime = t.endTime
 		}
 	}
+
 	prefix := ""
-	if dryRun {
+	if w.dryRun {
 		prefix = PrefixColor(DRYRUN_PREFIX)
 	}
 
@@ -338,11 +536,9 @@ func (w *ttyWriter) lineText(t *task, pad string, terminalWidth, statusPadding i
 	)
 
 	// only show the aggregated progress while the root operation is in-progress
-	if parent := t; parent.status == api.Working {
-		for child := range w.childrenTasks(parent.ID) {
+	if t.status == api.Working {
+		for child := range w.childrenTasks(t.ID) {
 			if child.status == api.Working && child.total == 0 {
-				// we don't have totals available for all the child events
-				// so don't show the total progress yet
 				hideDetails = true
 			}
 			total += child.total
@@ -356,49 +552,49 @@ func (w *ttyWriter) lineText(t *task, pad string, terminalWidth, statusPadding i
 		}
 	}
 
-	// don't try to show detailed progress if we don't have any idea
 	if total == 0 {
 		hideDetails = true
 	}
 
-	txt := t.ID
+	var progress string
 	if len(completion) > 0 {
-		var progress string
+		progress = " [" + SuccessColor(strings.Join(completion, "")) + "]"
 		if !hideDetails {
-			progress = fmt.Sprintf(" %7s / %-7s", units.HumanSize(float64(current)), units.HumanSize(float64(total)))
+			progress += fmt.Sprintf(" %7s / %-7s", units.HumanSize(float64(current)), units.HumanSize(float64(total)))
 		}
-		txt = fmt.Sprintf("%s [%s]%s",
-			t.ID,
-			SuccessColor(strings.Join(completion, "")),
-			progress,
-		)
-	}
-	textLen := len(txt)
-	padding := statusPadding - textLen
-	if padding < 0 {
-		padding = 0
-	}
-	// calculate the max length for the status text, on errors it
-	// is 2-3 lines long and breaks the line formatting
-	maxDetailsLen := terminalWidth - textLen - statusPadding - 15
-	details := t.details
-	// in some cases (debugging under VS Code), terminalWidth is set to zero by goterm.Width() ; ensuring we don't tweak strings with negative char index
-	if maxDetailsLen > 0 && len(details) > maxDetailsLen {
-		details = details[:maxDetailsLen] + "..."
-	}
-	text := fmt.Sprintf("%s %s%s %s %s%s %s",
-		pad,
-		spinner(t),
-		prefix,
-		txt,
-		strings.Repeat(" ", padding),
-		colorFn(t.status)(t.text),
-		details,
-	)
-	timer := fmt.Sprintf("%.1fs ", elapsed)
-	o := align(text, TimerColor(timer), terminalWidth)
+	}
+
+	return lineData{
+		spinner:     spinner(t),
+		prefix:      prefix,
+		taskID:      t.ID,
+		progress:    progress,
+		status:      t.text,
+		statusColor: colorFn(t.status),
+		details:     t.details,
+		timer:       fmt.Sprintf("%.1fs", elapsed),
+	}
+}
 
-	return o
+func lineText(l lineData) string {
+	var sb strings.Builder
+	sb.WriteString(" ")
+	sb.WriteString(l.spinner)
+	sb.WriteString(l.prefix)
+	sb.WriteString(" ")
+	sb.WriteString(l.taskID)
+	sb.WriteString(l.progress)
+	sb.WriteString(strings.Repeat(" ", l.statusPad))
+	sb.WriteString(" ")
+	sb.WriteString(l.statusColor(l.status))
+	if l.details != "" {
+		sb.WriteString(" ")
+		sb.WriteString(l.details)
+	}
+	sb.WriteString(strings.Repeat(" ", l.timerPad))
+	sb.WriteString(TimerColor(l.timer))
+	sb.WriteString("\n")
+	return sb.String()
 }
 
 var (
@@ -443,17 +639,6 @@ func numDone(tasks map[string]*task) int {
 	return i
 }
 
-func align(l, r string, w int) string {
-	ll := lenAnsi(l)
-	lr := lenAnsi(r)
-	pad := ""
-	count := w - ll - lr
-	if count > 0 {
-		pad = strings.Repeat(" ", count)
-	}
-	return fmt.Sprintf("%s%s%s\n", l, pad, r)
-}
-
 // lenAnsi count of user-perceived characters in ANSI string.
 func lenAnsi(s string) int {
 	length := 0

+ 424 - 0
cmd/display/tty_test.go

@@ -0,0 +1,424 @@
+/*
+   Copyright 2020 Docker Compose CLI authors
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+*/
+
+package display
+
+import (
+	"bytes"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+	"unicode/utf8"
+
+	"gotest.tools/v3/assert"
+
+	"github.com/docker/compose/v5/pkg/api"
+)
+
+func newTestWriter() (*ttyWriter, *bytes.Buffer) {
+	var buf bytes.Buffer
+	w := &ttyWriter{
+		out:       &buf,
+		info:      &buf,
+		tasks:     map[string]*task{},
+		done:      make(chan bool),
+		mtx:       &sync.Mutex{},
+		operation: "pull",
+	}
+	return w, &buf
+}
+
+func addTask(w *ttyWriter, id, text, details string, status api.EventStatus) {
+	t := &task{
+		ID:        id,
+		parents:   make(map[string]struct{}),
+		startTime: time.Now(),
+		text:      text,
+		details:   details,
+		status:    status,
+		spinner:   NewSpinner(),
+	}
+	w.tasks[id] = t
+	w.ids = append(w.ids, id)
+}
+
+// extractLines parses the output buffer and returns lines without ANSI control sequences
+func extractLines(buf *bytes.Buffer) []string {
+	content := buf.String()
+	// Split by newline
+	rawLines := strings.Split(content, "\n")
+	var lines []string
+	for _, line := range rawLines {
+		// Skip empty lines and lines that are just ANSI codes
+		if lenAnsi(line) > 0 {
+			lines = append(lines, line)
+		}
+	}
+	return lines
+}
+
+func TestPrintWithDimensions_LinesFitTerminalWidth(t *testing.T) {
+	testCases := []struct {
+		name          string
+		taskID        string
+		status        string
+		details       string
+		terminalWidth int
+	}{
+		{
+			name:          "short task fits wide terminal",
+			taskID:        "Image foo",
+			status:        "Pulling",
+			details:       "layer abc123",
+			terminalWidth: 100,
+		},
+		{
+			name:          "long details truncated to fit",
+			taskID:        "Image foo",
+			status:        "Pulling",
+			details:       "downloading layer sha256:abc123def456789xyz0123456789abcdef",
+			terminalWidth: 50,
+		},
+		{
+			name:          "long taskID truncated to fit",
+			taskID:        "very-long-image-name-that-exceeds-terminal-width",
+			status:        "Pulling",
+			details:       "",
+			terminalWidth: 40,
+		},
+		{
+			name:          "both long taskID and details",
+			taskID:        "my-very-long-service-name-here",
+			status:        "Downloading",
+			details:       "layer sha256:abc123def456789xyz0123456789",
+			terminalWidth: 50,
+		},
+		{
+			name:          "narrow terminal",
+			taskID:        "service-name",
+			status:        "Pulling",
+			details:       "some details",
+			terminalWidth: 35,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			w, buf := newTestWriter()
+			addTask(w, tc.taskID, tc.status, tc.details, api.Working)
+
+			w.printWithDimensions(tc.terminalWidth, 24)
+
+			lines := extractLines(buf)
+			for i, line := range lines {
+				lineLen := lenAnsi(line)
+				assert.Assert(t, lineLen <= tc.terminalWidth,
+					"line %d has length %d which exceeds terminal width %d: %q",
+					i, lineLen, tc.terminalWidth, line)
+			}
+		})
+	}
+}
+
+func TestPrintWithDimensions_MultipleTasksFitTerminalWidth(t *testing.T) {
+	w, buf := newTestWriter()
+
+	// Add multiple tasks with varying lengths
+	addTask(w, "Image nginx", "Pulling", "layer sha256:abc123", api.Working)
+	addTask(w, "Image postgres-database", "Pulling", "downloading", api.Working)
+	addTask(w, "Image redis", "Pulled", "", api.Done)
+
+	terminalWidth := 60
+	w.printWithDimensions(terminalWidth, 24)
+
+	lines := extractLines(buf)
+	for i, line := range lines {
+		lineLen := lenAnsi(line)
+		assert.Assert(t, lineLen <= terminalWidth,
+			"line %d has length %d which exceeds terminal width %d: %q",
+			i, lineLen, terminalWidth, line)
+	}
+}
+
+func TestPrintWithDimensions_VeryNarrowTerminal(t *testing.T) {
+	w, buf := newTestWriter()
+	addTask(w, "Image nginx", "Pulling", "details", api.Working)
+
+	terminalWidth := 30
+	w.printWithDimensions(terminalWidth, 24)
+
+	lines := extractLines(buf)
+	for i, line := range lines {
+		lineLen := lenAnsi(line)
+		assert.Assert(t, lineLen <= terminalWidth,
+			"line %d has length %d which exceeds terminal width %d: %q",
+			i, lineLen, terminalWidth, line)
+	}
+}
+
+func TestPrintWithDimensions_TaskWithProgress(t *testing.T) {
+	w, buf := newTestWriter()
+
+	// Create parent task
+	parent := &task{
+		ID:        "Image nginx",
+		parents:   make(map[string]struct{}),
+		startTime: time.Now(),
+		text:      "Pulling",
+		status:    api.Working,
+		spinner:   NewSpinner(),
+	}
+	w.tasks["Image nginx"] = parent
+	w.ids = append(w.ids, "Image nginx")
+
+	// Create child tasks to trigger progress display
+	for i := 0; i < 3; i++ {
+		child := &task{
+			ID:        "layer" + string(rune('a'+i)),
+			parents:   map[string]struct{}{"Image nginx": {}},
+			startTime: time.Now(),
+			text:      "Downloading",
+			status:    api.Working,
+			total:     1000,
+			current:   500,
+			percent:   50,
+			spinner:   NewSpinner(),
+		}
+		w.tasks[child.ID] = child
+		w.ids = append(w.ids, child.ID)
+	}
+
+	terminalWidth := 80
+	w.printWithDimensions(terminalWidth, 24)
+
+	lines := extractLines(buf)
+	for i, line := range lines {
+		lineLen := lenAnsi(line)
+		assert.Assert(t, lineLen <= terminalWidth,
+			"line %d has length %d which exceeds terminal width %d: %q",
+			i, lineLen, terminalWidth, line)
+	}
+}
+
+func TestAdjustLineWidth_DetailsCorrectlyTruncated(t *testing.T) {
+	w := &ttyWriter{}
+	lines := []lineData{
+		{
+			taskID:  "Image foo",
+			status:  "Pulling",
+			details: "downloading layer sha256:abc123def456789xyz",
+		},
+	}
+
+	terminalWidth := 50
+	timerLen := 5
+	w.adjustLineWidth(lines, timerLen, terminalWidth)
+
+	// Verify the line fits
+	detailsLen := len(lines[0].details)
+	if detailsLen > 0 {
+		detailsLen++ // space before details
+	}
+	// widthWithoutDetails = 5 + prefix(0) + taskID(9) + progress(0) + status(7) + timer(5) = 26
+	lineWidth := 5 + len(lines[0].taskID) + len(lines[0].status) + detailsLen + timerLen
+
+	assert.Assert(t, lineWidth <= terminalWidth,
+		"line width %d should not exceed terminal width %d (taskID=%q, details=%q)",
+		lineWidth, terminalWidth, lines[0].taskID, lines[0].details)
+
+	// Verify details were truncated (not removed entirely)
+	assert.Assert(t, lines[0].details != "", "details should be truncated, not removed")
+	assert.Assert(t, strings.HasSuffix(lines[0].details, "..."), "truncated details should end with ...")
+}
+
+func TestAdjustLineWidth_TaskIDCorrectlyTruncated(t *testing.T) {
+	w := &ttyWriter{}
+	lines := []lineData{
+		{
+			taskID:  "very-long-image-name-that-exceeds-minimum-length",
+			status:  "Pulling",
+			details: "",
+		},
+	}
+
+	terminalWidth := 40
+	timerLen := 5
+	w.adjustLineWidth(lines, timerLen, terminalWidth)
+
+	lineWidth := 5 + len(lines[0].taskID) + 7 + timerLen
+
+	assert.Assert(t, lineWidth <= terminalWidth,
+		"line width %d should not exceed terminal width %d (taskID=%q)",
+		lineWidth, terminalWidth, lines[0].taskID)
+
+	assert.Assert(t, strings.HasSuffix(lines[0].taskID, "..."), "truncated taskID should end with ...")
+}
+
+func TestAdjustLineWidth_NoTruncationNeeded(t *testing.T) {
+	w := &ttyWriter{}
+	originalDetails := "short"
+	originalTaskID := "Image foo"
+	lines := []lineData{
+		{
+			taskID:  originalTaskID,
+			status:  "Pulling",
+			details: originalDetails,
+		},
+	}
+
+	// Wide terminal, nothing should be truncated
+	w.adjustLineWidth(lines, 5, 100)
+
+	assert.Equal(t, originalTaskID, lines[0].taskID, "taskID should not be modified")
+	assert.Equal(t, originalDetails, lines[0].details, "details should not be modified")
+}
+
+func TestAdjustLineWidth_DetailsRemovedWhenTooShort(t *testing.T) {
+	w := &ttyWriter{}
+	lines := []lineData{
+		{
+			taskID:  "Image foo",
+			status:  "Pulling",
+			details: "abc", // Very short, can't be meaningfully truncated
+		},
+	}
+
+	// Terminal so narrow that even minimal details + "..." wouldn't help
+	w.adjustLineWidth(lines, 5, 28)
+
+	assert.Equal(t, "", lines[0].details, "details should be removed entirely when too short to truncate")
+}
+
+// stripAnsi removes ANSI escape codes from a string
+func stripAnsi(s string) string {
+	var result strings.Builder
+	inAnsi := false
+	for _, r := range s {
+		if r == '\x1b' {
+			inAnsi = true
+			continue
+		}
+		if inAnsi {
+			// ANSI sequences end with a letter (m, h, l, G, etc.)
+			if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') {
+				inAnsi = false
+			}
+			continue
+		}
+		result.WriteRune(r)
+	}
+	return result.String()
+}
+
+func TestPrintWithDimensions_PulledAndPullingWithLongIDs(t *testing.T) {
+	w, buf := newTestWriter()
+
+	// Add a completed task with long ID
+	completedTask := &task{
+		ID:        "Image docker.io/library/nginx-long-name",
+		parents:   make(map[string]struct{}),
+		startTime: time.Now().Add(-2 * time.Second),
+		endTime:   time.Now(),
+		text:      "Pulled",
+		status:    api.Done,
+		spinner:   NewSpinner(),
+	}
+	completedTask.spinner.Stop()
+	w.tasks[completedTask.ID] = completedTask
+	w.ids = append(w.ids, completedTask.ID)
+
+	// Add a pending task with long ID
+	pendingTask := &task{
+		ID:        "Image docker.io/library/postgres-database",
+		parents:   make(map[string]struct{}),
+		startTime: time.Now(),
+		text:      "Pulling",
+		status:    api.Working,
+		spinner:   NewSpinner(),
+	}
+	w.tasks[pendingTask.ID] = pendingTask
+	w.ids = append(w.ids, pendingTask.ID)
+
+	terminalWidth := 50
+	w.printWithDimensions(terminalWidth, 24)
+
+	// Strip all ANSI codes from output and split by newline
+	stripped := stripAnsi(buf.String())
+	lines := strings.Split(stripped, "\n")
+
+	// Filter non-empty lines
+	var nonEmptyLines []string
+	for _, line := range lines {
+		if strings.TrimSpace(line) != "" {
+			nonEmptyLines = append(nonEmptyLines, line)
+		}
+	}
+
+	// Expected output format (50 runes per task line)
+	expected := `[+] pull 1/2
+ ✔ Image docker.io/library/nginx-l... Pulled  2.0s
+ ⠋ Image docker.io/library/postgre... Pulling 0.0s`
+
+	expectedLines := strings.Split(expected, "\n")
+
+	// Debug output
+	t.Logf("Actual output:\n")
+	for i, line := range nonEmptyLines {
+		t.Logf("  line %d (%2d runes): %q", i, utf8.RuneCountInString(line), line)
+	}
+
+	// Verify number of lines
+	assert.Equal(t, len(expectedLines), len(nonEmptyLines), "number of lines should match")
+
+	// Verify each line matches expected
+	for i, line := range nonEmptyLines {
+		if i < len(expectedLines) {
+			assert.Equal(t, expectedLines[i], line,
+				"line %d should match expected", i)
+		}
+	}
+
+	// Verify task lines fit within terminal width (strict - no tolerance)
+	for i, line := range nonEmptyLines {
+		if i > 0 { // Skip header line
+			runeCount := utf8.RuneCountInString(line)
+			assert.Assert(t, runeCount <= terminalWidth,
+				"line %d has %d runes which exceeds terminal width %d: %q",
+				i, runeCount, terminalWidth, line)
+		}
+	}
+}
+
+func TestLenAnsi(t *testing.T) {
+	testCases := []struct {
+		input    string
+		expected int
+	}{
+		{"hello", 5},
+		{"\x1b[32mhello\x1b[0m", 5},
+		{"\x1b[1;32mgreen\x1b[0m text", 10},
+		{"", 0},
+		{"\x1b[0m", 0},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.input, func(t *testing.T) {
+			result := lenAnsi(tc.input)
+			assert.Equal(t, tc.expected, result)
+		})
+	}
+}