detached 44355e4

fix(remote): preserve multiline bracketed paste

commit
44355e43125dff8864a53cadcfec44ebc63c5a0e
author
brice · · 2026-07-04 12:12 UTC
parent
14aa864
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +48 -16
M internal/adapters/term/pty_integration_test.go +1 -1
@@ -102,7 +102,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) {
102102
103103 select {
104104 case got := <-captured:
105- want := altScreenEnter + cursorHide + mouseEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit
105+ want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + altScreenExit
106106 if string(got) != want {
107107 t.Fatalf("captured escapes = %q, want %q", got, want)
108108 }
@@ -102,7 +102,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) {
102 102
103 select {103 select {
104 case got := <-captured:104 case got := <-captured:
105- want := altScreenEnter + cursorHide + mouseEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit105+ want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + altScreenExit
106 if string(got) != want {106 if string(got) != want {
107 t.Fatalf("captured escapes = %q, want %q", got, want)107 t.Fatalf("captured escapes = %q, want %q", got, want)
108 }108 }
M internal/adapters/term/terminal.go +12 -10
@@ -21,14 +21,16 @@ var _ ports.Terminal = (*Terminal)(nil)
2121 // Escape sequences for alt-screen and cursor visibility control. All
2222 // emissions go through the batched writer and are explicitly flushed.
2323 const (
24- altScreenEnter = "\x1b[?1049h"
25- altScreenExit = "\x1b[?1049l"
26- cursorHide = "\x1b[?25l"
27- cursorShow = "\x1b[?25h"
28- cursorStyleDefault = "\x1b[0 q"
29- mouseEnable = "\x1b[?1002h\x1b[?1006h"
30- mouseDisable = "\x1b[?1002l\x1b[?1006l"
31- oscColorQuery = "\x1b]10;?\x07\x1b]11;?\x07"
24+ altScreenEnter = "\x1b[?1049h"
25+ altScreenExit = "\x1b[?1049l"
26+ cursorHide = "\x1b[?25l"
27+ cursorShow = "\x1b[?25h"
28+ cursorStyleDefault = "\x1b[0 q"
29+ mouseEnable = "\x1b[?1002h\x1b[?1006h"
30+ mouseDisable = "\x1b[?1002l\x1b[?1006l"
31+ bracketedPasteEnable = "\x1b[?2004h"
32+ bracketedPasteDisable = "\x1b[?2004l"
33+ oscColorQuery = "\x1b]10;?\x07\x1b]11;?\x07"
3234 )
3335
3436 // bufSize is the batched writer's buffer capacity.
@@ -105,7 +107,7 @@ func (t *Terminal) EnterRaw() (func() error, error) {
105107 }
106108 t.orig = old
107109
108- if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + oscColorQuery); err != nil {
110+ if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + oscColorQuery); err != nil {
109111 _ = t.restoreRawLocked()
110112 return nil, fmt.Errorf("term: enter alt screen: %w", err)
111113 }
@@ -139,7 +141,7 @@ func (t *Terminal) restore() error {
139141 t.mu.Lock()
140142 defer t.mu.Unlock()
141143
142- _, werr := t.bw.WriteString(cursorShow + cursorStyleDefault + mouseDisable + altScreenExit)
144+ _, werr := t.bw.WriteString(cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + altScreenExit)
143145 ferr := t.bw.Flush()
144146
145147 rerr := t.restoreRawLocked()
@@ -21,14 +21,16 @@ var _ ports.Terminal = (*Terminal)(nil)
21 // Escape sequences for alt-screen and cursor visibility control. All21 // Escape sequences for alt-screen and cursor visibility control. All
22 // emissions go through the batched writer and are explicitly flushed.22 // emissions go through the batched writer and are explicitly flushed.
23 const (23 const (
24- altScreenEnter = "\x1b[?1049h"24+ altScreenEnter = "\x1b[?1049h"
25- altScreenExit = "\x1b[?1049l"25+ altScreenExit = "\x1b[?1049l"
26- cursorHide = "\x1b[?25l"26+ cursorHide = "\x1b[?25l"
27- cursorShow = "\x1b[?25h"27+ cursorShow = "\x1b[?25h"
28- cursorStyleDefault = "\x1b[0 q"28+ cursorStyleDefault = "\x1b[0 q"
29- mouseEnable = "\x1b[?1002h\x1b[?1006h"29+ mouseEnable = "\x1b[?1002h\x1b[?1006h"
30- mouseDisable = "\x1b[?1002l\x1b[?1006l"30+ mouseDisable = "\x1b[?1002l\x1b[?1006l"
31- oscColorQuery = "\x1b]10;?\x07\x1b]11;?\x07"31+ bracketedPasteEnable = "\x1b[?2004h"
32+ bracketedPasteDisable = "\x1b[?2004l"
33+ oscColorQuery = "\x1b]10;?\x07\x1b]11;?\x07"
32 )34 )
33 35
34 // bufSize is the batched writer's buffer capacity.36 // bufSize is the batched writer's buffer capacity.
@@ -105,7 +107,7 @@ func (t *Terminal) EnterRaw() (func() error, error) {
105 }107 }
106 t.orig = old108 t.orig = old
107 109
108- if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + oscColorQuery); err != nil {110+ if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + oscColorQuery); err != nil {
109 _ = t.restoreRawLocked()111 _ = t.restoreRawLocked()
110 return nil, fmt.Errorf("term: enter alt screen: %w", err)112 return nil, fmt.Errorf("term: enter alt screen: %w", err)
111 }113 }
@@ -139,7 +141,7 @@ func (t *Terminal) restore() error {
139 t.mu.Lock()141 t.mu.Lock()
140 defer t.mu.Unlock()142 defer t.mu.Unlock()
141 143
142- _, werr := t.bw.WriteString(cursorShow + cursorStyleDefault + mouseDisable + altScreenExit)144+ _, werr := t.bw.WriteString(cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + altScreenExit)
143 ferr := t.bw.Flush()145 ferr := t.bw.Flush()
144 146
145 rerr := t.restoreRawLocked()147 rerr := t.restoreRawLocked()
M internal/adapters/term/terminal_test.go +2 -2
@@ -60,7 +60,7 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) {
6060 _ = outW.Close()
6161 <-done
6262
63- want := altScreenEnter + cursorHide + mouseEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit
63+ want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + altScreenExit
6464 if got := captured.String(); got != want {
6565 t.Fatalf("captured escapes = %q, want %q", got, want)
6666 }
@@ -101,7 +101,7 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) {
101101 // Alt-screen/cursor/theme-query escapes must appear exactly once for enter and
102102 // cursor/alt-screen exits exactly once, regardless of how many times
103103 // EnterRaw/restore were called.
104- want := altScreenEnter + cursorHide + mouseEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit
104+ want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + altScreenExit
105105 if got := captured.String(); got != want {
106106 t.Fatalf("captured escapes = %q, want %q", got, want)
107107 }
@@ -60,7 +60,7 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) {
60 _ = outW.Close()60 _ = outW.Close()
61 <-done61 <-done
62 62
63- want := altScreenEnter + cursorHide + mouseEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit63+ want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + altScreenExit
64 if got := captured.String(); got != want {64 if got := captured.String(); got != want {
65 t.Fatalf("captured escapes = %q, want %q", got, want)65 t.Fatalf("captured escapes = %q, want %q", got, want)
66 }66 }
@@ -101,7 +101,7 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) {
101 // Alt-screen/cursor/theme-query escapes must appear exactly once for enter and101 // Alt-screen/cursor/theme-query escapes must appear exactly once for enter and
102 // cursor/alt-screen exits exactly once, regardless of how many times102 // cursor/alt-screen exits exactly once, regardless of how many times
103 // EnterRaw/restore were called.103 // EnterRaw/restore were called.
104- want := altScreenEnter + cursorHide + mouseEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit104+ want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + altScreenExit
105 if got := captured.String(); got != want {105 if got := captured.String(); got != want {
106 t.Fatalf("captured escapes = %q, want %q", got, want)106 t.Fatalf("captured escapes = %q, want %q", got, want)
107 }107 }
M internal/usecase/daemon/input_test.go +15 -0
@@ -79,6 +79,21 @@ func TestAltCForwardsToPTY(t *testing.T) {
7979 releasePTY()
8080 }
8181
82+func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) {
83+ writes := make(chan []byte, 1)
84+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
85+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
86+ pane := sess.tabs[0].focusedPane()
87+ pane.screen.Write([]byte("\x1b[?2004h"))
88+ require.True(t, pane.screen.BracketedPasteMode())
89+
90+ paste := []byte("\x1b[200~first line\nsecond line\n\x1b[201~")
91+ d.handleInput(sess, ac, paste)
92+
93+ require.Equal(t, paste, <-writes)
94+ releasePTY()
95+}
96+
8297 func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) {
8398 cases := []struct {
8499 name string
@@ -79,6 +79,21 @@ func TestAltCForwardsToPTY(t *testing.T) {
79 releasePTY()79 releasePTY()
80 }80 }
81 81
82+func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) {
83+ writes := make(chan []byte, 1)
84+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
85+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
86+ pane := sess.tabs[0].focusedPane()
87+ pane.screen.Write([]byte("\x1b[?2004h"))
88+ require.True(t, pane.screen.BracketedPasteMode())
89+
90+ paste := []byte("\x1b[200~first line\nsecond line\n\x1b[201~")
91+ d.handleInput(sess, ac, paste)
92+
93+ require.Equal(t, paste, <-writes)
94+ releasePTY()
95+}
96+
82 func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) {97 func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) {
83 cases := []struct {98 cases := []struct {
84 name string99 name string
M pkg/vt/screen.go +9 -1
@@ -65,6 +65,7 @@ type Screen struct {
6565 cursorStyleSet bool
6666 mouseMode int
6767 mouseSGR bool
68+ bracketedPaste bool
6869 }
6970
7071 func NewScreen(width, height int) *Screen {
@@ -128,6 +129,10 @@ func (s *Screen) ClearDamage() { s.damage = s.damage[:0] }
128129 // is currently enabled by the child process.
129130 func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive }
130131
132+// BracketedPasteMode reports whether DEC private mode 2004 is currently enabled
133+// by the child process.
134+func (s *Screen) BracketedPasteMode() bool { return s.bracketedPaste }
135+
131136 // ForceSyncEnd forcibly leaves DEC private mode 2026 (synchronized update).
132137 // Hosts use this as a safety valve if a child enters synchronized update mode
133138 // and never sends the matching end sequence.
@@ -906,6 +911,7 @@ func (s *Screen) reset() {
906911 s.cursorStyleSet = false
907912 s.mouseMode = 0
908913 s.mouseSGR = false
914+ s.bracketedPaste = false
909915 s.resetScrollRegion()
910916 s.fullRedraw()
911917 }
@@ -966,7 +972,9 @@ func (s *Screen) setMode(private bool, parts []int, enabled bool) {
966972 }
967973 case 1006:
968974 s.mouseSGR = enabled
969- case 1, 1004, 1005, 2004:
975+ case 2004:
976+ s.bracketedPaste = enabled
977+ case 1, 1004, 1005:
970978 // Trackable terminal modes that do not directly affect the current
971979 // cell model yet. Consuming them prevents mode bytes from leaking.
972980 continue
@@ -65,6 +65,7 @@ type Screen struct {
65 cursorStyleSet bool65 cursorStyleSet bool
66 mouseMode int66 mouseMode int
67 mouseSGR bool67 mouseSGR bool
68+ bracketedPaste bool
68 }69 }
69 70
70 func NewScreen(width, height int) *Screen {71 func NewScreen(width, height int) *Screen {
@@ -128,6 +129,10 @@ func (s *Screen) ClearDamage() { s.damage = s.damage[:0] }
128 // is currently enabled by the child process.129 // is currently enabled by the child process.
129 func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive }130 func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive }
130 131
132+// BracketedPasteMode reports whether DEC private mode 2004 is currently enabled
133+// by the child process.
134+func (s *Screen) BracketedPasteMode() bool { return s.bracketedPaste }
135+
131 // ForceSyncEnd forcibly leaves DEC private mode 2026 (synchronized update).136 // ForceSyncEnd forcibly leaves DEC private mode 2026 (synchronized update).
132 // Hosts use this as a safety valve if a child enters synchronized update mode137 // Hosts use this as a safety valve if a child enters synchronized update mode
133 // and never sends the matching end sequence.138 // and never sends the matching end sequence.
@@ -906,6 +911,7 @@ func (s *Screen) reset() {
906 s.cursorStyleSet = false911 s.cursorStyleSet = false
907 s.mouseMode = 0912 s.mouseMode = 0
908 s.mouseSGR = false913 s.mouseSGR = false
914+ s.bracketedPaste = false
909 s.resetScrollRegion()915 s.resetScrollRegion()
910 s.fullRedraw()916 s.fullRedraw()
911 }917 }
@@ -966,7 +972,9 @@ func (s *Screen) setMode(private bool, parts []int, enabled bool) {
966 }972 }
967 case 1006:973 case 1006:
968 s.mouseSGR = enabled974 s.mouseSGR = enabled
969- case 1, 1004, 1005, 2004:975+ case 2004:
976+ s.bracketedPaste = enabled
977+ case 1, 1004, 1005:
970 // Trackable terminal modes that do not directly affect the current978 // Trackable terminal modes that do not directly affect the current
971 // cell model yet. Consuming them prevents mode bytes from leaking.979 // cell model yet. Consuming them prevents mode bytes from leaking.
972 continue980 continue
M pkg/vt/screen_test.go +9 -2
@@ -1352,10 +1352,10 @@ func TestResize(t *testing.T) {
13521352 },
13531353 },
13541354 {
1355- name: "style mouse and cursor modes survive",
1355+ name: "style mouse cursor and bracketed paste modes survive",
13561356 run: func(t *testing.T) {
13571357 s := NewScreen(5, 2)
1358- s.Write([]byte("\x1b[1m\x1b[?1000h\x1b[?1006h\x1b[?25l\x1b[3 q"))
1358+ s.Write([]byte("\x1b[1m\x1b[?1000h\x1b[?1006h\x1b[?2004h\x1b[?25l\x1b[3 q"))
13591359
13601360 s.Resize(6, 3)
13611361
@@ -1365,12 +1365,19 @@ func TestResize(t *testing.T) {
13651365 if mode, sgr := s.MouseMode(); mode != 1000 || !sgr {
13661366 t.Fatalf("mouse mode = (%d,%v), want (1000,true)", mode, sgr)
13671367 }
1368+ if !s.BracketedPasteMode() {
1369+ t.Fatal("bracketed paste mode was reset")
1370+ }
13681371 if s.CursorVisible() {
13691372 t.Fatal("cursor visibility was reset")
13701373 }
13711374 if style, ok := s.CursorStyle(); style != 3 || !ok {
13721375 t.Fatalf("cursor style = (%d,%v), want (3,true)", style, ok)
13731376 }
1377+ s.Write([]byte("\x1b[?2004l"))
1378+ if s.BracketedPasteMode() {
1379+ t.Fatal("bracketed paste mode remained enabled after reset")
1380+ }
13741381 },
13751382 },
13761383 }
@@ -1352,10 +1352,10 @@ func TestResize(t *testing.T) {
1352 },1352 },
1353 },1353 },
1354 {1354 {
1355- name: "style mouse and cursor modes survive",1355+ name: "style mouse cursor and bracketed paste modes survive",
1356 run: func(t *testing.T) {1356 run: func(t *testing.T) {
1357 s := NewScreen(5, 2)1357 s := NewScreen(5, 2)
1358- s.Write([]byte("\x1b[1m\x1b[?1000h\x1b[?1006h\x1b[?25l\x1b[3 q"))1358+ s.Write([]byte("\x1b[1m\x1b[?1000h\x1b[?1006h\x1b[?2004h\x1b[?25l\x1b[3 q"))
1359 1359
1360 s.Resize(6, 3)1360 s.Resize(6, 3)
1361 1361
@@ -1365,12 +1365,19 @@ func TestResize(t *testing.T) {
1365 if mode, sgr := s.MouseMode(); mode != 1000 || !sgr {1365 if mode, sgr := s.MouseMode(); mode != 1000 || !sgr {
1366 t.Fatalf("mouse mode = (%d,%v), want (1000,true)", mode, sgr)1366 t.Fatalf("mouse mode = (%d,%v), want (1000,true)", mode, sgr)
1367 }1367 }
1368+ if !s.BracketedPasteMode() {
1369+ t.Fatal("bracketed paste mode was reset")
1370+ }
1368 if s.CursorVisible() {1371 if s.CursorVisible() {
1369 t.Fatal("cursor visibility was reset")1372 t.Fatal("cursor visibility was reset")
1370 }1373 }
1371 if style, ok := s.CursorStyle(); style != 3 || !ok {1374 if style, ok := s.CursorStyle(); style != 3 || !ok {
1372 t.Fatalf("cursor style = (%d,%v), want (3,true)", style, ok)1375 t.Fatalf("cursor style = (%d,%v), want (3,true)", style, ok)
1373 }1376 }
1377+ s.Write([]byte("\x1b[?2004l"))
1378+ if s.BracketedPasteMode() {
1379+ t.Fatal("bracketed paste mode remained enabled after reset")
1380+ }
1374 },1381 },
1375 },1382 },
1376 }1383 }

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