Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions internal/ui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ type styledLine struct {
highlight bool
secondary bool
message *output.MessageEvent
logLine *output.LogLineEvent
}

type App struct {
Expand Down Expand Up @@ -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" {
Expand Down Expand Up @@ -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")
Expand Down
29 changes: 25 additions & 4 deletions internal/ui/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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()

Expand Down
75 changes: 74 additions & 1 deletion internal/ui/logrender.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down
101 changes: 101 additions & 0 deletions internal/ui/logrender_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 4 additions & 0 deletions internal/ui/testdata/log_line_event_wrap.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
container | abcdefgh
ijklmnop
qrstuvwx
yz
22 changes: 22 additions & 0 deletions openspec/changes/logs-follow-full-terminal-width/proposal.md
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: can you remove this spec? We don't usually add any of those. Only Peter commited a couple when he worked on bigger features, but this is just a small bug fix.

Loading