detached 6340314

fix(vt,daemon): pass through app OSC 52 clipboard to attached client

Apps inside a pane (e.g. claude CLI auto-copy) emit OSC 52 clipboard
sequences that pkg/vt's Screen parsed and silently discarded. Surface
them via a new Screen.OnClipboard callback and re-emit the normalized
sequence to the attached client through the same bounded output-send
path copy mode already uses for its own yank-to-clipboard.
commit
63403142b709caf4db296a3cb0be68dce4fc1c4f
author
brice · · 2026-07-04 13:50 UTC
parent
51529aa
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +268 -4
M internal/usecase/copy/mode.go +12 -0
@@ -239,6 +239,18 @@ func OSC52(text string) [][]byte {
239239 return [][]byte{[]byte("\x1b]52;c;" + encoded + "\x07")}
240240 }
241241
242+// OSC52FromBase64 builds the normalized OSC 52 sequence to forward a
243+// clipboard set request that a pane app already emitted as base64. It
244+// returns nil if b64 is not valid base64 or its decoded length exceeds
245+// OSC52MaxPayloadBytes — callers must drop the request silently in that case.
246+func OSC52FromBase64(b64 string) []byte {
247+ decoded, err := base64.StdEncoding.DecodeString(b64)
248+ if err != nil || len(decoded) > OSC52MaxPayloadBytes {
249+ return nil
250+ }
251+ return []byte("\x1b]52;c;" + b64 + "\x07")
252+}
253+
242254 func strconvItoa(n int) string {
243255 if n == 0 {
244256 return "0"
@@ -239,6 +239,18 @@ func OSC52(text string) [][]byte {
239 return [][]byte{[]byte("\x1b]52;c;" + encoded + "\x07")}239 return [][]byte{[]byte("\x1b]52;c;" + encoded + "\x07")}
240 }240 }
241 241
242+// OSC52FromBase64 builds the normalized OSC 52 sequence to forward a
243+// clipboard set request that a pane app already emitted as base64. It
244+// returns nil if b64 is not valid base64 or its decoded length exceeds
245+// OSC52MaxPayloadBytes — callers must drop the request silently in that case.
246+func OSC52FromBase64(b64 string) []byte {
247+ decoded, err := base64.StdEncoding.DecodeString(b64)
248+ if err != nil || len(decoded) > OSC52MaxPayloadBytes {
249+ return nil
250+ }
251+ return []byte("\x1b]52;c;" + b64 + "\x07")
252+}
253+
242 func strconvItoa(n int) string {254 func strconvItoa(n int) string {
243 if n == 0 {255 if n == 0 {
244 return "0"256 return "0"
A internal/usecase/daemon/clipboard.go +30 -0
@@ -0,0 +1,30 @@
1+package daemon
2+
3+import (
4+ scopy "github.com/bnema/vev/internal/usecase/copy"
5+)
6+
7+// forwardClipboard re-emits an app-originated OSC 52 clipboard set request
8+// (already captured off the pane's screen while ptyReader held pane.mu, then
9+// handed here after that lock is released) to the session's attached client,
10+// if any. Invalid base64 or an oversized decoded payload is dropped silently,
11+// matching copy mode's own cap (scopy.OSC52MaxPayloadBytes). The caller must
12+// not hold pane.mu, tab.mu, or session.mu.
13+func (d *Daemon) forwardClipboard(sess *session, b64 string) {
14+ seq := scopy.OSC52FromBase64(b64)
15+ if seq == nil {
16+ return
17+ }
18+
19+ sess.mu.Lock()
20+ ac := sess.client
21+ sess.mu.Unlock()
22+ if ac == nil {
23+ return
24+ }
25+
26+ failed, err := d.boundedSendOutputErrTransport(ac, seq)
27+ if err != nil {
28+ d.detachOnSendError(sess, ac, failed)
29+ }
30+}
@@ -0,0 +1,30 @@
1+package daemon
2+
3+import (
4+ scopy "github.com/bnema/vev/internal/usecase/copy"
5+)
6+
7+// forwardClipboard re-emits an app-originated OSC 52 clipboard set request
8+// (already captured off the pane's screen while ptyReader held pane.mu, then
9+// handed here after that lock is released) to the session's attached client,
10+// if any. Invalid base64 or an oversized decoded payload is dropped silently,
11+// matching copy mode's own cap (scopy.OSC52MaxPayloadBytes). The caller must
12+// not hold pane.mu, tab.mu, or session.mu.
13+func (d *Daemon) forwardClipboard(sess *session, b64 string) {
14+ seq := scopy.OSC52FromBase64(b64)
15+ if seq == nil {
16+ return
17+ }
18+
19+ sess.mu.Lock()
20+ ac := sess.client
21+ sess.mu.Unlock()
22+ if ac == nil {
23+ return
24+ }
25+
26+ failed, err := d.boundedSendOutputErrTransport(ac, seq)
27+ if err != nil {
28+ d.detachOnSendError(sess, ac, failed)
29+ }
30+}
A internal/usecase/daemon/clipboard_test.go +122 -0
@@ -0,0 +1,122 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "encoding/base64"
6+ "io"
7+ "strings"
8+ "testing"
9+
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/ports"
14+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
15+ scopy "github.com/bnema/vev/internal/usecase/copy"
16+ "github.com/bnema/vev/pkg/renderer"
17+)
18+
19+// chunkReadPTY returns a MockPTY whose Read yields each chunk in order, then
20+// reports io.EOF — driving ptyReader through exactly len(chunks) reads before
21+// it unwinds via reapPane, matching the style of TestPTYQueryGetsResponseWrittenBackToPTY.
22+func chunkReadPTY(t *testing.T, chunks ...[]byte) *portsmocks.MockPTY {
23+ t.Helper()
24+ p := portsmocks.NewMockPTY(t)
25+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
26+ if len(chunks) == 0 {
27+ return 0, io.EOF
28+ }
29+ n := copy(buf, chunks[0])
30+ chunks = chunks[1:]
31+ return n, nil
32+ })
33+ p.EXPECT().Write(mock.Anything).Return(0, nil).Maybe()
34+ p.EXPECT().Close().Return(nil).Maybe()
35+ return p
36+}
37+
38+func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) {
39+ d := newTestDaemon(t, nil, stubClock{})
40+ p := chunkReadPTY(t, []byte("\x1b]52;c;aGVsbG8=\x07"))
41+
42+ tr, sends := newCapturingTransport(t)
43+ sctx, cancel := context.WithCancel(context.Background())
44+ defer cancel()
45+ win := newTestTabWithContext(p, sctx, cancel)
46+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
47+ sess := &session{id: "clip", name: "clip", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
48+ ac.setSession(sess)
49+ d.sessions[sess.id] = sess
50+
51+ d.sessWg.Add(1)
52+ d.ptyReader(sess, win, win.focusedPane())
53+
54+ f := awaitFrame(t, sends, ports.MsgOutput)
55+ out, err := ports.UnmarshalOutput(f.Payload)
56+ require.NoError(t, err)
57+ require.Contains(t, string(out.Data), "\x1b]52;c;aGVsbG8=\x07")
58+}
59+
60+func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) {
61+ d := newTestDaemon(t, nil, stubClock{})
62+ big := strings.Repeat("a", scopy.OSC52MaxPayloadBytes+1)
63+ b64 := base64.StdEncoding.EncodeToString([]byte(big))
64+ p := chunkReadPTY(t, []byte("\x1b]52;c;"+b64+"\x07"))
65+
66+ tr, sends := newCapturingTransport(t)
67+ sctx, cancel := context.WithCancel(context.Background())
68+ defer cancel()
69+ win := newTestTabWithContext(p, sctx, cancel)
70+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
71+ sess := &session{id: "clip-big", name: "clip-big", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
72+ ac.setSession(sess)
73+ d.sessions[sess.id] = sess
74+
75+ d.sessWg.Add(1)
76+ d.ptyReader(sess, win, win.focusedPane())
77+
78+ select {
79+ case f := <-sends:
80+ require.NotEqual(t, ports.MsgOutput, f.Type)
81+ default:
82+ }
83+}
84+
85+func TestPTYReaderDropsInvalidBase64Clipboard(t *testing.T) {
86+ d := newTestDaemon(t, nil, stubClock{})
87+ p := chunkReadPTY(t, []byte("\x1b]52;c;not-valid-base64!!\x07"))
88+
89+ tr, sends := newCapturingTransport(t)
90+ sctx, cancel := context.WithCancel(context.Background())
91+ defer cancel()
92+ win := newTestTabWithContext(p, sctx, cancel)
93+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
94+ sess := &session{id: "clip-bad", name: "clip-bad", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
95+ ac.setSession(sess)
96+ d.sessions[sess.id] = sess
97+
98+ d.sessWg.Add(1)
99+ d.ptyReader(sess, win, win.focusedPane())
100+
101+ select {
102+ case f := <-sends:
103+ require.NotEqual(t, ports.MsgOutput, f.Type)
104+ default:
105+ }
106+}
107+
108+func TestPTYReaderClipboardNoAttachedClientDoesNotPanic(t *testing.T) {
109+ d := newTestDaemon(t, nil, stubClock{})
110+ p := chunkReadPTY(t, []byte("\x1b]52;c;aGVsbG8=\x07"))
111+
112+ sctx, cancel := context.WithCancel(context.Background())
113+ defer cancel()
114+ win := newTestTabWithContext(p, sctx, cancel)
115+ sess := &session{id: "clip-noclient", name: "clip-noclient", tabs: []*tab{win}, ctx: sctx, cancel: cancel}
116+ d.sessions[sess.id] = sess
117+
118+ d.sessWg.Add(1)
119+ require.NotPanics(t, func() {
120+ d.ptyReader(sess, win, win.focusedPane())
121+ })
122+}
@@ -0,0 +1,122 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "encoding/base64"
6+ "io"
7+ "strings"
8+ "testing"
9+
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/ports"
14+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
15+ scopy "github.com/bnema/vev/internal/usecase/copy"
16+ "github.com/bnema/vev/pkg/renderer"
17+)
18+
19+// chunkReadPTY returns a MockPTY whose Read yields each chunk in order, then
20+// reports io.EOF — driving ptyReader through exactly len(chunks) reads before
21+// it unwinds via reapPane, matching the style of TestPTYQueryGetsResponseWrittenBackToPTY.
22+func chunkReadPTY(t *testing.T, chunks ...[]byte) *portsmocks.MockPTY {
23+ t.Helper()
24+ p := portsmocks.NewMockPTY(t)
25+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
26+ if len(chunks) == 0 {
27+ return 0, io.EOF
28+ }
29+ n := copy(buf, chunks[0])
30+ chunks = chunks[1:]
31+ return n, nil
32+ })
33+ p.EXPECT().Write(mock.Anything).Return(0, nil).Maybe()
34+ p.EXPECT().Close().Return(nil).Maybe()
35+ return p
36+}
37+
38+func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) {
39+ d := newTestDaemon(t, nil, stubClock{})
40+ p := chunkReadPTY(t, []byte("\x1b]52;c;aGVsbG8=\x07"))
41+
42+ tr, sends := newCapturingTransport(t)
43+ sctx, cancel := context.WithCancel(context.Background())
44+ defer cancel()
45+ win := newTestTabWithContext(p, sctx, cancel)
46+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
47+ sess := &session{id: "clip", name: "clip", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
48+ ac.setSession(sess)
49+ d.sessions[sess.id] = sess
50+
51+ d.sessWg.Add(1)
52+ d.ptyReader(sess, win, win.focusedPane())
53+
54+ f := awaitFrame(t, sends, ports.MsgOutput)
55+ out, err := ports.UnmarshalOutput(f.Payload)
56+ require.NoError(t, err)
57+ require.Contains(t, string(out.Data), "\x1b]52;c;aGVsbG8=\x07")
58+}
59+
60+func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) {
61+ d := newTestDaemon(t, nil, stubClock{})
62+ big := strings.Repeat("a", scopy.OSC52MaxPayloadBytes+1)
63+ b64 := base64.StdEncoding.EncodeToString([]byte(big))
64+ p := chunkReadPTY(t, []byte("\x1b]52;c;"+b64+"\x07"))
65+
66+ tr, sends := newCapturingTransport(t)
67+ sctx, cancel := context.WithCancel(context.Background())
68+ defer cancel()
69+ win := newTestTabWithContext(p, sctx, cancel)
70+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
71+ sess := &session{id: "clip-big", name: "clip-big", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
72+ ac.setSession(sess)
73+ d.sessions[sess.id] = sess
74+
75+ d.sessWg.Add(1)
76+ d.ptyReader(sess, win, win.focusedPane())
77+
78+ select {
79+ case f := <-sends:
80+ require.NotEqual(t, ports.MsgOutput, f.Type)
81+ default:
82+ }
83+}
84+
85+func TestPTYReaderDropsInvalidBase64Clipboard(t *testing.T) {
86+ d := newTestDaemon(t, nil, stubClock{})
87+ p := chunkReadPTY(t, []byte("\x1b]52;c;not-valid-base64!!\x07"))
88+
89+ tr, sends := newCapturingTransport(t)
90+ sctx, cancel := context.WithCancel(context.Background())
91+ defer cancel()
92+ win := newTestTabWithContext(p, sctx, cancel)
93+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
94+ sess := &session{id: "clip-bad", name: "clip-bad", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
95+ ac.setSession(sess)
96+ d.sessions[sess.id] = sess
97+
98+ d.sessWg.Add(1)
99+ d.ptyReader(sess, win, win.focusedPane())
100+
101+ select {
102+ case f := <-sends:
103+ require.NotEqual(t, ports.MsgOutput, f.Type)
104+ default:
105+ }
106+}
107+
108+func TestPTYReaderClipboardNoAttachedClientDoesNotPanic(t *testing.T) {
109+ d := newTestDaemon(t, nil, stubClock{})
110+ p := chunkReadPTY(t, []byte("\x1b]52;c;aGVsbG8=\x07"))
111+
112+ sctx, cancel := context.WithCancel(context.Background())
113+ defer cancel()
114+ win := newTestTabWithContext(p, sctx, cancel)
115+ sess := &session{id: "clip-noclient", name: "clip-noclient", tabs: []*tab{win}, ctx: sctx, cancel: cancel}
116+ d.sessions[sess.id] = sess
117+
118+ d.sessWg.Add(1)
119+ require.NotPanics(t, func() {
120+ d.ptyReader(sess, win, win.focusedPane())
121+ })
122+}
M internal/usecase/daemon/render.go +8 -0
@@ -54,11 +54,13 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
5454 }
5555 buf := make([]byte, ptyReadBufSize)
5656 var resp []byte
57+ var clipboards []string
5758 attentionCh := make(chan struct{}, 1)
5859 p.mu.Lock()
5960 p.screen.OnResponse = func(b []byte) { resp = append(resp, b...) }
6061 p.screen.OnBell = func() { signal(attentionCh) }
6162 p.screen.OnNotify = func(string, string) { signal(attentionCh) }
63+ p.screen.OnClipboard = func(b64 string) { clipboards = append(clipboards, b64) }
6264 p.mu.Unlock()
6365 for {
6466 n, err := p.pty.Read(buf)
@@ -74,6 +76,12 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
7476 d.noteAttention(sess, tb)
7577 default:
7678 }
79+ if len(clipboards) > 0 {
80+ for _, b64 := range clipboards {
81+ d.forwardClipboard(sess, b64)
82+ }
83+ clipboards = clipboards[:0]
84+ }
7785 if len(resp) > 0 {
7886 if _, writeErr := p.pty.Write(resp); writeErr != nil {
7987 d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name)
@@ -54,11 +54,13 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
54 }54 }
55 buf := make([]byte, ptyReadBufSize)55 buf := make([]byte, ptyReadBufSize)
56 var resp []byte56 var resp []byte
57+ var clipboards []string
57 attentionCh := make(chan struct{}, 1)58 attentionCh := make(chan struct{}, 1)
58 p.mu.Lock()59 p.mu.Lock()
59 p.screen.OnResponse = func(b []byte) { resp = append(resp, b...) }60 p.screen.OnResponse = func(b []byte) { resp = append(resp, b...) }
60 p.screen.OnBell = func() { signal(attentionCh) }61 p.screen.OnBell = func() { signal(attentionCh) }
61 p.screen.OnNotify = func(string, string) { signal(attentionCh) }62 p.screen.OnNotify = func(string, string) { signal(attentionCh) }
63+ p.screen.OnClipboard = func(b64 string) { clipboards = append(clipboards, b64) }
62 p.mu.Unlock()64 p.mu.Unlock()
63 for {65 for {
64 n, err := p.pty.Read(buf)66 n, err := p.pty.Read(buf)
@@ -74,6 +76,12 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
74 d.noteAttention(sess, tb)76 d.noteAttention(sess, tb)
75 default:77 default:
76 }78 }
79+ if len(clipboards) > 0 {
80+ for _, b64 := range clipboards {
81+ d.forwardClipboard(sess, b64)
82+ }
83+ clipboards = clipboards[:0]
84+ }
77 if len(resp) > 0 {85 if len(resp) > 0 {
78 if _, writeErr := p.pty.Write(resp); writeErr != nil {86 if _, writeErr := p.pty.Write(resp); writeErr != nil {
79 d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name)87 d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name)
M pkg/vt/screen.go +32 -4
@@ -25,7 +25,7 @@ type screenState struct {
2525 savedCursor cursorState
2626 }
2727
28-const maxEscapeBufferLen = 4096
28+const maxEscapeBufferLen = 128 * 1024
2929
3030 type Screen struct {
3131 Frame renderer.Frame
@@ -47,6 +47,11 @@ type Screen struct {
4747 // notifications: OSC 9 (body only) and OSC 777 "notify" (title;body).
4848 // All other OSC payloads remain discarded. Nil disables it.
4949 OnNotify func(title, body string)
50+ // OnClipboard is called synchronously from Write for a complete OSC 52
51+ // clipboard set request from the child ("52;<selection>;<base64>"),
52+ // receiving the raw base64 payload. Clipboard queries (data == "?") and
53+ // malformed payloads are ignored and never invoke it. Nil disables it.
54+ OnClipboard func(b64 string)
5055
5156 defaultFG renderer.RGB
5257 defaultBG renderer.RGB
@@ -625,13 +630,18 @@ func appendOSCColorComponent(dst []byte, c uint8) []byte {
625630 }
626631
627632 // handleOSC inspects a complete OSC payload (between "ESC ]" and its
628-// terminator). Only notification sequences are acted on; titles, clipboard
629-// and every other OSC are still discarded.
633+// terminator). Notification sequences (OSC 9, OSC 777 "notify") and clipboard
634+// set requests (OSC 52) are acted on; titles and every other OSC are still
635+// discarded.
630636 func (s *Screen) handleOSC(payload []byte) {
637+ p := string(payload)
638+ if strings.HasPrefix(p, "52;") {
639+ s.handleOSC52(p[len("52;"):])
640+ return
641+ }
631642 if s.OnNotify == nil {
632643 return
633644 }
634- p := string(payload)
635645 switch {
636646 case strings.HasPrefix(p, "9;"):
637647 if strings.HasPrefix(p, "9;4;") {
@@ -654,6 +664,24 @@ func (s *Screen) handleOSC(payload []byte) {
654664 }
655665 }
656666
667+// handleOSC52 parses the "<selection>;<data>" remainder of an OSC 52
668+// clipboard payload (selection may be empty; split on the first ";"). A
669+// clipboard query (data == "?") is always ignored — vev never answers
670+// clipboard queries. A payload with no second ";" is malformed and ignored.
671+func (s *Screen) handleOSC52(rest string) {
672+ idx := strings.IndexByte(rest, ';')
673+ if idx < 0 {
674+ return
675+ }
676+ data := rest[idx+1:]
677+ if data == "?" {
678+ return
679+ }
680+ if s.OnClipboard != nil {
681+ s.OnClipboard(data)
682+ }
683+}
684+
657685 func consumeSTString(data []byte) (consumed int, partial bool) {
658686 for i := 2; i < len(data); i++ {
659687 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
@@ -25,7 +25,7 @@ type screenState struct {
25 savedCursor cursorState25 savedCursor cursorState
26 }26 }
27 27
28-const maxEscapeBufferLen = 409628+const maxEscapeBufferLen = 128 * 1024
29 29
30 type Screen struct {30 type Screen struct {
31 Frame renderer.Frame31 Frame renderer.Frame
@@ -47,6 +47,11 @@ type Screen struct {
47 // notifications: OSC 9 (body only) and OSC 777 "notify" (title;body).47 // notifications: OSC 9 (body only) and OSC 777 "notify" (title;body).
48 // All other OSC payloads remain discarded. Nil disables it.48 // All other OSC payloads remain discarded. Nil disables it.
49 OnNotify func(title, body string)49 OnNotify func(title, body string)
50+ // OnClipboard is called synchronously from Write for a complete OSC 52
51+ // clipboard set request from the child ("52;<selection>;<base64>"),
52+ // receiving the raw base64 payload. Clipboard queries (data == "?") and
53+ // malformed payloads are ignored and never invoke it. Nil disables it.
54+ OnClipboard func(b64 string)
50 55
51 defaultFG renderer.RGB56 defaultFG renderer.RGB
52 defaultBG renderer.RGB57 defaultBG renderer.RGB
@@ -625,13 +630,18 @@ func appendOSCColorComponent(dst []byte, c uint8) []byte {
625 }630 }
626 631
627 // handleOSC inspects a complete OSC payload (between "ESC ]" and its632 // handleOSC inspects a complete OSC payload (between "ESC ]" and its
628-// terminator). Only notification sequences are acted on; titles, clipboard633+// terminator). Notification sequences (OSC 9, OSC 777 "notify") and clipboard
629-// and every other OSC are still discarded.634+// set requests (OSC 52) are acted on; titles and every other OSC are still
635+// discarded.
630 func (s *Screen) handleOSC(payload []byte) {636 func (s *Screen) handleOSC(payload []byte) {
637+ p := string(payload)
638+ if strings.HasPrefix(p, "52;") {
639+ s.handleOSC52(p[len("52;"):])
640+ return
641+ }
631 if s.OnNotify == nil {642 if s.OnNotify == nil {
632 return643 return
633 }644 }
634- p := string(payload)
635 switch {645 switch {
636 case strings.HasPrefix(p, "9;"):646 case strings.HasPrefix(p, "9;"):
637 if strings.HasPrefix(p, "9;4;") {647 if strings.HasPrefix(p, "9;4;") {
@@ -654,6 +664,24 @@ func (s *Screen) handleOSC(payload []byte) {
654 }664 }
655 }665 }
656 666
667+// handleOSC52 parses the "<selection>;<data>" remainder of an OSC 52
668+// clipboard payload (selection may be empty; split on the first ";"). A
669+// clipboard query (data == "?") is always ignored — vev never answers
670+// clipboard queries. A payload with no second ";" is malformed and ignored.
671+func (s *Screen) handleOSC52(rest string) {
672+ idx := strings.IndexByte(rest, ';')
673+ if idx < 0 {
674+ return
675+ }
676+ data := rest[idx+1:]
677+ if data == "?" {
678+ return
679+ }
680+ if s.OnClipboard != nil {
681+ s.OnClipboard(data)
682+ }
683+}
684+
657 func consumeSTString(data []byte) (consumed int, partial bool) {685 func consumeSTString(data []byte) (consumed int, partial bool) {
658 for i := 2; i < len(data); i++ {686 for i := 2; i < len(data); i++ {
659 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {687 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
M pkg/vt/screen_test.go +64 -0
@@ -157,6 +157,70 @@ func TestOnNotifySplitAcrossWrites(t *testing.T) {
157157 require.Equal(t, 1, calls)
158158 }
159159
160+func TestOnClipboardBELTerminated(t *testing.T) {
161+ s := NewScreen(10, 2)
162+ var got string
163+ calls := 0
164+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
165+ s.Write([]byte("\x1b]52;c;aGVsbG8=\x07"))
166+ require.Equal(t, 1, calls)
167+ require.Equal(t, "aGVsbG8=", got)
168+}
169+
170+func TestOnClipboardSTTerminated(t *testing.T) {
171+ s := NewScreen(10, 2)
172+ var got string
173+ calls := 0
174+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
175+ s.Write([]byte("\x1b]52;c;aGVsbG8=\x1b\\"))
176+ require.Equal(t, 1, calls)
177+ require.Equal(t, "aGVsbG8=", got)
178+}
179+
180+func TestOnClipboardQueryIgnored(t *testing.T) {
181+ s := NewScreen(10, 2)
182+ calls := 0
183+ s.OnClipboard = func(string) { calls++ }
184+ s.Write([]byte("\x1b]52;c;?\x07"))
185+ require.Equal(t, 0, calls)
186+}
187+
188+func TestOnClipboardEmptySelection(t *testing.T) {
189+ s := NewScreen(10, 2)
190+ var got string
191+ calls := 0
192+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
193+ s.Write([]byte("\x1b]52;;aGVsbG8=\x07"))
194+ require.Equal(t, 1, calls)
195+ require.Equal(t, "aGVsbG8=", got)
196+}
197+
198+func TestOnClipboardNilCallbackDoesNotPanic(t *testing.T) {
199+ s := NewScreen(10, 2)
200+ require.NotPanics(t, func() {
201+ s.Write([]byte("\x1b]52;c;aGVsbG8=\x07"))
202+ })
203+}
204+
205+func TestOnClipboardSplitAcrossWrites(t *testing.T) {
206+ s := NewScreen(10, 2)
207+ var got string
208+ calls := 0
209+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
210+ s.Write([]byte("\x1b]52;c;aGVs"))
211+ s.Write([]byte("bG8=\x07"))
212+ require.Equal(t, 1, calls)
213+ require.Equal(t, "aGVsbG8=", got)
214+}
215+
216+func TestOnClipboardNoSecondSemicolonIgnored(t *testing.T) {
217+ s := NewScreen(10, 2)
218+ calls := 0
219+ s.OnClipboard = func(string) { calls++ }
220+ s.Write([]byte("\x1b]52;c\x07")) // no second ";" -> no data field
221+ require.Equal(t, 0, calls)
222+}
223+
160224 // ---------------------------------------------------------------------------
161225 // Printable ASCII / UTF-8
162226 // ---------------------------------------------------------------------------
@@ -157,6 +157,70 @@ func TestOnNotifySplitAcrossWrites(t *testing.T) {
157 require.Equal(t, 1, calls)157 require.Equal(t, 1, calls)
158 }158 }
159 159
160+func TestOnClipboardBELTerminated(t *testing.T) {
161+ s := NewScreen(10, 2)
162+ var got string
163+ calls := 0
164+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
165+ s.Write([]byte("\x1b]52;c;aGVsbG8=\x07"))
166+ require.Equal(t, 1, calls)
167+ require.Equal(t, "aGVsbG8=", got)
168+}
169+
170+func TestOnClipboardSTTerminated(t *testing.T) {
171+ s := NewScreen(10, 2)
172+ var got string
173+ calls := 0
174+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
175+ s.Write([]byte("\x1b]52;c;aGVsbG8=\x1b\\"))
176+ require.Equal(t, 1, calls)
177+ require.Equal(t, "aGVsbG8=", got)
178+}
179+
180+func TestOnClipboardQueryIgnored(t *testing.T) {
181+ s := NewScreen(10, 2)
182+ calls := 0
183+ s.OnClipboard = func(string) { calls++ }
184+ s.Write([]byte("\x1b]52;c;?\x07"))
185+ require.Equal(t, 0, calls)
186+}
187+
188+func TestOnClipboardEmptySelection(t *testing.T) {
189+ s := NewScreen(10, 2)
190+ var got string
191+ calls := 0
192+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
193+ s.Write([]byte("\x1b]52;;aGVsbG8=\x07"))
194+ require.Equal(t, 1, calls)
195+ require.Equal(t, "aGVsbG8=", got)
196+}
197+
198+func TestOnClipboardNilCallbackDoesNotPanic(t *testing.T) {
199+ s := NewScreen(10, 2)
200+ require.NotPanics(t, func() {
201+ s.Write([]byte("\x1b]52;c;aGVsbG8=\x07"))
202+ })
203+}
204+
205+func TestOnClipboardSplitAcrossWrites(t *testing.T) {
206+ s := NewScreen(10, 2)
207+ var got string
208+ calls := 0
209+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
210+ s.Write([]byte("\x1b]52;c;aGVs"))
211+ s.Write([]byte("bG8=\x07"))
212+ require.Equal(t, 1, calls)
213+ require.Equal(t, "aGVsbG8=", got)
214+}
215+
216+func TestOnClipboardNoSecondSemicolonIgnored(t *testing.T) {
217+ s := NewScreen(10, 2)
218+ calls := 0
219+ s.OnClipboard = func(string) { calls++ }
220+ s.Write([]byte("\x1b]52;c\x07")) // no second ";" -> no data field
221+ require.Equal(t, 0, calls)
222+}
223+
160 // ---------------------------------------------------------------------------224 // ---------------------------------------------------------------------------
161 // Printable ASCII / UTF-8225 // Printable ASCII / UTF-8
162 // ---------------------------------------------------------------------------226 // ---------------------------------------------------------------------------

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