Skip to content

Commit 4d1fb51

Browse files
authored
Merge pull request #3736 from dgageot/fix/tui-degenerate-terminal-size-panic
fix(tui): don't panic when the terminal reports a degenerate size
2 parents 4e43638 + 27dcba5 commit 4d1fb51

5 files changed

Lines changed: 269 additions & 12 deletions

File tree

e2e/tui/degenerate_resize_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package tui_test
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/docker/docker-agent/pkg/tui/tuitest"
8+
)
9+
10+
// TestChat_DegenerateResizeDoesNotPanic drives the full TUI through
11+
// degenerate terminal sizes (0x0, 1x1, …) that real terminals and tmux can
12+
// transiently report. Rendering at those sizes used to panic (negative
13+
// strings.Repeat count in the resize handle); the TUI must survive and
14+
// recover once a sane size returns.
15+
func TestChat_DegenerateResizeDoesNotPanic(t *testing.T) {
16+
d := newTUI(t, "testdata/basic.yaml", 120, 40)
17+
18+
d.WaitFor(tuitest.Contains("Type your message here"))
19+
20+
// Submit a prompt first so the busy chrome (working indicator, resize
21+
// handle suffix) can render during the sweep when replay is still
22+
// streaming; the sizes themselves are what must never panic.
23+
d.Type("What's 2+2?").Enter()
24+
for _, size := range [][2]int{{0, 0}, {1, 1}, {2, 2}, {1, 40}, {120, 1}, {3, 3}} {
25+
d.Resize(size[0], size[1])
26+
d.WaitForStable(50 * time.Millisecond)
27+
}
28+
29+
d.Resize(120, 40)
30+
d.WaitFor(tuitest.Contains("2 + 2 equals 4."))
31+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
---
2+
version: 2
3+
interactions:
4+
- id: 0
5+
request:
6+
proto: HTTP/1.1
7+
proto_major: 1
8+
proto_minor: 1
9+
content_length: 0
10+
host: api.openai.com
11+
body: '{"messages":[{"content":"You are a knowledgeable assistant that helps users with various tasks.\nBe helpful, accurate, and concise in your responses.\n","role":"system"},{"content":"What''s 2+2?","role":"user"}],"model":"gpt-3.5-turbo","stream_options":{"include_usage":true},"stream":true}'
12+
url: https://api.openai.com/v1/chat/completions
13+
method: POST
14+
response:
15+
proto: HTTP/2.0
16+
proto_major: 2
17+
proto_minor: 0
18+
content_length: -1
19+
body: |+
20+
data: {"id":"chatcmpl-Cn4xGpdnsbgLIIeH49VdYprjDjc6P","object":"chat.completion.chunk","created":1765813154,"model":"gpt-3.5-turbo-0125","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"31MAoT3T"}
21+
22+
data: {"id":"chatcmpl-Cn4xGpdnsbgLIIeH49VdYprjDjc6P","object":"chat.completion.chunk","created":1765813154,"model":"gpt-3.5-turbo-0125","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"2"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"f989QXFJX"}
23+
24+
data: {"id":"chatcmpl-Cn4xGpdnsbgLIIeH49VdYprjDjc6P","object":"chat.completion.chunk","created":1765813154,"model":"gpt-3.5-turbo-0125","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" +"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"LjplBv32"}
25+
26+
data: {"id":"chatcmpl-Cn4xGpdnsbgLIIeH49VdYprjDjc6P","object":"chat.completion.chunk","created":1765813154,"model":"gpt-3.5-turbo-0125","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" "},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"OS4QsUws6"}
27+
28+
data: {"id":"chatcmpl-Cn4xGpdnsbgLIIeH49VdYprjDjc6P","object":"chat.completion.chunk","created":1765813154,"model":"gpt-3.5-turbo-0125","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"2"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"TW89LuVuk"}
29+
30+
data: {"id":"chatcmpl-Cn4xGpdnsbgLIIeH49VdYprjDjc6P","object":"chat.completion.chunk","created":1765813154,"model":"gpt-3.5-turbo-0125","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" equals"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"vrL"}
31+
32+
data: {"id":"chatcmpl-Cn4xGpdnsbgLIIeH49VdYprjDjc6P","object":"chat.completion.chunk","created":1765813154,"model":"gpt-3.5-turbo-0125","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" "},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"T8WY0RY3s"}
33+
34+
data: {"id":"chatcmpl-Cn4xGpdnsbgLIIeH49VdYprjDjc6P","object":"chat.completion.chunk","created":1765813154,"model":"gpt-3.5-turbo-0125","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"4"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"0kTGMpnIE"}
35+
36+
data: {"id":"chatcmpl-Cn4xGpdnsbgLIIeH49VdYprjDjc6P","object":"chat.completion.chunk","created":1765813154,"model":"gpt-3.5-turbo-0125","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"."},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"1pmLrWtWp"}
37+
38+
data: {"id":"chatcmpl-Cn4xGpdnsbgLIIeH49VdYprjDjc6P","object":"chat.completion.chunk","created":1765813154,"model":"gpt-3.5-turbo-0125","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null,"obfuscation":"Rsle"}
39+
40+
data: {"id":"chatcmpl-Cn4xGpdnsbgLIIeH49VdYprjDjc6P","object":"chat.completion.chunk","created":1765813154,"model":"gpt-3.5-turbo-0125","service_tier":"default","system_fingerprint":null,"choices":[],"usage":{"prompt_tokens":41,"completion_tokens":8,"total_tokens":49,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}},"obfuscation":"FdScpa1UzO"}
41+
42+
data: [DONE]
43+
44+
headers: {}
45+
status: 200 OK
46+
code: 200
47+
duration: 376.601459ms
48+
- id: 1
49+
request:
50+
proto: HTTP/1.1
51+
proto_major: 1
52+
proto_minor: 1
53+
content_length: 0
54+
host: api.openai.com
55+
body: '{"messages":[{"content":"You are a helpful AI assistant that generates concise, descriptive titles for conversations. You will be given up to 2 recent user messages and asked to create a single-line title that captures the main topic. Never use newlines or line breaks in your response.","role":"system"},{"content":"Based on the following recent user messages from a conversation with an AI assistant, generate a short, descriptive title (maximum 50 characters) that captures the main topic or purpose of the conversation. Return ONLY the title text on a single line, nothing else. Do not include any newlines, explanations, or formatting.\n\nRecent user messages:\n1. What''s 2+2?\n\n\n","role":"user"}],"model":"gpt-3.5-turbo","stream_options":{"include_usage":true},"stream":true}'
56+
url: https://api.openai.com/v1/chat/completions
57+
method: POST
58+
response:
59+
proto: HTTP/2.0
60+
proto_major: 2
61+
proto_minor: 0
62+
content_length: -1
63+
body: |+
64+
data: {"id":"chatcmpl-Cn4xGduSFgxtSBIWwGDKiENMSp67u","object":"chat.completion.chunk","created":1765813154,"model":"gpt-3.5-turbo-0125","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"3praRFP3"}
65+
66+
data: {"id":"chatcmpl-Cn4xGduSFgxtSBIWwGDKiENMSp67u","object":"chat.completion.chunk","created":1765813154,"model":"gpt-3.5-turbo-0125","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"Simple"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"0Qwz"}
67+
68+
data: {"id":"chatcmpl-Cn4xGduSFgxtSBIWwGDKiENMSp67u","object":"chat.completion.chunk","created":1765813154,"model":"gpt-3.5-turbo-0125","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" Math"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"j2ifs"}
69+
70+
data: {"id":"chatcmpl-Cn4xGduSFgxtSBIWwGDKiENMSp67u","object":"chat.completion.chunk","created":1765813154,"model":"gpt-3.5-turbo-0125","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":":"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"NiTsH3kiT"}
71+
72+
data: {"id":"chatcmpl-Cn4xGduSFgxtSBIWwGDKiENMSp67u","object":"chat.completion.chunk","created":1765813154,"model":"gpt-3.5-turbo-0125","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" Addition"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"x"}
73+
74+
data: {"id":"chatcmpl-Cn4xGduSFgxtSBIWwGDKiENMSp67u","object":"chat.completion.chunk","created":1765813154,"model":"gpt-3.5-turbo-0125","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" of"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"eHDT8Y6"}
75+
76+
data: {"id":"chatcmpl-Cn4xGduSFgxtSBIWwGDKiENMSp67u","object":"chat.completion.chunk","created":1765813154,"model":"gpt-3.5-turbo-0125","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" "},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"ImGlkQ1UD"}
77+
78+
data: {"id":"chatcmpl-Cn4xGduSFgxtSBIWwGDKiENMSp67u","object":"chat.completion.chunk","created":1765813154,"model":"gpt-3.5-turbo-0125","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"2"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"r0pbd1luX"}
79+
80+
data: {"id":"chatcmpl-Cn4xGduSFgxtSBIWwGDKiENMSp67u","object":"chat.completion.chunk","created":1765813154,"model":"gpt-3.5-turbo-0125","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" and"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"fbgjGd"}
81+
82+
data: {"id":"chatcmpl-Cn4xGduSFgxtSBIWwGDKiENMSp67u","object":"chat.completion.chunk","created":1765813154,"model":"gpt-3.5-turbo-0125","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" "},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"zJqaxx5ri"}
83+
84+
data: {"id":"chatcmpl-Cn4xGduSFgxtSBIWwGDKiENMSp67u","object":"chat.completion.chunk","created":1765813154,"model":"gpt-3.5-turbo-0125","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"2"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"irCJfvsMd"}
85+
86+
data: {"id":"chatcmpl-Cn4xGduSFgxtSBIWwGDKiENMSp67u","object":"chat.completion.chunk","created":1765813154,"model":"gpt-3.5-turbo-0125","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null,"obfuscation":"psUW"}
87+
88+
data: {"id":"chatcmpl-Cn4xGduSFgxtSBIWwGDKiENMSp67u","object":"chat.completion.chunk","created":1765813154,"model":"gpt-3.5-turbo-0125","service_tier":"default","system_fingerprint":null,"choices":[],"usage":{"prompt_tokens":100,"completion_tokens":10,"total_tokens":110,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}},"obfuscation":"3lP94vJ"}
89+
90+
data: [DONE]
91+
92+
headers: {}
93+
status: 200 OK
94+
code: 200
95+
duration: 443.446208ms
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package tui
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
7+
tea "charm.land/bubbletea/v2"
8+
9+
"github.com/docker/docker-agent/pkg/board"
10+
"github.com/docker/docker-agent/pkg/tui/styles"
11+
)
12+
13+
// TestDegenerateSizesDoNotPanic renders the board — with and without every
14+
// dialog — at the degenerate terminal sizes (0x0, 1x1, …) that real
15+
// terminals and tmux can transiently report. Rendering must never panic.
16+
func TestDegenerateSizesDoNotPanic(t *testing.T) {
17+
// Theme colors are only bound after ApplyTheme; not parallel because it
18+
// mutates package-level style state.
19+
styles.ApplyThemeRef(styles.DefaultThemeRef)
20+
21+
newModel := func() *model {
22+
return &model{
23+
columns: []board.Column{{ID: "dev", Name: "Dev"}, {ID: "done", Name: "Done"}},
24+
cards: map[string][]*board.Card{
25+
"dev": {{ID: "a", Column: "dev", Title: "Card A", Project: "p1"}},
26+
"done": {{ID: "c", Column: "done", Title: "Card C", Project: "p2"}},
27+
},
28+
projects: []board.Project{{Name: "p1"}, {Name: "p2"}},
29+
scroll: map[string]int{},
30+
}
31+
}
32+
33+
sizes := [][2]int{{0, 0}, {1, 1}, {2, 2}, {5, 3}, {0, 40}, {80, 0}}
34+
dialogs := map[string]func() dialog{
35+
"none": func() dialog { return nil },
36+
"help": func() dialog { return newHelpDialog() },
37+
"card": func() dialog { return newCardDialog([]board.Project{{Name: "p1"}, {Name: "p2"}}, "") },
38+
"prompt": func() dialog { return newPromptDialog(board.Column{ID: "dev", Name: "Dev"}) },
39+
"columns": func() dialog { return newColumnsDialog([]board.Column{{ID: "dev", Name: "Dev"}}) },
40+
"projects": func() dialog { return newProjectsDialog([]board.Project{{Name: "p1"}}) },
41+
"diff": func() dialog { return newDiffDialog("a", "t", "+x\n-y", 3) },
42+
"confirm": func() dialog { return newConfirmDialog(&board.Card{ID: "a", Title: "T"}) },
43+
}
44+
45+
for _, size := range sizes {
46+
for name, mk := range dialogs {
47+
t.Run(fmt.Sprintf("%dx%d/%s", size[0], size[1], name), func(t *testing.T) {
48+
m := newModel()
49+
m.dialog = mk()
50+
if d, ok := m.dialog.(*projectsDialog); ok {
51+
// "a" opens the directory picker: exercise that view too.
52+
_, _ = d.Update(tea.KeyPressMsg{Code: 'a', Text: "a"})
53+
}
54+
_, _ = m.Update(tea.WindowSizeMsg{Width: size[0], Height: size[1]})
55+
_ = m.View()
56+
57+
// The welcome overlay renders on an empty board.
58+
m.cards = map[string][]*board.Card{}
59+
m.dialog = nil
60+
_ = m.View()
61+
})
62+
}
63+
}
64+
}

pkg/tui/tui.go

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2703,12 +2703,13 @@ func (m *appModel) renderLeanWorkingIndicator() string {
27032703

27042704
// renderResizeHandle renders the draggable separator between content and bottom panel.
27052705
func (m *appModel) renderResizeHandle(width int) string {
2706-
if width <= 0 {
2706+
// The terminal can report degenerate sizes (0x0, 1x1) where the padded
2707+
// inner width goes negative; there is nothing to draw a handle on.
2708+
innerWidth := width - appPaddingHorizontal
2709+
if innerWidth <= 0 {
27072710
return ""
27082711
}
27092712

2710-
innerWidth := width - appPaddingHorizontal
2711-
27122713
// Use brighter style when actively dragging
27132714
centerStyle := styles.ResizeHandleHoverStyle
27142715
if m.isDragging {
@@ -2721,7 +2722,7 @@ func (m *appModel) renderResizeHandle(width int) string {
27212722

27222723
// Always center handle on full width
27232724
fullLine := lipgloss.PlaceHorizontal(
2724-
max(0, innerWidth), lipgloss.Center, handle,
2725+
innerWidth, lipgloss.Center, handle,
27252726
lipgloss.WithWhitespaceChars("─"),
27262727
lipgloss.WithWhitespaceStyle(styles.ResizeHandleStyle),
27272728
)
@@ -2732,14 +2733,12 @@ func (m *appModel) renderResizeHandle(width int) string {
27322733
// Static indicator: the loop is idle until the user resumes.
27332734
resumeKey := styles.HighlightWhiteStyle.Render("/pause")
27342735
suffix := " " + styles.WarningStyle.Render("⏸ Paused") + " (" + resumeKey + " to resume)"
2735-
suffixWidth := lipgloss.Width(suffix)
2736-
result = lipgloss.NewStyle().MaxWidth(innerWidth-suffixWidth).Render(fullLine) + suffix
2736+
result = lineWithSuffix(fullLine, suffix, innerWidth)
27372737

27382738
case m.sessionState.PauseState() == service.PausePausing:
27392739
// The agent is finishing the in-flight request before it pauses.
27402740
suffix := " " + m.workingSpinner.View() + " " + styles.WarningStyle.Render("Pausing…") + " (finishing current request)"
2741-
suffixWidth := lipgloss.Width(suffix)
2742-
result = lipgloss.NewStyle().MaxWidth(innerWidth-suffixWidth).Render(fullLine) + suffix
2741+
result = lineWithSuffix(fullLine, suffix, innerWidth)
27432742

27442743
case m.chatPage.IsWorking():
27452744
// Truncate right side and append spinner (handle stays centered)
@@ -2750,14 +2749,12 @@ func (m *appModel) renderResizeHandle(width int) string {
27502749
suffix := " " + m.workingSpinner.View() + " " + styles.SpinnerDotsHighlightStyle.Render(workingText)
27512750
cancelKeyPart := styles.HighlightWhiteStyle.Render("Esc")
27522751
suffix += " (" + cancelKeyPart + " to interrupt)"
2753-
suffixWidth := lipgloss.Width(suffix)
2754-
result = lipgloss.NewStyle().MaxWidth(innerWidth-suffixWidth).Render(fullLine) + suffix
2752+
result = lineWithSuffix(fullLine, suffix, innerWidth)
27552753

27562754
case m.chatPage.QueueLength() > 0:
27572755
queueText := fmt.Sprintf("%d queued", m.chatPage.QueueLength())
27582756
suffix := " " + styles.WarningStyle.Render(queueText) + " "
2759-
suffixWidth := lipgloss.Width(suffix)
2760-
result = lipgloss.NewStyle().MaxWidth(innerWidth-suffixWidth).Render(fullLine) + suffix
2757+
result = lineWithSuffix(fullLine, suffix, innerWidth)
27612758

27622759
default:
27632760
result = fullLine
@@ -2766,6 +2763,18 @@ func (m *appModel) renderResizeHandle(width int) string {
27662763
return lipgloss.NewStyle().Padding(0, styles.AppPadding).Render(result)
27672764
}
27682765

2766+
// lineWithSuffix appends a status suffix to the centered handle line,
2767+
// truncating so the total never exceeds width. lipgloss ignores non-positive
2768+
// MaxWidth values, so a suffix wider than the line must be truncated itself
2769+
// or the row would overflow on narrow terminals.
2770+
func lineWithSuffix(line, suffix string, width int) string {
2771+
suffixWidth := lipgloss.Width(suffix)
2772+
if suffixWidth >= width {
2773+
return lipgloss.NewStyle().MaxWidth(width).Render(suffix)
2774+
}
2775+
return lipgloss.NewStyle().MaxWidth(width-suffixWidth).Render(line) + suffix
2776+
}
2777+
27692778
// View renders the model.
27702779
func (m *appModel) View() tea.View {
27712780
windowTitle := m.windowTitle()

pkg/tui/tui_resize_handle_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package tui
2+
3+
import (
4+
"testing"
5+
6+
"charm.land/lipgloss/v2"
7+
"github.com/stretchr/testify/assert"
8+
9+
"github.com/docker/docker-agent/pkg/tui/service"
10+
)
11+
12+
// TestRenderResizeHandle_TinyWidths guards against a panic (negative
13+
// strings.Repeat count) when the terminal reports degenerate sizes such as
14+
// 0x0 or 1x1: the padded inner width goes negative for widths smaller than
15+
// the app's horizontal padding.
16+
func TestRenderResizeHandle_TinyWidths(t *testing.T) {
17+
t.Parallel()
18+
19+
m, _ := newTestModel(t)
20+
21+
for _, width := range []int{-1, 0, 1, appPaddingHorizontal, appPaddingHorizontal + 1, 80} {
22+
out := m.renderResizeHandle(width)
23+
if width <= appPaddingHorizontal {
24+
assert.Empty(t, out, "width %d", width)
25+
} else {
26+
assert.NotEmpty(t, out, "width %d", width)
27+
}
28+
}
29+
}
30+
31+
// TestRenderResizeHandle_SuffixNeverOverflows pins that a status suffix wider
32+
// than the handle line is truncated instead of overflowing the row on narrow
33+
// terminals.
34+
func TestRenderResizeHandle_SuffixNeverOverflows(t *testing.T) {
35+
t.Parallel()
36+
37+
m, _ := newTestModel(t)
38+
m.sessionState = &service.SessionState{}
39+
m.sessionState.SetPauseState(service.PausePaused)
40+
41+
for _, width := range []int{5, 10, 20, 80, 200} {
42+
out := m.renderResizeHandle(width)
43+
assert.LessOrEqual(t, lipgloss.Width(out), width, "width %d", width)
44+
}
45+
}
46+
47+
func TestLineWithSuffix(t *testing.T) {
48+
t.Parallel()
49+
50+
// The suffix fits: the line is truncated to make room.
51+
out := lineWithSuffix("──────────", " ok", 8)
52+
assert.Equal(t, "───── ok", out)
53+
assert.Equal(t, 8, lipgloss.Width(out))
54+
55+
// The suffix alone is too wide: it is truncated and the line dropped.
56+
out = lineWithSuffix("──────────", " a very long status", 8)
57+
assert.Equal(t, 8, lipgloss.Width(out))
58+
}

0 commit comments

Comments
 (0)