detached fd7fd71

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
8989
9090 select {
9191 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
9393 if string(got) != want {
9494 t.Fatalf("captured escapes = %q, want %q", got, want)
9595 }
@@ -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 + altScreenExit92+ 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 (
3131 bracketedPasteDisable = "\x1b[?2004l"
3232 colorSchemeEnable = "\x1b[?2031h"
3333 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"
3442 )
3543
3644 // bufSize is the batched writer's buffer capacity.
@@ -107,7 +115,7 @@ func (t *Terminal) EnterRaw() (func() error, error) {
107115 }
108116 t.orig = old
109117
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 {
111119 _ = t.restoreRawLocked()
112120 return nil, fmt.Errorf("term: enter alt screen: %w", err)
113121 }
@@ -141,7 +149,7 @@ func (t *Terminal) restore() error {
141149 t.mu.Lock()
142150 defer t.mu.Unlock()
143151
144- _, werr := t.bw.WriteString(cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit)
152+ _, werr := t.bw.WriteString(cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit)
145153 ferr := t.bw.Flush()
146154
147155 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 = old116 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 (
44 "bytes"
55 "io"
66 "os"
7+ "strings"
78 "testing"
89 "time"
910
@@ -60,7 +61,7 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) {
6061 _ = outW.Close()
6162 <-done
6263
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
6465 if got := captured.String(); got != want {
6566 t.Fatalf("captured escapes = %q, want %q", got, want)
6667 }
@@ -69,6 +70,80 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) {
6970 }
7071 }
7172
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+
72147 func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) {
73148 inR, inW, err := os.Pipe()
74149 if err != nil {
@@ -103,7 +178,7 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) {
103178
104179 // Alt-screen/cursor escapes must appear exactly once for enter and exits
105180 // 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
107182 if got := captured.String(); got != want {
108183 t.Fatalf("captured escapes = %q, want %q", got, want)
109184 }
@@ -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 <-done62 <-done
62 63
63- want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit64+ 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 exits179 // 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 + altScreenExit181+ 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 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close