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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 239 | 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 | 254 | func strconvItoa(n int) string { | |
| 243 | 255 | if n == 0 { | |
| 244 | 256 | 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) { | |||
| 54 | 54 | } | |
| 55 | 55 | buf := make([]byte, ptyReadBufSize) | |
| 56 | 56 | var resp []byte | |
| 57 | + | var clipboards []string | |
| 57 | 58 | attentionCh := make(chan struct{}, 1) | |
| 58 | 59 | p.mu.Lock() | |
| 59 | 60 | p.screen.OnResponse = func(b []byte) { resp = append(resp, b...) } | |
| 60 | 61 | p.screen.OnBell = func() { signal(attentionCh) } | |
| 61 | 62 | p.screen.OnNotify = func(string, string) { signal(attentionCh) } | |
| 63 | + | p.screen.OnClipboard = func(b64 string) { clipboards = append(clipboards, b64) } | |
| 62 | 64 | p.mu.Unlock() | |
| 63 | 65 | for { | |
| 64 | 66 | n, err := p.pty.Read(buf) | |
| @@ -74,6 +76,12 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) { | |||
| 74 | 76 | d.noteAttention(sess, tb) | |
| 75 | 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 | 85 | if len(resp) > 0 { | |
| 78 | 86 | if _, writeErr := p.pty.Write(resp); writeErr != nil { | |
| 79 | 87 | 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 []byte | 56 | 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 { | |||
| 25 | 25 | savedCursor cursorState | |
| 26 | 26 | } | |
| 27 | 27 | ||
| 28 | - | const maxEscapeBufferLen = 4096 | |
| 28 | + | const maxEscapeBufferLen = 128 * 1024 | |
| 29 | 29 | ||
| 30 | 30 | type Screen struct { | |
| 31 | 31 | Frame renderer.Frame | |
| @@ -47,6 +47,11 @@ type Screen struct { | |||
| 47 | 47 | // notifications: OSC 9 (body only) and OSC 777 "notify" (title;body). | |
| 48 | 48 | // All other OSC payloads remain discarded. Nil disables it. | |
| 49 | 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 | 56 | defaultFG renderer.RGB | |
| 52 | 57 | defaultBG renderer.RGB | |
| @@ -625,13 +630,18 @@ func appendOSCColorComponent(dst []byte, c uint8) []byte { | |||
| 625 | 630 | } | |
| 626 | 631 | ||
| 627 | 632 | // 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. | |
| 630 | 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 | 642 | if s.OnNotify == nil { | |
| 632 | 643 | return | |
| 633 | 644 | } | |
| 634 | - | p := string(payload) | |
| 635 | 645 | switch { | |
| 636 | 646 | case strings.HasPrefix(p, "9;"): | |
| 637 | 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 | 685 | func consumeSTString(data []byte) (consumed int, partial bool) { | |
| 658 | 686 | for i := 2; i < len(data); i++ { | |
| 659 | 687 | if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' { | |
| @@ -25,7 +25,7 @@ type screenState struct { | |||||
| 25 | savedCursor cursorState | 25 | savedCursor cursorState | ||
| 26 | } | 26 | } | ||
| 27 | 27 | ||||
| 28 | - | const maxEscapeBufferLen = 4096 | 28 | + | const maxEscapeBufferLen = 128 * 1024 |
| 29 | 29 | ||||
| 30 | type Screen struct { | 30 | type Screen struct { | ||
| 31 | Frame renderer.Frame | 31 | 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.RGB | 56 | defaultFG renderer.RGB | ||
| 52 | defaultBG renderer.RGB | 57 | 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 its | 632 | // handleOSC inspects a complete OSC payload (between "ESC ]" and its | ||
| 628 | - | // terminator). Only notification sequences are acted on; titles, clipboard | 633 | + | // 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 | return | 643 | 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) { | |||
| 157 | 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 | 225 | // Printable ASCII / UTF-8 | |
| 162 | 226 | // --------------------------------------------------------------------------- | |
| @@ -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-8 | 225 | // Printable ASCII / UTF-8 | ||
| 162 | // --------------------------------------------------------------------------- | 226 | // --------------------------------------------------------------------------- | ||