diff --git a/internal/ui/app.go b/internal/ui/app.go index 361f2a91..bd39b00c 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -45,6 +45,7 @@ type styledLine struct { highlight bool secondary bool message *output.MessageEvent + logLine *output.LogLineEvent } type App struct { @@ -252,8 +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 + " | ") - a.addLine(styledLine{text: prefix + renderLogLine(msg.Line, msg.Level)}) + logCopy := msg + a.addLine(styledLine{logLine: &logCopy}) return a, nil case output.ContainerStatusEvent: if msg.Phase == "pulling" { @@ -514,6 +515,11 @@ func (a App) View() string { sb.WriteString("\n") continue } + if line.logLine != nil { + sb.WriteString(renderLogLineEvent(*line.logLine, 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 489b1158..ce38e495 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) { @@ -307,10 +308,6 @@ 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) }) - app := NewApp("dev", "", "", nil) app.width = 40 @@ -398,6 +395,30 @@ func TestAppDeferredNoteStyledLikeStatus(t *testing.T) { } } +func TestAppLogLineEventWrapsAtTerminalWidth(t *testing.T) { + t.Parallel() + + 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) + } + } +} + func TestAppErrorEventStopsSpinner(t *testing.T) { t.Parallel() diff --git a/internal/ui/logrender.go b/internal/ui/logrender.go index e3a29bfb..cbfed43f 100644 --- a/internal/ui/logrender.go +++ b/internal/ui/logrender.go @@ -3,11 +3,45 @@ 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" ) -func renderLogLine(line string, level output.LogLevel) string { +// 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) + } + + 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 +51,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..eb371544 --- /dev/null +++ b/internal/ui/logrender_test.go @@ -0,0 +1,101 @@ +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`) + +func stripANSI(s string) string { + return ansiRegexp.ReplaceAllString(s, "") +} + +func TestRenderLogLineWrapsPlainTextAtAvailableWidth(t *testing.T) { + t.Parallel() + + 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) { + t.Parallel() + + 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) { + // 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) + 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) { + t.Parallel() + + 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) { + t.Parallel() + + 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