detached f87757e

fix(client,keys): keep bracketed paste markers intact across input frames

Over a remote attach a multiline bracketed paste reached the pane app
line-by-line. The client frames stdin in fixed 4096-byte reads and the
daemon keys router retains a trailing ESC for ESCDelay (40ms) so split
Alt-key sequences can still be intercepted. When an input-frame boundary
landed between a paste marker's ESC and its '[' and the next frame arrived
later than the retention window (ssh jitter / terminal chunking), the router
forwarded a lone ESC to the pane as its own write. TUIs read a lone-ESC as
the Esc key; a broken opening marker meant the app never entered paste mode,
so every '\n' submitted a line. The daemon cannot fix the opening-marker
case alone: while holding a bare trailing ESC it cannot know '[200~' is
coming.

Part A (primary, client): add a paste coalescer to the stdin pump so a whole
bracketed paste is framed as one MsgInput. It follows the theme.Scanner
stdin-scanning precedent, sits between the theme scanner's byte output and
the MsgInput framing, and is driven by an injected ports.Clock/ports.Timer
(no wall-clock). Outside a paste it passes bytes through; a trailing strict
prefix of the opening marker is held briefly (bridging a local kernel read
split) and recombined when the rest arrives, or flushed verbatim if it was a
real Esc. Once the opening marker completes it buffers the whole paste until
the closing marker, with 2 MiB / 2s safety valves that degrade to today's
behavior and never drop bytes. The clock is threaded through client.Run /
Attach from the app layer.

Part B (defense in depth, keys): when the router sees a complete bracketed
paste within one frame, forward the whole span (both markers inclusive)
verbatim so pasted content — including embedded Alt-binding or CSI
lookalikes — can never fire an Action mid-paste. A marker split across
frames keeps today's per-byte behavior; Part A makes single-frame pastes the
norm.
commit
f87757e0279d58740fd1701f8a03c36b4754f4b7
author
brice · · 2026-07-04 14:22 UTC
parent
51529aa
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +600 -40
M internal/app/run.go +3 -3
@@ -285,7 +285,7 @@ type runAttachDeps struct {
285285 attachLocal func(context.Context, uint8, string) error // compatibility hook for focused tests
286286 localDialer func() ports.Dialer
287287 remoteDialer func(target, session string) ports.Dialer
288- runClient func(context.Context, ports.Dialer, ports.Terminal, uint8, string) error
288+ runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string) error
289289 createDetached func(context.Context, string) error
290290 }
291291
@@ -306,7 +306,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
306306 if remoteDialer == nil {
307307 remoteDialer = defaultRemoteDialer
308308 }
309- return runClient(ctx, remoteDialer(remoteTarget, name), term.New(), intent, name)
309+ return runClient(ctx, remoteDialer(remoteTarget, name), term.New(), clock.New(), intent, name)
310310 }
311311
312312 attachLocal := deps.attachLocal
@@ -316,7 +316,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
316316 localDialer = defaultLocalDialer
317317 }
318318 attachLocal = func(ctx context.Context, intent uint8, name string) error {
319- return runClient(ctx, localDialer(), term.New(), intent, name)
319+ return runClient(ctx, localDialer(), term.New(), clock.New(), intent, name)
320320 }
321321 }
322322 return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{
@@ -285,7 +285,7 @@ type runAttachDeps struct {
285 attachLocal func(context.Context, uint8, string) error // compatibility hook for focused tests285 attachLocal func(context.Context, uint8, string) error // compatibility hook for focused tests
286 localDialer func() ports.Dialer286 localDialer func() ports.Dialer
287 remoteDialer func(target, session string) ports.Dialer287 remoteDialer func(target, session string) ports.Dialer
288- runClient func(context.Context, ports.Dialer, ports.Terminal, uint8, string) error288+ runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string) error
289 createDetached func(context.Context, string) error289 createDetached func(context.Context, string) error
290 }290 }
291 291
@@ -306,7 +306,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
306 if remoteDialer == nil {306 if remoteDialer == nil {
307 remoteDialer = defaultRemoteDialer307 remoteDialer = defaultRemoteDialer
308 }308 }
309- return runClient(ctx, remoteDialer(remoteTarget, name), term.New(), intent, name)309+ return runClient(ctx, remoteDialer(remoteTarget, name), term.New(), clock.New(), intent, name)
310 }310 }
311 311
312 attachLocal := deps.attachLocal312 attachLocal := deps.attachLocal
@@ -316,7 +316,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
316 localDialer = defaultLocalDialer316 localDialer = defaultLocalDialer
317 }317 }
318 attachLocal = func(ctx context.Context, intent uint8, name string) error {318 attachLocal = func(ctx context.Context, intent uint8, name string) error {
319- return runClient(ctx, localDialer(), term.New(), intent, name)319+ return runClient(ctx, localDialer(), term.New(), clock.New(), intent, name)
320 }320 }
321 }321 }
322 return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{322 return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{
M internal/app/run_test.go +2 -2
@@ -400,7 +400,7 @@ func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) {
400400 gotTarget, gotSession = target, session
401401 return namedDialer{name: "remote"}
402402 },
403- runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, intent uint8, name string) error {
403+ runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string) error {
404404 gotDialer = d.(namedDialer).name
405405 if intent != ports.IntentAttach || name != "work" {
406406 t.Fatalf("intent/name = %d/%q, want attach/work", intent, name)
@@ -420,7 +420,7 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
420420 var gotDialer string
421421 err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", runAttachDeps{
422422 localDialer: func() ports.Dialer { return namedDialer{name: "local"} },
423- runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, intent uint8, name string) error {
423+ runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string) error {
424424 gotDialer = d.(namedDialer).name
425425 if intent != ports.IntentEphemeral || name != "" {
426426 t.Fatalf("intent/name = %d/%q, want ephemeral/empty", intent, name)
@@ -400,7 +400,7 @@ func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) {
400 gotTarget, gotSession = target, session400 gotTarget, gotSession = target, session
401 return namedDialer{name: "remote"}401 return namedDialer{name: "remote"}
402 },402 },
403- runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, intent uint8, name string) error {403+ runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string) error {
404 gotDialer = d.(namedDialer).name404 gotDialer = d.(namedDialer).name
405 if intent != ports.IntentAttach || name != "work" {405 if intent != ports.IntentAttach || name != "work" {
406 t.Fatalf("intent/name = %d/%q, want attach/work", intent, name)406 t.Fatalf("intent/name = %d/%q, want attach/work", intent, name)
@@ -420,7 +420,7 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
420 var gotDialer string420 var gotDialer string
421 err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", runAttachDeps{421 err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", runAttachDeps{
422 localDialer: func() ports.Dialer { return namedDialer{name: "local"} },422 localDialer: func() ports.Dialer { return namedDialer{name: "local"} },
423- runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, intent uint8, name string) error {423+ runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string) error {
424 gotDialer = d.(namedDialer).name424 gotDialer = d.(namedDialer).name
425 if intent != ports.IntentEphemeral || name != "" {425 if intent != ports.IntentEphemeral || name != "" {
426 t.Fatalf("intent/name = %d/%q, want ephemeral/empty", intent, name)426 t.Fatalf("intent/name = %d/%q, want ephemeral/empty", intent, name)
M internal/usecase/client/client.go +27 -21
@@ -16,6 +16,7 @@ import (
1616 "log/slog"
1717 "os"
1818 "strings"
19+ "sync/atomic"
1920 "time"
2021
2122 "github.com/bnema/vev/internal/domain"
@@ -118,7 +119,7 @@ const (
118119 // Run connects via dialer and runs the attach client. It owns the terminal
119120 // lifecycle above attach attempts so raw mode remains active while a live
120121 // client process redials a lost link.
121-func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, intent uint8, name string) (retErr error) {
122+func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string) (retErr error) {
122123 log := slog.Default().With("component", "client")
123124 ms := milestones{}
124125
@@ -188,7 +189,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, intent u
188189 }
189190 ms.dialed = true
190191
191- result := attachOnce(ctx, transport, term, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus)
192+ result := attachOnce(ctx, transport, term, clk, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus)
192193 if result.welcomed {
193194 backoff = defaultReconnectBackoff.initial
194195 }
@@ -249,8 +250,8 @@ func shouldReconnect(err error) bool {
249250 // disappears, or the context is cancelled.
250251 //
251252 // It is kept as a compatibility wrapper for callers that still own dialing.
252-func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, intent uint8, name string) error {
253- return Run(ctx, singleTransportDialer{transport: transport}, term, intent, name)
253+func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string) error {
254+ return Run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name)
254255 }
255256
256257 type singleTransportDialer struct{ transport ports.Transport }
@@ -265,7 +266,7 @@ type attachResult struct {
265266 err error
266267 }
267268
268-func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, enterRaw func() error, clearStatus func()) attachResult {
269+func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, enterRaw func() error, clearStatus func()) attachResult {
269270 // 1. Handshake: send Hello with our size and TERM.
270271 size, err := term.Size()
271272 if err != nil {
@@ -331,7 +332,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
331332 sendErrCh := make(chan error, 1)
332333
333334 go runSender(loopCtx, cancel, transport, sendCh, sendErrCh)
334- go runStdin(loopCtx, cancel, term.In(), sendCh, trueColor)
335+ go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor)
335336 go runResize(loopCtx, term.ResizeEvents(), sendCh)
336337
337338 // 5. Output/main loop: the only goroutine that touches the terminal.
@@ -445,23 +446,35 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
445446 // exits. That is harmless here — Attach has already returned and restored
446447 // the terminal — and matches the standard pattern for stdin pumps; a
447448 // closable stdin duplicate could lift it later if ever needed.
448-func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, trueColor bool) {
449+func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool) {
449450 buf := make([]byte, stdinBufSize)
450451 var scanner theme.Scanner
451452 var inputSeq uint64
452453 current := ports.Theme{TrueColor: trueColor}
453- send := func(frame ports.Frame) bool {
454+ var sendOK atomic.Bool
455+ sendOK.Store(true)
456+ send := func(frame ports.Frame) {
454457 select {
455458 case out <- frame:
456- return true
457459 case <-ctx.Done():
458- return false
460+ sendOK.Store(false)
459461 }
460462 }
463+ // The coalescer reframes a bracketed paste split across reads into one
464+ // MsgInput frame, so a marker boundary can never leave a lone ESC on the
465+ // wire. Its emit runs one MsgInput per call, keeping the inputSeq contract.
466+ coalescer := newPasteCoalescer(clk, func(data []byte) {
467+ if len(data) == 0 {
468+ return
469+ }
470+ copyData := append([]byte(nil), data...)
471+ inputSeq++
472+ send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: copyData})})
473+ })
474+ defer coalescer.Close()
461475 for {
462476 n, rerr := in.Read(buf)
463477 if n > 0 {
464- ok := true
465478 scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) {
466479 switch kind {
467480 case 10:
@@ -473,16 +486,9 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
473486 default:
474487 return
475488 }
476- ok = send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)})
477- }, func(data []byte) {
478- if len(data) == 0 || !ok {
479- return
480- }
481- copyData := append([]byte(nil), data...)
482- inputSeq++
483- ok = send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: copyData})})
484- })
485- if !ok {
489+ send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)})
490+ }, coalescer.Scan)
491+ if !sendOK.Load() {
486492 return
487493 }
488494 }
@@ -16,6 +16,7 @@ import (
16 "log/slog"16 "log/slog"
17 "os"17 "os"
18 "strings"18 "strings"
19+ "sync/atomic"
19 "time"20 "time"
20 21
21 "github.com/bnema/vev/internal/domain"22 "github.com/bnema/vev/internal/domain"
@@ -118,7 +119,7 @@ const (
118 // Run connects via dialer and runs the attach client. It owns the terminal119 // Run connects via dialer and runs the attach client. It owns the terminal
119 // lifecycle above attach attempts so raw mode remains active while a live120 // lifecycle above attach attempts so raw mode remains active while a live
120 // client process redials a lost link.121 // client process redials a lost link.
121-func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, intent uint8, name string) (retErr error) {122+func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string) (retErr error) {
122 log := slog.Default().With("component", "client")123 log := slog.Default().With("component", "client")
123 ms := milestones{}124 ms := milestones{}
124 125
@@ -188,7 +189,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, intent u
188 }189 }
189 ms.dialed = true190 ms.dialed = true
190 191
191- result := attachOnce(ctx, transport, term, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus)192+ result := attachOnce(ctx, transport, term, clk, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus)
192 if result.welcomed {193 if result.welcomed {
193 backoff = defaultReconnectBackoff.initial194 backoff = defaultReconnectBackoff.initial
194 }195 }
@@ -249,8 +250,8 @@ func shouldReconnect(err error) bool {
249 // disappears, or the context is cancelled.250 // disappears, or the context is cancelled.
250 //251 //
251 // It is kept as a compatibility wrapper for callers that still own dialing.252 // It is kept as a compatibility wrapper for callers that still own dialing.
252-func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, intent uint8, name string) error {253+func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string) error {
253- return Run(ctx, singleTransportDialer{transport: transport}, term, intent, name)254+ return Run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name)
254 }255 }
255 256
256 type singleTransportDialer struct{ transport ports.Transport }257 type singleTransportDialer struct{ transport ports.Transport }
@@ -265,7 +266,7 @@ type attachResult struct {
265 err error266 err error
266 }267 }
267 268
268-func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, enterRaw func() error, clearStatus func()) attachResult {269+func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, enterRaw func() error, clearStatus func()) attachResult {
269 // 1. Handshake: send Hello with our size and TERM.270 // 1. Handshake: send Hello with our size and TERM.
270 size, err := term.Size()271 size, err := term.Size()
271 if err != nil {272 if err != nil {
@@ -331,7 +332,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
331 sendErrCh := make(chan error, 1)332 sendErrCh := make(chan error, 1)
332 333
333 go runSender(loopCtx, cancel, transport, sendCh, sendErrCh)334 go runSender(loopCtx, cancel, transport, sendCh, sendErrCh)
334- go runStdin(loopCtx, cancel, term.In(), sendCh, trueColor)335+ go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor)
335 go runResize(loopCtx, term.ResizeEvents(), sendCh)336 go runResize(loopCtx, term.ResizeEvents(), sendCh)
336 337
337 // 5. Output/main loop: the only goroutine that touches the terminal.338 // 5. Output/main loop: the only goroutine that touches the terminal.
@@ -445,23 +446,35 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
445 // exits. That is harmless here — Attach has already returned and restored446 // exits. That is harmless here — Attach has already returned and restored
446 // the terminal — and matches the standard pattern for stdin pumps; a447 // the terminal — and matches the standard pattern for stdin pumps; a
447 // closable stdin duplicate could lift it later if ever needed.448 // closable stdin duplicate could lift it later if ever needed.
448-func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, trueColor bool) {449+func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool) {
449 buf := make([]byte, stdinBufSize)450 buf := make([]byte, stdinBufSize)
450 var scanner theme.Scanner451 var scanner theme.Scanner
451 var inputSeq uint64452 var inputSeq uint64
452 current := ports.Theme{TrueColor: trueColor}453 current := ports.Theme{TrueColor: trueColor}
453- send := func(frame ports.Frame) bool {454+ var sendOK atomic.Bool
455+ sendOK.Store(true)
456+ send := func(frame ports.Frame) {
454 select {457 select {
455 case out <- frame:458 case out <- frame:
456- return true
457 case <-ctx.Done():459 case <-ctx.Done():
458- return false460+ sendOK.Store(false)
459 }461 }
460 }462 }
463+ // The coalescer reframes a bracketed paste split across reads into one
464+ // MsgInput frame, so a marker boundary can never leave a lone ESC on the
465+ // wire. Its emit runs one MsgInput per call, keeping the inputSeq contract.
466+ coalescer := newPasteCoalescer(clk, func(data []byte) {
467+ if len(data) == 0 {
468+ return
469+ }
470+ copyData := append([]byte(nil), data...)
471+ inputSeq++
472+ send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: copyData})})
473+ })
474+ defer coalescer.Close()
461 for {475 for {
462 n, rerr := in.Read(buf)476 n, rerr := in.Read(buf)
463 if n > 0 {477 if n > 0 {
464- ok := true
465 scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) {478 scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) {
466 switch kind {479 switch kind {
467 case 10:480 case 10:
@@ -473,16 +486,9 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
473 default:486 default:
474 return487 return
475 }488 }
476- ok = send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)})489+ send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)})
477- }, func(data []byte) {490+ }, coalescer.Scan)
478- if len(data) == 0 || !ok {491+ if !sendOK.Load() {
479- return
480- }
481- copyData := append([]byte(nil), data...)
482- inputSeq++
483- ok = send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: copyData})})
484- })
485- if !ok {
486 return492 return
487 }493 }
488 }494 }
M internal/usecase/client/client_test.go +29 -14
@@ -25,6 +25,21 @@ func init() {
2525 slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil)))
2626 }
2727
28+// realClock is a stdlib-backed ports.Clock for tests that exercise the stdin
29+// pump end to end; the paste coalescer's flush timer runs on wall time here.
30+type realClock struct{}
31+
32+func (realClock) Now() time.Time { return time.Now() }
33+func (realClock) NewTimer(d time.Duration) ports.Timer {
34+ return &realTimer{t: time.NewTimer(d)}
35+}
36+
37+type realTimer struct{ t *time.Timer }
38+
39+func (r *realTimer) C() <-chan time.Time { return r.t.C }
40+func (r *realTimer) Reset(d time.Duration) bool { return r.t.Reset(d) }
41+func (r *realTimer) Stop() bool { return r.t.Stop() }
42+
2843 // recvItem is one scripted Recv result.
2944 type recvItem struct {
3045 f ports.Frame
@@ -128,7 +143,7 @@ func TestAttachHappyPath(t *testing.T) {
128143 defer unblock()
129144 tr.EXPECT().Close().Return(nil).Once()
130145
131- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")
146+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
132147 require.NoError(t, err)
133148 require.Equal(t, "hello world", out.String())
134149 require.Equal(t, int32(1), restoreCount.Load(), "restore must run exactly once")
@@ -147,7 +162,7 @@ func TestAttachVersionMismatch(t *testing.T) {
147162 ).Once()
148163 tr.EXPECT().Close().Return(nil).Once()
149164
150- err := client.Attach(context.Background(), tr, tm, ports.IntentAttach, "main")
165+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentAttach, "main")
151166 require.Error(t, err)
152167 var pe *client.ProtocolError
153168 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
@@ -171,7 +186,7 @@ func TestAttachRestoredOnRecvErrorMidStream(t *testing.T) {
171186 defer unblock()
172187 tr.EXPECT().Close().Return(nil).Once()
173188
174- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")
189+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
175190 require.Error(t, err)
176191 require.Equal(t, int32(1), restoreCount.Load(), "restore must run after mid-stream Recv error")
177192 }
@@ -192,7 +207,7 @@ func TestAttachDaemonVanishedOnEOF(t *testing.T) {
192207 defer unblock()
193208 tr.EXPECT().Close().Return(nil).Once()
194209
195- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")
210+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
196211 require.Error(t, err)
197212 require.ErrorIs(t, err, io.EOF)
198213 require.Equal(t, int32(1), restoreCount.Load())
@@ -264,7 +279,7 @@ func TestAttachStdinOSCColorResponseSendsThemeAndPreservesInput(t *testing.T) {
264279 }).Maybe()
265280 tr.EXPECT().Close().Return(nil).Once()
266281
267- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")
282+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
268283 require.NoError(t, err)
269284 require.Equal(t, int32(1), restoreCount.Load())
270285
@@ -342,7 +357,7 @@ func TestAttachStdinThemeTrueColorFalseWhenCOLORTERMNotTruecolor(t *testing.T) {
342357 }).Maybe()
343358 tr.EXPECT().Close().Return(nil).Once()
344359
345- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")
360+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
346361 require.NoError(t, err)
347362 select {
348363 case th := <-gotTheme:
@@ -402,7 +417,7 @@ func TestAttachForwardsStandaloneEscapeInput(t *testing.T) {
402417 }).Maybe()
403418 tr.EXPECT().Close().Return(nil).Once()
404419
405- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")
420+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
406421 require.NoError(t, err)
407422 select {
408423 case got := <-gotInput:
@@ -461,7 +476,7 @@ func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) {
461476 }).Maybe()
462477 tr.EXPECT().Close().Return(nil).Once()
463478
464- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")
479+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
465480 require.NoError(t, err)
466481 select {
467482 case got := <-gotInput:
@@ -521,7 +536,7 @@ func TestAttachForwardsResize(t *testing.T) {
521536 resizeCh <- domain.Size{Cols: 120, Rows: 40}
522537 }()
523538
524- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")
539+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
525540 require.NoError(t, err)
526541 select {
527542 case r := <-gotResize:
@@ -550,7 +565,7 @@ func TestRunRestoresRawModeAfterAttachError(t *testing.T) {
550565 d := portsmocks.NewMockDialer(t)
551566 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
552567
553- err := client.Run(context.Background(), d, tm, ports.IntentEphemeral, "")
568+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "")
554569 require.Error(t, err)
555570 require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors")
556571 }
@@ -570,7 +585,7 @@ func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) {
570585 d := portsmocks.NewMockDialer(t)
571586 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
572587
573- err := client.Run(context.Background(), d, tm, ports.IntentAttach, "main")
588+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentAttach, "main")
574589 require.Error(t, err)
575590 var pe *client.ProtocolError
576591 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
@@ -582,7 +597,7 @@ func TestRunPhaseASingleAttempt(t *testing.T) {
582597 d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once()
583598 tm := portsmocks.NewMockTerminal(t)
584599
585- err := client.Run(context.Background(), d, tm, ports.IntentEphemeral, "")
600+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "")
586601 require.ErrorIs(t, err, dialErr)
587602 }
588603
@@ -671,7 +686,7 @@ func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) {
671686 tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}}
672687 d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}}
673688
674- err := client.Run(context.Background(), d, term, ports.IntentAttach, "main")
689+ err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main")
675690 require.NoError(t, err)
676691 require.Equal(t, int32(3), d.calls.Load())
677692 require.Equal(t, int32(1), term.rawCount.Load())
@@ -698,7 +713,7 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) {
698713 tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}}
699714 d := &sequenceDialer{trs: []ports.Transport{tr}}
700715
701- err := client.Run(context.Background(), d, term, ports.IntentAttach, "main")
716+ err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main")
702717 require.Error(t, err)
703718 var de *client.DetachedError
704719 require.True(t, errors.As(err, &de))
@@ -25,6 +25,21 @@ func init() {
25 slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil)))25 slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil)))
26 }26 }
27 27
28+// realClock is a stdlib-backed ports.Clock for tests that exercise the stdin
29+// pump end to end; the paste coalescer's flush timer runs on wall time here.
30+type realClock struct{}
31+
32+func (realClock) Now() time.Time { return time.Now() }
33+func (realClock) NewTimer(d time.Duration) ports.Timer {
34+ return &realTimer{t: time.NewTimer(d)}
35+}
36+
37+type realTimer struct{ t *time.Timer }
38+
39+func (r *realTimer) C() <-chan time.Time { return r.t.C }
40+func (r *realTimer) Reset(d time.Duration) bool { return r.t.Reset(d) }
41+func (r *realTimer) Stop() bool { return r.t.Stop() }
42+
28 // recvItem is one scripted Recv result.43 // recvItem is one scripted Recv result.
29 type recvItem struct {44 type recvItem struct {
30 f ports.Frame45 f ports.Frame
@@ -128,7 +143,7 @@ func TestAttachHappyPath(t *testing.T) {
128 defer unblock()143 defer unblock()
129 tr.EXPECT().Close().Return(nil).Once()144 tr.EXPECT().Close().Return(nil).Once()
130 145
131- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")146+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
132 require.NoError(t, err)147 require.NoError(t, err)
133 require.Equal(t, "hello world", out.String())148 require.Equal(t, "hello world", out.String())
134 require.Equal(t, int32(1), restoreCount.Load(), "restore must run exactly once")149 require.Equal(t, int32(1), restoreCount.Load(), "restore must run exactly once")
@@ -147,7 +162,7 @@ func TestAttachVersionMismatch(t *testing.T) {
147 ).Once()162 ).Once()
148 tr.EXPECT().Close().Return(nil).Once()163 tr.EXPECT().Close().Return(nil).Once()
149 164
150- err := client.Attach(context.Background(), tr, tm, ports.IntentAttach, "main")165+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentAttach, "main")
151 require.Error(t, err)166 require.Error(t, err)
152 var pe *client.ProtocolError167 var pe *client.ProtocolError
153 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)168 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
@@ -171,7 +186,7 @@ func TestAttachRestoredOnRecvErrorMidStream(t *testing.T) {
171 defer unblock()186 defer unblock()
172 tr.EXPECT().Close().Return(nil).Once()187 tr.EXPECT().Close().Return(nil).Once()
173 188
174- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")189+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
175 require.Error(t, err)190 require.Error(t, err)
176 require.Equal(t, int32(1), restoreCount.Load(), "restore must run after mid-stream Recv error")191 require.Equal(t, int32(1), restoreCount.Load(), "restore must run after mid-stream Recv error")
177 }192 }
@@ -192,7 +207,7 @@ func TestAttachDaemonVanishedOnEOF(t *testing.T) {
192 defer unblock()207 defer unblock()
193 tr.EXPECT().Close().Return(nil).Once()208 tr.EXPECT().Close().Return(nil).Once()
194 209
195- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")210+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
196 require.Error(t, err)211 require.Error(t, err)
197 require.ErrorIs(t, err, io.EOF)212 require.ErrorIs(t, err, io.EOF)
198 require.Equal(t, int32(1), restoreCount.Load())213 require.Equal(t, int32(1), restoreCount.Load())
@@ -264,7 +279,7 @@ func TestAttachStdinOSCColorResponseSendsThemeAndPreservesInput(t *testing.T) {
264 }).Maybe()279 }).Maybe()
265 tr.EXPECT().Close().Return(nil).Once()280 tr.EXPECT().Close().Return(nil).Once()
266 281
267- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")282+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
268 require.NoError(t, err)283 require.NoError(t, err)
269 require.Equal(t, int32(1), restoreCount.Load())284 require.Equal(t, int32(1), restoreCount.Load())
270 285
@@ -342,7 +357,7 @@ func TestAttachStdinThemeTrueColorFalseWhenCOLORTERMNotTruecolor(t *testing.T) {
342 }).Maybe()357 }).Maybe()
343 tr.EXPECT().Close().Return(nil).Once()358 tr.EXPECT().Close().Return(nil).Once()
344 359
345- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")360+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
346 require.NoError(t, err)361 require.NoError(t, err)
347 select {362 select {
348 case th := <-gotTheme:363 case th := <-gotTheme:
@@ -402,7 +417,7 @@ func TestAttachForwardsStandaloneEscapeInput(t *testing.T) {
402 }).Maybe()417 }).Maybe()
403 tr.EXPECT().Close().Return(nil).Once()418 tr.EXPECT().Close().Return(nil).Once()
404 419
405- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")420+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
406 require.NoError(t, err)421 require.NoError(t, err)
407 select {422 select {
408 case got := <-gotInput:423 case got := <-gotInput:
@@ -461,7 +476,7 @@ func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) {
461 }).Maybe()476 }).Maybe()
462 tr.EXPECT().Close().Return(nil).Once()477 tr.EXPECT().Close().Return(nil).Once()
463 478
464- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")479+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
465 require.NoError(t, err)480 require.NoError(t, err)
466 select {481 select {
467 case got := <-gotInput:482 case got := <-gotInput:
@@ -521,7 +536,7 @@ func TestAttachForwardsResize(t *testing.T) {
521 resizeCh <- domain.Size{Cols: 120, Rows: 40}536 resizeCh <- domain.Size{Cols: 120, Rows: 40}
522 }()537 }()
523 538
524- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")539+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
525 require.NoError(t, err)540 require.NoError(t, err)
526 select {541 select {
527 case r := <-gotResize:542 case r := <-gotResize:
@@ -550,7 +565,7 @@ func TestRunRestoresRawModeAfterAttachError(t *testing.T) {
550 d := portsmocks.NewMockDialer(t)565 d := portsmocks.NewMockDialer(t)
551 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()566 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
552 567
553- err := client.Run(context.Background(), d, tm, ports.IntentEphemeral, "")568+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "")
554 require.Error(t, err)569 require.Error(t, err)
555 require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors")570 require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors")
556 }571 }
@@ -570,7 +585,7 @@ func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) {
570 d := portsmocks.NewMockDialer(t)585 d := portsmocks.NewMockDialer(t)
571 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()586 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
572 587
573- err := client.Run(context.Background(), d, tm, ports.IntentAttach, "main")588+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentAttach, "main")
574 require.Error(t, err)589 require.Error(t, err)
575 var pe *client.ProtocolError590 var pe *client.ProtocolError
576 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)591 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
@@ -582,7 +597,7 @@ func TestRunPhaseASingleAttempt(t *testing.T) {
582 d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once()597 d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once()
583 tm := portsmocks.NewMockTerminal(t)598 tm := portsmocks.NewMockTerminal(t)
584 599
585- err := client.Run(context.Background(), d, tm, ports.IntentEphemeral, "")600+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "")
586 require.ErrorIs(t, err, dialErr)601 require.ErrorIs(t, err, dialErr)
587 }602 }
588 603
@@ -671,7 +686,7 @@ func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) {
671 tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}}686 tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}}
672 d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}}687 d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}}
673 688
674- err := client.Run(context.Background(), d, term, ports.IntentAttach, "main")689+ err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main")
675 require.NoError(t, err)690 require.NoError(t, err)
676 require.Equal(t, int32(3), d.calls.Load())691 require.Equal(t, int32(3), d.calls.Load())
677 require.Equal(t, int32(1), term.rawCount.Load())692 require.Equal(t, int32(1), term.rawCount.Load())
@@ -698,7 +713,7 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) {
698 tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}}713 tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}}
699 d := &sequenceDialer{trs: []ports.Transport{tr}}714 d := &sequenceDialer{trs: []ports.Transport{tr}}
700 715
701- err := client.Run(context.Background(), d, term, ports.IntentAttach, "main")716+ err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main")
702 require.Error(t, err)717 require.Error(t, err)
703 var de *client.DetachedError718 var de *client.DetachedError
704 require.True(t, errors.As(err, &de))719 require.True(t, errors.As(err, &de))
A internal/usecase/client/paste_coalescer.go +253 -0
@@ -0,0 +1,253 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/ports"
9+)
10+
11+// Bracketed-paste markers. A terminal in bracketed-paste mode brackets pasted
12+// text with these so the receiving application can tell a paste from typed
13+// input.
14+var (
15+ pasteOpenMarker = []byte("\x1b[200~")
16+ pasteCloseMarker = []byte("\x1b[201~")
17+)
18+
19+const (
20+ // pasteFlushDelay bounds how long a trailing strict prefix of the opening
21+ // marker is held before it is emitted as ordinary bytes. It only has to
22+ // bridge a local kernel read split: the rest of the marker is already in
23+ // the tty buffer and arrives on the next Read microseconds later. It is NOT
24+ // meant to bridge a network-scale gap — once the opening marker completes,
25+ // buffering mode holds the whole paste (up to the 2s safety timer), so a
26+ // close-marker split at any realistic latency is covered by whole-paste
27+ // framing rather than this timer. Kept well under the daemon router's
28+ // ESCDelay so a real lone Esc keypress still reaches the pane promptly.
29+ pasteFlushDelay = 20 * time.Millisecond
30+ // pasteSafetyDelay caps how long the coalescer waits for a closing marker
31+ // before flushing a partial paste, so a paste with a lost closing marker
32+ // degrades to today's per-frame behavior instead of stalling input.
33+ pasteSafetyDelay = 2 * time.Second
34+ // maxPasteBuffer caps buffered paste bytes. Beyond it the buffer is flushed
35+ // verbatim and normal scanning resumes; input is never dropped.
36+ maxPasteBuffer = 2 << 20 // 2 MiB
37+)
38+
39+// pasteCoalescer reframes a bracketed paste that arrives split across input
40+// reads into a single emit call. Over a remote attach, stdin is read in fixed
41+// chunks and forwarded frame by frame; an input-frame boundary can land inside
42+// a paste marker (e.g. between the marker's ESC and its '['). When the daemon's
43+// keys router then sees a lone trailing ESC it retains it for ESCDelay and, if
44+// the next frame is late, forwards it as a standalone Esc keypress — breaking
45+// the opening marker so the pane's TUI never enters paste mode. Coalescing the
46+// whole paste into one emit keeps both markers contiguous on the wire.
47+//
48+// It is safe for concurrent use: Scan runs on the stdin pump goroutine while a
49+// flush/safety timer may fire on its own goroutine; both take the mutex, so
50+// emit calls are serialised.
51+type pasteCoalescer struct {
52+ clock ports.Clock
53+ emit func([]byte)
54+
55+ mu sync.Mutex
56+ buffering bool // inside a paste: accumulating until the closing marker
57+ buf []byte // paste bytes accumulated in buffering mode
58+ pending []byte // held trailing strict prefix of the opening marker
59+
60+ flushTimer ports.Timer
61+ flushDone chan struct{}
62+ safetyTimer ports.Timer
63+ safetyDone chan struct{}
64+ closed bool
65+}
66+
67+func newPasteCoalescer(clock ports.Clock, emit func([]byte)) *pasteCoalescer {
68+ return &pasteCoalescer{clock: clock, emit: emit}
69+}
70+
71+// Scan consumes one run of ordinary input bytes, emitting either pass-through
72+// bytes immediately or a whole bracketed paste as a single emit call once its
73+// closing marker is seen.
74+func (c *pasteCoalescer) Scan(data []byte) {
75+ if len(data) == 0 {
76+ return
77+ }
78+ c.mu.Lock()
79+ defer c.mu.Unlock()
80+ if len(c.pending) > 0 {
81+ // New bytes arrived before the flush timer fired: the held prefix was
82+ // the start of this data, not a lone Esc. Recombine and rescan.
83+ c.stopFlushTimer()
84+ data = append(c.pending, data...)
85+ c.pending = nil
86+ }
87+ c.process(data)
88+}
89+
90+// process runs the scan/buffer state machine with the mutex held.
91+func (c *pasteCoalescer) process(data []byte) {
92+ for {
93+ if c.buffering {
94+ c.buf = append(c.buf, data...)
95+ data = nil
96+ idx := bytes.Index(c.buf, pasteCloseMarker)
97+ if idx < 0 {
98+ if len(c.buf) > maxPasteBuffer {
99+ c.emit(c.buf)
100+ c.resetBuffering()
101+ }
102+ return
103+ }
104+ end := idx + len(pasteCloseMarker)
105+ c.emit(c.buf[:end])
106+ rest := append([]byte(nil), c.buf[end:]...)
107+ c.resetBuffering()
108+ data = rest
109+ // Fall through to scan any bytes trailing the paste.
110+ }
111+
112+ if len(data) == 0 {
113+ return
114+ }
115+
116+ if idx := bytes.Index(data, pasteOpenMarker); idx >= 0 {
117+ if idx > 0 {
118+ c.emit(data[:idx])
119+ }
120+ c.buffering = true
121+ c.startSafetyTimer()
122+ data = data[idx:] // marker onward; re-enter the buffering branch
123+ continue
124+ }
125+
126+ if p := trailingOpenPrefixLen(data); p > 0 {
127+ if len(data) > p {
128+ c.emit(data[:len(data)-p])
129+ }
130+ c.pending = append([]byte(nil), data[len(data)-p:]...)
131+ c.startFlushTimer()
132+ return
133+ }
134+
135+ c.emit(data)
136+ return
137+ }
138+}
139+
140+// resetBuffering leaves paste mode and stops the safety timer.
141+func (c *pasteCoalescer) resetBuffering() {
142+ c.buffering = false
143+ c.buf = nil
144+ c.stopSafetyTimer()
145+}
146+
147+// Close stops any pending timers and their goroutines. Held bytes are dropped:
148+// Close runs only as the stdin pump unwinds on detach, when there is nowhere
149+// left to deliver them.
150+func (c *pasteCoalescer) Close() {
151+ c.mu.Lock()
152+ defer c.mu.Unlock()
153+ c.closed = true
154+ c.stopFlushTimer()
155+ c.stopSafetyTimer()
156+}
157+
158+func (c *pasteCoalescer) startFlushTimer() {
159+ c.stopFlushTimer()
160+ if c.closed {
161+ return
162+ }
163+ timer := c.clock.NewTimer(pasteFlushDelay)
164+ done := make(chan struct{})
165+ c.flushTimer = timer
166+ c.flushDone = done
167+ go func() {
168+ select {
169+ case <-timer.C():
170+ case <-done:
171+ return
172+ }
173+ c.mu.Lock()
174+ defer c.mu.Unlock()
175+ if c.flushTimer != timer || len(c.pending) == 0 {
176+ return
177+ }
178+ c.flushTimer = nil
179+ c.flushDone = nil
180+ held := c.pending
181+ c.pending = nil
182+ c.emit(held)
183+ }()
184+}
185+
186+func (c *pasteCoalescer) stopFlushTimer() {
187+ if c.flushTimer != nil {
188+ c.flushTimer.Stop()
189+ c.flushTimer = nil
190+ }
191+ if c.flushDone != nil {
192+ close(c.flushDone)
193+ c.flushDone = nil
194+ }
195+}
196+
197+func (c *pasteCoalescer) startSafetyTimer() {
198+ c.stopSafetyTimer()
199+ if c.closed {
200+ return
201+ }
202+ timer := c.clock.NewTimer(pasteSafetyDelay)
203+ done := make(chan struct{})
204+ c.safetyTimer = timer
205+ c.safetyDone = done
206+ go func() {
207+ select {
208+ case <-timer.C():
209+ case <-done:
210+ return
211+ }
212+ c.mu.Lock()
213+ defer c.mu.Unlock()
214+ if c.safetyTimer != timer || !c.buffering {
215+ return
216+ }
217+ buffered := c.buf
218+ c.buffering = false
219+ c.buf = nil
220+ c.safetyTimer = nil
221+ c.safetyDone = nil
222+ if len(buffered) > 0 {
223+ c.emit(buffered)
224+ }
225+ }()
226+}
227+
228+func (c *pasteCoalescer) stopSafetyTimer() {
229+ if c.safetyTimer != nil {
230+ c.safetyTimer.Stop()
231+ c.safetyTimer = nil
232+ }
233+ if c.safetyDone != nil {
234+ close(c.safetyDone)
235+ c.safetyDone = nil
236+ }
237+}
238+
239+// trailingOpenPrefixLen returns the length of the longest suffix of data that
240+// is a non-empty strict prefix of the opening marker (e.g. a trailing "\x1b" or
241+// "\x1b[20"). A full marker match is handled by the caller before this runs.
242+func trailingOpenPrefixLen(data []byte) int {
243+ max := len(pasteOpenMarker) - 1
244+ if max > len(data) {
245+ max = len(data)
246+ }
247+ for n := max; n >= 1; n-- {
248+ if bytes.Equal(data[len(data)-n:], pasteOpenMarker[:n]) {
249+ return n
250+ }
251+ }
252+ return 0
253+}
@@ -0,0 +1,253 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/ports"
9+)
10+
11+// Bracketed-paste markers. A terminal in bracketed-paste mode brackets pasted
12+// text with these so the receiving application can tell a paste from typed
13+// input.
14+var (
15+ pasteOpenMarker = []byte("\x1b[200~")
16+ pasteCloseMarker = []byte("\x1b[201~")
17+)
18+
19+const (
20+ // pasteFlushDelay bounds how long a trailing strict prefix of the opening
21+ // marker is held before it is emitted as ordinary bytes. It only has to
22+ // bridge a local kernel read split: the rest of the marker is already in
23+ // the tty buffer and arrives on the next Read microseconds later. It is NOT
24+ // meant to bridge a network-scale gap — once the opening marker completes,
25+ // buffering mode holds the whole paste (up to the 2s safety timer), so a
26+ // close-marker split at any realistic latency is covered by whole-paste
27+ // framing rather than this timer. Kept well under the daemon router's
28+ // ESCDelay so a real lone Esc keypress still reaches the pane promptly.
29+ pasteFlushDelay = 20 * time.Millisecond
30+ // pasteSafetyDelay caps how long the coalescer waits for a closing marker
31+ // before flushing a partial paste, so a paste with a lost closing marker
32+ // degrades to today's per-frame behavior instead of stalling input.
33+ pasteSafetyDelay = 2 * time.Second
34+ // maxPasteBuffer caps buffered paste bytes. Beyond it the buffer is flushed
35+ // verbatim and normal scanning resumes; input is never dropped.
36+ maxPasteBuffer = 2 << 20 // 2 MiB
37+)
38+
39+// pasteCoalescer reframes a bracketed paste that arrives split across input
40+// reads into a single emit call. Over a remote attach, stdin is read in fixed
41+// chunks and forwarded frame by frame; an input-frame boundary can land inside
42+// a paste marker (e.g. between the marker's ESC and its '['). When the daemon's
43+// keys router then sees a lone trailing ESC it retains it for ESCDelay and, if
44+// the next frame is late, forwards it as a standalone Esc keypress — breaking
45+// the opening marker so the pane's TUI never enters paste mode. Coalescing the
46+// whole paste into one emit keeps both markers contiguous on the wire.
47+//
48+// It is safe for concurrent use: Scan runs on the stdin pump goroutine while a
49+// flush/safety timer may fire on its own goroutine; both take the mutex, so
50+// emit calls are serialised.
51+type pasteCoalescer struct {
52+ clock ports.Clock
53+ emit func([]byte)
54+
55+ mu sync.Mutex
56+ buffering bool // inside a paste: accumulating until the closing marker
57+ buf []byte // paste bytes accumulated in buffering mode
58+ pending []byte // held trailing strict prefix of the opening marker
59+
60+ flushTimer ports.Timer
61+ flushDone chan struct{}
62+ safetyTimer ports.Timer
63+ safetyDone chan struct{}
64+ closed bool
65+}
66+
67+func newPasteCoalescer(clock ports.Clock, emit func([]byte)) *pasteCoalescer {
68+ return &pasteCoalescer{clock: clock, emit: emit}
69+}
70+
71+// Scan consumes one run of ordinary input bytes, emitting either pass-through
72+// bytes immediately or a whole bracketed paste as a single emit call once its
73+// closing marker is seen.
74+func (c *pasteCoalescer) Scan(data []byte) {
75+ if len(data) == 0 {
76+ return
77+ }
78+ c.mu.Lock()
79+ defer c.mu.Unlock()
80+ if len(c.pending) > 0 {
81+ // New bytes arrived before the flush timer fired: the held prefix was
82+ // the start of this data, not a lone Esc. Recombine and rescan.
83+ c.stopFlushTimer()
84+ data = append(c.pending, data...)
85+ c.pending = nil
86+ }
87+ c.process(data)
88+}
89+
90+// process runs the scan/buffer state machine with the mutex held.
91+func (c *pasteCoalescer) process(data []byte) {
92+ for {
93+ if c.buffering {
94+ c.buf = append(c.buf, data...)
95+ data = nil
96+ idx := bytes.Index(c.buf, pasteCloseMarker)
97+ if idx < 0 {
98+ if len(c.buf) > maxPasteBuffer {
99+ c.emit(c.buf)
100+ c.resetBuffering()
101+ }
102+ return
103+ }
104+ end := idx + len(pasteCloseMarker)
105+ c.emit(c.buf[:end])
106+ rest := append([]byte(nil), c.buf[end:]...)
107+ c.resetBuffering()
108+ data = rest
109+ // Fall through to scan any bytes trailing the paste.
110+ }
111+
112+ if len(data) == 0 {
113+ return
114+ }
115+
116+ if idx := bytes.Index(data, pasteOpenMarker); idx >= 0 {
117+ if idx > 0 {
118+ c.emit(data[:idx])
119+ }
120+ c.buffering = true
121+ c.startSafetyTimer()
122+ data = data[idx:] // marker onward; re-enter the buffering branch
123+ continue
124+ }
125+
126+ if p := trailingOpenPrefixLen(data); p > 0 {
127+ if len(data) > p {
128+ c.emit(data[:len(data)-p])
129+ }
130+ c.pending = append([]byte(nil), data[len(data)-p:]...)
131+ c.startFlushTimer()
132+ return
133+ }
134+
135+ c.emit(data)
136+ return
137+ }
138+}
139+
140+// resetBuffering leaves paste mode and stops the safety timer.
141+func (c *pasteCoalescer) resetBuffering() {
142+ c.buffering = false
143+ c.buf = nil
144+ c.stopSafetyTimer()
145+}
146+
147+// Close stops any pending timers and their goroutines. Held bytes are dropped:
148+// Close runs only as the stdin pump unwinds on detach, when there is nowhere
149+// left to deliver them.
150+func (c *pasteCoalescer) Close() {
151+ c.mu.Lock()
152+ defer c.mu.Unlock()
153+ c.closed = true
154+ c.stopFlushTimer()
155+ c.stopSafetyTimer()
156+}
157+
158+func (c *pasteCoalescer) startFlushTimer() {
159+ c.stopFlushTimer()
160+ if c.closed {
161+ return
162+ }
163+ timer := c.clock.NewTimer(pasteFlushDelay)
164+ done := make(chan struct{})
165+ c.flushTimer = timer
166+ c.flushDone = done
167+ go func() {
168+ select {
169+ case <-timer.C():
170+ case <-done:
171+ return
172+ }
173+ c.mu.Lock()
174+ defer c.mu.Unlock()
175+ if c.flushTimer != timer || len(c.pending) == 0 {
176+ return
177+ }
178+ c.flushTimer = nil
179+ c.flushDone = nil
180+ held := c.pending
181+ c.pending = nil
182+ c.emit(held)
183+ }()
184+}
185+
186+func (c *pasteCoalescer) stopFlushTimer() {
187+ if c.flushTimer != nil {
188+ c.flushTimer.Stop()
189+ c.flushTimer = nil
190+ }
191+ if c.flushDone != nil {
192+ close(c.flushDone)
193+ c.flushDone = nil
194+ }
195+}
196+
197+func (c *pasteCoalescer) startSafetyTimer() {
198+ c.stopSafetyTimer()
199+ if c.closed {
200+ return
201+ }
202+ timer := c.clock.NewTimer(pasteSafetyDelay)
203+ done := make(chan struct{})
204+ c.safetyTimer = timer
205+ c.safetyDone = done
206+ go func() {
207+ select {
208+ case <-timer.C():
209+ case <-done:
210+ return
211+ }
212+ c.mu.Lock()
213+ defer c.mu.Unlock()
214+ if c.safetyTimer != timer || !c.buffering {
215+ return
216+ }
217+ buffered := c.buf
218+ c.buffering = false
219+ c.buf = nil
220+ c.safetyTimer = nil
221+ c.safetyDone = nil
222+ if len(buffered) > 0 {
223+ c.emit(buffered)
224+ }
225+ }()
226+}
227+
228+func (c *pasteCoalescer) stopSafetyTimer() {
229+ if c.safetyTimer != nil {
230+ c.safetyTimer.Stop()
231+ c.safetyTimer = nil
232+ }
233+ if c.safetyDone != nil {
234+ close(c.safetyDone)
235+ c.safetyDone = nil
236+ }
237+}
238+
239+// trailingOpenPrefixLen returns the length of the longest suffix of data that
240+// is a non-empty strict prefix of the opening marker (e.g. a trailing "\x1b" or
241+// "\x1b[20"). A full marker match is handled by the caller before this runs.
242+func trailingOpenPrefixLen(data []byte) int {
243+ max := len(pasteOpenMarker) - 1
244+ if max > len(data) {
245+ max = len(data)
246+ }
247+ for n := max; n >= 1; n-- {
248+ if bytes.Equal(data[len(data)-n:], pasteOpenMarker[:n]) {
249+ return n
250+ }
251+ }
252+ return 0
253+}
A internal/usecase/client/paste_coalescer_test.go +215 -0
@@ -0,0 +1,215 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "strconv"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/ports"
13+)
14+
15+// pcFakeClock hands out fake timers the test fires by hand. The coalescer only
16+// ever has one timer live at a time (a flush timer in normal mode or a safety
17+// timer while buffering), so fireLast drives whichever is pending.
18+type pcFakeClock struct {
19+ mu sync.Mutex
20+ timers []*pcFakeTimer
21+}
22+
23+func (c *pcFakeClock) Now() time.Time { return time.Time{} }
24+
25+func (c *pcFakeClock) NewTimer(d time.Duration) ports.Timer {
26+ c.mu.Lock()
27+ defer c.mu.Unlock()
28+ t := &pcFakeTimer{ch: make(chan time.Time, 1), d: d}
29+ c.timers = append(c.timers, t)
30+ return t
31+}
32+
33+func (c *pcFakeClock) fireLast() {
34+ c.mu.Lock()
35+ defer c.mu.Unlock()
36+ for i := len(c.timers) - 1; i >= 0; i-- {
37+ if !c.timers[i].stopped && !c.timers[i].fired {
38+ c.timers[i].fired = true
39+ c.timers[i].ch <- time.Time{}
40+ return
41+ }
42+ }
43+}
44+
45+type pcFakeTimer struct {
46+ ch chan time.Time
47+ d time.Duration
48+ stopped bool
49+ fired bool
50+}
51+
52+func (t *pcFakeTimer) C() <-chan time.Time { return t.ch }
53+func (t *pcFakeTimer) Reset(d time.Duration) bool { t.d = d; return !t.stopped }
54+func (t *pcFakeTimer) Stop() bool { s := t.stopped; t.stopped = true; return !s }
55+
56+// pcCollector records emit calls under a mutex so a timer goroutine's emit is
57+// safe to observe from the test goroutine.
58+type pcCollector struct {
59+ mu sync.Mutex
60+ emits [][]byte
61+}
62+
63+func (c *pcCollector) emit(data []byte) {
64+ c.mu.Lock()
65+ defer c.mu.Unlock()
66+ c.emits = append(c.emits, append([]byte(nil), data...))
67+}
68+
69+func (c *pcCollector) snapshot() [][]byte {
70+ c.mu.Lock()
71+ defer c.mu.Unlock()
72+ out := make([][]byte, len(c.emits))
73+ copy(out, c.emits)
74+ return out
75+}
76+
77+func (c *pcCollector) waitEmits(t *testing.T, n int) {
78+ t.Helper()
79+ require.Eventually(t, func() bool {
80+ c.mu.Lock()
81+ defer c.mu.Unlock()
82+ return len(c.emits) >= n
83+ }, time.Second, time.Millisecond)
84+}
85+
86+func newTestCoalescer() (*pasteCoalescer, *pcFakeClock, *pcCollector) {
87+ clk := &pcFakeClock{}
88+ col := &pcCollector{}
89+ return newPasteCoalescer(clk, col.emit), clk, col
90+}
91+
92+// samplePaste is a multiline bracketed paste whose content deliberately embeds
93+// sequences that would otherwise be interpreted (a lone ESC+letter, an Alt
94+// arrow CSI, a marker-looking prefix) to prove content is carried verbatim.
95+var samplePaste = []byte("\x1b[200~line one\nline\x1btwo\n\x1b[1;3Athree\x1b[20not-a-marker\x1b[201~")
96+
97+func TestPasteCoalescerSplitAtEveryOffsetTwoReads(t *testing.T) {
98+ for s := 0; s <= len(samplePaste); s++ {
99+ t.Run("split_"+strconv.Itoa(s), func(t *testing.T) {
100+ pc, _, col := newTestCoalescer()
101+ defer pc.Close()
102+ pc.Scan(samplePaste[:s])
103+ pc.Scan(samplePaste[s:])
104+ require.Equal(t, [][]byte{samplePaste}, col.snapshot(),
105+ "paste split at offset %d must emit exactly once, byte-identical", s)
106+ })
107+ }
108+}
109+
110+func TestPasteCoalescerByteByByte(t *testing.T) {
111+ pc, _, col := newTestCoalescer()
112+ defer pc.Close()
113+ for i := 0; i < len(samplePaste); i++ {
114+ pc.Scan(samplePaste[i : i+1])
115+ }
116+ require.Equal(t, [][]byte{samplePaste}, col.snapshot(),
117+ "paste fed one byte at a time must still emit as one contiguous paste")
118+}
119+
120+func TestPasteCoalescerLoneEscapeFlushedAfterTimer(t *testing.T) {
121+ pc, clk, col := newTestCoalescer()
122+ defer pc.Close()
123+
124+ pc.Scan([]byte{0x1b})
125+ require.Empty(t, col.snapshot(), "a trailing ESC must be held, not emitted immediately")
126+
127+ clk.fireLast()
128+ col.waitEmits(t, 1)
129+ require.Equal(t, [][]byte{{0x1b}}, col.snapshot(), "held ESC must flush unchanged after the timer")
130+}
131+
132+func TestPasteCoalescerEscapeThenOpenMarkerNextRead(t *testing.T) {
133+ pc, _, col := newTestCoalescer()
134+ defer pc.Close()
135+
136+ pc.Scan([]byte{0x1b})
137+ require.Empty(t, col.snapshot(), "ESC held pending the rest of the marker")
138+
139+ pc.Scan(samplePaste[1:]) // "[200~...\x1b[201~"
140+ require.Equal(t, [][]byte{samplePaste}, col.snapshot(),
141+ "ESC recombined with the following marker must emit a single paste")
142+}
143+
144+func TestPasteCoalescerPlainTextPassthrough(t *testing.T) {
145+ pc, _, col := newTestCoalescer()
146+ defer pc.Close()
147+
148+ pc.Scan([]byte("hello world"))
149+ pc.Scan([]byte("second read"))
150+ require.Equal(t, [][]byte{[]byte("hello world"), []byte("second read")}, col.snapshot(),
151+ "plain text must pass through unchanged at the same call granularity")
152+}
153+
154+func TestPasteCoalescerTextPasteTextInOneRead(t *testing.T) {
155+ pc, _, col := newTestCoalescer()
156+ defer pc.Close()
157+
158+ paste := []byte("\x1b[200~pasted\x1b[201~")
159+ read := append(append([]byte("AB"), paste...), []byte("CD")...)
160+ pc.Scan(read)
161+ require.Equal(t, [][]byte{[]byte("AB"), paste, []byte("CD")}, col.snapshot(),
162+ "text + paste + text in one read must emit three chunks in order")
163+}
164+
165+func TestPasteCoalescerBufferCapOverflowFlushesVerbatim(t *testing.T) {
166+ pc, _, col := newTestCoalescer()
167+ defer pc.Close()
168+
169+ big := append([]byte("\x1b[200~"), bytes.Repeat([]byte("x"), maxPasteBuffer+16)...)
170+ pc.Scan(big)
171+ require.Equal(t, [][]byte{big}, col.snapshot(),
172+ "an oversized unterminated paste must flush verbatim without dropping bytes")
173+
174+ // The coalescer must be back in normal mode and keep working.
175+ pc.Scan([]byte("after"))
176+ require.Equal(t, [][]byte{big, []byte("after")}, col.snapshot())
177+}
178+
179+func TestPasteCoalescerSafetyTimeoutFlushesVerbatim(t *testing.T) {
180+ pc, clk, col := newTestCoalescer()
181+ defer pc.Close()
182+
183+ partial := []byte("\x1b[200~unterminated paste")
184+ pc.Scan(partial)
185+ require.Empty(t, col.snapshot(), "an open paste with no closing marker is buffered, not emitted")
186+
187+ clk.fireLast() // safety timer
188+ col.waitEmits(t, 1)
189+ require.Equal(t, [][]byte{partial}, col.snapshot(),
190+ "the safety timeout must flush buffered paste bytes verbatim")
191+
192+ pc.Scan([]byte("normal"))
193+ require.Equal(t, [][]byte{partial, []byte("normal")}, col.snapshot(),
194+ "after the safety flush the coalescer must resume normal scanning")
195+}
196+
197+func TestTrailingOpenPrefixLen(t *testing.T) {
198+ cases := []struct {
199+ in string
200+ want int
201+ }{
202+ {"", 0},
203+ {"abc", 0},
204+ {"abc\x1b", 1},
205+ {"abc\x1b[", 2},
206+ {"\x1b[2", 3},
207+ {"\x1b[20", 4},
208+ {"\x1b[200", 5},
209+ {"\x1b[200~", 0}, // full marker is not a strict prefix
210+ {"[200", 0}, // no ESC: not a marker start
211+ }
212+ for _, tc := range cases {
213+ require.Equal(t, tc.want, trailingOpenPrefixLen([]byte(tc.in)), "input %q", tc.in)
214+ }
215+}
@@ -0,0 +1,215 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "strconv"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/ports"
13+)
14+
15+// pcFakeClock hands out fake timers the test fires by hand. The coalescer only
16+// ever has one timer live at a time (a flush timer in normal mode or a safety
17+// timer while buffering), so fireLast drives whichever is pending.
18+type pcFakeClock struct {
19+ mu sync.Mutex
20+ timers []*pcFakeTimer
21+}
22+
23+func (c *pcFakeClock) Now() time.Time { return time.Time{} }
24+
25+func (c *pcFakeClock) NewTimer(d time.Duration) ports.Timer {
26+ c.mu.Lock()
27+ defer c.mu.Unlock()
28+ t := &pcFakeTimer{ch: make(chan time.Time, 1), d: d}
29+ c.timers = append(c.timers, t)
30+ return t
31+}
32+
33+func (c *pcFakeClock) fireLast() {
34+ c.mu.Lock()
35+ defer c.mu.Unlock()
36+ for i := len(c.timers) - 1; i >= 0; i-- {
37+ if !c.timers[i].stopped && !c.timers[i].fired {
38+ c.timers[i].fired = true
39+ c.timers[i].ch <- time.Time{}
40+ return
41+ }
42+ }
43+}
44+
45+type pcFakeTimer struct {
46+ ch chan time.Time
47+ d time.Duration
48+ stopped bool
49+ fired bool
50+}
51+
52+func (t *pcFakeTimer) C() <-chan time.Time { return t.ch }
53+func (t *pcFakeTimer) Reset(d time.Duration) bool { t.d = d; return !t.stopped }
54+func (t *pcFakeTimer) Stop() bool { s := t.stopped; t.stopped = true; return !s }
55+
56+// pcCollector records emit calls under a mutex so a timer goroutine's emit is
57+// safe to observe from the test goroutine.
58+type pcCollector struct {
59+ mu sync.Mutex
60+ emits [][]byte
61+}
62+
63+func (c *pcCollector) emit(data []byte) {
64+ c.mu.Lock()
65+ defer c.mu.Unlock()
66+ c.emits = append(c.emits, append([]byte(nil), data...))
67+}
68+
69+func (c *pcCollector) snapshot() [][]byte {
70+ c.mu.Lock()
71+ defer c.mu.Unlock()
72+ out := make([][]byte, len(c.emits))
73+ copy(out, c.emits)
74+ return out
75+}
76+
77+func (c *pcCollector) waitEmits(t *testing.T, n int) {
78+ t.Helper()
79+ require.Eventually(t, func() bool {
80+ c.mu.Lock()
81+ defer c.mu.Unlock()
82+ return len(c.emits) >= n
83+ }, time.Second, time.Millisecond)
84+}
85+
86+func newTestCoalescer() (*pasteCoalescer, *pcFakeClock, *pcCollector) {
87+ clk := &pcFakeClock{}
88+ col := &pcCollector{}
89+ return newPasteCoalescer(clk, col.emit), clk, col
90+}
91+
92+// samplePaste is a multiline bracketed paste whose content deliberately embeds
93+// sequences that would otherwise be interpreted (a lone ESC+letter, an Alt
94+// arrow CSI, a marker-looking prefix) to prove content is carried verbatim.
95+var samplePaste = []byte("\x1b[200~line one\nline\x1btwo\n\x1b[1;3Athree\x1b[20not-a-marker\x1b[201~")
96+
97+func TestPasteCoalescerSplitAtEveryOffsetTwoReads(t *testing.T) {
98+ for s := 0; s <= len(samplePaste); s++ {
99+ t.Run("split_"+strconv.Itoa(s), func(t *testing.T) {
100+ pc, _, col := newTestCoalescer()
101+ defer pc.Close()
102+ pc.Scan(samplePaste[:s])
103+ pc.Scan(samplePaste[s:])
104+ require.Equal(t, [][]byte{samplePaste}, col.snapshot(),
105+ "paste split at offset %d must emit exactly once, byte-identical", s)
106+ })
107+ }
108+}
109+
110+func TestPasteCoalescerByteByByte(t *testing.T) {
111+ pc, _, col := newTestCoalescer()
112+ defer pc.Close()
113+ for i := 0; i < len(samplePaste); i++ {
114+ pc.Scan(samplePaste[i : i+1])
115+ }
116+ require.Equal(t, [][]byte{samplePaste}, col.snapshot(),
117+ "paste fed one byte at a time must still emit as one contiguous paste")
118+}
119+
120+func TestPasteCoalescerLoneEscapeFlushedAfterTimer(t *testing.T) {
121+ pc, clk, col := newTestCoalescer()
122+ defer pc.Close()
123+
124+ pc.Scan([]byte{0x1b})
125+ require.Empty(t, col.snapshot(), "a trailing ESC must be held, not emitted immediately")
126+
127+ clk.fireLast()
128+ col.waitEmits(t, 1)
129+ require.Equal(t, [][]byte{{0x1b}}, col.snapshot(), "held ESC must flush unchanged after the timer")
130+}
131+
132+func TestPasteCoalescerEscapeThenOpenMarkerNextRead(t *testing.T) {
133+ pc, _, col := newTestCoalescer()
134+ defer pc.Close()
135+
136+ pc.Scan([]byte{0x1b})
137+ require.Empty(t, col.snapshot(), "ESC held pending the rest of the marker")
138+
139+ pc.Scan(samplePaste[1:]) // "[200~...\x1b[201~"
140+ require.Equal(t, [][]byte{samplePaste}, col.snapshot(),
141+ "ESC recombined with the following marker must emit a single paste")
142+}
143+
144+func TestPasteCoalescerPlainTextPassthrough(t *testing.T) {
145+ pc, _, col := newTestCoalescer()
146+ defer pc.Close()
147+
148+ pc.Scan([]byte("hello world"))
149+ pc.Scan([]byte("second read"))
150+ require.Equal(t, [][]byte{[]byte("hello world"), []byte("second read")}, col.snapshot(),
151+ "plain text must pass through unchanged at the same call granularity")
152+}
153+
154+func TestPasteCoalescerTextPasteTextInOneRead(t *testing.T) {
155+ pc, _, col := newTestCoalescer()
156+ defer pc.Close()
157+
158+ paste := []byte("\x1b[200~pasted\x1b[201~")
159+ read := append(append([]byte("AB"), paste...), []byte("CD")...)
160+ pc.Scan(read)
161+ require.Equal(t, [][]byte{[]byte("AB"), paste, []byte("CD")}, col.snapshot(),
162+ "text + paste + text in one read must emit three chunks in order")
163+}
164+
165+func TestPasteCoalescerBufferCapOverflowFlushesVerbatim(t *testing.T) {
166+ pc, _, col := newTestCoalescer()
167+ defer pc.Close()
168+
169+ big := append([]byte("\x1b[200~"), bytes.Repeat([]byte("x"), maxPasteBuffer+16)...)
170+ pc.Scan(big)
171+ require.Equal(t, [][]byte{big}, col.snapshot(),
172+ "an oversized unterminated paste must flush verbatim without dropping bytes")
173+
174+ // The coalescer must be back in normal mode and keep working.
175+ pc.Scan([]byte("after"))
176+ require.Equal(t, [][]byte{big, []byte("after")}, col.snapshot())
177+}
178+
179+func TestPasteCoalescerSafetyTimeoutFlushesVerbatim(t *testing.T) {
180+ pc, clk, col := newTestCoalescer()
181+ defer pc.Close()
182+
183+ partial := []byte("\x1b[200~unterminated paste")
184+ pc.Scan(partial)
185+ require.Empty(t, col.snapshot(), "an open paste with no closing marker is buffered, not emitted")
186+
187+ clk.fireLast() // safety timer
188+ col.waitEmits(t, 1)
189+ require.Equal(t, [][]byte{partial}, col.snapshot(),
190+ "the safety timeout must flush buffered paste bytes verbatim")
191+
192+ pc.Scan([]byte("normal"))
193+ require.Equal(t, [][]byte{partial, []byte("normal")}, col.snapshot(),
194+ "after the safety flush the coalescer must resume normal scanning")
195+}
196+
197+func TestTrailingOpenPrefixLen(t *testing.T) {
198+ cases := []struct {
199+ in string
200+ want int
201+ }{
202+ {"", 0},
203+ {"abc", 0},
204+ {"abc\x1b", 1},
205+ {"abc\x1b[", 2},
206+ {"\x1b[2", 3},
207+ {"\x1b[20", 4},
208+ {"\x1b[200", 5},
209+ {"\x1b[200~", 0}, // full marker is not a strict prefix
210+ {"[200", 0}, // no ESC: not a marker start
211+ }
212+ for _, tc := range cases {
213+ require.Equal(t, tc.want, trailingOpenPrefixLen([]byte(tc.in)), "input %q", tc.in)
214+ }
215+}
M internal/usecase/keys/router.go +23 -0
@@ -3,6 +3,7 @@
33 package keys
44
55 import (
6+ "bytes"
67 "slices"
78 "sync"
89 "time"
@@ -16,6 +17,13 @@ const (
1617 ESCDelay = 40 * time.Millisecond
1718 )
1819
20+// Bracketed-paste markers. A paste whose opening and closing markers land in
21+// one frame is forwarded verbatim so its content can never trip a key binding.
22+var (
23+ pasteOpenMarker = []byte("\x1b[200~")
24+ pasteCloseMarker = []byte("\x1b[201~")
25+)
26+
1927 // Action is an intercepted vev key binding.
2028 type Action int
2129
@@ -104,6 +112,21 @@ func (r *Router) route(data []byte) {
104112 return
105113 }
106114 remaining := data[i+1:]
115+ if bytes.HasPrefix(data[i:], pasteOpenMarker) {
116+ // A bracketed paste whose closing marker is in this same frame is
117+ // forwarded verbatim: pasted content bytes (including embedded
118+ // ESC+letter Alt lookalikes or ESC [ 1 ; 3 A sequences) must never
119+ // fire an Action mid-paste. Without a closing marker in-frame we
120+ // fall through to today's per-byte routing (the '[' passes through
121+ // as a control prefix); Part A keeps pastes single-frame in
122+ // practice, so no cross-frame paste state is tracked here.
123+ if rel := bytes.Index(data[i:], pasteCloseMarker); rel >= 0 {
124+ end := i + rel + len(pasteCloseMarker)
125+ buf = append(buf, data[i:end]...)
126+ i = end
127+ continue
128+ }
129+ }
107130 if action, size, ok, partial := altArrowCSI(remaining); ok {
108131 flush()
109132 r.h.Action(action)
@@ -3,6 +3,7 @@
3 package keys3 package keys
4 4
5 import (5 import (
6+ "bytes"
6 "slices"7 "slices"
7 "sync"8 "sync"
8 "time"9 "time"
@@ -16,6 +17,13 @@ const (
16 ESCDelay = 40 * time.Millisecond17 ESCDelay = 40 * time.Millisecond
17 )18 )
18 19
20+// Bracketed-paste markers. A paste whose opening and closing markers land in
21+// one frame is forwarded verbatim so its content can never trip a key binding.
22+var (
23+ pasteOpenMarker = []byte("\x1b[200~")
24+ pasteCloseMarker = []byte("\x1b[201~")
25+)
26+
19 // Action is an intercepted vev key binding.27 // Action is an intercepted vev key binding.
20 type Action int28 type Action int
21 29
@@ -104,6 +112,21 @@ func (r *Router) route(data []byte) {
104 return112 return
105 }113 }
106 remaining := data[i+1:]114 remaining := data[i+1:]
115+ if bytes.HasPrefix(data[i:], pasteOpenMarker) {
116+ // A bracketed paste whose closing marker is in this same frame is
117+ // forwarded verbatim: pasted content bytes (including embedded
118+ // ESC+letter Alt lookalikes or ESC [ 1 ; 3 A sequences) must never
119+ // fire an Action mid-paste. Without a closing marker in-frame we
120+ // fall through to today's per-byte routing (the '[' passes through
121+ // as a control prefix); Part A keeps pastes single-frame in
122+ // practice, so no cross-frame paste state is tracked here.
123+ if rel := bytes.Index(data[i:], pasteCloseMarker); rel >= 0 {
124+ end := i + rel + len(pasteCloseMarker)
125+ buf = append(buf, data[i:end]...)
126+ i = end
127+ continue
128+ }
129+ }
107 if action, size, ok, partial := altArrowCSI(remaining); ok {130 if action, size, ok, partial := altArrowCSI(remaining); ok {
108 flush()131 flush()
109 r.h.Action(action)132 r.h.Action(action)
M internal/usecase/keys/router_test.go +48 -0
@@ -496,3 +496,51 @@ func TestRouterFlushesPendingESCBeforeOtherByte(t *testing.T) {
496496 require.Equal(t, [][]byte{{ESC}, []byte("z")}, h.forwards)
497497 require.Empty(t, h.actions)
498498 }
499+
500+// pasteBindingLookalikes are content bytes that, seen outside a paste, each map
501+// to a binding: Alt-j, Alt-space, Alt-1, and an Alt-arrow CSI.
502+var pasteBindingLookalikes = []byte("\x1bj\x1b \x1b1\x1b[1;3A")
503+
504+func TestRouterForwardsSingleFramePasteVerbatim(t *testing.T) {
505+ clk := &fakeClock{}
506+ h := &captureHandler{}
507+ r := NewRouter(clk, h)
508+
509+ paste := append(append(append([]byte(nil), pasteOpenMarker...), pasteBindingLookalikes...), pasteCloseMarker...)
510+ r.Route(paste)
511+
512+ require.Empty(t, h.actions, "paste content must not fire any binding")
513+ require.Equal(t, [][]byte{paste}, h.forwards, "the whole paste must be forwarded byte-identical")
514+ require.Empty(t, clk.timers, "a fully bracketed paste retains no trailing ESC")
515+}
516+
517+func TestRouterFiresBindingsForSameBytesOutsidePaste(t *testing.T) {
518+ clk := &fakeClock{}
519+ h := &captureHandler{}
520+ r := NewRouter(clk, h)
521+
522+ r.Route(pasteBindingLookalikes)
523+
524+ require.Equal(t, []Action{
525+ ActionFocusPaneDown, // Alt-j
526+ ActionOpenPalette, // Alt-space
527+ ActionSwitchTab1, // Alt-1
528+ ActionFocusPaneUp, // Alt-arrow up
529+ }, h.actions)
530+ require.Empty(t, h.forwards)
531+}
532+
533+func TestRouterPasteMarkerSplitAcrossFramesKeepsCurrentBehavior(t *testing.T) {
534+ clk := &fakeClock{}
535+ h := &captureHandler{}
536+ r := NewRouter(clk, h)
537+
538+ // Opening marker + content in one frame, closing marker in the next: with
539+ // no closing marker in-frame the router keeps its historical per-byte
540+ // routing, forwarding each frame's bytes as they arrive.
541+ r.Route([]byte("\x1b[200~hello"))
542+ r.Route([]byte("\x1b[201~"))
543+
544+ require.Empty(t, h.actions)
545+ require.Equal(t, [][]byte{[]byte("\x1b[200~hello"), []byte("\x1b[201~")}, h.forwards)
546+}
@@ -496,3 +496,51 @@ func TestRouterFlushesPendingESCBeforeOtherByte(t *testing.T) {
496 require.Equal(t, [][]byte{{ESC}, []byte("z")}, h.forwards)496 require.Equal(t, [][]byte{{ESC}, []byte("z")}, h.forwards)
497 require.Empty(t, h.actions)497 require.Empty(t, h.actions)
498 }498 }
499+
500+// pasteBindingLookalikes are content bytes that, seen outside a paste, each map
501+// to a binding: Alt-j, Alt-space, Alt-1, and an Alt-arrow CSI.
502+var pasteBindingLookalikes = []byte("\x1bj\x1b \x1b1\x1b[1;3A")
503+
504+func TestRouterForwardsSingleFramePasteVerbatim(t *testing.T) {
505+ clk := &fakeClock{}
506+ h := &captureHandler{}
507+ r := NewRouter(clk, h)
508+
509+ paste := append(append(append([]byte(nil), pasteOpenMarker...), pasteBindingLookalikes...), pasteCloseMarker...)
510+ r.Route(paste)
511+
512+ require.Empty(t, h.actions, "paste content must not fire any binding")
513+ require.Equal(t, [][]byte{paste}, h.forwards, "the whole paste must be forwarded byte-identical")
514+ require.Empty(t, clk.timers, "a fully bracketed paste retains no trailing ESC")
515+}
516+
517+func TestRouterFiresBindingsForSameBytesOutsidePaste(t *testing.T) {
518+ clk := &fakeClock{}
519+ h := &captureHandler{}
520+ r := NewRouter(clk, h)
521+
522+ r.Route(pasteBindingLookalikes)
523+
524+ require.Equal(t, []Action{
525+ ActionFocusPaneDown, // Alt-j
526+ ActionOpenPalette, // Alt-space
527+ ActionSwitchTab1, // Alt-1
528+ ActionFocusPaneUp, // Alt-arrow up
529+ }, h.actions)
530+ require.Empty(t, h.forwards)
531+}
532+
533+func TestRouterPasteMarkerSplitAcrossFramesKeepsCurrentBehavior(t *testing.T) {
534+ clk := &fakeClock{}
535+ h := &captureHandler{}
536+ r := NewRouter(clk, h)
537+
538+ // Opening marker + content in one frame, closing marker in the next: with
539+ // no closing marker in-frame the router keeps its historical per-byte
540+ // routing, forwarding each frame's bytes as they arrive.
541+ r.Route([]byte("\x1b[200~hello"))
542+ r.Route([]byte("\x1b[201~"))
543+
544+ require.Empty(t, h.actions)
545+ require.Equal(t, [][]byte{[]byte("\x1b[200~hello"), []byte("\x1b[201~")}, h.forwards)
546+}

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