From afc1c4ff96792f8b3de278d2fab679793f7a3d03 Mon Sep 17 00:00:00 2001 From: Jim Wilkinson <5576978+jw2@users.noreply.github.com> Date: Fri, 10 Apr 2026 13:11:42 +0100 Subject: [PATCH 1/3] Wrap logs correctly at terminal width --- internal/ui/app.go | 28 ++++- internal/ui/app_test.go | 31 ++++- internal/ui/logrender.go | 65 ++++++++++- internal/ui/logrender_test.go | 109 ++++++++++++++++++ .../ui/testdata/log_line_event_wrap.golden | 4 + .../proposal.md | 22 ++++ 6 files changed, 248 insertions(+), 11 deletions(-) create mode 100644 internal/ui/logrender_test.go create mode 100644 internal/ui/testdata/log_line_event_wrap.golden create mode 100644 openspec/changes/logs-follow-full-terminal-width/proposal.md diff --git a/internal/ui/app.go b/internal/ui/app.go index 361f2a91..46325a24 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -10,6 +10,7 @@ import ( "github.com/charmbracelet/bubbles/progress" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/localstack/lstk/internal/output" "github.com/localstack/lstk/internal/ui/components" "github.com/localstack/lstk/internal/ui/styles" @@ -41,10 +42,11 @@ func headerTick() tea.Cmd { } type styledLine struct { - text string - highlight bool - secondary bool - message *output.MessageEvent + text string + highlight bool + secondary bool + preWrapped bool + message *output.MessageEvent } type App struct { @@ -253,7 +255,18 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, nil case output.LogLineEvent: prefix := styles.Secondary.Render(msg.Source + " | ") - a.addLine(styledLine{text: prefix + renderLogLine(msg.Line, msg.Level)}) + prefixWidth := lipgloss.Width(prefix) + availableWidth := 0 + if a.width > 0 { + availableWidth = a.width - prefixWidth + if availableWidth < 0 { + availableWidth = 0 + } + } + a.addLine(styledLine{ + text: prefix + renderLogLine(msg.Line, msg.Level, availableWidth, prefixWidth), + preWrapped: true, + }) return a, nil case output.ContainerStatusEvent: if msg.Phase == "pulling" { @@ -514,6 +527,11 @@ func (a App) View() string { sb.WriteString("\n") continue } + if line.preWrapped { + sb.WriteString(line.text) + 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 489b1158..bdd6ef92 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -14,6 +14,7 @@ import ( "github.com/localstack/lstk/internal/ui/components" "github.com/localstack/lstk/internal/ui/styles" "github.com/muesli/termenv" + "gotest.tools/v3/golden" ) func TestAppAddsFormattedLinesInOrder(t *testing.T) { @@ -305,11 +306,7 @@ func TestAppSnapshotLoadedEventRendersGreen(t *testing.T) { } func TestAppMessageEventWrapsOnVisibleWidth(t *testing.T) { - t.Parallel() - - original := lipgloss.ColorProfile() - lipgloss.SetColorProfile(termenv.TrueColor) - t.Cleanup(func() { lipgloss.SetColorProfile(original) }) + withTrueColorProfile(t) app := NewApp("dev", "", "", nil) app.width = 40 @@ -395,6 +392,30 @@ func TestAppDeferredNoteStyledLikeStatus(t *testing.T) { } if strings.HasPrefix(out, "\n") { t.Fatalf("note should not be padded with a leading blank line, got %q", out) + } +} + +func TestAppLogLineEventWrapsAtTerminalWidth(t *testing.T) { + withTrueColorProfile(t) + + app := NewApp("dev", "", "", nil, withoutHeader()) + model, _ := app.Update(tea.WindowSizeMsg{Width: 20, Height: 10}) + app = model.(App) + + model, _ = app.Update(output.LogLineEvent{ + Source: "container", + Line: "abcdefghijklmnopqrstuvwxyz", + Level: output.LogLevelInfo, + }) + app = model.(App) + + view := stripANSI(app.View()) + golden.Assert(t, view, "log_line_event_wrap.golden") + + for _, line := range strings.Split(strings.TrimSuffix(view, "\n"), "\n") { + if lipgloss.Width(line) > 20 { + t.Fatalf("expected wrapped log line to fit width 20, got %q", line) + } } } diff --git a/internal/ui/logrender.go b/internal/ui/logrender.go index e3a29bfb..1e4e51ae 100644 --- a/internal/ui/logrender.go +++ b/internal/ui/logrender.go @@ -5,9 +5,33 @@ import ( "github.com/localstack/lstk/internal/output" "github.com/localstack/lstk/internal/ui/styles" + "github.com/localstack/lstk/internal/ui/wrap" ) -func renderLogLine(line string, level output.LogLevel) string { +func renderLogLine(line string, level output.LogLevel, availableWidth int, continuationIndent int) string { + if availableWidth <= 0 { + return renderStyledLogLine(line, level) + } + + indent := strings.Repeat(" ", continuationIndent) + logicalLines := strings.Split(line, "\n") + rendered := make([]string, 0, len(logicalLines)) + firstOutputLine := true + + for _, logicalLine := range logicalLines { + for _, part := range renderWrappedLogLine(logicalLine, level, availableWidth) { + if !firstOutputLine { + part = indent + part + } + rendered = append(rendered, part) + firstOutputLine = false + } + } + + return strings.Join(rendered, "\n") +} + +func renderStyledLogLine(line string, level output.LogLevel) string { idx := strings.Index(line, " : ") if idx < 0 { return renderLogMessage(line, level) @@ -17,6 +41,45 @@ func renderLogLine(line string, level output.LogLevel) string { return styles.Secondary.Render(meta) + renderLogMessage(msg, level) } +func renderWrappedLogLine(line string, level output.LogLevel, availableWidth int) []string { + idx := strings.Index(line, " : ") + if idx < 0 { + return wrapStyledLogParts("", line, level, availableWidth) + } + + meta := line[:idx+3] // up to and including " : " + msg := line[idx+3:] + return wrapStyledLogParts(meta, msg, level, availableWidth) +} + +func wrapStyledLogParts(meta string, msg string, level output.LogLevel, availableWidth int) []string { + plain := meta + msg + if plain == "" { + return []string{""} + } + + metaRemaining := len([]rune(meta)) + parts := strings.Split(wrap.HardWrap(plain, availableWidth), "\n") + wrapped := make([]string, 0, len(parts)) + + for _, part := range parts { + partRunes := []rune(part) + metaCount := min(len(partRunes), metaRemaining) + var sb strings.Builder + + if metaCount > 0 { + sb.WriteString(styles.Secondary.Render(string(partRunes[:metaCount]))) + metaRemaining -= metaCount + } + if metaCount < len(partRunes) { + sb.WriteString(renderLogMessage(string(partRunes[metaCount:]), level)) + } + wrapped = append(wrapped, sb.String()) + } + + return wrapped +} + func renderLogMessage(s string, level output.LogLevel) string { switch level { case output.LogLevelWarn: diff --git a/internal/ui/logrender_test.go b/internal/ui/logrender_test.go new file mode 100644 index 00000000..e5ecfbdc --- /dev/null +++ b/internal/ui/logrender_test.go @@ -0,0 +1,109 @@ +package ui + +import ( + "regexp" + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/localstack/lstk/internal/output" + "github.com/localstack/lstk/internal/ui/styles" + "github.com/localstack/lstk/internal/ui/wrap" + "github.com/muesli/termenv" +) + +var ansiRegexp = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +// withTrueColorProfile forces a TrueColor profile so styling assertions are +// deterministic. It mutates the global lipgloss color profile, so callers must +// not run in parallel: a sibling's cleanup restoring the default profile +// mid-run would strip the colors this test depends on. +func withTrueColorProfile(t *testing.T) { + t.Helper() + + original := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.TrueColor) + t.Cleanup(func() { lipgloss.SetColorProfile(original) }) +} + +func stripANSI(s string) string { + return ansiRegexp.ReplaceAllString(s, "") +} + +func TestRenderLogLineWrapsPlainTextAtAvailableWidth(t *testing.T) { + withTrueColorProfile(t) + + got := stripANSI(renderLogLine("abcdefghij", output.LogLevelInfo, 4, 0)) + want := "abcd\nefgh\nij" + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestRenderLogLineUsesVisiblePrefixWidth(t *testing.T) { + withTrueColorProfile(t) + + prefix := styles.Secondary.Render("container | ") + prefixWidth := lipgloss.Width(prefix) + + got := stripANSI(prefix + renderLogLine("abcdefghijklmnop", output.LogLevelInfo, 20-prefixWidth, prefixWidth)) + lines := strings.Split(got, "\n") + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d: %q", len(lines), got) + } + if lines[0] != "container | abcdefgh" { + t.Fatalf("unexpected first line: %q", lines[0]) + } + if lines[1] != " ijklmnop" { + t.Fatalf("unexpected continuation line: %q", lines[1]) + } +} + +func TestRenderLogLineWrapsMetaAndMessage(t *testing.T) { + withTrueColorProfile(t) + + line := "abcd : WXYZ" + got := renderLogLine(line, output.LogLevelWarn, 4, 0) + want := wrap.HardWrap(line, 4) + + if stripANSI(got) != want { + t.Fatalf("expected %q, got %q", want, stripANSI(got)) + } + + lines := strings.Split(got, "\n") + if len(lines) != 3 { + t.Fatalf("expected 3 wrapped lines, got %d: %q", len(lines), got) + } + if lines[0] != styles.Secondary.Render("abcd") { + t.Fatalf("unexpected first line styling: %q", lines[0]) + } + if lines[1] != styles.Secondary.Render(" : ")+styles.Warning.Render("W") { + t.Fatalf("unexpected mixed meta/message styling: %q", lines[1]) + } + if lines[2] != styles.Warning.Render("XYZ") { + t.Fatalf("unexpected final message styling: %q", lines[2]) + } +} + +func TestRenderLogLineIndentsContinuationLines(t *testing.T) { + withTrueColorProfile(t) + + got := stripANSI(renderLogLine("abcdefghij", output.LogLevelInfo, 4, 3)) + want := "abcd\n efgh\n ij" + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestRenderLogLineZeroWidthPassesThrough(t *testing.T) { + withTrueColorProfile(t) + + got := stripANSI(renderLogLine("meta : payload", output.LogLevelWarn, 0, 12)) + want := "meta : payload" + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } + if strings.Contains(got, "\n") { + t.Fatalf("expected zero-width passthrough, got %q", got) + } +} diff --git a/internal/ui/testdata/log_line_event_wrap.golden b/internal/ui/testdata/log_line_event_wrap.golden new file mode 100644 index 00000000..435b43fe --- /dev/null +++ b/internal/ui/testdata/log_line_event_wrap.golden @@ -0,0 +1,4 @@ +container | abcdefgh + ijklmnop + qrstuvwx + yz diff --git a/openspec/changes/logs-follow-full-terminal-width/proposal.md b/openspec/changes/logs-follow-full-terminal-width/proposal.md new file mode 100644 index 00000000..4999bd20 --- /dev/null +++ b/openspec/changes/logs-follow-full-terminal-width/proposal.md @@ -0,0 +1,22 @@ +## Why + +`lstk logs --follow` renders log lines narrower than the terminal because `HardWrap` counts ANSI escape code characters as visible width. Styled prefixes (e.g. `styles.Secondary.Render("container | ")`) embed invisible escape sequences that are included in the rune count, so wrapping fires before the visible content reaches the terminal edge. + +## What Changes + +- Log lines are wrapped using ANSI-aware width calculation so they fill the full terminal width. +- The `renderLogLine` rendering path in `app.go` is updated to measure visible prefix width and wrap the message content independently before re-combining with the styled prefix. + +## Capabilities + +### New Capabilities + +- `logs-full-width-rendering`: Log lines in `--follow` mode wrap at the correct terminal width by accounting for invisible ANSI sequences in styled prefixes. + +### Modified Capabilities + +## Impact + +- `internal/ui/logrender.go` — `renderLogLine` signature or callers updated to accept a `width` parameter +- `internal/ui/app.go` — `output.LogLineEvent` handler uses ANSI-aware wrapping +- `internal/ui/wrap/` — may need a new ANSI-aware wrap helper From 0866a3029032d0fc8a658f0a97639da712059fe3 Mon Sep 17 00:00:00 2001 From: carole-lavillonniere Date: Mon, 6 Jul 2026 15:41:35 +0200 Subject: [PATCH 2/3] refactor: render streamed log lines lazily in view via logLine field instead of preWrapped flag --- internal/ui/app.go | 30 +++++++++--------------------- internal/ui/logrender.go | 10 ++++++++++ 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index 46325a24..bd39b00c 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -10,7 +10,6 @@ import ( "github.com/charmbracelet/bubbles/progress" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/localstack/lstk/internal/output" "github.com/localstack/lstk/internal/ui/components" "github.com/localstack/lstk/internal/ui/styles" @@ -42,11 +41,11 @@ func headerTick() tea.Cmd { } type styledLine struct { - text string - highlight bool - secondary bool - preWrapped bool - message *output.MessageEvent + text string + highlight bool + secondary bool + message *output.MessageEvent + logLine *output.LogLineEvent } type App struct { @@ -254,19 +253,8 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return a, nil case output.LogLineEvent: - prefix := styles.Secondary.Render(msg.Source + " | ") - prefixWidth := lipgloss.Width(prefix) - availableWidth := 0 - if a.width > 0 { - availableWidth = a.width - prefixWidth - if availableWidth < 0 { - availableWidth = 0 - } - } - a.addLine(styledLine{ - text: prefix + renderLogLine(msg.Line, msg.Level, availableWidth, prefixWidth), - preWrapped: true, - }) + logCopy := msg + a.addLine(styledLine{logLine: &logCopy}) return a, nil case output.ContainerStatusEvent: if msg.Phase == "pulling" { @@ -527,8 +515,8 @@ func (a App) View() string { sb.WriteString("\n") continue } - if line.preWrapped { - sb.WriteString(line.text) + if line.logLine != nil { + sb.WriteString(renderLogLineEvent(*line.logLine, a.width)) sb.WriteString("\n") continue } diff --git a/internal/ui/logrender.go b/internal/ui/logrender.go index 1e4e51ae..cbfed43f 100644 --- a/internal/ui/logrender.go +++ b/internal/ui/logrender.go @@ -3,11 +3,21 @@ package ui import ( "strings" + "github.com/charmbracelet/lipgloss" "github.com/localstack/lstk/internal/output" "github.com/localstack/lstk/internal/ui/styles" "github.com/localstack/lstk/internal/ui/wrap" ) +// renderLogLineEvent renders a streamed log line — the styled "source | " prefix +// followed by the wrapped, continuation-indented body — to fit the given width. +func renderLogLineEvent(ev output.LogLineEvent, width int) string { + prefix := styles.Secondary.Render(ev.Source + " | ") + prefixWidth := lipgloss.Width(prefix) + availableWidth := max(0, width-prefixWidth) + return prefix + renderLogLine(ev.Line, ev.Level, availableWidth, prefixWidth) +} + func renderLogLine(line string, level output.LogLevel, availableWidth int, continuationIndent int) string { if availableWidth <= 0 { return renderStyledLogLine(line, level) From 3ccee90192e7d2c9b5786648e58009a078b50cd4 Mon Sep 17 00:00:00 2001 From: carole-lavillonniere Date: Mon, 6 Jul 2026 16:22:57 +0200 Subject: [PATCH 3/3] test: parallelize log-render wrap tests, keep only the styled-output assertion serial --- internal/ui/app_test.go | 6 +++--- internal/ui/logrender_test.go | 26 +++++++++----------------- 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index bdd6ef92..ce38e495 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -306,7 +306,7 @@ func TestAppSnapshotLoadedEventRendersGreen(t *testing.T) { } func TestAppMessageEventWrapsOnVisibleWidth(t *testing.T) { - withTrueColorProfile(t) + t.Parallel() app := NewApp("dev", "", "", nil) app.width = 40 @@ -392,11 +392,11 @@ func TestAppDeferredNoteStyledLikeStatus(t *testing.T) { } if strings.HasPrefix(out, "\n") { t.Fatalf("note should not be padded with a leading blank line, got %q", out) - } + } } func TestAppLogLineEventWrapsAtTerminalWidth(t *testing.T) { - withTrueColorProfile(t) + t.Parallel() app := NewApp("dev", "", "", nil, withoutHeader()) model, _ := app.Update(tea.WindowSizeMsg{Width: 20, Height: 10}) diff --git a/internal/ui/logrender_test.go b/internal/ui/logrender_test.go index e5ecfbdc..eb371544 100644 --- a/internal/ui/logrender_test.go +++ b/internal/ui/logrender_test.go @@ -14,24 +14,12 @@ import ( var ansiRegexp = regexp.MustCompile(`\x1b\[[0-9;]*m`) -// withTrueColorProfile forces a TrueColor profile so styling assertions are -// deterministic. It mutates the global lipgloss color profile, so callers must -// not run in parallel: a sibling's cleanup restoring the default profile -// mid-run would strip the colors this test depends on. -func withTrueColorProfile(t *testing.T) { - t.Helper() - - original := lipgloss.ColorProfile() - lipgloss.SetColorProfile(termenv.TrueColor) - t.Cleanup(func() { lipgloss.SetColorProfile(original) }) -} - func stripANSI(s string) string { return ansiRegexp.ReplaceAllString(s, "") } func TestRenderLogLineWrapsPlainTextAtAvailableWidth(t *testing.T) { - withTrueColorProfile(t) + t.Parallel() got := stripANSI(renderLogLine("abcdefghij", output.LogLevelInfo, 4, 0)) want := "abcd\nefgh\nij" @@ -41,7 +29,7 @@ func TestRenderLogLineWrapsPlainTextAtAvailableWidth(t *testing.T) { } func TestRenderLogLineUsesVisiblePrefixWidth(t *testing.T) { - withTrueColorProfile(t) + t.Parallel() prefix := styles.Secondary.Render("container | ") prefixWidth := lipgloss.Width(prefix) @@ -60,7 +48,11 @@ func TestRenderLogLineUsesVisiblePrefixWidth(t *testing.T) { } func TestRenderLogLineWrapsMetaAndMessage(t *testing.T) { - withTrueColorProfile(t) + // Asserts on the raw styled output, so it forces a TrueColor profile. That + // mutates the global lipgloss color profile, so it must not run in parallel. + original := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.TrueColor) + t.Cleanup(func() { lipgloss.SetColorProfile(original) }) line := "abcd : WXYZ" got := renderLogLine(line, output.LogLevelWarn, 4, 0) @@ -86,7 +78,7 @@ func TestRenderLogLineWrapsMetaAndMessage(t *testing.T) { } func TestRenderLogLineIndentsContinuationLines(t *testing.T) { - withTrueColorProfile(t) + t.Parallel() got := stripANSI(renderLogLine("abcdefghij", output.LogLevelInfo, 4, 3)) want := "abcd\n efgh\n ij" @@ -96,7 +88,7 @@ func TestRenderLogLineIndentsContinuationLines(t *testing.T) { } func TestRenderLogLineZeroWidthPassesThrough(t *testing.T) { - withTrueColorProfile(t) + t.Parallel() got := stripANSI(renderLogLine("meta : payload", output.LogLevelWarn, 0, 12)) want := "meta : payload"