detached 71edbd4

Merge pull request #13 from bnema/fix/bracketed-paste-marker-splits

fix(client,keys): keep bracketed paste markers intact across input frames
commit
71edbd43255fdca35592d03d18b757dbef9512ef
author
bnema · · 2026-07-04 16:39 UTC
committer
GitHub · · 2026-07-04 16:39 UTC
merge of
8f91483 + 5d67c2f · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
9 files · +699 -40
M internal/app/run.go +3 -3
@@ -299,7 +299,7 @@ type runAttachDeps struct {
299299 attachLocal func(context.Context, uint8, string, *slog.Logger) error // compatibility hook for focused tests
300300 localDialer func() ports.Dialer
301301 remoteDialer func(target, session string, log *slog.Logger) ports.Dialer
302- runClient func(context.Context, ports.Dialer, ports.Terminal, uint8, string, *slog.Logger) error
302+ runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, *slog.Logger) error
303303 createDetached func(context.Context, string) error
304304 }
305305
@@ -323,7 +323,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
323323 if log != nil {
324324 log.Info("attaching to remote session", "target", remoteTarget, "name", name)
325325 }
326- return runClient(ctx, remoteDialer(remoteTarget, name, log), term.New(), intent, name, log)
326+ return runClient(ctx, remoteDialer(remoteTarget, name, log), term.New(), clock.New(), intent, name, log)
327327 }
328328
329329 attachLocal := deps.attachLocal
@@ -336,7 +336,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
336336 if log != nil {
337337 log.Info("attaching to local session", "intent", intent, "name", name)
338338 }
339- return runClient(ctx, localDialer(), term.New(), intent, name, log)
339+ return runClient(ctx, localDialer(), term.New(), clock.New(), intent, name, log)
340340 }
341341 }
342342 return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{
@@ -299,7 +299,7 @@ type runAttachDeps struct {
299 attachLocal func(context.Context, uint8, string, *slog.Logger) error // compatibility hook for focused tests299 attachLocal func(context.Context, uint8, string, *slog.Logger) error // compatibility hook for focused tests
300 localDialer func() ports.Dialer300 localDialer func() ports.Dialer
301 remoteDialer func(target, session string, log *slog.Logger) ports.Dialer301 remoteDialer func(target, session string, log *slog.Logger) ports.Dialer
302- runClient func(context.Context, ports.Dialer, ports.Terminal, uint8, string, *slog.Logger) error302+ runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, *slog.Logger) error
303 createDetached func(context.Context, string) error303 createDetached func(context.Context, string) error
304 }304 }
305 305
@@ -323,7 +323,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
323 if log != nil {323 if log != nil {
324 log.Info("attaching to remote session", "target", remoteTarget, "name", name)324 log.Info("attaching to remote session", "target", remoteTarget, "name", name)
325 }325 }
326- return runClient(ctx, remoteDialer(remoteTarget, name, log), term.New(), intent, name, log)326+ return runClient(ctx, remoteDialer(remoteTarget, name, log), term.New(), clock.New(), intent, name, log)
327 }327 }
328 328
329 attachLocal := deps.attachLocal329 attachLocal := deps.attachLocal
@@ -336,7 +336,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
336 if log != nil {336 if log != nil {
337 log.Info("attaching to local session", "intent", intent, "name", name)337 log.Info("attaching to local session", "intent", intent, "name", name)
338 }338 }
339- return runClient(ctx, localDialer(), term.New(), intent, name, log)339+ return runClient(ctx, localDialer(), term.New(), clock.New(), intent, name, log)
340 }340 }
341 }341 }
342 return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{342 return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{
M internal/app/run_test.go +2 -2
@@ -401,7 +401,7 @@ func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) {
401401 gotTarget, gotSession = target, session
402402 return namedDialer{name: "remote"}
403403 },
404- runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, intent uint8, name string, _ *slog.Logger) error {
404+ runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, _ *slog.Logger) error {
405405 gotDialer = d.(namedDialer).name
406406 if intent != ports.IntentAttach || name != "work" {
407407 t.Fatalf("intent/name = %d/%q, want attach/work", intent, name)
@@ -421,7 +421,7 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
421421 var gotDialer string
422422 err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", nil, runAttachDeps{
423423 localDialer: func() ports.Dialer { return namedDialer{name: "local"} },
424- runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, intent uint8, name string, _ *slog.Logger) error {
424+ runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, _ *slog.Logger) error {
425425 gotDialer = d.(namedDialer).name
426426 if intent != ports.IntentEphemeral || name != "" {
427427 t.Fatalf("intent/name = %d/%q, want ephemeral/empty", intent, name)
@@ -401,7 +401,7 @@ func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) {
401 gotTarget, gotSession = target, session401 gotTarget, gotSession = target, session
402 return namedDialer{name: "remote"}402 return namedDialer{name: "remote"}
403 },403 },
404- runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, intent uint8, name string, _ *slog.Logger) error {404+ runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, _ *slog.Logger) error {
405 gotDialer = d.(namedDialer).name405 gotDialer = d.(namedDialer).name
406 if intent != ports.IntentAttach || name != "work" {406 if intent != ports.IntentAttach || name != "work" {
407 t.Fatalf("intent/name = %d/%q, want attach/work", intent, name)407 t.Fatalf("intent/name = %d/%q, want attach/work", intent, name)
@@ -421,7 +421,7 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
421 var gotDialer string421 var gotDialer string
422 err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", nil, runAttachDeps{422 err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", nil, runAttachDeps{
423 localDialer: func() ports.Dialer { return namedDialer{name: "local"} },423 localDialer: func() ports.Dialer { return namedDialer{name: "local"} },
424- runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, intent uint8, name string, _ *slog.Logger) error {424+ runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, _ *slog.Logger) error {
425 gotDialer = d.(namedDialer).name425 gotDialer = d.(namedDialer).name
426 if intent != ports.IntentEphemeral || name != "" {426 if intent != ports.IntentEphemeral || name != "" {
427 t.Fatalf("intent/name = %d/%q, want ephemeral/empty", intent, name)427 t.Fatalf("intent/name = %d/%q, want ephemeral/empty", intent, name)
A internal/ports/paste.go +9 -0
@@ -0,0 +1,9 @@
1+package ports
2+
3+// Bracketed-paste markers bracket terminal paste payloads when bracketed-paste
4+// mode is enabled. The client coalescer and daemon key router both depend on
5+// these bytes staying in sync.
6+var (
7+ BracketedPasteOpenMarker = []byte("\x1b[200~")
8+ BracketedPasteCloseMarker = []byte("\x1b[201~")
9+)
@@ -0,0 +1,9 @@
1+package ports
2+
3+// Bracketed-paste markers bracket terminal paste payloads when bracketed-paste
4+// mode is enabled. The client coalescer and daemon key router both depend on
5+// these bytes staying in sync.
6+var (
7+ BracketedPasteOpenMarker = []byte("\x1b[200~")
8+ BracketedPasteCloseMarker = []byte("\x1b[201~")
9+)
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, log *slog.Logger) (retErr error) {
122+func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, log *slog.Logger) (retErr error) {
122123 if log == nil {
123124 log = slog.Default()
124125 }
@@ -191,7 +192,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, intent u
191192 }
192193 ms.dialed = true
193194
194- result := attachOnce(ctx, transport, term, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus, log)
195+ result := attachOnce(ctx, transport, term, clk, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus, log)
195196 if result.welcomed {
196197 backoff = defaultReconnectBackoff.initial
197198 }
@@ -253,8 +254,8 @@ func shouldReconnect(err error) bool {
253254 // disappears, or the context is cancelled.
254255 //
255256 // It is kept as a compatibility wrapper for callers that still own dialing.
256-func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, intent uint8, name string) error {
257- return Run(ctx, singleTransportDialer{transport: transport}, term, intent, name, slog.Default())
257+func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string) error {
258+ return Run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name, slog.Default())
258259 }
259260
260261 type singleTransportDialer struct{ transport ports.Transport }
@@ -269,7 +270,7 @@ type attachResult struct {
269270 err error
270271 }
271272
272-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(), log *slog.Logger) attachResult {
273+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(), log *slog.Logger) attachResult {
273274 // 1. Handshake: send Hello with our size and TERM.
274275 size, err := term.Size()
275276 if err != nil {
@@ -336,7 +337,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
336337 sendErrCh := make(chan error, 1)
337338
338339 go runSender(loopCtx, cancel, transport, sendCh, sendErrCh, log)
339- go runStdin(loopCtx, cancel, term.In(), sendCh, trueColor, log)
340+ go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, log)
340341 go runResize(loopCtx, term.ResizeEvents(), sendCh, log)
341342
342343 // 5. Output/main loop: the only goroutine that touches the terminal.
@@ -460,24 +461,36 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
460461 // exits. That is harmless here — Attach has already returned and restored
461462 // the terminal — and matches the standard pattern for stdin pumps; a
462463 // closable stdin duplicate could lift it later if ever needed.
463-func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, trueColor bool, log *slog.Logger) {
464+func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, log *slog.Logger) {
464465 defer log.Debug("stdin pump exited")
465466 buf := make([]byte, stdinBufSize)
466467 var scanner theme.Scanner
467468 var inputSeq uint64
468469 current := ports.Theme{TrueColor: trueColor}
469- send := func(frame ports.Frame) bool {
470+ var sendOK atomic.Bool
471+ sendOK.Store(true)
472+ send := func(frame ports.Frame) {
470473 select {
471474 case out <- frame:
472- return true
473475 case <-ctx.Done():
474- return false
476+ sendOK.Store(false)
475477 }
476478 }
479+ // The coalescer reframes a bracketed paste split across reads into one
480+ // MsgInput frame, so a marker boundary can never leave a lone ESC on the
481+ // wire. Its emit runs one MsgInput per call, keeping the inputSeq contract.
482+ coalescer := newPasteCoalescer(clk, func(data []byte) {
483+ if len(data) == 0 {
484+ return
485+ }
486+ copyData := append([]byte(nil), data...)
487+ inputSeq++
488+ send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: copyData})})
489+ })
490+ defer coalescer.Close()
477491 for {
478492 n, rerr := in.Read(buf)
479493 if n > 0 {
480- ok := true
481494 scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) {
482495 switch kind {
483496 case 10:
@@ -489,16 +502,9 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
489502 default:
490503 return
491504 }
492- ok = send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)})
493- }, func(data []byte) {
494- if len(data) == 0 || !ok {
495- return
496- }
497- copyData := append([]byte(nil), data...)
498- inputSeq++
499- ok = send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: copyData})})
500- })
501- if !ok {
505+ send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)})
506+ }, coalescer.Scan)
507+ if !sendOK.Load() {
502508 return
503509 }
504510 }
@@ -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, log *slog.Logger) (retErr error) {122+func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, log *slog.Logger) (retErr error) {
122 if log == nil {123 if log == nil {
123 log = slog.Default()124 log = slog.Default()
124 }125 }
@@ -191,7 +192,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, intent u
191 }192 }
192 ms.dialed = true193 ms.dialed = true
193 194
194- result := attachOnce(ctx, transport, term, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus, log)195+ result := attachOnce(ctx, transport, term, clk, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus, log)
195 if result.welcomed {196 if result.welcomed {
196 backoff = defaultReconnectBackoff.initial197 backoff = defaultReconnectBackoff.initial
197 }198 }
@@ -253,8 +254,8 @@ func shouldReconnect(err error) bool {
253 // disappears, or the context is cancelled.254 // disappears, or the context is cancelled.
254 //255 //
255 // It is kept as a compatibility wrapper for callers that still own dialing.256 // It is kept as a compatibility wrapper for callers that still own dialing.
256-func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, intent uint8, name string) error {257+func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string) error {
257- return Run(ctx, singleTransportDialer{transport: transport}, term, intent, name, slog.Default())258+ return Run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name, slog.Default())
258 }259 }
259 260
260 type singleTransportDialer struct{ transport ports.Transport }261 type singleTransportDialer struct{ transport ports.Transport }
@@ -269,7 +270,7 @@ type attachResult struct {
269 err error270 err error
270 }271 }
271 272
272-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(), log *slog.Logger) attachResult {273+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(), log *slog.Logger) attachResult {
273 // 1. Handshake: send Hello with our size and TERM.274 // 1. Handshake: send Hello with our size and TERM.
274 size, err := term.Size()275 size, err := term.Size()
275 if err != nil {276 if err != nil {
@@ -336,7 +337,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
336 sendErrCh := make(chan error, 1)337 sendErrCh := make(chan error, 1)
337 338
338 go runSender(loopCtx, cancel, transport, sendCh, sendErrCh, log)339 go runSender(loopCtx, cancel, transport, sendCh, sendErrCh, log)
339- go runStdin(loopCtx, cancel, term.In(), sendCh, trueColor, log)340+ go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, log)
340 go runResize(loopCtx, term.ResizeEvents(), sendCh, log)341 go runResize(loopCtx, term.ResizeEvents(), sendCh, log)
341 342
342 // 5. Output/main loop: the only goroutine that touches the terminal.343 // 5. Output/main loop: the only goroutine that touches the terminal.
@@ -460,24 +461,36 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
460 // exits. That is harmless here — Attach has already returned and restored461 // exits. That is harmless here — Attach has already returned and restored
461 // the terminal — and matches the standard pattern for stdin pumps; a462 // the terminal — and matches the standard pattern for stdin pumps; a
462 // closable stdin duplicate could lift it later if ever needed.463 // closable stdin duplicate could lift it later if ever needed.
463-func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, trueColor bool, log *slog.Logger) {464+func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, log *slog.Logger) {
464 defer log.Debug("stdin pump exited")465 defer log.Debug("stdin pump exited")
465 buf := make([]byte, stdinBufSize)466 buf := make([]byte, stdinBufSize)
466 var scanner theme.Scanner467 var scanner theme.Scanner
467 var inputSeq uint64468 var inputSeq uint64
468 current := ports.Theme{TrueColor: trueColor}469 current := ports.Theme{TrueColor: trueColor}
469- send := func(frame ports.Frame) bool {470+ var sendOK atomic.Bool
471+ sendOK.Store(true)
472+ send := func(frame ports.Frame) {
470 select {473 select {
471 case out <- frame:474 case out <- frame:
472- return true
473 case <-ctx.Done():475 case <-ctx.Done():
474- return false476+ sendOK.Store(false)
475 }477 }
476 }478 }
479+ // The coalescer reframes a bracketed paste split across reads into one
480+ // MsgInput frame, so a marker boundary can never leave a lone ESC on the
481+ // wire. Its emit runs one MsgInput per call, keeping the inputSeq contract.
482+ coalescer := newPasteCoalescer(clk, func(data []byte) {
483+ if len(data) == 0 {
484+ return
485+ }
486+ copyData := append([]byte(nil), data...)
487+ inputSeq++
488+ send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: copyData})})
489+ })
490+ defer coalescer.Close()
477 for {491 for {
478 n, rerr := in.Read(buf)492 n, rerr := in.Read(buf)
479 if n > 0 {493 if n > 0 {
480- ok := true
481 scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) {494 scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) {
482 switch kind {495 switch kind {
483 case 10:496 case 10:
@@ -489,16 +502,9 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
489 default:502 default:
490 return503 return
491 }504 }
492- ok = send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)})505+ send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)})
493- }, func(data []byte) {506+ }, coalescer.Scan)
494- if len(data) == 0 || !ok {507+ if !sendOK.Load() {
495- return
496- }
497- copyData := append([]byte(nil), data...)
498- inputSeq++
499- ok = send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: copyData})})
500- })
501- if !ok {
502 return508 return
503 }509 }
504 }510 }
M internal/usecase/client/client_test.go +114 -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
@@ -92,6 +107,31 @@ func (r *oneShotBlockingReader) Read(p []byte) (int, error) {
92107
93108 func (r *oneShotBlockingReader) unblock() { close(r.done) }
94109
110+type chunkedBlockingReader struct {
111+ chunks [][]byte
112+ done chan struct{}
113+ mu sync.Mutex
114+ next int
115+}
116+
117+func newChunkedBlockingReader(chunks ...[]byte) *chunkedBlockingReader {
118+ return &chunkedBlockingReader{chunks: chunks, done: make(chan struct{})}
119+}
120+
121+func (r *chunkedBlockingReader) Read(p []byte) (int, error) {
122+ r.mu.Lock()
123+ defer r.mu.Unlock()
124+ if r.next < len(r.chunks) {
125+ chunk := r.chunks[r.next]
126+ r.next++
127+ return copy(p, chunk), nil
128+ }
129+ <-r.done
130+ return 0, io.EOF
131+}
132+
133+func (r *chunkedBlockingReader) unblock() { close(r.done) }
134+
95135 func newHappyTerminal(t *testing.T, out *bytes.Buffer, restoreCount *atomic.Int32, resizeCh chan domain.Size) (*portsmocks.MockTerminal, *blockingReader) {
96136 tm := portsmocks.NewMockTerminal(t)
97137 tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
@@ -128,7 +168,7 @@ func TestAttachHappyPath(t *testing.T) {
128168 defer unblock()
129169 tr.EXPECT().Close().Return(nil).Once()
130170
131- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")
171+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
132172 require.NoError(t, err)
133173 require.Equal(t, "hello world", out.String())
134174 require.Equal(t, int32(1), restoreCount.Load(), "restore must run exactly once")
@@ -147,7 +187,7 @@ func TestAttachVersionMismatch(t *testing.T) {
147187 ).Once()
148188 tr.EXPECT().Close().Return(nil).Once()
149189
150- err := client.Attach(context.Background(), tr, tm, ports.IntentAttach, "main")
190+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentAttach, "main")
151191 require.Error(t, err)
152192 var pe *client.ProtocolError
153193 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
@@ -171,7 +211,7 @@ func TestAttachRestoredOnRecvErrorMidStream(t *testing.T) {
171211 defer unblock()
172212 tr.EXPECT().Close().Return(nil).Once()
173213
174- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")
214+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
175215 require.Error(t, err)
176216 require.Equal(t, int32(1), restoreCount.Load(), "restore must run after mid-stream Recv error")
177217 }
@@ -192,7 +232,7 @@ func TestAttachDaemonVanishedOnEOF(t *testing.T) {
192232 defer unblock()
193233 tr.EXPECT().Close().Return(nil).Once()
194234
195- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")
235+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
196236 require.Error(t, err)
197237 require.ErrorIs(t, err, io.EOF)
198238 require.Equal(t, int32(1), restoreCount.Load())
@@ -264,7 +304,7 @@ func TestAttachStdinOSCColorResponseSendsThemeAndPreservesInput(t *testing.T) {
264304 }).Maybe()
265305 tr.EXPECT().Close().Return(nil).Once()
266306
267- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")
307+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
268308 require.NoError(t, err)
269309 require.Equal(t, int32(1), restoreCount.Load())
270310
@@ -342,7 +382,7 @@ func TestAttachStdinThemeTrueColorFalseWhenCOLORTERMNotTruecolor(t *testing.T) {
342382 }).Maybe()
343383 tr.EXPECT().Close().Return(nil).Once()
344384
345- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")
385+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
346386 require.NoError(t, err)
347387 select {
348388 case th := <-gotTheme:
@@ -402,7 +442,7 @@ func TestAttachForwardsStandaloneEscapeInput(t *testing.T) {
402442 }).Maybe()
403443 tr.EXPECT().Close().Return(nil).Once()
404444
405- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")
445+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
406446 require.NoError(t, err)
407447 select {
408448 case got := <-gotInput:
@@ -461,7 +501,7 @@ func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) {
461501 }).Maybe()
462502 tr.EXPECT().Close().Return(nil).Once()
463503
464- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")
504+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
465505 require.NoError(t, err)
466506 select {
467507 case got := <-gotInput:
@@ -471,6 +511,66 @@ func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) {
471511 }
472512 }
473513
514+func TestAttachStdinCoalescesSplitBracketedPaste(t *testing.T) {
515+ var out bytes.Buffer
516+ var restoreCount atomic.Int32
517+ resizeCh := make(chan domain.Size)
518+ paste := []byte("\x1b[200~hello\nworld\x1b[201~")
519+ input := newChunkedBlockingReader([]byte("\x1b[200~hello\n"), []byte("world\x1b[201~"))
520+ defer input.unblock()
521+
522+ tm := portsmocks.NewMockTerminal(t)
523+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
524+ tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
525+ tm.EXPECT().In().Return(input).Maybe()
526+ tm.EXPECT().Out().Return(&out).Maybe()
527+ tm.EXPECT().Flush().Return(nil).Maybe()
528+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
529+
530+ gotInput := make(chan []byte, 1)
531+ allowDetach := make(chan struct{})
532+ tr := portsmocks.NewMockTransport(t)
533+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
534+ tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
535+ in, err := ports.UnmarshalInput(f.Payload)
536+ require.NoError(t, err)
537+ gotInput <- in.Data
538+ close(allowDetach)
539+ return nil
540+ }).Once()
541+ welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
542+ detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
543+ recvCh := make(chan recvItem, 1)
544+ recvCh <- recvItem{f: welcome}
545+ closed := make(chan struct{})
546+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
547+ select {
548+ case it := <-recvCh:
549+ return it.f, it.err
550+ case <-allowDetach:
551+ select {
552+ case <-closed:
553+ return ports.Frame{}, io.EOF
554+ default:
555+ close(closed)
556+ return detached, nil
557+ }
558+ case <-closed:
559+ return ports.Frame{}, io.EOF
560+ }
561+ }).Maybe()
562+ tr.EXPECT().Close().Return(nil).Once()
563+
564+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
565+ require.NoError(t, err)
566+ select {
567+ case got := <-gotInput:
568+ require.Equal(t, paste, got, "split bracketed paste must arrive as one intact MsgInput frame")
569+ case <-time.After(2 * time.Second):
570+ t.Fatal("split bracketed paste input was not sent")
571+ }
572+}
573+
474574 func TestAttachForwardsResize(t *testing.T) {
475575 var out bytes.Buffer
476576 var restoreCount atomic.Int32
@@ -521,7 +621,7 @@ func TestAttachForwardsResize(t *testing.T) {
521621 resizeCh <- domain.Size{Cols: 120, Rows: 40}
522622 }()
523623
524- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")
624+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
525625 require.NoError(t, err)
526626 select {
527627 case r := <-gotResize:
@@ -550,7 +650,7 @@ func TestRunRestoresRawModeAfterAttachError(t *testing.T) {
550650 d := portsmocks.NewMockDialer(t)
551651 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
552652
553- err := client.Run(context.Background(), d, tm, ports.IntentEphemeral, "", slog.New(slog.DiscardHandler))
653+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", slog.New(slog.DiscardHandler))
554654 require.Error(t, err)
555655 require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors")
556656 }
@@ -570,7 +670,7 @@ func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) {
570670 d := portsmocks.NewMockDialer(t)
571671 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
572672
573- err := client.Run(context.Background(), d, tm, ports.IntentAttach, "main", slog.New(slog.DiscardHandler))
673+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentAttach, "main", slog.New(slog.DiscardHandler))
574674 require.Error(t, err)
575675 var pe *client.ProtocolError
576676 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
@@ -582,7 +682,7 @@ func TestRunPhaseASingleAttempt(t *testing.T) {
582682 d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once()
583683 tm := portsmocks.NewMockTerminal(t)
584684
585- err := client.Run(context.Background(), d, tm, ports.IntentEphemeral, "", slog.New(slog.DiscardHandler))
685+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", slog.New(slog.DiscardHandler))
586686 require.ErrorIs(t, err, dialErr)
587687 }
588688
@@ -671,7 +771,7 @@ func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) {
671771 tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}}
672772 d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}}
673773
674- err := client.Run(context.Background(), d, term, ports.IntentAttach, "main", slog.New(slog.DiscardHandler))
774+ err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", slog.New(slog.DiscardHandler))
675775 require.NoError(t, err)
676776 require.Equal(t, int32(3), d.calls.Load())
677777 require.Equal(t, int32(1), term.rawCount.Load())
@@ -698,7 +798,7 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) {
698798 tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}}
699799 d := &sequenceDialer{trs: []ports.Transport{tr}}
700800
701- err := client.Run(context.Background(), d, term, ports.IntentAttach, "main", slog.New(slog.DiscardHandler))
801+ err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", slog.New(slog.DiscardHandler))
702802 require.Error(t, err)
703803 var de *client.DetachedError
704804 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
@@ -92,6 +107,31 @@ func (r *oneShotBlockingReader) Read(p []byte) (int, error) {
92 107
93 func (r *oneShotBlockingReader) unblock() { close(r.done) }108 func (r *oneShotBlockingReader) unblock() { close(r.done) }
94 109
110+type chunkedBlockingReader struct {
111+ chunks [][]byte
112+ done chan struct{}
113+ mu sync.Mutex
114+ next int
115+}
116+
117+func newChunkedBlockingReader(chunks ...[]byte) *chunkedBlockingReader {
118+ return &chunkedBlockingReader{chunks: chunks, done: make(chan struct{})}
119+}
120+
121+func (r *chunkedBlockingReader) Read(p []byte) (int, error) {
122+ r.mu.Lock()
123+ defer r.mu.Unlock()
124+ if r.next < len(r.chunks) {
125+ chunk := r.chunks[r.next]
126+ r.next++
127+ return copy(p, chunk), nil
128+ }
129+ <-r.done
130+ return 0, io.EOF
131+}
132+
133+func (r *chunkedBlockingReader) unblock() { close(r.done) }
134+
95 func newHappyTerminal(t *testing.T, out *bytes.Buffer, restoreCount *atomic.Int32, resizeCh chan domain.Size) (*portsmocks.MockTerminal, *blockingReader) {135 func newHappyTerminal(t *testing.T, out *bytes.Buffer, restoreCount *atomic.Int32, resizeCh chan domain.Size) (*portsmocks.MockTerminal, *blockingReader) {
96 tm := portsmocks.NewMockTerminal(t)136 tm := portsmocks.NewMockTerminal(t)
97 tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()137 tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
@@ -128,7 +168,7 @@ func TestAttachHappyPath(t *testing.T) {
128 defer unblock()168 defer unblock()
129 tr.EXPECT().Close().Return(nil).Once()169 tr.EXPECT().Close().Return(nil).Once()
130 170
131- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")171+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
132 require.NoError(t, err)172 require.NoError(t, err)
133 require.Equal(t, "hello world", out.String())173 require.Equal(t, "hello world", out.String())
134 require.Equal(t, int32(1), restoreCount.Load(), "restore must run exactly once")174 require.Equal(t, int32(1), restoreCount.Load(), "restore must run exactly once")
@@ -147,7 +187,7 @@ func TestAttachVersionMismatch(t *testing.T) {
147 ).Once()187 ).Once()
148 tr.EXPECT().Close().Return(nil).Once()188 tr.EXPECT().Close().Return(nil).Once()
149 189
150- err := client.Attach(context.Background(), tr, tm, ports.IntentAttach, "main")190+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentAttach, "main")
151 require.Error(t, err)191 require.Error(t, err)
152 var pe *client.ProtocolError192 var pe *client.ProtocolError
153 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)193 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
@@ -171,7 +211,7 @@ func TestAttachRestoredOnRecvErrorMidStream(t *testing.T) {
171 defer unblock()211 defer unblock()
172 tr.EXPECT().Close().Return(nil).Once()212 tr.EXPECT().Close().Return(nil).Once()
173 213
174- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")214+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
175 require.Error(t, err)215 require.Error(t, err)
176 require.Equal(t, int32(1), restoreCount.Load(), "restore must run after mid-stream Recv error")216 require.Equal(t, int32(1), restoreCount.Load(), "restore must run after mid-stream Recv error")
177 }217 }
@@ -192,7 +232,7 @@ func TestAttachDaemonVanishedOnEOF(t *testing.T) {
192 defer unblock()232 defer unblock()
193 tr.EXPECT().Close().Return(nil).Once()233 tr.EXPECT().Close().Return(nil).Once()
194 234
195- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")235+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
196 require.Error(t, err)236 require.Error(t, err)
197 require.ErrorIs(t, err, io.EOF)237 require.ErrorIs(t, err, io.EOF)
198 require.Equal(t, int32(1), restoreCount.Load())238 require.Equal(t, int32(1), restoreCount.Load())
@@ -264,7 +304,7 @@ func TestAttachStdinOSCColorResponseSendsThemeAndPreservesInput(t *testing.T) {
264 }).Maybe()304 }).Maybe()
265 tr.EXPECT().Close().Return(nil).Once()305 tr.EXPECT().Close().Return(nil).Once()
266 306
267- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")307+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
268 require.NoError(t, err)308 require.NoError(t, err)
269 require.Equal(t, int32(1), restoreCount.Load())309 require.Equal(t, int32(1), restoreCount.Load())
270 310
@@ -342,7 +382,7 @@ func TestAttachStdinThemeTrueColorFalseWhenCOLORTERMNotTruecolor(t *testing.T) {
342 }).Maybe()382 }).Maybe()
343 tr.EXPECT().Close().Return(nil).Once()383 tr.EXPECT().Close().Return(nil).Once()
344 384
345- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")385+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
346 require.NoError(t, err)386 require.NoError(t, err)
347 select {387 select {
348 case th := <-gotTheme:388 case th := <-gotTheme:
@@ -402,7 +442,7 @@ func TestAttachForwardsStandaloneEscapeInput(t *testing.T) {
402 }).Maybe()442 }).Maybe()
403 tr.EXPECT().Close().Return(nil).Once()443 tr.EXPECT().Close().Return(nil).Once()
404 444
405- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")445+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
406 require.NoError(t, err)446 require.NoError(t, err)
407 select {447 select {
408 case got := <-gotInput:448 case got := <-gotInput:
@@ -461,7 +501,7 @@ func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) {
461 }).Maybe()501 }).Maybe()
462 tr.EXPECT().Close().Return(nil).Once()502 tr.EXPECT().Close().Return(nil).Once()
463 503
464- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")504+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
465 require.NoError(t, err)505 require.NoError(t, err)
466 select {506 select {
467 case got := <-gotInput:507 case got := <-gotInput:
@@ -471,6 +511,66 @@ func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) {
471 }511 }
472 }512 }
473 513
514+func TestAttachStdinCoalescesSplitBracketedPaste(t *testing.T) {
515+ var out bytes.Buffer
516+ var restoreCount atomic.Int32
517+ resizeCh := make(chan domain.Size)
518+ paste := []byte("\x1b[200~hello\nworld\x1b[201~")
519+ input := newChunkedBlockingReader([]byte("\x1b[200~hello\n"), []byte("world\x1b[201~"))
520+ defer input.unblock()
521+
522+ tm := portsmocks.NewMockTerminal(t)
523+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
524+ tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
525+ tm.EXPECT().In().Return(input).Maybe()
526+ tm.EXPECT().Out().Return(&out).Maybe()
527+ tm.EXPECT().Flush().Return(nil).Maybe()
528+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
529+
530+ gotInput := make(chan []byte, 1)
531+ allowDetach := make(chan struct{})
532+ tr := portsmocks.NewMockTransport(t)
533+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
534+ tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
535+ in, err := ports.UnmarshalInput(f.Payload)
536+ require.NoError(t, err)
537+ gotInput <- in.Data
538+ close(allowDetach)
539+ return nil
540+ }).Once()
541+ welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
542+ detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
543+ recvCh := make(chan recvItem, 1)
544+ recvCh <- recvItem{f: welcome}
545+ closed := make(chan struct{})
546+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
547+ select {
548+ case it := <-recvCh:
549+ return it.f, it.err
550+ case <-allowDetach:
551+ select {
552+ case <-closed:
553+ return ports.Frame{}, io.EOF
554+ default:
555+ close(closed)
556+ return detached, nil
557+ }
558+ case <-closed:
559+ return ports.Frame{}, io.EOF
560+ }
561+ }).Maybe()
562+ tr.EXPECT().Close().Return(nil).Once()
563+
564+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
565+ require.NoError(t, err)
566+ select {
567+ case got := <-gotInput:
568+ require.Equal(t, paste, got, "split bracketed paste must arrive as one intact MsgInput frame")
569+ case <-time.After(2 * time.Second):
570+ t.Fatal("split bracketed paste input was not sent")
571+ }
572+}
573+
474 func TestAttachForwardsResize(t *testing.T) {574 func TestAttachForwardsResize(t *testing.T) {
475 var out bytes.Buffer575 var out bytes.Buffer
476 var restoreCount atomic.Int32576 var restoreCount atomic.Int32
@@ -521,7 +621,7 @@ func TestAttachForwardsResize(t *testing.T) {
521 resizeCh <- domain.Size{Cols: 120, Rows: 40}621 resizeCh <- domain.Size{Cols: 120, Rows: 40}
522 }()622 }()
523 623
524- err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")624+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
525 require.NoError(t, err)625 require.NoError(t, err)
526 select {626 select {
527 case r := <-gotResize:627 case r := <-gotResize:
@@ -550,7 +650,7 @@ func TestRunRestoresRawModeAfterAttachError(t *testing.T) {
550 d := portsmocks.NewMockDialer(t)650 d := portsmocks.NewMockDialer(t)
551 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()651 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
552 652
553- err := client.Run(context.Background(), d, tm, ports.IntentEphemeral, "", slog.New(slog.DiscardHandler))653+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", slog.New(slog.DiscardHandler))
554 require.Error(t, err)654 require.Error(t, err)
555 require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors")655 require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors")
556 }656 }
@@ -570,7 +670,7 @@ func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) {
570 d := portsmocks.NewMockDialer(t)670 d := portsmocks.NewMockDialer(t)
571 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()671 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
572 672
573- err := client.Run(context.Background(), d, tm, ports.IntentAttach, "main", slog.New(slog.DiscardHandler))673+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentAttach, "main", slog.New(slog.DiscardHandler))
574 require.Error(t, err)674 require.Error(t, err)
575 var pe *client.ProtocolError675 var pe *client.ProtocolError
576 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)676 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
@@ -582,7 +682,7 @@ func TestRunPhaseASingleAttempt(t *testing.T) {
582 d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once()682 d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once()
583 tm := portsmocks.NewMockTerminal(t)683 tm := portsmocks.NewMockTerminal(t)
584 684
585- err := client.Run(context.Background(), d, tm, ports.IntentEphemeral, "", slog.New(slog.DiscardHandler))685+ err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", slog.New(slog.DiscardHandler))
586 require.ErrorIs(t, err, dialErr)686 require.ErrorIs(t, err, dialErr)
587 }687 }
588 688
@@ -671,7 +771,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}))}}}771 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}}772 d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}}
673 773
674- err := client.Run(context.Background(), d, term, ports.IntentAttach, "main", slog.New(slog.DiscardHandler))774+ err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", slog.New(slog.DiscardHandler))
675 require.NoError(t, err)775 require.NoError(t, err)
676 require.Equal(t, int32(3), d.calls.Load())776 require.Equal(t, int32(3), d.calls.Load())
677 require.Equal(t, int32(1), term.rawCount.Load())777 require.Equal(t, int32(1), term.rawCount.Load())
@@ -698,7 +798,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}))}}}798 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}}799 d := &sequenceDialer{trs: []ports.Transport{tr}}
700 800
701- err := client.Run(context.Background(), d, term, ports.IntentAttach, "main", slog.New(slog.DiscardHandler))801+ err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", slog.New(slog.DiscardHandler))
702 require.Error(t, err)802 require.Error(t, err)
703 var de *client.DetachedError803 var de *client.DetachedError
704 require.True(t, errors.As(err, &de))804 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+const (
12+ // pasteFlushDelay bounds how long a trailing strict prefix of the opening
13+ // marker is held before it is emitted as ordinary bytes. It only has to
14+ // bridge a local kernel read split: the rest of the marker is already in
15+ // the tty buffer and arrives on the next Read microseconds later. It is NOT
16+ // meant to bridge a network-scale gap — once the opening marker completes,
17+ // buffering mode holds the whole paste (up to the 2s safety timer), so a
18+ // close-marker split at any realistic latency is covered by whole-paste
19+ // framing rather than this timer. Kept well under the daemon router's
20+ // ESCDelay so a real lone Esc keypress still reaches the pane promptly.
21+ pasteFlushDelay = 20 * time.Millisecond
22+ // pasteSafetyDelay caps how long the coalescer waits for a closing marker
23+ // before flushing a partial paste, so a paste with a lost closing marker
24+ // degrades to today's per-frame behavior instead of stalling input.
25+ pasteSafetyDelay = 2 * time.Second
26+ // maxPasteBuffer caps buffered paste bytes. Beyond it the buffer is flushed
27+ // verbatim and normal scanning resumes; input is never dropped.
28+ maxPasteBuffer = 2 << 20 // 2 MiB
29+)
30+
31+// pasteCoalescer reframes a bracketed paste that arrives split across input
32+// reads into a single emit call. Over a remote attach, stdin is read in fixed
33+// chunks and forwarded frame by frame; an input-frame boundary can land inside
34+// a paste marker (e.g. between the marker's ESC and its '['). When the daemon's
35+// keys router then sees a lone trailing ESC it retains it for ESCDelay and, if
36+// the next frame is late, forwards it as a standalone Esc keypress — breaking
37+// the opening marker so the pane's TUI never enters paste mode. Coalescing the
38+// whole paste into one emit keeps both markers contiguous on the wire.
39+//
40+// It is safe for concurrent use: Scan runs on the stdin pump goroutine while a
41+// flush/safety timer may fire on its own goroutine; both take the mutex, so
42+// emit calls are serialised.
43+type pasteCoalescer struct {
44+ clock ports.Clock
45+ emit func([]byte)
46+
47+ mu sync.Mutex
48+ buffering bool // inside a paste: accumulating until the closing marker
49+ buf []byte // paste bytes accumulated in buffering mode
50+ pending []byte // held trailing strict prefix of the opening marker
51+
52+ flushTimer ports.Timer
53+ flushDone chan struct{}
54+ safetyTimer ports.Timer
55+ safetyDone chan struct{}
56+ closed bool
57+}
58+
59+func newPasteCoalescer(clock ports.Clock, emit func([]byte)) *pasteCoalescer {
60+ return &pasteCoalescer{clock: clock, emit: emit}
61+}
62+
63+// Scan consumes one run of ordinary input bytes, emitting either pass-through
64+// bytes immediately or a whole bracketed paste as a single emit call once its
65+// closing marker is seen.
66+func (c *pasteCoalescer) Scan(data []byte) {
67+ if len(data) == 0 {
68+ return
69+ }
70+ c.mu.Lock()
71+ defer c.mu.Unlock()
72+ if len(c.pending) > 0 {
73+ // New bytes arrived before the flush timer fired: the held prefix was
74+ // the start of this data, not a lone Esc. Recombine and rescan.
75+ c.stopFlushTimer()
76+ data = append(c.pending, data...)
77+ c.pending = nil
78+ }
79+ c.process(data)
80+}
81+
82+// process runs the scan/buffer state machine with the mutex held.
83+func (c *pasteCoalescer) process(data []byte) {
84+ for {
85+ if c.buffering {
86+ searchFrom := len(c.buf)
87+ if overlap := len(ports.BracketedPasteCloseMarker) - 1; searchFrom > overlap {
88+ searchFrom -= overlap
89+ } else {
90+ searchFrom = 0
91+ }
92+ c.buf = append(c.buf, data...)
93+ idx := bytes.Index(c.buf[searchFrom:], ports.BracketedPasteCloseMarker)
94+ if idx >= 0 {
95+ idx += searchFrom
96+ }
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(ports.BracketedPasteCloseMarker)
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, ports.BracketedPasteOpenMarker); 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+ matchLimit := len(ports.BracketedPasteOpenMarker) - 1
244+ if matchLimit > len(data) {
245+ matchLimit = len(data)
246+ }
247+ for n := matchLimit; n >= 1; n-- {
248+ if bytes.Equal(data[len(data)-n:], ports.BracketedPasteOpenMarker[: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+const (
12+ // pasteFlushDelay bounds how long a trailing strict prefix of the opening
13+ // marker is held before it is emitted as ordinary bytes. It only has to
14+ // bridge a local kernel read split: the rest of the marker is already in
15+ // the tty buffer and arrives on the next Read microseconds later. It is NOT
16+ // meant to bridge a network-scale gap — once the opening marker completes,
17+ // buffering mode holds the whole paste (up to the 2s safety timer), so a
18+ // close-marker split at any realistic latency is covered by whole-paste
19+ // framing rather than this timer. Kept well under the daemon router's
20+ // ESCDelay so a real lone Esc keypress still reaches the pane promptly.
21+ pasteFlushDelay = 20 * time.Millisecond
22+ // pasteSafetyDelay caps how long the coalescer waits for a closing marker
23+ // before flushing a partial paste, so a paste with a lost closing marker
24+ // degrades to today's per-frame behavior instead of stalling input.
25+ pasteSafetyDelay = 2 * time.Second
26+ // maxPasteBuffer caps buffered paste bytes. Beyond it the buffer is flushed
27+ // verbatim and normal scanning resumes; input is never dropped.
28+ maxPasteBuffer = 2 << 20 // 2 MiB
29+)
30+
31+// pasteCoalescer reframes a bracketed paste that arrives split across input
32+// reads into a single emit call. Over a remote attach, stdin is read in fixed
33+// chunks and forwarded frame by frame; an input-frame boundary can land inside
34+// a paste marker (e.g. between the marker's ESC and its '['). When the daemon's
35+// keys router then sees a lone trailing ESC it retains it for ESCDelay and, if
36+// the next frame is late, forwards it as a standalone Esc keypress — breaking
37+// the opening marker so the pane's TUI never enters paste mode. Coalescing the
38+// whole paste into one emit keeps both markers contiguous on the wire.
39+//
40+// It is safe for concurrent use: Scan runs on the stdin pump goroutine while a
41+// flush/safety timer may fire on its own goroutine; both take the mutex, so
42+// emit calls are serialised.
43+type pasteCoalescer struct {
44+ clock ports.Clock
45+ emit func([]byte)
46+
47+ mu sync.Mutex
48+ buffering bool // inside a paste: accumulating until the closing marker
49+ buf []byte // paste bytes accumulated in buffering mode
50+ pending []byte // held trailing strict prefix of the opening marker
51+
52+ flushTimer ports.Timer
53+ flushDone chan struct{}
54+ safetyTimer ports.Timer
55+ safetyDone chan struct{}
56+ closed bool
57+}
58+
59+func newPasteCoalescer(clock ports.Clock, emit func([]byte)) *pasteCoalescer {
60+ return &pasteCoalescer{clock: clock, emit: emit}
61+}
62+
63+// Scan consumes one run of ordinary input bytes, emitting either pass-through
64+// bytes immediately or a whole bracketed paste as a single emit call once its
65+// closing marker is seen.
66+func (c *pasteCoalescer) Scan(data []byte) {
67+ if len(data) == 0 {
68+ return
69+ }
70+ c.mu.Lock()
71+ defer c.mu.Unlock()
72+ if len(c.pending) > 0 {
73+ // New bytes arrived before the flush timer fired: the held prefix was
74+ // the start of this data, not a lone Esc. Recombine and rescan.
75+ c.stopFlushTimer()
76+ data = append(c.pending, data...)
77+ c.pending = nil
78+ }
79+ c.process(data)
80+}
81+
82+// process runs the scan/buffer state machine with the mutex held.
83+func (c *pasteCoalescer) process(data []byte) {
84+ for {
85+ if c.buffering {
86+ searchFrom := len(c.buf)
87+ if overlap := len(ports.BracketedPasteCloseMarker) - 1; searchFrom > overlap {
88+ searchFrom -= overlap
89+ } else {
90+ searchFrom = 0
91+ }
92+ c.buf = append(c.buf, data...)
93+ idx := bytes.Index(c.buf[searchFrom:], ports.BracketedPasteCloseMarker)
94+ if idx >= 0 {
95+ idx += searchFrom
96+ }
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(ports.BracketedPasteCloseMarker)
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, ports.BracketedPasteOpenMarker); 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+ matchLimit := len(ports.BracketedPasteOpenMarker) - 1
244+ if matchLimit > len(data) {
245+ matchLimit = len(data)
246+ }
247+ for n := matchLimit; n >= 1; n-- {
248+ if bytes.Equal(data[len(data)-n:], ports.BracketedPasteOpenMarker[:n]) {
249+ return n
250+ }
251+ }
252+ return 0
253+}
A internal/usecase/client/paste_coalescer_test.go +227 -0
@@ -0,0 +1,227 @@
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 TestPasteCoalescerBackToBackPastesInOneRead(t *testing.T) {
121+ pc, _, col := newTestCoalescer()
122+ defer pc.Close()
123+
124+ first := []byte("\x1b[200~first\x1b[201~")
125+ second := []byte("\x1b[200~second\x1b[201~")
126+ pc.Scan(append(append([]byte(nil), first...), second...))
127+
128+ require.Equal(t, [][]byte{first, second}, col.snapshot(),
129+ "back-to-back pastes in one read must emit as two intact paste frames")
130+}
131+
132+func TestPasteCoalescerLoneEscapeFlushedAfterTimer(t *testing.T) {
133+ pc, clk, col := newTestCoalescer()
134+ defer pc.Close()
135+
136+ pc.Scan([]byte{0x1b})
137+ require.Empty(t, col.snapshot(), "a trailing ESC must be held, not emitted immediately")
138+
139+ clk.fireLast()
140+ col.waitEmits(t, 1)
141+ require.Equal(t, [][]byte{{0x1b}}, col.snapshot(), "held ESC must flush unchanged after the timer")
142+}
143+
144+func TestPasteCoalescerEscapeThenOpenMarkerNextRead(t *testing.T) {
145+ pc, _, col := newTestCoalescer()
146+ defer pc.Close()
147+
148+ pc.Scan([]byte{0x1b})
149+ require.Empty(t, col.snapshot(), "ESC held pending the rest of the marker")
150+
151+ pc.Scan(samplePaste[1:]) // "[200~...\x1b[201~"
152+ require.Equal(t, [][]byte{samplePaste}, col.snapshot(),
153+ "ESC recombined with the following marker must emit a single paste")
154+}
155+
156+func TestPasteCoalescerPlainTextPassthrough(t *testing.T) {
157+ pc, _, col := newTestCoalescer()
158+ defer pc.Close()
159+
160+ pc.Scan([]byte("hello world"))
161+ pc.Scan([]byte("second read"))
162+ require.Equal(t, [][]byte{[]byte("hello world"), []byte("second read")}, col.snapshot(),
163+ "plain text must pass through unchanged at the same call granularity")
164+}
165+
166+func TestPasteCoalescerTextPasteTextInOneRead(t *testing.T) {
167+ pc, _, col := newTestCoalescer()
168+ defer pc.Close()
169+
170+ paste := []byte("\x1b[200~pasted\x1b[201~")
171+ read := append(append([]byte("AB"), paste...), []byte("CD")...)
172+ pc.Scan(read)
173+ require.Equal(t, [][]byte{[]byte("AB"), paste, []byte("CD")}, col.snapshot(),
174+ "text + paste + text in one read must emit three chunks in order")
175+}
176+
177+func TestPasteCoalescerBufferCapOverflowFlushesVerbatim(t *testing.T) {
178+ pc, _, col := newTestCoalescer()
179+ defer pc.Close()
180+
181+ big := append([]byte("\x1b[200~"), bytes.Repeat([]byte("x"), maxPasteBuffer+16)...)
182+ pc.Scan(big)
183+ require.Equal(t, [][]byte{big}, col.snapshot(),
184+ "an oversized unterminated paste must flush verbatim without dropping bytes")
185+
186+ // The coalescer must be back in normal mode and keep working.
187+ pc.Scan([]byte("after"))
188+ require.Equal(t, [][]byte{big, []byte("after")}, col.snapshot())
189+}
190+
191+func TestPasteCoalescerSafetyTimeoutFlushesVerbatim(t *testing.T) {
192+ pc, clk, col := newTestCoalescer()
193+ defer pc.Close()
194+
195+ partial := []byte("\x1b[200~unterminated paste")
196+ pc.Scan(partial)
197+ require.Empty(t, col.snapshot(), "an open paste with no closing marker is buffered, not emitted")
198+
199+ clk.fireLast() // safety timer
200+ col.waitEmits(t, 1)
201+ require.Equal(t, [][]byte{partial}, col.snapshot(),
202+ "the safety timeout must flush buffered paste bytes verbatim")
203+
204+ pc.Scan([]byte("normal"))
205+ require.Equal(t, [][]byte{partial, []byte("normal")}, col.snapshot(),
206+ "after the safety flush the coalescer must resume normal scanning")
207+}
208+
209+func TestTrailingOpenPrefixLen(t *testing.T) {
210+ cases := []struct {
211+ in string
212+ want int
213+ }{
214+ {"", 0},
215+ {"abc", 0},
216+ {"abc\x1b", 1},
217+ {"abc\x1b[", 2},
218+ {"\x1b[2", 3},
219+ {"\x1b[20", 4},
220+ {"\x1b[200", 5},
221+ {"\x1b[200~", 0}, // full marker is not a strict prefix
222+ {"[200", 0}, // no ESC: not a marker start
223+ }
224+ for _, tc := range cases {
225+ require.Equal(t, tc.want, trailingOpenPrefixLen([]byte(tc.in)), "input %q", tc.in)
226+ }
227+}
@@ -0,0 +1,227 @@
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 TestPasteCoalescerBackToBackPastesInOneRead(t *testing.T) {
121+ pc, _, col := newTestCoalescer()
122+ defer pc.Close()
123+
124+ first := []byte("\x1b[200~first\x1b[201~")
125+ second := []byte("\x1b[200~second\x1b[201~")
126+ pc.Scan(append(append([]byte(nil), first...), second...))
127+
128+ require.Equal(t, [][]byte{first, second}, col.snapshot(),
129+ "back-to-back pastes in one read must emit as two intact paste frames")
130+}
131+
132+func TestPasteCoalescerLoneEscapeFlushedAfterTimer(t *testing.T) {
133+ pc, clk, col := newTestCoalescer()
134+ defer pc.Close()
135+
136+ pc.Scan([]byte{0x1b})
137+ require.Empty(t, col.snapshot(), "a trailing ESC must be held, not emitted immediately")
138+
139+ clk.fireLast()
140+ col.waitEmits(t, 1)
141+ require.Equal(t, [][]byte{{0x1b}}, col.snapshot(), "held ESC must flush unchanged after the timer")
142+}
143+
144+func TestPasteCoalescerEscapeThenOpenMarkerNextRead(t *testing.T) {
145+ pc, _, col := newTestCoalescer()
146+ defer pc.Close()
147+
148+ pc.Scan([]byte{0x1b})
149+ require.Empty(t, col.snapshot(), "ESC held pending the rest of the marker")
150+
151+ pc.Scan(samplePaste[1:]) // "[200~...\x1b[201~"
152+ require.Equal(t, [][]byte{samplePaste}, col.snapshot(),
153+ "ESC recombined with the following marker must emit a single paste")
154+}
155+
156+func TestPasteCoalescerPlainTextPassthrough(t *testing.T) {
157+ pc, _, col := newTestCoalescer()
158+ defer pc.Close()
159+
160+ pc.Scan([]byte("hello world"))
161+ pc.Scan([]byte("second read"))
162+ require.Equal(t, [][]byte{[]byte("hello world"), []byte("second read")}, col.snapshot(),
163+ "plain text must pass through unchanged at the same call granularity")
164+}
165+
166+func TestPasteCoalescerTextPasteTextInOneRead(t *testing.T) {
167+ pc, _, col := newTestCoalescer()
168+ defer pc.Close()
169+
170+ paste := []byte("\x1b[200~pasted\x1b[201~")
171+ read := append(append([]byte("AB"), paste...), []byte("CD")...)
172+ pc.Scan(read)
173+ require.Equal(t, [][]byte{[]byte("AB"), paste, []byte("CD")}, col.snapshot(),
174+ "text + paste + text in one read must emit three chunks in order")
175+}
176+
177+func TestPasteCoalescerBufferCapOverflowFlushesVerbatim(t *testing.T) {
178+ pc, _, col := newTestCoalescer()
179+ defer pc.Close()
180+
181+ big := append([]byte("\x1b[200~"), bytes.Repeat([]byte("x"), maxPasteBuffer+16)...)
182+ pc.Scan(big)
183+ require.Equal(t, [][]byte{big}, col.snapshot(),
184+ "an oversized unterminated paste must flush verbatim without dropping bytes")
185+
186+ // The coalescer must be back in normal mode and keep working.
187+ pc.Scan([]byte("after"))
188+ require.Equal(t, [][]byte{big, []byte("after")}, col.snapshot())
189+}
190+
191+func TestPasteCoalescerSafetyTimeoutFlushesVerbatim(t *testing.T) {
192+ pc, clk, col := newTestCoalescer()
193+ defer pc.Close()
194+
195+ partial := []byte("\x1b[200~unterminated paste")
196+ pc.Scan(partial)
197+ require.Empty(t, col.snapshot(), "an open paste with no closing marker is buffered, not emitted")
198+
199+ clk.fireLast() // safety timer
200+ col.waitEmits(t, 1)
201+ require.Equal(t, [][]byte{partial}, col.snapshot(),
202+ "the safety timeout must flush buffered paste bytes verbatim")
203+
204+ pc.Scan([]byte("normal"))
205+ require.Equal(t, [][]byte{partial, []byte("normal")}, col.snapshot(),
206+ "after the safety flush the coalescer must resume normal scanning")
207+}
208+
209+func TestTrailingOpenPrefixLen(t *testing.T) {
210+ cases := []struct {
211+ in string
212+ want int
213+ }{
214+ {"", 0},
215+ {"abc", 0},
216+ {"abc\x1b", 1},
217+ {"abc\x1b[", 2},
218+ {"\x1b[2", 3},
219+ {"\x1b[20", 4},
220+ {"\x1b[200", 5},
221+ {"\x1b[200~", 0}, // full marker is not a strict prefix
222+ {"[200", 0}, // no ESC: not a marker start
223+ }
224+ for _, tc := range cases {
225+ require.Equal(t, tc.want, trailingOpenPrefixLen([]byte(tc.in)), "input %q", tc.in)
226+ }
227+}
M internal/usecase/keys/router.go +16 -0
@@ -3,6 +3,7 @@
33 package keys
44
55 import (
6+ "bytes"
67 "slices"
78 "sync"
89 "time"
@@ -104,6 +105,21 @@ func (r *Router) route(data []byte) {
104105 return
105106 }
106107 remaining := data[i+1:]
108+ if bytes.HasPrefix(data[i:], ports.BracketedPasteOpenMarker) {
109+ // A bracketed paste whose closing marker is in this same frame is
110+ // forwarded verbatim: pasted content bytes (including embedded
111+ // ESC+letter Alt lookalikes or ESC [ 1 ; 3 A sequences) must never
112+ // fire an Action mid-paste. Without a closing marker in-frame we
113+ // fall through to today's per-byte routing (the '[' passes through
114+ // as a control prefix); Part A keeps pastes single-frame in
115+ // practice, so no cross-frame paste state is tracked here.
116+ if rel := bytes.Index(data[i:], ports.BracketedPasteCloseMarker); rel >= 0 {
117+ end := i + rel + len(ports.BracketedPasteCloseMarker)
118+ buf = append(buf, data[i:end]...)
119+ i = end
120+ continue
121+ }
122+ }
107123 if action, size, ok, partial := altArrowCSI(remaining); ok {
108124 flush()
109125 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"
@@ -104,6 +105,21 @@ func (r *Router) route(data []byte) {
104 return105 return
105 }106 }
106 remaining := data[i+1:]107 remaining := data[i+1:]
108+ if bytes.HasPrefix(data[i:], ports.BracketedPasteOpenMarker) {
109+ // A bracketed paste whose closing marker is in this same frame is
110+ // forwarded verbatim: pasted content bytes (including embedded
111+ // ESC+letter Alt lookalikes or ESC [ 1 ; 3 A sequences) must never
112+ // fire an Action mid-paste. Without a closing marker in-frame we
113+ // fall through to today's per-byte routing (the '[' passes through
114+ // as a control prefix); Part A keeps pastes single-frame in
115+ // practice, so no cross-frame paste state is tracked here.
116+ if rel := bytes.Index(data[i:], ports.BracketedPasteCloseMarker); rel >= 0 {
117+ end := i + rel + len(ports.BracketedPasteCloseMarker)
118+ buf = append(buf, data[i:end]...)
119+ i = end
120+ continue
121+ }
122+ }
107 if action, size, ok, partial := altArrowCSI(remaining); ok {123 if action, size, ok, partial := altArrowCSI(remaining); ok {
108 flush()124 flush()
109 r.h.Action(action)125 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), ports.BracketedPasteOpenMarker...), pasteBindingLookalikes...), ports.BracketedPasteCloseMarker...)
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), ports.BracketedPasteOpenMarker...), pasteBindingLookalikes...), ports.BracketedPasteCloseMarker...)
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