From 5450f04413ee90ef564919d0e4b03446e6081163 Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Wed, 4 Mar 2026 01:49:20 +0200 Subject: [PATCH 1/3] Improve container engine error --- internal/ui/app.go | 3 +- internal/ui/components/error_display.go | 63 +++++++++++++++++--- internal/ui/components/error_display_test.go | 6 +- 3 files changed, 60 insertions(+), 12 deletions(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index 7b12393..b95f8d5 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -328,8 +328,7 @@ func (a App) View() string { sb.WriteString("\n") } - if errorView := a.errorDisplay.View(); errorView != "" { - sb.WriteString("\n") + if errorView := a.errorDisplay.View(contentWidth); errorView != "" { sb.WriteString(errorView) } diff --git a/internal/ui/components/error_display.go b/internal/ui/components/error_display.go index ee1079f..ece2e33 100644 --- a/internal/ui/components/error_display.go +++ b/internal/ui/components/error_display.go @@ -26,7 +26,7 @@ func (e ErrorDisplay) Visible() bool { return e.visible } -func (e ErrorDisplay) View() string { +func (e ErrorDisplay) View(maxWidth int) string { if !e.visible || e.event == nil { return "" } @@ -37,9 +37,18 @@ func (e ErrorDisplay) View() string { sb.WriteString("\n") if e.event.Summary != "" { - sb.WriteString(styles.Secondary.Render("> ")) - sb.WriteString(styles.Message.Render(e.event.Summary)) - sb.WriteString("\n") + prefix := "> " + summaryWidth := maxWidth - len(prefix) + lines := softWrap(e.event.Summary, summaryWidth) + for i, line := range lines { + if i == 0 { + sb.WriteString(styles.Secondary.Render(prefix)) + } else { + sb.WriteString(strings.Repeat(" ", len(prefix))) + } + sb.WriteString(styles.Message.Render(line)) + sb.WriteString("\n") + } } if e.event.Detail != "" { @@ -50,12 +59,52 @@ func (e ErrorDisplay) View() string { if len(e.event.Actions) > 0 { sb.WriteString("\n") - for _, action := range e.event.Actions { - sb.WriteString(styles.ErrorAction.Render("⇒ " + action.Label + " ")) - sb.WriteString(styles.Message.Bold(true).Render(action.Value)) + for i, action := range e.event.Actions { + if i > 0 { + sb.WriteString(styles.SecondaryMessage.Render("⇒ " + action.Label + " " + action.Value)) + } else { + sb.WriteString(styles.ErrorAction.Render("⇒ " + action.Label + " ")) + sb.WriteString(styles.Message.Bold(true).Render(action.Value)) + } sb.WriteString("\n") } } return sb.String() } + +// softWrap breaks text into lines at word boundaries, falling back to hard +// breaks for words longer than maxWidth. +func softWrap(text string, maxWidth int) []string { + if maxWidth <= 0 || len(text) <= maxWidth { + return []string{text} + } + + words := strings.Fields(text) + if len(words) == 0 { + return []string{text} + } + + var lines []string + var current strings.Builder + + for _, word := range words { + if current.Len() == 0 { + current.WriteString(word) + continue + } + if current.Len()+1+len(word) > maxWidth { + lines = append(lines, current.String()) + current.Reset() + current.WriteString(word) + } else { + current.WriteByte(' ') + current.WriteString(word) + } + } + if current.Len() > 0 { + lines = append(lines, current.String()) + } + + return lines +} diff --git a/internal/ui/components/error_display_test.go b/internal/ui/components/error_display_test.go index 9094fdf..733f780 100644 --- a/internal/ui/components/error_display_test.go +++ b/internal/ui/components/error_display_test.go @@ -15,7 +15,7 @@ func TestErrorDisplay_ShowView(t *testing.T) { t.Fatal("expected error display to be hidden initially") } - if e.View() != "" { + if e.View(80) != "" { t.Fatal("expected empty view when error display is hidden") } @@ -32,7 +32,7 @@ func TestErrorDisplay_ShowView(t *testing.T) { t.Fatal("expected error display to be visible after Show") } - view := e.View() + view := e.View(80) if !strings.Contains(view, "Connection failed") { t.Fatalf("expected view to contain title, got: %q", view) } @@ -56,7 +56,7 @@ func TestErrorDisplay_MinimalEvent(t *testing.T) { e := NewErrorDisplay() e = e.Show(output.ErrorEvent{Title: "Something went wrong"}) - view := e.View() + view := e.View(80) if !strings.Contains(view, "Something went wrong") { t.Fatalf("expected view to contain title, got: %q", view) } From 46f368ed87c67d6bf5c1f479d47f952aead600b5 Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Wed, 4 Mar 2026 22:42:36 +0200 Subject: [PATCH 2/3] Extract hardWrap and softWrap --- internal/ui/app.go | 29 +---- internal/ui/app_test.go | 10 -- internal/ui/components/error_display.go | 38 +----- internal/ui/wrap/wrap.go | 80 +++++++++++++ internal/ui/wrap/wrap_test.go | 147 ++++++++++++++++++++++++ 5 files changed, 235 insertions(+), 69 deletions(-) create mode 100644 internal/ui/wrap/wrap.go create mode 100644 internal/ui/wrap/wrap_test.go diff --git a/internal/ui/app.go b/internal/ui/app.go index b95f8d5..ed0e67c 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -10,6 +10,7 @@ import ( "github.com/localstack/lstk/internal/output" "github.com/localstack/lstk/internal/ui/components" "github.com/localstack/lstk/internal/ui/styles" + "github.com/localstack/lstk/internal/ui/wrap" ) const maxLines = 200 @@ -251,24 +252,6 @@ func formatResolvedInput(req output.UserInputRequestEvent, selectedKey string) s const lineIndent = 2 -func hardWrap(s string, maxWidth int) string { - rs := []rune(s) - if maxWidth <= 0 || len(rs) <= maxWidth { - return s - } - var sb strings.Builder - for i := 0; i < len(rs); i += maxWidth { - if i > 0 { - sb.WriteByte('\n') - } - end := i + maxWidth - if end > len(rs) { - end = len(rs) - } - sb.WriteString(string(rs[i:end])) - } - return sb.String() -} func isURL(s string) bool { return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") @@ -290,7 +273,7 @@ func (a App) View() string { for _, line := range a.lines { if line.highlight { if isURL(line.text) { - wrapped := strings.Split(hardWrap(line.text, contentWidth), "\n") + wrapped := strings.Split(wrap.HardWrap(line.text, contentWidth), "\n") var styledParts []string for _, part := range wrapped { styledParts = append(styledParts, styles.Link.Render(part)) @@ -299,22 +282,22 @@ func (a App) View() string { sb.WriteString(hyperlink(line.text, strings.Join(styledParts, "\n"+indent))) } else { sb.WriteString(indent) - sb.WriteString(styles.Highlight.Render(hardWrap(line.text, contentWidth))) + sb.WriteString(styles.Highlight.Render(wrap.HardWrap(line.text, contentWidth))) } sb.WriteString("\n\n") continue } else if line.secondary { if strings.HasPrefix(line.text, ">") { - sb.WriteString(styles.SecondaryMessage.Render(hardWrap(line.text, contentWidth))) + sb.WriteString(styles.SecondaryMessage.Render(wrap.HardWrap(line.text, contentWidth))) sb.WriteString("\n\n") continue } sb.WriteString(indent) - text := hardWrap(line.text, contentWidth) + text := wrap.HardWrap(line.text, contentWidth) sb.WriteString(styles.SecondaryMessage.Render(text)) } else { sb.WriteString(indent) - text := hardWrap(line.text, contentWidth) + text := wrap.HardWrap(line.text, contentWidth) sb.WriteString(text) } sb.WriteString("\n") diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index d25b83c..a003aa2 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -272,16 +272,6 @@ func TestAppEnterDoesNothingWithoutExplicitEnterOption(t *testing.T) { } } -func TestHardWrapHandlesUTF8Runes(t *testing.T) { - t.Parallel() - - got := hardWrap("A🙂BC", 2) - want := "A🙂\nBC" - if got != want { - t.Fatalf("expected %q, got %q", want, got) - } -} - func TestAppAnyKeyOptionResolvesOnAnyKeypress(t *testing.T) { t.Parallel() diff --git a/internal/ui/components/error_display.go b/internal/ui/components/error_display.go index ece2e33..390eed4 100644 --- a/internal/ui/components/error_display.go +++ b/internal/ui/components/error_display.go @@ -5,6 +5,7 @@ import ( "github.com/localstack/lstk/internal/output" "github.com/localstack/lstk/internal/ui/styles" + "github.com/localstack/lstk/internal/ui/wrap" ) type ErrorDisplay struct { @@ -39,7 +40,7 @@ func (e ErrorDisplay) View(maxWidth int) string { if e.event.Summary != "" { prefix := "> " summaryWidth := maxWidth - len(prefix) - lines := softWrap(e.event.Summary, summaryWidth) + lines := wrap.SoftWrap(e.event.Summary, summaryWidth) for i, line := range lines { if i == 0 { sb.WriteString(styles.Secondary.Render(prefix)) @@ -73,38 +74,3 @@ func (e ErrorDisplay) View(maxWidth int) string { return sb.String() } -// softWrap breaks text into lines at word boundaries, falling back to hard -// breaks for words longer than maxWidth. -func softWrap(text string, maxWidth int) []string { - if maxWidth <= 0 || len(text) <= maxWidth { - return []string{text} - } - - words := strings.Fields(text) - if len(words) == 0 { - return []string{text} - } - - var lines []string - var current strings.Builder - - for _, word := range words { - if current.Len() == 0 { - current.WriteString(word) - continue - } - if current.Len()+1+len(word) > maxWidth { - lines = append(lines, current.String()) - current.Reset() - current.WriteString(word) - } else { - current.WriteByte(' ') - current.WriteString(word) - } - } - if current.Len() > 0 { - lines = append(lines, current.String()) - } - - return lines -} diff --git a/internal/ui/wrap/wrap.go b/internal/ui/wrap/wrap.go new file mode 100644 index 0000000..830249f --- /dev/null +++ b/internal/ui/wrap/wrap.go @@ -0,0 +1,80 @@ +package wrap + +import "strings" + +// HardWrap inserts newlines so that no output line exceeds maxWidth runes. +// It breaks at exact rune boundaries with no regard for word boundaries. +func HardWrap(s string, maxWidth int) string { + rs := []rune(s) + if maxWidth <= 0 || len(rs) <= maxWidth { + return s + } + var sb strings.Builder + for i := 0; i < len(rs); i += maxWidth { + if i > 0 { + sb.WriteByte('\n') + } + end := i + maxWidth + if end > len(rs) { + end = len(rs) + } + sb.WriteString(string(rs[i:end])) + } + return sb.String() +} + +// SoftWrap breaks text into lines at word boundaries, falling back to hard +// breaks for words longer than maxWidth. +func SoftWrap(text string, maxWidth int) []string { + if maxWidth <= 0 || len(text) <= maxWidth { + return []string{text} + } + + words := strings.Fields(text) + if len(words) == 0 { + return []string{text} + } + + var lines []string + var current strings.Builder + + for _, word := range words { + runes := []rune(word) + if len(runes) > maxWidth { + if current.Len() > 0 { + lines = append(lines, current.String()) + current.Reset() + } + for len(runes) > 0 { + chunk := runes + if len(chunk) > maxWidth { + chunk = runes[:maxWidth] + } + runes = runes[len(chunk):] + if len(runes) > 0 || len(chunk) == maxWidth { + lines = append(lines, string(chunk)) + } else { + current.WriteString(string(chunk)) + } + } + continue + } + if current.Len() == 0 { + current.WriteString(word) + continue + } + if current.Len()+1+len(runes) > maxWidth { + lines = append(lines, current.String()) + current.Reset() + current.WriteString(word) + } else { + current.WriteByte(' ') + current.WriteString(word) + } + } + if current.Len() > 0 { + lines = append(lines, current.String()) + } + + return lines +} diff --git a/internal/ui/wrap/wrap_test.go b/internal/ui/wrap/wrap_test.go new file mode 100644 index 0000000..f32aeb3 --- /dev/null +++ b/internal/ui/wrap/wrap_test.go @@ -0,0 +1,147 @@ +package wrap + +import ( + "strings" + "testing" +) + +func TestHardWrap(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + width int + expected string + }{ + {name: "short string unchanged", input: "hello", width: 10, expected: "hello"}, + {name: "exact width unchanged", input: "abcde", width: 5, expected: "abcde"}, + {name: "breaks at width", input: "abcdef", width: 3, expected: "abc\ndef"}, + {name: "utf8 rune aware", input: "A🙂BC", width: 2, expected: "A🙂\nBC"}, + {name: "zero width returns input", input: "abc", width: 0, expected: "abc"}, + {name: "negative width returns input", input: "abc", width: -1, expected: "abc"}, + {name: "empty string", input: "", width: 5, expected: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := HardWrap(tt.input, tt.width) + if got != tt.expected { + t.Fatalf("HardWrap(%q, %d) = %q, want %q", tt.input, tt.width, got, tt.expected) + } + }) + } +} + +func TestSoftWrap(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + width int + expected []string + }{ + { + name: "short text unchanged", + input: "hello world", + width: 20, + expected: []string{"hello world"}, + }, + { + name: "wraps at word boundary", + input: "hello world foo", + width: 11, + expected: []string{"hello world", "foo"}, + }, + { + name: "long word hard-breaks", + input: "abcdefghij", + width: 4, + expected: []string{"abcd", "efgh", "ij"}, + }, + { + name: "long word followed by short word", + input: "abcdefgh xy", + width: 4, + expected: []string{"abcd", "efgh", "xy"}, + }, + { + name: "short word then long word", + input: "hi abcdefgh", + width: 4, + expected: []string{"hi", "abcd", "efgh"}, + }, + { + name: "long word exact multiple of width", + input: "abcdef", + width: 3, + expected: []string{"abc", "def"}, + }, + { + name: "utf8 rune aware split", + input: "🙂🙂🙂🙂🙂", + width: 2, + expected: []string{"🙂🙂", "🙂🙂", "🙂"}, + }, + { + name: "zero width returns input", + input: "abc", + width: 0, + expected: []string{"abc"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := SoftWrap(tt.input, tt.width) + if len(got) != len(tt.expected) { + t.Fatalf("SoftWrap(%q, %d) returned %d lines %v, want %d lines %v", + tt.input, tt.width, len(got), got, len(tt.expected), tt.expected) + } + for i := range got { + if got[i] != tt.expected[i] { + t.Fatalf("SoftWrap(%q, %d)[%d] = %q, want %q", + tt.input, tt.width, i, got[i], tt.expected[i]) + } + } + }) + } +} + +func TestSoftWrapNoLineExceedsMaxWidth(t *testing.T) { + t.Parallel() + + inputs := []string{ + "short", + "a]very-long-token-without-spaces,here", + "hello world this is a test of soft wrapping behavior", + "🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂", + } + + for _, input := range inputs { + for width := 1; width <= 10; width++ { + lines := SoftWrap(input, width) + for i, line := range lines { + runes := []rune(line) + if len(runes) > width { + t.Errorf("SoftWrap(%q, %d): line %d has %d runes (%q), exceeds maxWidth", + input, width, i, len(runes), line) + } + } + } + } +} + +func TestSoftWrapPreservesContent(t *testing.T) { + t.Parallel() + + input := "hello world foo bar" + lines := SoftWrap(input, 7) + got := strings.Join(lines, " ") + if got != input { + t.Fatalf("rejoined output %q != input %q", got, input) + } +} From 9723f8013bb449f25605367415486a59e146d7b4 Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Wed, 4 Mar 2026 22:53:54 +0200 Subject: [PATCH 3/3] Update wrap methods --- internal/ui/wrap/wrap.go | 24 +++++++++++++++++------- internal/ui/wrap/wrap_test.go | 6 ++++++ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/internal/ui/wrap/wrap.go b/internal/ui/wrap/wrap.go index 830249f..7f68919 100644 --- a/internal/ui/wrap/wrap.go +++ b/internal/ui/wrap/wrap.go @@ -1,6 +1,9 @@ package wrap -import "strings" +import ( + "strings" + "unicode/utf8" +) // HardWrap inserts newlines so that no output line exceeds maxWidth runes. // It breaks at exact rune boundaries with no regard for word boundaries. @@ -26,7 +29,7 @@ func HardWrap(s string, maxWidth int) string { // SoftWrap breaks text into lines at word boundaries, falling back to hard // breaks for words longer than maxWidth. func SoftWrap(text string, maxWidth int) []string { - if maxWidth <= 0 || len(text) <= maxWidth { + if maxWidth <= 0 || utf8.RuneCountInString(text) <= maxWidth { return []string{text} } @@ -37,13 +40,16 @@ func SoftWrap(text string, maxWidth int) []string { var lines []string var current strings.Builder + currentRunes := 0 for _, word := range words { runes := []rune(word) - if len(runes) > maxWidth { - if current.Len() > 0 { + wordRunes := len(runes) + if wordRunes > maxWidth { + if currentRunes > 0 { lines = append(lines, current.String()) current.Reset() + currentRunes = 0 } for len(runes) > 0 { chunk := runes @@ -55,24 +61,28 @@ func SoftWrap(text string, maxWidth int) []string { lines = append(lines, string(chunk)) } else { current.WriteString(string(chunk)) + currentRunes = len(chunk) } } continue } - if current.Len() == 0 { + if currentRunes == 0 { current.WriteString(word) + currentRunes = wordRunes continue } - if current.Len()+1+len(runes) > maxWidth { + if currentRunes+1+wordRunes > maxWidth { lines = append(lines, current.String()) current.Reset() current.WriteString(word) + currentRunes = wordRunes } else { current.WriteByte(' ') current.WriteString(word) + currentRunes += 1 + wordRunes } } - if current.Len() > 0 { + if currentRunes > 0 { lines = append(lines, current.String()) } diff --git a/internal/ui/wrap/wrap_test.go b/internal/ui/wrap/wrap_test.go index f32aeb3..7d460b1 100644 --- a/internal/ui/wrap/wrap_test.go +++ b/internal/ui/wrap/wrap_test.go @@ -85,6 +85,12 @@ func TestSoftWrap(t *testing.T) { width: 2, expected: []string{"🙂🙂", "🙂🙂", "🙂"}, }, + { + name: "multibyte words wrap by rune count", + input: "café résumé", + width: 6, + expected: []string{"café", "résumé"}, + }, { name: "zero width returns input", input: "abc",