fix(term): disable autowrap while vev owns the alt screen
A frame the daemon composed for a wider terminal than the one currently attached bleeds onto the next row: the tab bar is a full-width styled run, so with DECAWM on it wraps and paints a second row in the accent colour. The result reads as a multi-line text selection flashing at startup, when the terminal is created at its configured initial size and the compositor resizes it a few milliseconds later. vev addresses every row explicitly and never relies on the terminal wrapping its output — emitSpan invalidates the tracked cursor as soon as a span reaches the right edge, so the next write always re-issues an absolute move. Turning DECAWM off therefore only changes what happens to an over-long row: it is clipped at the right margin instead of wrapping. DECAWM is global and is not saved by the alt-screen switch, so it is restored alongside the other modes, before the alt-screen exit. Verified against pyte (which implements DECAWM, unlike pkg/vt) by replaying a captured 204-column frame into an 80-column screen: 2 rows carry a non-default background with autowrap on, 1 with it off.
- commit
- fd7fd716c488fccd4875c28625549c48610cbf71
- author
- brice · · 2026-07-25 13:58 UTC
- parent
- 33be6b4
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +88 -5
M internal/adapters/term/pty_integration_test.go +1 -1
| @@ -89,7 +89,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) { | |||
| 89 | 89 | ||
| 90 | 90 | select { | |
| 91 | 91 | case got := <-captured: | |
| 92 | - | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit | |
| 92 | + | want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit | |
| 93 | 93 | if string(got) != want { | |
| 94 | 94 | t.Fatalf("captured escapes = %q, want %q", got, want) | |
| 95 | 95 | } | |
| @@ -89,7 +89,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) { | |||||
| 89 | 89 | ||||
| 90 | select { | 90 | select { | ||
| 91 | case got := <-captured: | 91 | case got := <-captured: | ||
| 92 | - | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit | 92 | + | want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit |
| 93 | if string(got) != want { | 93 | if string(got) != want { | ||
| 94 | t.Fatalf("captured escapes = %q, want %q", got, want) | 94 | t.Fatalf("captured escapes = %q, want %q", got, want) | ||
| 95 | } | 95 | } | ||
M internal/adapters/term/terminal.go +10 -2
| @@ -31,6 +31,14 @@ const ( | |||
| 31 | 31 | bracketedPasteDisable = "\x1b[?2004l" | |
| 32 | 32 | colorSchemeEnable = "\x1b[?2031h" | |
| 33 | 33 | colorSchemeDisable = "\x1b[?2031l" | |
| 34 | + | // DECAWM. vev addresses every row explicitly and never relies on the | |
| 35 | + | // terminal wrapping its output, so autowrap stays off while it owns the | |
| 36 | + | // alt screen: a frame the daemon composed for a wider terminal is then | |
| 37 | + | // clipped at the right margin instead of bleeding a styled run onto the | |
| 38 | + | // next row. Restored alongside the other modes, since DECAWM is global | |
| 39 | + | // and is not saved by the alt-screen switch. | |
| 40 | + | autowrapDisable = "\x1b[?7l" | |
| 41 | + | autowrapEnable = "\x1b[?7h" | |
| 34 | 42 | ) | |
| 35 | 43 | ||
| 36 | 44 | // bufSize is the batched writer's buffer capacity. | |
| @@ -107,7 +115,7 @@ func (t *Terminal) EnterRaw() (func() error, error) { | |||
| 107 | 115 | } | |
| 108 | 116 | t.orig = old | |
| 109 | 117 | ||
| 110 | - | if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable); err != nil { | |
| 118 | + | if _, err := t.bw.WriteString(altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable); err != nil { | |
| 111 | 119 | _ = t.restoreRawLocked() | |
| 112 | 120 | return nil, fmt.Errorf("term: enter alt screen: %w", err) | |
| 113 | 121 | } | |
| @@ -141,7 +149,7 @@ func (t *Terminal) restore() error { | |||
| 141 | 149 | t.mu.Lock() | |
| 142 | 150 | defer t.mu.Unlock() | |
| 143 | 151 | ||
| 144 | - | _, werr := t.bw.WriteString(cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit) | |
| 152 | + | _, werr := t.bw.WriteString(cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit) | |
| 145 | 153 | ferr := t.bw.Flush() | |
| 146 | 154 | ||
| 147 | 155 | rerr := t.restoreRawLocked() | |
| @@ -31,6 +31,14 @@ const ( | |||||
| 31 | bracketedPasteDisable = "\x1b[?2004l" | 31 | bracketedPasteDisable = "\x1b[?2004l" | ||
| 32 | colorSchemeEnable = "\x1b[?2031h" | 32 | colorSchemeEnable = "\x1b[?2031h" | ||
| 33 | colorSchemeDisable = "\x1b[?2031l" | 33 | colorSchemeDisable = "\x1b[?2031l" | ||
| 34 | + | // DECAWM. vev addresses every row explicitly and never relies on the | |||
| 35 | + | // terminal wrapping its output, so autowrap stays off while it owns the | |||
| 36 | + | // alt screen: a frame the daemon composed for a wider terminal is then | |||
| 37 | + | // clipped at the right margin instead of bleeding a styled run onto the | |||
| 38 | + | // next row. Restored alongside the other modes, since DECAWM is global | |||
| 39 | + | // and is not saved by the alt-screen switch. | |||
| 40 | + | autowrapDisable = "\x1b[?7l" | |||
| 41 | + | autowrapEnable = "\x1b[?7h" | |||
| 34 | ) | 42 | ) | ||
| 35 | 43 | ||||
| 36 | // bufSize is the batched writer's buffer capacity. | 44 | // bufSize is the batched writer's buffer capacity. | ||
| @@ -107,7 +115,7 @@ func (t *Terminal) EnterRaw() (func() error, error) { | |||||
| 107 | } | 115 | } | ||
| 108 | t.orig = old | 116 | t.orig = old | ||
| 109 | 117 | ||||
| 110 | - | if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable); err != nil { | 118 | + | if _, err := t.bw.WriteString(altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable); err != nil { |
| 111 | _ = t.restoreRawLocked() | 119 | _ = t.restoreRawLocked() | ||
| 112 | return nil, fmt.Errorf("term: enter alt screen: %w", err) | 120 | return nil, fmt.Errorf("term: enter alt screen: %w", err) | ||
| 113 | } | 121 | } | ||
| @@ -141,7 +149,7 @@ func (t *Terminal) restore() error { | |||||
| 141 | t.mu.Lock() | 149 | t.mu.Lock() | ||
| 142 | defer t.mu.Unlock() | 150 | defer t.mu.Unlock() | ||
| 143 | 151 | ||||
| 144 | - | _, werr := t.bw.WriteString(cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit) | 152 | + | _, werr := t.bw.WriteString(cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit) |
| 145 | ferr := t.bw.Flush() | 153 | ferr := t.bw.Flush() | ||
| 146 | 154 | ||||
| 147 | rerr := t.restoreRawLocked() | 155 | rerr := t.restoreRawLocked() | ||
M internal/adapters/term/terminal_test.go +77 -2
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "bytes" | |
| 5 | 5 | "io" | |
| 6 | 6 | "os" | |
| 7 | + | "strings" | |
| 7 | 8 | "testing" | |
| 8 | 9 | "time" | |
| 9 | 10 | ||
| @@ -60,7 +61,7 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) { | |||
| 60 | 61 | _ = outW.Close() | |
| 61 | 62 | <-done | |
| 62 | 63 | ||
| 63 | - | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit | |
| 64 | + | want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit | |
| 64 | 65 | if got := captured.String(); got != want { | |
| 65 | 66 | t.Fatalf("captured escapes = %q, want %q", got, want) | |
| 66 | 67 | } | |
| @@ -69,6 +70,80 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) { | |||
| 69 | 70 | } | |
| 70 | 71 | } | |
| 71 | 72 | ||
| 73 | + | // A frame the daemon composed for a wider terminal must never bleed onto the | |
| 74 | + | // next row: with DECAWM left on, an over-long styled run (the full-width tab | |
| 75 | + | // bar) wraps and paints a second row in the accent colour. vev positions every | |
| 76 | + | // row explicitly and never relies on the terminal wrapping for it, so autowrap | |
| 77 | + | // stays off for as long as vev owns the alt screen. | |
| 78 | + | func TestTerminal_EnterRaw_DisablesAutowrapInsideAltScreen(t *testing.T) { | |
| 79 | + | const ( | |
| 80 | + | autowrapOff = "\x1b[?7l" | |
| 81 | + | autowrapOn = "\x1b[?7h" | |
| 82 | + | altEnter = "\x1b[?1049h" | |
| 83 | + | altExit = "\x1b[?1049l" | |
| 84 | + | ) | |
| 85 | + | ||
| 86 | + | inR, inW, err := os.Pipe() | |
| 87 | + | if err != nil { | |
| 88 | + | t.Fatalf("os.Pipe(in): %v", err) | |
| 89 | + | } | |
| 90 | + | defer func() { _ = inW.Close() }() | |
| 91 | + | defer func() { _ = inR.Close() }() | |
| 92 | + | ||
| 93 | + | outR, outW, captured, done := pipeCapture(t) | |
| 94 | + | defer func() { _ = outR.Close() }() | |
| 95 | + | ||
| 96 | + | tm := NewWithFiles(inR, outW) | |
| 97 | + | ||
| 98 | + | restore, err := tm.EnterRaw() | |
| 99 | + | if err != nil { | |
| 100 | + | t.Fatalf("EnterRaw: %v", err) | |
| 101 | + | } | |
| 102 | + | if err := restore(); err != nil { | |
| 103 | + | t.Fatalf("restore: %v", err) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | _ = outW.Close() | |
| 107 | + | <-done | |
| 108 | + | got := captured.String() | |
| 109 | + | ||
| 110 | + | for _, tc := range []struct { | |
| 111 | + | name string | |
| 112 | + | seq string | |
| 113 | + | }{ | |
| 114 | + | {"autowrap disabled exactly once", autowrapOff}, | |
| 115 | + | {"autowrap restored exactly once", autowrapOn}, | |
| 116 | + | } { | |
| 117 | + | t.Run(tc.name, func(t *testing.T) { | |
| 118 | + | if n := strings.Count(got, tc.seq); n != 1 { | |
| 119 | + | t.Fatalf("emitted %q %d times, want exactly 1; full output %q", tc.seq, n, got) | |
| 120 | + | } | |
| 121 | + | }) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // Ordering: disabling autowrap must land inside the alt screen, and it | |
| 125 | + | // must be restored before vev hands the screen back to the shell. | |
| 126 | + | for _, tc := range []struct { | |
| 127 | + | name string | |
| 128 | + | before string | |
| 129 | + | after string | |
| 130 | + | }{ | |
| 131 | + | {"alt screen entered before autowrap is disabled", altEnter, autowrapOff}, | |
| 132 | + | {"autowrap disabled before it is restored", autowrapOff, autowrapOn}, | |
| 133 | + | {"autowrap restored before leaving the alt screen", autowrapOn, altExit}, | |
| 134 | + | } { | |
| 135 | + | t.Run(tc.name, func(t *testing.T) { | |
| 136 | + | i, j := strings.Index(got, tc.before), strings.Index(got, tc.after) | |
| 137 | + | if i < 0 || j < 0 { | |
| 138 | + | t.Fatalf("missing sequence: %q at %d, %q at %d; full output %q", tc.before, i, tc.after, j, got) | |
| 139 | + | } | |
| 140 | + | if i >= j { | |
| 141 | + | t.Fatalf("%q (index %d) must precede %q (index %d); full output %q", tc.before, i, tc.after, j, got) | |
| 142 | + | } | |
| 143 | + | }) | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 72 | 147 | func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) { | |
| 73 | 148 | inR, inW, err := os.Pipe() | |
| 74 | 149 | if err != nil { | |
| @@ -103,7 +178,7 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) { | |||
| 103 | 178 | ||
| 104 | 179 | // Alt-screen/cursor escapes must appear exactly once for enter and exits | |
| 105 | 180 | // exactly once, regardless of how many times EnterRaw/restore were called. | |
| 106 | - | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit | |
| 181 | + | want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit | |
| 107 | 182 | if got := captured.String(); got != want { | |
| 108 | 183 | t.Fatalf("captured escapes = %q, want %q", got, want) | |
| 109 | 184 | } | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "io" | 5 | "io" | ||
| 6 | "os" | 6 | "os" | ||
| 7 | + | "strings" | |||
| 7 | "testing" | 8 | "testing" | ||
| 8 | "time" | 9 | "time" | ||
| 9 | 10 | ||||
| @@ -60,7 +61,7 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) { | |||||
| 60 | _ = outW.Close() | 61 | _ = outW.Close() | ||
| 61 | <-done | 62 | <-done | ||
| 62 | 63 | ||||
| 63 | - | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit | 64 | + | want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit |
| 64 | if got := captured.String(); got != want { | 65 | if got := captured.String(); got != want { | ||
| 65 | t.Fatalf("captured escapes = %q, want %q", got, want) | 66 | t.Fatalf("captured escapes = %q, want %q", got, want) | ||
| 66 | } | 67 | } | ||
| @@ -69,6 +70,80 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) { | |||||
| 69 | } | 70 | } | ||
| 70 | } | 71 | } | ||
| 71 | 72 | ||||
| 73 | + | // A frame the daemon composed for a wider terminal must never bleed onto the | |||
| 74 | + | // next row: with DECAWM left on, an over-long styled run (the full-width tab | |||
| 75 | + | // bar) wraps and paints a second row in the accent colour. vev positions every | |||
| 76 | + | // row explicitly and never relies on the terminal wrapping for it, so autowrap | |||
| 77 | + | // stays off for as long as vev owns the alt screen. | |||
| 78 | + | func TestTerminal_EnterRaw_DisablesAutowrapInsideAltScreen(t *testing.T) { | |||
| 79 | + | const ( | |||
| 80 | + | autowrapOff = "\x1b[?7l" | |||
| 81 | + | autowrapOn = "\x1b[?7h" | |||
| 82 | + | altEnter = "\x1b[?1049h" | |||
| 83 | + | altExit = "\x1b[?1049l" | |||
| 84 | + | ) | |||
| 85 | + | ||||
| 86 | + | inR, inW, err := os.Pipe() | |||
| 87 | + | if err != nil { | |||
| 88 | + | t.Fatalf("os.Pipe(in): %v", err) | |||
| 89 | + | } | |||
| 90 | + | defer func() { _ = inW.Close() }() | |||
| 91 | + | defer func() { _ = inR.Close() }() | |||
| 92 | + | ||||
| 93 | + | outR, outW, captured, done := pipeCapture(t) | |||
| 94 | + | defer func() { _ = outR.Close() }() | |||
| 95 | + | ||||
| 96 | + | tm := NewWithFiles(inR, outW) | |||
| 97 | + | ||||
| 98 | + | restore, err := tm.EnterRaw() | |||
| 99 | + | if err != nil { | |||
| 100 | + | t.Fatalf("EnterRaw: %v", err) | |||
| 101 | + | } | |||
| 102 | + | if err := restore(); err != nil { | |||
| 103 | + | t.Fatalf("restore: %v", err) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | _ = outW.Close() | |||
| 107 | + | <-done | |||
| 108 | + | got := captured.String() | |||
| 109 | + | ||||
| 110 | + | for _, tc := range []struct { | |||
| 111 | + | name string | |||
| 112 | + | seq string | |||
| 113 | + | }{ | |||
| 114 | + | {"autowrap disabled exactly once", autowrapOff}, | |||
| 115 | + | {"autowrap restored exactly once", autowrapOn}, | |||
| 116 | + | } { | |||
| 117 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 118 | + | if n := strings.Count(got, tc.seq); n != 1 { | |||
| 119 | + | t.Fatalf("emitted %q %d times, want exactly 1; full output %q", tc.seq, n, got) | |||
| 120 | + | } | |||
| 121 | + | }) | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // Ordering: disabling autowrap must land inside the alt screen, and it | |||
| 125 | + | // must be restored before vev hands the screen back to the shell. | |||
| 126 | + | for _, tc := range []struct { | |||
| 127 | + | name string | |||
| 128 | + | before string | |||
| 129 | + | after string | |||
| 130 | + | }{ | |||
| 131 | + | {"alt screen entered before autowrap is disabled", altEnter, autowrapOff}, | |||
| 132 | + | {"autowrap disabled before it is restored", autowrapOff, autowrapOn}, | |||
| 133 | + | {"autowrap restored before leaving the alt screen", autowrapOn, altExit}, | |||
| 134 | + | } { | |||
| 135 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 136 | + | i, j := strings.Index(got, tc.before), strings.Index(got, tc.after) | |||
| 137 | + | if i < 0 || j < 0 { | |||
| 138 | + | t.Fatalf("missing sequence: %q at %d, %q at %d; full output %q", tc.before, i, tc.after, j, got) | |||
| 139 | + | } | |||
| 140 | + | if i >= j { | |||
| 141 | + | t.Fatalf("%q (index %d) must precede %q (index %d); full output %q", tc.before, i, tc.after, j, got) | |||
| 142 | + | } | |||
| 143 | + | }) | |||
| 144 | + | } | |||
| 145 | + | } | |||
| 146 | + | ||||
| 72 | func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) { | 147 | func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) { | ||
| 73 | inR, inW, err := os.Pipe() | 148 | inR, inW, err := os.Pipe() | ||
| 74 | if err != nil { | 149 | if err != nil { | ||
| @@ -103,7 +178,7 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) { | |||||
| 103 | 178 | ||||
| 104 | // Alt-screen/cursor escapes must appear exactly once for enter and exits | 179 | // Alt-screen/cursor escapes must appear exactly once for enter and exits | ||
| 105 | // exactly once, regardless of how many times EnterRaw/restore were called. | 180 | // exactly once, regardless of how many times EnterRaw/restore were called. | ||
| 106 | - | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit | 181 | + | want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit |
| 107 | if got := captured.String(); got != want { | 182 | if got := captured.String(); got != want { | ||
| 108 | t.Fatalf("captured escapes = %q, want %q", got, want) | 183 | t.Fatalf("captured escapes = %q, want %q", got, want) | ||
| 109 | } | 184 | } | ||