detached bfba9f2

fix(remote): negotiate ACK-driven output state

commit
bfba9f26587871c37982592ccfc7041c490a485a
author
brice · · 2026-07-09 18:27 UTC
parent
5d4ffc4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +69 -23
M internal/adapters/dgram/proxy_test.go +27 -0
@@ -78,6 +78,33 @@ func TestProxyRuntimeCopiesFramesAndDaemonEOFIsTerminal(t *testing.T) {
7878 }
7979 }
8080
81+func TestProxyRuntimeForwardsOutputAckCapabilityInHello(t *testing.T) {
82+ client := newFakeTransport()
83+ daemon := newFakeTransport()
84+ errCh := make(chan error, 1)
85+ go func() { errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Hour}.Run(t.Context()) }()
86+ hello := ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", AckOutput: true}
87+ client.recv <- recvResult{frame: ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}}
88+
89+ select {
90+ case frame := <-daemon.sent:
91+ got, err := ports.UnmarshalHello(frame.Payload)
92+ if err != nil {
93+ t.Fatal(err)
94+ }
95+ if !got.AckOutput {
96+ t.Fatal("proxied Hello lost AckOutput capability")
97+ }
98+ case <-time.After(time.Second):
99+ t.Fatal("timeout waiting for proxied Hello")
100+ }
101+
102+ daemon.recv <- recvResult{err: io.EOF}
103+ if err := <-errCh; err != nil {
104+ t.Fatalf("Run err=%v, want nil", err)
105+ }
106+}
107+
81108 func TestProxyRuntimeRetriesRecoverableClientSendWithoutDroppingFrame(t *testing.T) {
82109 tests := []struct {
83110 name string
@@ -78,6 +78,33 @@ func TestProxyRuntimeCopiesFramesAndDaemonEOFIsTerminal(t *testing.T) {
78 }78 }
79 }79 }
80 80
81+func TestProxyRuntimeForwardsOutputAckCapabilityInHello(t *testing.T) {
82+ client := newFakeTransport()
83+ daemon := newFakeTransport()
84+ errCh := make(chan error, 1)
85+ go func() { errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Hour}.Run(t.Context()) }()
86+ hello := ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", AckOutput: true}
87+ client.recv <- recvResult{frame: ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}}
88+
89+ select {
90+ case frame := <-daemon.sent:
91+ got, err := ports.UnmarshalHello(frame.Payload)
92+ if err != nil {
93+ t.Fatal(err)
94+ }
95+ if !got.AckOutput {
96+ t.Fatal("proxied Hello lost AckOutput capability")
97+ }
98+ case <-time.After(time.Second):
99+ t.Fatal("timeout waiting for proxied Hello")
100+ }
101+
102+ daemon.recv <- recvResult{err: io.EOF}
103+ if err := <-errCh; err != nil {
104+ t.Fatalf("Run err=%v, want nil", err)
105+ }
106+}
107+
81 func TestProxyRuntimeRetriesRecoverableClientSendWithoutDroppingFrame(t *testing.T) {108 func TestProxyRuntimeRetriesRecoverableClientSendWithoutDroppingFrame(t *testing.T) {
82 tests := []struct {109 tests := []struct {
83 name string110 name string
M internal/ports/frame.go +1 -1
@@ -1,7 +1,7 @@
11 package ports
22
33 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 10
4+const ProtocolVersion uint16 = 11
55
66 // MsgType identifies the kind of payload carried by a Frame.
77 type MsgType uint8
@@ -1,7 +1,7 @@
1 package ports1 package ports
2 2
3 // ProtocolVersion is the current vev IPC wire protocol version.3 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 104+const ProtocolVersion uint16 = 11
5 5
6 // MsgType identifies the kind of payload carried by a Frame.6 // MsgType identifies the kind of payload carried by a Frame.
7 type MsgType uint87 type MsgType uint8
M internal/ports/frame_test.go +2 -2
@@ -3,8 +3,8 @@ package ports
33 import "testing"
44
55 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 10 {
7- t.Fatalf("ProtocolVersion = %d, want 10", ProtocolVersion)
6+ if ProtocolVersion != 11 {
7+ t.Fatalf("ProtocolVersion = %d, want 11", ProtocolVersion)
88 }
99 }
1010
@@ -3,8 +3,8 @@ package ports
3 import "testing"3 import "testing"
4 4
5 func TestProtocolVersion(t *testing.T) {5 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 10 {6+ if ProtocolVersion != 11 {
7- t.Fatalf("ProtocolVersion = %d, want 10", ProtocolVersion)7+ t.Fatalf("ProtocolVersion = %d, want 11", ProtocolVersion)
8 }8 }
9 }9 }
10 10
M internal/ports/wire.go +11 -0
@@ -66,6 +66,7 @@ type Hello struct {
6666 TermEnv string
6767 Cwd string
6868 TrueColor bool
69+ AckOutput bool
6970 }
7071
7172 // Input carries raw bytes typed/pasted by the client, destined for the PTY.
@@ -309,6 +310,11 @@ func MarshalHello(h Hello) []byte {
309310 } else {
310311 w.putUint8(0)
311312 }
313+ if h.AckOutput {
314+ w.putUint8(1)
315+ } else {
316+ w.putUint8(0)
317+ }
312318 return w.b
313319 }
314320
@@ -355,6 +361,11 @@ func UnmarshalHello(b []byte) (Hello, error) {
355361 return Hello{}, err
356362 }
357363 h.TrueColor = trueColor != 0
364+ ackOutput, err := r.getUint8()
365+ if err != nil {
366+ return Hello{}, err
367+ }
368+ h.AckOutput = ackOutput != 0
358369 if err := r.done(); err != nil {
359370 return Hello{}, err
360371 }
@@ -66,6 +66,7 @@ type Hello struct {
66 TermEnv string66 TermEnv string
67 Cwd string67 Cwd string
68 TrueColor bool68 TrueColor bool
69+ AckOutput bool
69 }70 }
70 71
71 // Input carries raw bytes typed/pasted by the client, destined for the PTY.72 // Input carries raw bytes typed/pasted by the client, destined for the PTY.
@@ -309,6 +310,11 @@ func MarshalHello(h Hello) []byte {
309 } else {310 } else {
310 w.putUint8(0)311 w.putUint8(0)
311 }312 }
313+ if h.AckOutput {
314+ w.putUint8(1)
315+ } else {
316+ w.putUint8(0)
317+ }
312 return w.b318 return w.b
313 }319 }
314 320
@@ -355,6 +361,11 @@ func UnmarshalHello(b []byte) (Hello, error) {
355 return Hello{}, err361 return Hello{}, err
356 }362 }
357 h.TrueColor = trueColor != 0363 h.TrueColor = trueColor != 0
364+ ackOutput, err := r.getUint8()
365+ if err != nil {
366+ return Hello{}, err
367+ }
368+ h.AckOutput = ackOutput != 0
358 if err := r.done(); err != nil {369 if err := r.done(); err != nil {
359 return Hello{}, err370 return Hello{}, err
360 }371 }
M internal/ports/wire_test.go +3 -2
@@ -69,8 +69,9 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
6969 TermEnv: "xterm-256color",
7070 Cwd: "/tmp/project",
7171 TrueColor: true,
72+ AckOutput: true,
7273 },
73- want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01},
74+ want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01, 0x01},
7475 },
7576 {
7677 name: "empty strings",
@@ -83,7 +84,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
8384 Cwd: "",
8485 TrueColor: false,
8586 },
86- want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
87+ want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
8788 },
8889 }
8990
@@ -69,8 +69,9 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
69 TermEnv: "xterm-256color",69 TermEnv: "xterm-256color",
70 Cwd: "/tmp/project",70 Cwd: "/tmp/project",
71 TrueColor: true,71 TrueColor: true,
72+ AckOutput: true,
72 },73 },
73- want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01},74+ want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01, 0x01},
74 },75 },
75 {76 {
76 name: "empty strings",77 name: "empty strings",
@@ -83,7 +84,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
83 Cwd: "",84 Cwd: "",
84 TrueColor: false,85 TrueColor: false,
85 },86 },
86- want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},87+ want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
87 },88 },
88 }89 }
89 90
M internal/usecase/client/client.go +2 -0
@@ -352,6 +352,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
352352 termEnv := os.Getenv("TERM")
353353 colorTerm := os.Getenv("COLORTERM")
354354 trueColor := DetectTrueColor(termEnv, colorTerm)
355+ _, ackOutput := transport.(ports.DatagramTransport)
355356 hello := ports.Hello{
356357 Version: ports.ProtocolVersion,
357358 Intent: intent,
@@ -362,6 +363,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
362363 TermEnv: termEnv,
363364 Cwd: cwd,
364365 TrueColor: trueColor,
366+ AckOutput: ackOutput,
365367 }
366368 if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {
367369 return attachResult{err: fmt.Errorf("vev: sending hello: %w", err)}
@@ -352,6 +352,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
352 termEnv := os.Getenv("TERM")352 termEnv := os.Getenv("TERM")
353 colorTerm := os.Getenv("COLORTERM")353 colorTerm := os.Getenv("COLORTERM")
354 trueColor := DetectTrueColor(termEnv, colorTerm)354 trueColor := DetectTrueColor(termEnv, colorTerm)
355+ _, ackOutput := transport.(ports.DatagramTransport)
355 hello := ports.Hello{356 hello := ports.Hello{
356 Version: ports.ProtocolVersion,357 Version: ports.ProtocolVersion,
357 Intent: intent,358 Intent: intent,
@@ -362,6 +363,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
362 TermEnv: termEnv,363 TermEnv: termEnv,
363 Cwd: cwd,364 Cwd: cwd,
364 TrueColor: trueColor,365 TrueColor: trueColor,
366+ AckOutput: ackOutput,
365 }367 }
366 if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {368 if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {
367 return attachResult{err: fmt.Errorf("vev: sending hello: %w", err)}369 return attachResult{err: fmt.Errorf("vev: sending hello: %w", err)}
M internal/usecase/client/client_test.go +6 -1
@@ -151,6 +151,10 @@ func isType(typ ports.MsgType) any {
151151 return mock.MatchedBy(func(f ports.Frame) bool { return f.Type == typ })
152152 }
153153
154+type datagramMarkedTransport struct{ ports.Transport }
155+
156+func (datagramMarkedTransport) DatagramTransport() {}
157+
154158 func TestAttachHelloIncludesTrueColor(t *testing.T) {
155159 t.Setenv("TERM", "xterm-256color")
156160 t.Setenv("COLORTERM", "truecolor")
@@ -176,13 +180,14 @@ func TestAttachHelloIncludesTrueColor(t *testing.T) {
176180 defer unblock()
177181 tr.EXPECT().Close().Return(nil).Once()
178182
179- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
183+ err := client.Attach(context.Background(), datagramMarkedTransport{Transport: tr}, tm, realClock{}, ports.IntentEphemeral, "")
180184 require.NoError(t, err)
181185
182186 select {
183187 case hello := <-gotHello:
184188 require.Equal(t, "xterm-256color", hello.TermEnv)
185189 require.True(t, hello.TrueColor)
190+ require.True(t, hello.AckOutput)
186191 case <-time.After(2 * time.Second):
187192 t.Fatal("hello frame was not sent")
188193 }
@@ -151,6 +151,10 @@ func isType(typ ports.MsgType) any {
151 return mock.MatchedBy(func(f ports.Frame) bool { return f.Type == typ })151 return mock.MatchedBy(func(f ports.Frame) bool { return f.Type == typ })
152 }152 }
153 153
154+type datagramMarkedTransport struct{ ports.Transport }
155+
156+func (datagramMarkedTransport) DatagramTransport() {}
157+
154 func TestAttachHelloIncludesTrueColor(t *testing.T) {158 func TestAttachHelloIncludesTrueColor(t *testing.T) {
155 t.Setenv("TERM", "xterm-256color")159 t.Setenv("TERM", "xterm-256color")
156 t.Setenv("COLORTERM", "truecolor")160 t.Setenv("COLORTERM", "truecolor")
@@ -176,13 +180,14 @@ func TestAttachHelloIncludesTrueColor(t *testing.T) {
176 defer unblock()180 defer unblock()
177 tr.EXPECT().Close().Return(nil).Once()181 tr.EXPECT().Close().Return(nil).Once()
178 182
179- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")183+ err := client.Attach(context.Background(), datagramMarkedTransport{Transport: tr}, tm, realClock{}, ports.IntentEphemeral, "")
180 require.NoError(t, err)184 require.NoError(t, err)
181 185
182 select {186 select {
183 case hello := <-gotHello:187 case hello := <-gotHello:
184 require.Equal(t, "xterm-256color", hello.TermEnv)188 require.Equal(t, "xterm-256color", hello.TermEnv)
185 require.True(t, hello.TrueColor)189 require.True(t, hello.TrueColor)
190+ require.True(t, hello.AckOutput)
186 case <-time.After(2 * time.Second):191 case <-time.After(2 * time.Second):
187 t.Fatal("hello frame was not sent")192 t.Fatal("hello frame was not sent")
188 }193 }
M internal/usecase/daemon/client.go +2 -7
@@ -425,12 +425,7 @@ func (d *Daemon) notifiesSnapshot() []chan struct{} {
425425 type attachClientOptions struct {
426426 clientID [16]byte
427427 resumeCapable bool
428- datagram bool
429-}
430-
431-func isDatagramTransport(tr ports.Transport) bool {
432- _, ok := tr.(ports.DatagramTransport)
433- return ok
428+ ackOutput bool
434429 }
435430
436431 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient) {
@@ -439,7 +434,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
439434 resumeToken = d.nextResumeTokenLocked()
440435 }
441436 caps := renderer.Capabilities{}
442- advanceOutputOnAck := opts.datagram || isDatagramTransport(tr)
437+ advanceOutputOnAck := opts.ackOutput
443438 if advanceOutputOnAck {
444439 caps.AdvancePolicy = renderer.AdvanceOnAck
445440 }
@@ -425,12 +425,7 @@ func (d *Daemon) notifiesSnapshot() []chan struct{} {
425 type attachClientOptions struct {425 type attachClientOptions struct {
426 clientID [16]byte426 clientID [16]byte
427 resumeCapable bool427 resumeCapable bool
428- datagram bool428+ ackOutput bool
429-}
430-
431-func isDatagramTransport(tr ports.Transport) bool {
432- _, ok := tr.(ports.DatagramTransport)
433- return ok
434 }429 }
435 430
436 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient) {431 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient) {
@@ -439,7 +434,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
439 resumeToken = d.nextResumeTokenLocked()434 resumeToken = d.nextResumeTokenLocked()
440 }435 }
441 caps := renderer.Capabilities{}436 caps := renderer.Capabilities{}
442- advanceOutputOnAck := opts.datagram || isDatagramTransport(tr)437+ advanceOutputOnAck := opts.ackOutput
443 if advanceOutputOnAck {438 if advanceOutputOnAck {
444 caps.AdvancePolicy = renderer.AdvanceOnAck439 caps.AdvancePolicy = renderer.AdvanceOnAck
445 }440 }
M internal/usecase/daemon/daemon.go +4 -4
@@ -654,7 +654,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
654654 }
655655 }
656656 d.purgeParkedForSessionLocked(sess)
657- ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})
657+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, ackOutput: h.AckOutput})
658658 sess.mu.Lock()
659659 sess.terminal = term
660660 sess.mu.Unlock()
@@ -670,7 +670,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
670670 return nil, nil, err
671671 }
672672 d.purgeParkedForSessionLocked(sess)
673- ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})
673+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, ackOutput: h.AckOutput})
674674 sess.mu.Lock()
675675 sess.terminal = term
676676 sess.mu.Unlock()
@@ -697,7 +697,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
697697 return nil, nil, err
698698 }
699699 d.purgeParkedForSessionLocked(sess)
700- ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})
700+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, ackOutput: h.AckOutput})
701701 sess.mu.Lock()
702702 sess.terminal = term
703703 sess.mu.Unlock()
@@ -722,7 +722,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
722722 }
723723 }
724724 d.purgeParkedForSessionLocked(sess)
725- ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})
725+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, ackOutput: h.AckOutput})
726726 sess.mu.Lock()
727727 sess.terminal = term
728728 sess.mu.Unlock()
@@ -654,7 +654,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
654 }654 }
655 }655 }
656 d.purgeParkedForSessionLocked(sess)656 d.purgeParkedForSessionLocked(sess)
657- ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})657+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, ackOutput: h.AckOutput})
658 sess.mu.Lock()658 sess.mu.Lock()
659 sess.terminal = term659 sess.terminal = term
660 sess.mu.Unlock()660 sess.mu.Unlock()
@@ -670,7 +670,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
670 return nil, nil, err670 return nil, nil, err
671 }671 }
672 d.purgeParkedForSessionLocked(sess)672 d.purgeParkedForSessionLocked(sess)
673- ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})673+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, ackOutput: h.AckOutput})
674 sess.mu.Lock()674 sess.mu.Lock()
675 sess.terminal = term675 sess.terminal = term
676 sess.mu.Unlock()676 sess.mu.Unlock()
@@ -697,7 +697,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
697 return nil, nil, err697 return nil, nil, err
698 }698 }
699 d.purgeParkedForSessionLocked(sess)699 d.purgeParkedForSessionLocked(sess)
700- ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})700+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, ackOutput: h.AckOutput})
701 sess.mu.Lock()701 sess.mu.Lock()
702 sess.terminal = term702 sess.terminal = term
703 sess.mu.Unlock()703 sess.mu.Unlock()
@@ -722,7 +722,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
722 }722 }
723 }723 }
724 d.purgeParkedForSessionLocked(sess)724 d.purgeParkedForSessionLocked(sess)
725- ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})725+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, ackOutput: h.AckOutput})
726 sess.mu.Lock()726 sess.mu.Lock()
727 sess.terminal = term727 sess.terminal = term
728 sess.mu.Unlock()728 sess.mu.Unlock()
M internal/usecase/daemon/output_state_sync_test.go +11 -6
@@ -28,17 +28,22 @@ func (t *datagramTestTransport) Recv() (ports.Frame, error) {
2828 }
2929 return f, nil
3030 }
31-func (t *datagramTestTransport) Close() error { t.once.Do(func() { close(t.recv) }); return nil }
32-func (t *datagramTestTransport) DatagramTransport() {}
31+func (t *datagramTestTransport) Close() error { t.once.Do(func() { close(t.recv) }); return nil }
3332
3433 func TestDatagramAttachAdvancesRendererOnlyOnAck(t *testing.T) {
3534 p, releasePTY := newBlockingPTY(t)
3635 defer releasePTY()
37- d, sess, old, _ := newManualSessionWithPTYs(t, p)
36+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
3837 tr := newDatagramTestTransport()
39- ac, replaced := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{datagram: true})
40- require.Same(t, old, replaced)
41- defer d.detachReplacedClient(replaced)
38+ routed, ac, err := d.route(ports.Hello{
39+ Version: ports.ProtocolVersion,
40+ Intent: ports.IntentAttach,
41+ Name: sess.name,
42+ Size: domain.Size{Cols: 80, Rows: 24},
43+ AckOutput: true,
44+ }, tr)
45+ require.NoError(t, err)
46+ require.Same(t, sess, routed)
4247
4348 sess.tabs[0].focusedPane().screen.Write([]byte("A"))
4449 d.paint(sess, ac, false)
@@ -28,17 +28,22 @@ func (t *datagramTestTransport) Recv() (ports.Frame, error) {
28 }28 }
29 return f, nil29 return f, nil
30 }30 }
31-func (t *datagramTestTransport) Close() error { t.once.Do(func() { close(t.recv) }); return nil }31+func (t *datagramTestTransport) Close() error { t.once.Do(func() { close(t.recv) }); return nil }
32-func (t *datagramTestTransport) DatagramTransport() {}
33 32
34 func TestDatagramAttachAdvancesRendererOnlyOnAck(t *testing.T) {33 func TestDatagramAttachAdvancesRendererOnlyOnAck(t *testing.T) {
35 p, releasePTY := newBlockingPTY(t)34 p, releasePTY := newBlockingPTY(t)
36 defer releasePTY()35 defer releasePTY()
37- d, sess, old, _ := newManualSessionWithPTYs(t, p)36+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
38 tr := newDatagramTestTransport()37 tr := newDatagramTestTransport()
39- ac, replaced := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{datagram: true})38+ routed, ac, err := d.route(ports.Hello{
40- require.Same(t, old, replaced)39+ Version: ports.ProtocolVersion,
41- defer d.detachReplacedClient(replaced)40+ Intent: ports.IntentAttach,
41+ Name: sess.name,
42+ Size: domain.Size{Cols: 80, Rows: 24},
43+ AckOutput: true,
44+ }, tr)
45+ require.NoError(t, err)
46+ require.Same(t, sess, routed)
42 47
43 sess.tabs[0].focusedPane().screen.Write([]byte("A"))48 sess.tabs[0].focusedPane().screen.Write([]byte("A"))
44 d.paint(sess, ac, false)49 d.paint(sess, ac, false)

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