From 2f0b19dc0f5648ee3bfe956b85e1f75fb5d07c4c Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Thu, 12 Mar 2026 18:33:58 +0200 Subject: [PATCH] Prevent early wrapping for styled TUI messages --- internal/ui/app.go | 9 ++++++- internal/ui/app_test.go | 27 +++++++++++++++++++++ internal/ui/components/message.go | 40 +++++++++++++++++++++++++++---- 3 files changed, 71 insertions(+), 5 deletions(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index cef685d..8fe5b04 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -27,6 +27,7 @@ type styledLine struct { text string highlight bool secondary bool + message *output.MessageEvent } type App struct { @@ -141,7 +142,8 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.spinner, _ = a.spinner.Stop() return a, nil case output.MessageEvent: - line := styledLine{text: components.RenderMessage(msg)} + msgCopy := msg + line := styledLine{text: components.RenderMessage(msg), message: &msgCopy} if a.spinner.PendingStop() { a.bufferedLines = append(a.bufferedLines, line) } else { @@ -314,6 +316,11 @@ func (a App) View() string { } for _, line := range a.lines { + if line.message != nil { + sb.WriteString(components.RenderWrappedMessage(*line.message, a.width)) + sb.WriteString("\n") + continue + } if line.highlight { if isURL(line.text) { wrapped := strings.Split(wrap.HardWrap(line.text, a.width), "\n") diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index 6d3a582..0949fa2 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -8,8 +8,10 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/exp/teatest" "github.com/localstack/lstk/internal/output" + "github.com/muesli/termenv" ) func TestAppAddsFormattedLinesInOrder(t *testing.T) { @@ -179,6 +181,31 @@ func TestAppMessageEventRendering(t *testing.T) { } } +func TestAppMessageEventWrapsOnVisibleWidth(t *testing.T) { + t.Parallel() + + original := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.TrueColor) + t.Cleanup(func() { lipgloss.SetColorProfile(original) }) + + app := NewApp("dev", "", "", nil) + app.width = 40 + + model, _ := app.Update(output.MessageEvent{ + Severity: output.SeverityNote, + Text: "LocalStack is still running in the background", + }) + app = model.(App) + + view := app.View() + if !strings.Contains(view, "background") { + t.Fatalf("expected wrapped message to include full word, got: %q", view) + } + if strings.Contains(view, "backg\nround") { + t.Fatalf("expected message to wrap at word boundary, got: %q", view) + } +} + func TestAppErrorEventStopsSpinner(t *testing.T) { t.Parallel() diff --git a/internal/ui/components/message.go b/internal/ui/components/message.go index 9ba2da6..48d01fa 100644 --- a/internal/ui/components/message.go +++ b/internal/ui/components/message.go @@ -1,20 +1,52 @@ package components import ( + "strings" + "github.com/localstack/lstk/internal/output" "github.com/localstack/lstk/internal/ui/styles" + "github.com/localstack/lstk/internal/ui/wrap" ) func RenderMessage(e output.MessageEvent) string { + return RenderWrappedMessage(e, 0) +} + +func RenderWrappedMessage(e output.MessageEvent, width int) string { + prefixText, prefix := messagePrefix(e) + if prefixText == "" { + return styles.Message.Render(strings.Join(wrap.SoftWrap(e.Text, width), "\n")) + } + + if width <= len([]rune(prefixText))+1 { + return prefix + " " + styles.Message.Render(e.Text) + } + + availableWidth := width - len([]rune(prefixText)) - 1 + lines := wrap.SoftWrap(e.Text, availableWidth) + if len(lines) == 0 { + return prefix + } + + indent := strings.Repeat(" ", len([]rune(prefixText))) + rendered := make([]string, 0, len(lines)) + rendered = append(rendered, prefix+" "+styles.Message.Render(lines[0])) + for _, line := range lines[1:] { + rendered = append(rendered, styles.Secondary.Render(indent)+" "+styles.Message.Render(line)) + } + return strings.Join(rendered, "\n") +} + +func messagePrefix(e output.MessageEvent) (string, string) { prefix := styles.Secondary.Render("> ") switch e.Severity { case output.SeveritySuccess: - return prefix + styles.Success.Render("Success:") + " " + styles.Message.Render(e.Text) + return "> Success:", prefix + styles.Success.Render("Success:") case output.SeverityNote: - return prefix + styles.Note.Render("Note:") + " " + styles.Message.Render(e.Text) + return "> Note:", prefix + styles.Note.Render("Note:") case output.SeverityWarning: - return prefix + styles.Warning.Render("Warning:") + " " + styles.Message.Render(e.Text) + return "> Warning:", prefix + styles.Warning.Render("Warning:") default: - return styles.Message.Render(e.Text) + return "", "" } }