detached efdd7d6

feat(protocol): add proxied content and metadata contract

commit
efdd7d6554abd7b256623f70a9b1f5aa7cf6a78c
author
brice · · 2026-07-30 15:40 UTC
committer
brice · · 2026-07-31 09:02 UTC
parent
b6a8ce8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
20 files · +1121 -82
M AGENTS.md +1 -1
@@ -64,7 +64,7 @@ Before touching daemon teardown paths, read the lock-ordering notes at the top o
6464 Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/wire.go`. Connection framing lives in `internal/adapters/ipc/transport.go`.
6565
6666 - IPC frames on a connection are 4-byte big-endian length, 1 type byte, then payload.
67-- Client message types currently occupy `1–12` (`MsgCommand` is `12`); server types currently occupy `16–22` (`MsgCommandResult` is `22`). The reserved bands are `1–15` and `16+` respectively.
67+- Client message types currently occupy `1–13` (`MsgOutputResetRequest` is `13`); server types currently occupy `16–23` (`MsgSessionMeta` is `23`). The reserved bands are `1–15` and `16+` respectively.
6868 - Version negotiation requires strict equality.
6969 - `Hello.Version` and `CommandRequest.Version` must stay first so their version peekers work.
7070 - Bump `ProtocolVersion` for any message layout change.
@@ -64,7 +64,7 @@ Before touching daemon teardown paths, read the lock-ordering notes at the top o
64 Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/wire.go`. Connection framing lives in `internal/adapters/ipc/transport.go`.64 Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/wire.go`. Connection framing lives in `internal/adapters/ipc/transport.go`.
65 65
66 - IPC frames on a connection are 4-byte big-endian length, 1 type byte, then payload.66 - IPC frames on a connection are 4-byte big-endian length, 1 type byte, then payload.
67-- Client message types currently occupy `1–12` (`MsgCommand` is `12`); server types currently occupy `16–22` (`MsgCommandResult` is `22`). The reserved bands are `1–15` and `16+` respectively.67+- Client message types currently occupy `1–13` (`MsgOutputResetRequest` is `13`); server types currently occupy `16–23` (`MsgSessionMeta` is `23`). The reserved bands are `1–15` and `16+` respectively.
68 - Version negotiation requires strict equality.68 - Version negotiation requires strict equality.
69 - `Hello.Version` and `CommandRequest.Version` must stay first so their version peekers work.69 - `Hello.Version` and `CommandRequest.Version` must stay first so their version peekers work.
70 - Bump `ProtocolVersion` for any message layout change.70 - Bump `ProtocolVersion` for any message layout change.
M internal/ports/frame.go +15 -13
@@ -1,7 +1,7 @@
11 package ports
22
33 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 19
4+const ProtocolVersion uint16 = 20
55
66 // MsgType identifies the kind of payload carried by a Frame.
77 type MsgType uint8
@@ -10,18 +10,19 @@ type MsgType uint8
1010 // through 15, server-originated messages from 16, leaving room for growth in
1111 // each band.
1212 const (
13- MsgHello MsgType = 1
14- MsgInput MsgType = 2
15- MsgResize MsgType = 3
16- MsgDetach MsgType = 4
17- MsgPing MsgType = 5
18- MsgList MsgType = 6
19- MsgKill MsgType = 7
20- MsgTheme MsgType = 8
21- MsgAck MsgType = 9
22- MsgImagePush MsgType = 10
23- MsgClientNotice MsgType = 11
24- MsgCommand MsgType = 12
13+ MsgHello MsgType = 1
14+ MsgInput MsgType = 2
15+ MsgResize MsgType = 3
16+ MsgDetach MsgType = 4
17+ MsgPing MsgType = 5
18+ MsgList MsgType = 6
19+ MsgKill MsgType = 7
20+ MsgTheme MsgType = 8
21+ MsgAck MsgType = 9
22+ MsgImagePush MsgType = 10
23+ MsgClientNotice MsgType = 11
24+ MsgCommand MsgType = 12
25+ MsgOutputResetRequest MsgType = 13
2526
2627 MsgWelcome MsgType = 16
2728 MsgError MsgType = 17
@@ -30,6 +31,7 @@ const (
3031 MsgPong MsgType = 20
3132 MsgSessions MsgType = 21
3233 MsgCommandResult MsgType = 22
34+ MsgSessionMeta MsgType = 23
3335 )
3436
3537 // Frame is the unit of exchange over a Transport: a typed, length-delimited
@@ -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 = 194+const ProtocolVersion uint16 = 20
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
@@ -10,18 +10,19 @@ type MsgType uint8
10 // through 15, server-originated messages from 16, leaving room for growth in10 // through 15, server-originated messages from 16, leaving room for growth in
11 // each band.11 // each band.
12 const (12 const (
13- MsgHello MsgType = 113+ MsgHello MsgType = 1
14- MsgInput MsgType = 214+ MsgInput MsgType = 2
15- MsgResize MsgType = 315+ MsgResize MsgType = 3
16- MsgDetach MsgType = 416+ MsgDetach MsgType = 4
17- MsgPing MsgType = 517+ MsgPing MsgType = 5
18- MsgList MsgType = 618+ MsgList MsgType = 6
19- MsgKill MsgType = 719+ MsgKill MsgType = 7
20- MsgTheme MsgType = 820+ MsgTheme MsgType = 8
21- MsgAck MsgType = 921+ MsgAck MsgType = 9
22- MsgImagePush MsgType = 1022+ MsgImagePush MsgType = 10
23- MsgClientNotice MsgType = 1123+ MsgClientNotice MsgType = 11
24- MsgCommand MsgType = 1224+ MsgCommand MsgType = 12
25+ MsgOutputResetRequest MsgType = 13
25 26
26 MsgWelcome MsgType = 1627 MsgWelcome MsgType = 16
27 MsgError MsgType = 1728 MsgError MsgType = 17
@@ -30,6 +31,7 @@ const (
30 MsgPong MsgType = 2031 MsgPong MsgType = 20
31 MsgSessions MsgType = 2132 MsgSessions MsgType = 21
32 MsgCommandResult MsgType = 2233 MsgCommandResult MsgType = 22
34+ MsgSessionMeta MsgType = 23
33 )35 )
34 36
35 // Frame is the unit of exchange over a Transport: a typed, length-delimited37 // Frame is the unit of exchange over a Transport: a typed, length-delimited
M internal/ports/frame_test.go +13 -2
@@ -3,8 +3,8 @@ package ports
33 import "testing"
44
55 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 19 {
7- t.Fatalf("ProtocolVersion = %d, want 19", ProtocolVersion)
6+ if ProtocolVersion != 20 {
7+ t.Fatalf("ProtocolVersion = %d, want 20", ProtocolVersion)
88 }
99 }
1010
@@ -15,6 +15,15 @@ func TestControlMsgTypes(t *testing.T) {
1515 if MsgCommandResult != 22 {
1616 t.Fatalf("MsgCommandResult = %d, want 22", MsgCommandResult)
1717 }
18+ if MsgOutputResetRequest != 13 {
19+ t.Fatalf("MsgOutputResetRequest = %d, want 13", MsgOutputResetRequest)
20+ }
21+ if MsgSessionMeta != 23 {
22+ t.Fatalf("MsgSessionMeta = %d, want 23", MsgSessionMeta)
23+ }
24+ if CapabilityProxied != 1<<3 {
25+ t.Fatalf("CapabilityProxied = %d, want 8", CapabilityProxied)
26+ }
1827 }
1928
2029 func TestMsgTypeUnique(t *testing.T) {
@@ -34,6 +43,7 @@ func TestMsgTypeUnique(t *testing.T) {
3443 {"MsgImagePush", MsgImagePush},
3544 {"MsgClientNotice", MsgClientNotice},
3645 {"MsgCommand", MsgCommand},
46+ {"MsgOutputResetRequest", MsgOutputResetRequest},
3747 {"MsgWelcome", MsgWelcome},
3848 {"MsgError", MsgError},
3949 {"MsgOutput", MsgOutput},
@@ -41,6 +51,7 @@ func TestMsgTypeUnique(t *testing.T) {
4151 {"MsgPong", MsgPong},
4252 {"MsgSessions", MsgSessions},
4353 {"MsgCommandResult", MsgCommandResult},
54+ {"MsgSessionMeta", MsgSessionMeta},
4455 }
4556
4657 seen := make(map[MsgType]string, len(tests))
@@ -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 != 19 {6+ if ProtocolVersion != 20 {
7- t.Fatalf("ProtocolVersion = %d, want 19", ProtocolVersion)7+ t.Fatalf("ProtocolVersion = %d, want 20", ProtocolVersion)
8 }8 }
9 }9 }
10 10
@@ -15,6 +15,15 @@ func TestControlMsgTypes(t *testing.T) {
15 if MsgCommandResult != 22 {15 if MsgCommandResult != 22 {
16 t.Fatalf("MsgCommandResult = %d, want 22", MsgCommandResult)16 t.Fatalf("MsgCommandResult = %d, want 22", MsgCommandResult)
17 }17 }
18+ if MsgOutputResetRequest != 13 {
19+ t.Fatalf("MsgOutputResetRequest = %d, want 13", MsgOutputResetRequest)
20+ }
21+ if MsgSessionMeta != 23 {
22+ t.Fatalf("MsgSessionMeta = %d, want 23", MsgSessionMeta)
23+ }
24+ if CapabilityProxied != 1<<3 {
25+ t.Fatalf("CapabilityProxied = %d, want 8", CapabilityProxied)
26+ }
18 }27 }
19 28
20 func TestMsgTypeUnique(t *testing.T) {29 func TestMsgTypeUnique(t *testing.T) {
@@ -34,6 +43,7 @@ func TestMsgTypeUnique(t *testing.T) {
34 {"MsgImagePush", MsgImagePush},43 {"MsgImagePush", MsgImagePush},
35 {"MsgClientNotice", MsgClientNotice},44 {"MsgClientNotice", MsgClientNotice},
36 {"MsgCommand", MsgCommand},45 {"MsgCommand", MsgCommand},
46+ {"MsgOutputResetRequest", MsgOutputResetRequest},
37 {"MsgWelcome", MsgWelcome},47 {"MsgWelcome", MsgWelcome},
38 {"MsgError", MsgError},48 {"MsgError", MsgError},
39 {"MsgOutput", MsgOutput},49 {"MsgOutput", MsgOutput},
@@ -41,6 +51,7 @@ func TestMsgTypeUnique(t *testing.T) {
41 {"MsgPong", MsgPong},51 {"MsgPong", MsgPong},
42 {"MsgSessions", MsgSessions},52 {"MsgSessions", MsgSessions},
43 {"MsgCommandResult", MsgCommandResult},53 {"MsgCommandResult", MsgCommandResult},
54+ {"MsgSessionMeta", MsgSessionMeta},
44 }55 }
45 56
46 seen := make(map[MsgType]string, len(tests))57 seen := make(map[MsgType]string, len(tests))
M internal/ports/wire.go +144 -0
@@ -9,6 +9,7 @@ package ports
99 import (
1010 "encoding/binary"
1111 "errors"
12+ "math"
1213
1314 "github.com/bnema/vev/internal/domain"
1415 "github.com/bnema/vev/pkg/renderer"
@@ -23,6 +24,16 @@ var errShortPayload = errors.New("ports: payload too short")
2324 // catches version drift early instead of silently ignoring extra data.
2425 var errTrailingBytes = errors.New("ports: unexpected trailing bytes in payload")
2526
27+var errInvalidBoolean = errors.New("ports: invalid boolean flag")
28+
29+// ErrInvalidSessionMeta reports metadata that cannot be represented by the
30+// protocol's ordered uint16-indexed tab layout.
31+var ErrInvalidSessionMeta = errors.New("ports: invalid session metadata")
32+
33+// ErrSessionMetaStringTooLong reports metadata containing a string too large
34+// for the protocol's uint16 string encoding.
35+var ErrSessionMetaStringTooLong = errors.New("ports: session metadata string too long")
36+
2637 // Intent values carried by Hello, describing what the client wants to do.
2738 const (
2839 IntentEphemeral uint8 = 0
@@ -36,6 +47,7 @@ const (
3647 CapabilityResume uint32 = 1 << 0
3748 CapabilityUDP uint32 = 1 << 1
3849 CapabilityPredict uint32 = 1 << 2
50+ CapabilityProxied uint32 = 1 << 3
3951 )
4052
4153 // ErrorMsg codes.
@@ -65,6 +77,7 @@ const (
6577 type Hello struct {
6678 Version uint16
6779 Intent uint8
80+ Proxied bool
6881 ClientID [16]byte
6982 ResumeToken uint64
7083 Name string
@@ -214,6 +227,24 @@ type Sessions struct {
214227 Sessions []SessionInfo
215228 }
216229
230+// OutputResetRequest asks a proxied daemon to rebase its output stream.
231+type OutputResetRequest struct{}
232+
233+// SessionTabMeta describes one tab in a proxied session's authoritative
234+// metadata snapshot.
235+type SessionTabMeta struct {
236+ Index uint16
237+ Name string
238+ Attention bool
239+}
240+
241+// SessionMeta is the authoritative tab metadata sent to a proxied client.
242+type SessionMeta struct {
243+ SessionName string
244+ Active uint16
245+ Tabs []SessionTabMeta
246+}
247+
217248 // payloadWriter builds a message payload by appending fields in wire order.
218249 type payloadWriter struct {
219250 b []byte
@@ -270,6 +301,17 @@ func (r *payloadReader) getUint8() (uint8, error) {
270301 return v, nil
271302 }
272303
304+func (r *payloadReader) getBool() (bool, error) {
305+ v, err := r.getUint8()
306+ if err != nil {
307+ return false, err
308+ }
309+ if v > 1 {
310+ return false, errInvalidBoolean
311+ }
312+ return v == 1, nil
313+}
314+
273315 func (r *payloadReader) getUint16() (uint16, error) {
274316 if len(r.b) < 2 {
275317 return 0, errShortPayload
@@ -361,6 +403,11 @@ func MarshalHello(h Hello) []byte {
361403 w := payloadWriter{}
362404 w.putUint16(h.Version)
363405 w.putUint8(h.Intent)
406+ if h.Proxied {
407+ w.putUint8(1)
408+ } else {
409+ w.putUint8(0)
410+ }
364411 w.putBytes(h.ClientID[:])
365412 w.putUint64(h.ResumeToken)
366413 w.putString(h.Name)
@@ -393,6 +440,9 @@ func UnmarshalHello(b []byte) (Hello, error) {
393440 if h.Intent, err = r.getUint8(); err != nil {
394441 return Hello{}, err
395442 }
443+ if h.Proxied, err = r.getBool(); err != nil {
444+ return Hello{}, err
445+ }
396446 clientID, err := r.getBytes(len(h.ClientID))
397447 if err != nil {
398448 return Hello{}, err
@@ -835,6 +885,100 @@ func UnmarshalList(b []byte) (List, error) {
835885 return List{}, nil
836886 }
837887
888+// MarshalOutputResetRequest encodes an OutputResetRequest payload (always
889+// empty).
890+func MarshalOutputResetRequest(OutputResetRequest) []byte {
891+ return nil
892+}
893+
894+// UnmarshalOutputResetRequest decodes a strict empty OutputResetRequest
895+// payload.
896+func UnmarshalOutputResetRequest(b []byte) (OutputResetRequest, error) {
897+ r := payloadReader{b: b}
898+ if err := r.done(); err != nil {
899+ return OutputResetRequest{}, err
900+ }
901+ return OutputResetRequest{}, nil
902+}
903+
904+// MarshalSessionMeta encodes m when it satisfies the protocol's authoritative
905+// ordered-tab constraints.
906+func MarshalSessionMeta(m SessionMeta) ([]byte, error) {
907+ if len(m.Tabs) == 0 || len(m.Tabs) > math.MaxUint16 || int(m.Active) >= len(m.Tabs) {
908+ return nil, ErrInvalidSessionMeta
909+ }
910+ if len(m.SessionName) > math.MaxUint16 {
911+ return nil, ErrSessionMetaStringTooLong
912+ }
913+ for i, tab := range m.Tabs {
914+ if tab.Index != uint16(i) {
915+ return nil, ErrInvalidSessionMeta
916+ }
917+ if len(tab.Name) > math.MaxUint16 {
918+ return nil, ErrSessionMetaStringTooLong
919+ }
920+ }
921+
922+ w := payloadWriter{}
923+ w.putString(m.SessionName)
924+ w.putUint16(m.Active)
925+ w.putUint16(uint16(len(m.Tabs)))
926+ for _, tab := range m.Tabs {
927+ w.putUint16(tab.Index)
928+ w.putString(tab.Name)
929+ if tab.Attention {
930+ w.putUint8(1)
931+ } else {
932+ w.putUint8(0)
933+ }
934+ }
935+ return w.b, nil
936+}
937+
938+// UnmarshalSessionMeta decodes a strict authoritative metadata snapshot.
939+func UnmarshalSessionMeta(b []byte) (SessionMeta, error) {
940+ r := payloadReader{b: b}
941+ var m SessionMeta
942+ var err error
943+ if m.SessionName, err = r.getString(); err != nil {
944+ return SessionMeta{}, err
945+ }
946+ if m.Active, err = r.getUint16(); err != nil {
947+ return SessionMeta{}, err
948+ }
949+ count, err := r.getUint16()
950+ if err != nil {
951+ return SessionMeta{}, err
952+ }
953+ if count == 0 || int(m.Active) >= int(count) {
954+ return SessionMeta{}, ErrInvalidSessionMeta
955+ }
956+ if uint64(count) > uint64(len(r.b)/5) {
957+ return SessionMeta{}, errShortPayload
958+ }
959+ m.Tabs = make([]SessionTabMeta, 0, int(count))
960+ for i := range int(count) {
961+ var tab SessionTabMeta
962+ if tab.Index, err = r.getUint16(); err != nil {
963+ return SessionMeta{}, err
964+ }
965+ if tab.Index != uint16(i) {
966+ return SessionMeta{}, ErrInvalidSessionMeta
967+ }
968+ if tab.Name, err = r.getString(); err != nil {
969+ return SessionMeta{}, err
970+ }
971+ if tab.Attention, err = r.getBool(); err != nil {
972+ return SessionMeta{}, err
973+ }
974+ m.Tabs = append(m.Tabs, tab)
975+ }
976+ if err := r.done(); err != nil {
977+ return SessionMeta{}, err
978+ }
979+ return m, nil
980+}
981+
838982 // MarshalKill encodes m into a Kill message payload.
839983 func MarshalKill(m Kill) []byte {
840984 w := payloadWriter{}
@@ -9,6 +9,7 @@ package ports
9 import (9 import (
10 "encoding/binary"10 "encoding/binary"
11 "errors"11 "errors"
12+ "math"
12 13
13 "github.com/bnema/vev/internal/domain"14 "github.com/bnema/vev/internal/domain"
14 "github.com/bnema/vev/pkg/renderer"15 "github.com/bnema/vev/pkg/renderer"
@@ -23,6 +24,16 @@ var errShortPayload = errors.New("ports: payload too short")
23 // catches version drift early instead of silently ignoring extra data.24 // catches version drift early instead of silently ignoring extra data.
24 var errTrailingBytes = errors.New("ports: unexpected trailing bytes in payload")25 var errTrailingBytes = errors.New("ports: unexpected trailing bytes in payload")
25 26
27+var errInvalidBoolean = errors.New("ports: invalid boolean flag")
28+
29+// ErrInvalidSessionMeta reports metadata that cannot be represented by the
30+// protocol's ordered uint16-indexed tab layout.
31+var ErrInvalidSessionMeta = errors.New("ports: invalid session metadata")
32+
33+// ErrSessionMetaStringTooLong reports metadata containing a string too large
34+// for the protocol's uint16 string encoding.
35+var ErrSessionMetaStringTooLong = errors.New("ports: session metadata string too long")
36+
26 // Intent values carried by Hello, describing what the client wants to do.37 // Intent values carried by Hello, describing what the client wants to do.
27 const (38 const (
28 IntentEphemeral uint8 = 039 IntentEphemeral uint8 = 0
@@ -36,6 +47,7 @@ const (
36 CapabilityResume uint32 = 1 << 047 CapabilityResume uint32 = 1 << 0
37 CapabilityUDP uint32 = 1 << 148 CapabilityUDP uint32 = 1 << 1
38 CapabilityPredict uint32 = 1 << 249 CapabilityPredict uint32 = 1 << 2
50+ CapabilityProxied uint32 = 1 << 3
39 )51 )
40 52
41 // ErrorMsg codes.53 // ErrorMsg codes.
@@ -65,6 +77,7 @@ const (
65 type Hello struct {77 type Hello struct {
66 Version uint1678 Version uint16
67 Intent uint879 Intent uint8
80+ Proxied bool
68 ClientID [16]byte81 ClientID [16]byte
69 ResumeToken uint6482 ResumeToken uint64
70 Name string83 Name string
@@ -214,6 +227,24 @@ type Sessions struct {
214 Sessions []SessionInfo227 Sessions []SessionInfo
215 }228 }
216 229
230+// OutputResetRequest asks a proxied daemon to rebase its output stream.
231+type OutputResetRequest struct{}
232+
233+// SessionTabMeta describes one tab in a proxied session's authoritative
234+// metadata snapshot.
235+type SessionTabMeta struct {
236+ Index uint16
237+ Name string
238+ Attention bool
239+}
240+
241+// SessionMeta is the authoritative tab metadata sent to a proxied client.
242+type SessionMeta struct {
243+ SessionName string
244+ Active uint16
245+ Tabs []SessionTabMeta
246+}
247+
217 // payloadWriter builds a message payload by appending fields in wire order.248 // payloadWriter builds a message payload by appending fields in wire order.
218 type payloadWriter struct {249 type payloadWriter struct {
219 b []byte250 b []byte
@@ -270,6 +301,17 @@ func (r *payloadReader) getUint8() (uint8, error) {
270 return v, nil301 return v, nil
271 }302 }
272 303
304+func (r *payloadReader) getBool() (bool, error) {
305+ v, err := r.getUint8()
306+ if err != nil {
307+ return false, err
308+ }
309+ if v > 1 {
310+ return false, errInvalidBoolean
311+ }
312+ return v == 1, nil
313+}
314+
273 func (r *payloadReader) getUint16() (uint16, error) {315 func (r *payloadReader) getUint16() (uint16, error) {
274 if len(r.b) < 2 {316 if len(r.b) < 2 {
275 return 0, errShortPayload317 return 0, errShortPayload
@@ -361,6 +403,11 @@ func MarshalHello(h Hello) []byte {
361 w := payloadWriter{}403 w := payloadWriter{}
362 w.putUint16(h.Version)404 w.putUint16(h.Version)
363 w.putUint8(h.Intent)405 w.putUint8(h.Intent)
406+ if h.Proxied {
407+ w.putUint8(1)
408+ } else {
409+ w.putUint8(0)
410+ }
364 w.putBytes(h.ClientID[:])411 w.putBytes(h.ClientID[:])
365 w.putUint64(h.ResumeToken)412 w.putUint64(h.ResumeToken)
366 w.putString(h.Name)413 w.putString(h.Name)
@@ -393,6 +440,9 @@ func UnmarshalHello(b []byte) (Hello, error) {
393 if h.Intent, err = r.getUint8(); err != nil {440 if h.Intent, err = r.getUint8(); err != nil {
394 return Hello{}, err441 return Hello{}, err
395 }442 }
443+ if h.Proxied, err = r.getBool(); err != nil {
444+ return Hello{}, err
445+ }
396 clientID, err := r.getBytes(len(h.ClientID))446 clientID, err := r.getBytes(len(h.ClientID))
397 if err != nil {447 if err != nil {
398 return Hello{}, err448 return Hello{}, err
@@ -835,6 +885,100 @@ func UnmarshalList(b []byte) (List, error) {
835 return List{}, nil885 return List{}, nil
836 }886 }
837 887
888+// MarshalOutputResetRequest encodes an OutputResetRequest payload (always
889+// empty).
890+func MarshalOutputResetRequest(OutputResetRequest) []byte {
891+ return nil
892+}
893+
894+// UnmarshalOutputResetRequest decodes a strict empty OutputResetRequest
895+// payload.
896+func UnmarshalOutputResetRequest(b []byte) (OutputResetRequest, error) {
897+ r := payloadReader{b: b}
898+ if err := r.done(); err != nil {
899+ return OutputResetRequest{}, err
900+ }
901+ return OutputResetRequest{}, nil
902+}
903+
904+// MarshalSessionMeta encodes m when it satisfies the protocol's authoritative
905+// ordered-tab constraints.
906+func MarshalSessionMeta(m SessionMeta) ([]byte, error) {
907+ if len(m.Tabs) == 0 || len(m.Tabs) > math.MaxUint16 || int(m.Active) >= len(m.Tabs) {
908+ return nil, ErrInvalidSessionMeta
909+ }
910+ if len(m.SessionName) > math.MaxUint16 {
911+ return nil, ErrSessionMetaStringTooLong
912+ }
913+ for i, tab := range m.Tabs {
914+ if tab.Index != uint16(i) {
915+ return nil, ErrInvalidSessionMeta
916+ }
917+ if len(tab.Name) > math.MaxUint16 {
918+ return nil, ErrSessionMetaStringTooLong
919+ }
920+ }
921+
922+ w := payloadWriter{}
923+ w.putString(m.SessionName)
924+ w.putUint16(m.Active)
925+ w.putUint16(uint16(len(m.Tabs)))
926+ for _, tab := range m.Tabs {
927+ w.putUint16(tab.Index)
928+ w.putString(tab.Name)
929+ if tab.Attention {
930+ w.putUint8(1)
931+ } else {
932+ w.putUint8(0)
933+ }
934+ }
935+ return w.b, nil
936+}
937+
938+// UnmarshalSessionMeta decodes a strict authoritative metadata snapshot.
939+func UnmarshalSessionMeta(b []byte) (SessionMeta, error) {
940+ r := payloadReader{b: b}
941+ var m SessionMeta
942+ var err error
943+ if m.SessionName, err = r.getString(); err != nil {
944+ return SessionMeta{}, err
945+ }
946+ if m.Active, err = r.getUint16(); err != nil {
947+ return SessionMeta{}, err
948+ }
949+ count, err := r.getUint16()
950+ if err != nil {
951+ return SessionMeta{}, err
952+ }
953+ if count == 0 || int(m.Active) >= int(count) {
954+ return SessionMeta{}, ErrInvalidSessionMeta
955+ }
956+ if uint64(count) > uint64(len(r.b)/5) {
957+ return SessionMeta{}, errShortPayload
958+ }
959+ m.Tabs = make([]SessionTabMeta, 0, int(count))
960+ for i := range int(count) {
961+ var tab SessionTabMeta
962+ if tab.Index, err = r.getUint16(); err != nil {
963+ return SessionMeta{}, err
964+ }
965+ if tab.Index != uint16(i) {
966+ return SessionMeta{}, ErrInvalidSessionMeta
967+ }
968+ if tab.Name, err = r.getString(); err != nil {
969+ return SessionMeta{}, err
970+ }
971+ if tab.Attention, err = r.getBool(); err != nil {
972+ return SessionMeta{}, err
973+ }
974+ m.Tabs = append(m.Tabs, tab)
975+ }
976+ if err := r.done(); err != nil {
977+ return SessionMeta{}, err
978+ }
979+ return m, nil
980+}
981+
838 // MarshalKill encodes m into a Kill message payload.982 // MarshalKill encodes m into a Kill message payload.
839 func MarshalKill(m Kill) []byte {983 func MarshalKill(m Kill) []byte {
840 w := payloadWriter{}984 w := payloadWriter{}
M internal/ports/wire_command.go +26 -13
@@ -13,6 +13,8 @@ var ErrTooManyCommandArgs = errors.New("ports: too many command arguments")
1313 // stay first so a future payload layout can still be rejected cleanly.
1414 type CommandRequest struct {
1515 Version uint16
16+ RequestID uint64
17+ Attached bool
1618 Self bool
1719 Slug string
1820 Args []string
@@ -24,10 +26,11 @@ type CommandRequest struct {
2426
2527 // CommandResult reports a control command's outcome.
2628 type CommandResult struct {
27- OK bool
28- Code uint16
29- Text string
30- Output string
29+ RequestID uint64
30+ OK bool
31+ Code uint16
32+ Text string
33+ Output string
3134 }
3235
3336 // PeekCommandVersion returns the leading protocol version from a
@@ -47,6 +50,12 @@ func MarshalCommandRequest(m CommandRequest) ([]byte, error) {
4750
4851 w := payloadWriter{}
4952 w.putUint16(m.Version)
53+ w.putUint64(m.RequestID)
54+ if m.Attached {
55+ w.putUint8(1)
56+ } else {
57+ w.putUint8(0)
58+ }
5059 if m.Self {
5160 w.putUint8(1)
5261 } else {
@@ -77,11 +86,15 @@ func UnmarshalCommandRequest(b []byte) (CommandRequest, error) {
7786 if m.Version, err = r.getUint16(); err != nil {
7887 return CommandRequest{}, err
7988 }
80- selfFlag, err := r.getUint8()
81- if err != nil {
89+ if m.RequestID, err = r.getUint64(); err != nil {
90+ return CommandRequest{}, err
91+ }
92+ if m.Attached, err = r.getBool(); err != nil {
93+ return CommandRequest{}, err
94+ }
95+ if m.Self, err = r.getBool(); err != nil {
8296 return CommandRequest{}, err
8397 }
84- m.Self = selfFlag != 0
8598 if m.Slug, err = r.getString(); err != nil {
8699 return CommandRequest{}, err
87100 }
@@ -113,11 +126,9 @@ func UnmarshalCommandRequest(b []byte) (CommandRequest, error) {
113126 if m.TargetPane, err = r.getString(); err != nil {
114127 return CommandRequest{}, err
115128 }
116- jsonFlag, err := r.getUint8()
117- if err != nil {
129+ if m.JSON, err = r.getBool(); err != nil {
118130 return CommandRequest{}, err
119131 }
120- m.JSON = jsonFlag != 0
121132 if err := r.done(); err != nil {
122133 return CommandRequest{}, err
123134 }
@@ -127,6 +138,7 @@ func UnmarshalCommandRequest(b []byte) (CommandRequest, error) {
127138 // MarshalCommandResult encodes m into a CommandResult payload.
128139 func MarshalCommandResult(m CommandResult) []byte {
129140 w := payloadWriter{}
141+ w.putUint64(m.RequestID)
130142 if m.OK {
131143 w.putUint8(1)
132144 } else {
@@ -144,11 +156,12 @@ func UnmarshalCommandResult(b []byte) (CommandResult, error) {
144156 var m CommandResult
145157 var err error
146158
147- okFlag, err := r.getUint8()
148- if err != nil {
159+ if m.RequestID, err = r.getUint64(); err != nil {
160+ return CommandResult{}, err
161+ }
162+ if m.OK, err = r.getBool(); err != nil {
149163 return CommandResult{}, err
150164 }
151- m.OK = okFlag != 0
152165 if m.Code, err = r.getUint16(); err != nil {
153166 return CommandResult{}, err
154167 }
@@ -13,6 +13,8 @@ var ErrTooManyCommandArgs = errors.New("ports: too many command arguments")
13 // stay first so a future payload layout can still be rejected cleanly.13 // stay first so a future payload layout can still be rejected cleanly.
14 type CommandRequest struct {14 type CommandRequest struct {
15 Version uint1615 Version uint16
16+ RequestID uint64
17+ Attached bool
16 Self bool18 Self bool
17 Slug string19 Slug string
18 Args []string20 Args []string
@@ -24,10 +26,11 @@ type CommandRequest struct {
24 26
25 // CommandResult reports a control command's outcome.27 // CommandResult reports a control command's outcome.
26 type CommandResult struct {28 type CommandResult struct {
27- OK bool29+ RequestID uint64
28- Code uint1630+ OK bool
29- Text string31+ Code uint16
30- Output string32+ Text string
33+ Output string
31 }34 }
32 35
33 // PeekCommandVersion returns the leading protocol version from a36 // PeekCommandVersion returns the leading protocol version from a
@@ -47,6 +50,12 @@ func MarshalCommandRequest(m CommandRequest) ([]byte, error) {
47 50
48 w := payloadWriter{}51 w := payloadWriter{}
49 w.putUint16(m.Version)52 w.putUint16(m.Version)
53+ w.putUint64(m.RequestID)
54+ if m.Attached {
55+ w.putUint8(1)
56+ } else {
57+ w.putUint8(0)
58+ }
50 if m.Self {59 if m.Self {
51 w.putUint8(1)60 w.putUint8(1)
52 } else {61 } else {
@@ -77,11 +86,15 @@ func UnmarshalCommandRequest(b []byte) (CommandRequest, error) {
77 if m.Version, err = r.getUint16(); err != nil {86 if m.Version, err = r.getUint16(); err != nil {
78 return CommandRequest{}, err87 return CommandRequest{}, err
79 }88 }
80- selfFlag, err := r.getUint8()89+ if m.RequestID, err = r.getUint64(); err != nil {
81- if err != nil {90+ return CommandRequest{}, err
91+ }
92+ if m.Attached, err = r.getBool(); err != nil {
93+ return CommandRequest{}, err
94+ }
95+ if m.Self, err = r.getBool(); err != nil {
82 return CommandRequest{}, err96 return CommandRequest{}, err
83 }97 }
84- m.Self = selfFlag != 0
85 if m.Slug, err = r.getString(); err != nil {98 if m.Slug, err = r.getString(); err != nil {
86 return CommandRequest{}, err99 return CommandRequest{}, err
87 }100 }
@@ -113,11 +126,9 @@ func UnmarshalCommandRequest(b []byte) (CommandRequest, error) {
113 if m.TargetPane, err = r.getString(); err != nil {126 if m.TargetPane, err = r.getString(); err != nil {
114 return CommandRequest{}, err127 return CommandRequest{}, err
115 }128 }
116- jsonFlag, err := r.getUint8()129+ if m.JSON, err = r.getBool(); err != nil {
117- if err != nil {
118 return CommandRequest{}, err130 return CommandRequest{}, err
119 }131 }
120- m.JSON = jsonFlag != 0
121 if err := r.done(); err != nil {132 if err := r.done(); err != nil {
122 return CommandRequest{}, err133 return CommandRequest{}, err
123 }134 }
@@ -127,6 +138,7 @@ func UnmarshalCommandRequest(b []byte) (CommandRequest, error) {
127 // MarshalCommandResult encodes m into a CommandResult payload.138 // MarshalCommandResult encodes m into a CommandResult payload.
128 func MarshalCommandResult(m CommandResult) []byte {139 func MarshalCommandResult(m CommandResult) []byte {
129 w := payloadWriter{}140 w := payloadWriter{}
141+ w.putUint64(m.RequestID)
130 if m.OK {142 if m.OK {
131 w.putUint8(1)143 w.putUint8(1)
132 } else {144 } else {
@@ -144,11 +156,12 @@ func UnmarshalCommandResult(b []byte) (CommandResult, error) {
144 var m CommandResult156 var m CommandResult
145 var err error157 var err error
146 158
147- okFlag, err := r.getUint8()159+ if m.RequestID, err = r.getUint64(); err != nil {
148- if err != nil {160+ return CommandResult{}, err
161+ }
162+ if m.OK, err = r.getBool(); err != nil {
149 return CommandResult{}, err163 return CommandResult{}, err
150 }164 }
151- m.OK = okFlag != 0
152 if m.Code, err = r.getUint16(); err != nil {165 if m.Code, err = r.getUint16(); err != nil {
153 return CommandResult{}, err166 return CommandResult{}, err
154 }167 }
M internal/ports/wire_test.go +208 -11
@@ -73,7 +73,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
7373 TrueColor: true,
7474 MaxOutputInFlight: 8,
7575 },
76- 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, 0x08, 0x00, 0x00, 0x00, 0x00},
76+ want: []byte{0x00, 0x01, 0x01, 0x00, 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, 0x08, 0x00, 0x00, 0x00, 0x00},
7777 },
7878 {
7979 name: "empty strings",
@@ -86,7 +86,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
8686 Cwd: "",
8787 TrueColor: false,
8888 },
89- 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, 0x00, 0x00, 0x00, 0x00},
89+ 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, 0x00, 0x00, 0x00, 0x00, 0x00},
9090 },
9191 }
9292
@@ -122,7 +122,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
122122 Env: []string{"A=B", "XY=123"},
123123 })
124124 want := []byte{
125- 0x00, 0x13, 0x00, // version, intent
125+ 0x00, 0x14, 0x00, 0x00, // version, intent, proxied
126126 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
127127 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
128128 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -336,7 +336,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
336336 }
337337 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
338338 require.Equal(t, want, MarshalTheme(cleared))
339- require.Equal(t, uint16(19), ProtocolVersion)
339+ require.Equal(t, uint16(20), ProtocolVersion)
340340 }
341341
342342 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -461,8 +461,10 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
461461 name: "minimal",
462462 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
463463 want: []byte{
464- 0x00, 0x13,
465- 0x00,
464+ 0x00, 0x14,
465+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
466+ 0x00, // attached
467+ 0x00, // self
466468 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
467469 0x00, 0x00,
468470 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -482,8 +484,10 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
482484 JSON: true,
483485 },
484486 want: []byte{
485- 0x00, 0x13,
486- 0x01,
487+ 0x00, 0x14,
488+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
489+ 0x00, // attached
490+ 0x01, // self
487491 0x00, 0x05, 't', 'o', 'a', 's', 't',
488492 0x00, 0x03,
489493 0x00, 0x00, 0x00, 0x02, '-', 'l',
@@ -532,7 +536,7 @@ func TestMarshalCommandRequestRejectsTooManyArguments(t *testing.T) {
532536 func TestCommandRequestRejectsImpossibleArgumentCount(t *testing.T) {
533537 payload, err := MarshalCommandRequest(CommandRequest{Version: ProtocolVersion, Slug: "toast"})
534538 require.NoError(t, err)
535- argCountOffset := 2 + 1 + 2 + len("toast")
539+ argCountOffset := 2 + 8 + 1 + 1 + 2 + len("toast")
536540 payload[argCountOffset] = 0xff
537541 payload[argCountOffset+1] = 0xff
538542
@@ -551,6 +555,7 @@ func TestCommandResultGoldenAndRoundTrip(t *testing.T) {
551555 name: "error",
552556 msg: CommandResult{Code: ErrNoSuchTarget, Text: "no such session"},
553557 want: []byte{
558+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
554559 0x00,
555560 0x00, 0x09,
556561 0x00, 0x0f, 'n', 'o', ' ', 's', 'u', 'c', 'h', ' ', 's', 'e', 's', 's', 'i', 'o', 'n',
@@ -561,6 +566,7 @@ func TestCommandResultGoldenAndRoundTrip(t *testing.T) {
561566 name: "success with output",
562567 msg: CommandResult{OK: true, Text: "listed", Output: "ID\tFOCUSED\n"},
563568 want: []byte{
569+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
564570 0x01,
565571 0x00, 0x00,
566572 0x00, 0x06, 'l', 'i', 's', 't', 'e', 'd',
@@ -868,9 +874,9 @@ func TestHelloOutputWindowByteExactValues(t *testing.T) {
868874 for _, window := range []uint8{0, 1, 8} {
869875 t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) {
870876 hello := Hello{Version: 14, MaxOutputInFlight: window}
871- // The empty Hello has 38 zero bytes before the negotiated output window,
877+ // The empty Hello has 39 zero bytes before the negotiated output window,
872878 // followed by a uint32 zero environment-entry count.
873- want := append(make([]byte, 38), window, 0, 0, 0, 0)
879+ want := append(make([]byte, 39), window, 0, 0, 0, 0)
874880 want[1] = 14
875881 got := MarshalHello(hello)
876882 requireBytesEqual(t, want, got)
@@ -889,3 +895,194 @@ func requireBytesEqual(t *testing.T, want, got []byte) {
889895 t.Fatalf("bytes = %#v, want %#v", got, want)
890896 }
891897 }
898+func TestProxiedHelloGoldenAndStrict(t *testing.T) {
899+ msg := Hello{
900+ Version: ProtocolVersion,
901+ Intent: IntentAttach,
902+ Proxied: true,
903+ ClientID: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16},
904+ ResumeToken: 0x0102030405060708,
905+ Name: "remote",
906+ Size: domain.Size{Cols: 80, Rows: 22},
907+ TermEnv: "xterm",
908+ Cwd: "/tmp",
909+ TrueColor: true,
910+ MaxOutputInFlight: 8,
911+ }
912+ want := []byte{
913+ 0x00, 0x14, 0x02, 0x01,
914+ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
915+ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
916+ 0x00, 0x06, 'r', 'e', 'm', 'o', 't', 'e',
917+ 0x00, 0x50, 0x00, 0x16,
918+ 0x00, 0x05, 'x', 't', 'e', 'r', 'm',
919+ 0x00, 0x04, '/', 't', 'm', 'p',
920+ 0x01, 0x08, 0x00, 0x00, 0x00, 0x00,
921+ }
922+ got := MarshalHello(msg)
923+ require.Equal(t, want, got)
924+ back, err := UnmarshalHello(got)
925+ require.NoError(t, err)
926+ require.Equal(t, msg, back)
927+ assertAllPrefixesFail(t, got, UnmarshalHello)
928+ assertTrailingGarbageFails(t, got, UnmarshalHello)
929+
930+ malformed := append([]byte(nil), got...)
931+ malformed[3] = 2
932+ _, err = UnmarshalHello(malformed)
933+ require.Error(t, err)
934+}
935+
936+func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
937+ tests := []struct {
938+ name string
939+ msg CommandRequest
940+ want []byte
941+ }{
942+ {
943+ name: "attached",
944+ msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
945+ want: []byte{
946+ 0x00, 0x14, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
947+ 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
948+ 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
949+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
950+ },
951+ },
952+ {
953+ name: "control",
954+ msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
955+ want: []byte{
956+ 0x00, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
957+ 0x00, 0x02, 'l', 's', 0x00, 0x00,
958+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
959+ },
960+ },
961+ }
962+ for _, tt := range tests {
963+ t.Run(tt.name, func(t *testing.T) {
964+ got, err := MarshalCommandRequest(tt.msg)
965+ require.NoError(t, err)
966+ require.Equal(t, tt.want, got)
967+ back, err := UnmarshalCommandRequest(got)
968+ require.NoError(t, err)
969+ require.Equal(t, tt.msg, back)
970+ assertAllPrefixesFail(t, got, UnmarshalCommandRequest)
971+ assertTrailingGarbageFails(t, got, UnmarshalCommandRequest)
972+ })
973+ }
974+
975+ payload, err := MarshalCommandRequest(tests[0].msg)
976+ require.NoError(t, err)
977+ for _, offset := range []int{10, 11, len(payload) - 1} {
978+ malformed := append([]byte(nil), payload...)
979+ malformed[offset] = 2
980+ _, err := UnmarshalCommandRequest(malformed)
981+ require.Error(t, err)
982+ }
983+}
984+
985+func TestCommandResultCorrelationGoldenAndStrict(t *testing.T) {
986+ msg := CommandResult{RequestID: 0x0102030405060708, OK: true, Code: 7, Text: "ok", Output: "result"}
987+ want := []byte{
988+ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
989+ 0x01, 0x00, 0x07, 0x00, 0x02, 'o', 'k', 0x00, 0x00, 0x00, 0x06, 'r', 'e', 's', 'u', 'l', 't',
990+ }
991+ got := MarshalCommandResult(msg)
992+ require.Equal(t, want, got)
993+ back, err := UnmarshalCommandResult(got)
994+ require.NoError(t, err)
995+ require.Equal(t, msg, back)
996+ assertAllPrefixesFail(t, got, UnmarshalCommandResult)
997+ assertTrailingGarbageFails(t, got, UnmarshalCommandResult)
998+
999+ malformed := append([]byte(nil), got...)
1000+ malformed[8] = 2
1001+ _, err = UnmarshalCommandResult(malformed)
1002+ require.Error(t, err)
1003+}
1004+
1005+func TestOutputResetRequestStrict(t *testing.T) {
1006+ got := MarshalOutputResetRequest(OutputResetRequest{})
1007+ require.Empty(t, got)
1008+ back, err := UnmarshalOutputResetRequest(got)
1009+ require.NoError(t, err)
1010+ require.Equal(t, OutputResetRequest{}, back)
1011+ assertTrailingGarbageFails(t, got, UnmarshalOutputResetRequest)
1012+}
1013+
1014+func TestSessionMetaGoldenAndStrict(t *testing.T) {
1015+ tests := []struct {
1016+ name string
1017+ msg SessionMeta
1018+ want []byte
1019+ }{
1020+ {
1021+ name: "one unicode tab",
1022+ msg: SessionMeta{SessionName: "démo", Active: 0, Tabs: []SessionTabMeta{{Index: 0, Name: "é", Attention: true}}},
1023+ want: []byte{0x00, 0x05, 'd', 0xc3, 0xa9, 'm', 'o', 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0xc3, 0xa9, 0x01},
1024+ },
1025+ {
1026+ name: "multiple tabs",
1027+ msg: SessionMeta{SessionName: "work", Active: 1, Tabs: []SessionTabMeta{{Index: 0, Name: "shell"}, {Index: 1, Name: "build", Attention: true}}},
1028+ want: []byte{0x00, 0x04, 'w', 'o', 'r', 'k', 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x05, 's', 'h', 'e', 'l', 'l', 0x00, 0x00, 0x01, 0x00, 0x05, 'b', 'u', 'i', 'l', 'd', 0x01},
1029+ },
1030+ }
1031+ for _, tt := range tests {
1032+ t.Run(tt.name, func(t *testing.T) {
1033+ got, err := MarshalSessionMeta(tt.msg)
1034+ require.NoError(t, err)
1035+ require.Equal(t, tt.want, got)
1036+ back, err := UnmarshalSessionMeta(got)
1037+ require.NoError(t, err)
1038+ require.Equal(t, tt.msg, back)
1039+ assertAllPrefixesFail(t, got, UnmarshalSessionMeta)
1040+ assertTrailingGarbageFails(t, got, UnmarshalSessionMeta)
1041+ })
1042+ }
1043+
1044+ maxString := string(bytes.Repeat([]byte{'x'}, math.MaxUint16))
1045+ payload, err := MarshalSessionMeta(SessionMeta{SessionName: maxString, Tabs: []SessionTabMeta{{Name: maxString}}})
1046+ require.NoError(t, err)
1047+ back, err := UnmarshalSessionMeta(payload)
1048+ require.NoError(t, err)
1049+ require.Equal(t, maxString, back.SessionName)
1050+ require.Equal(t, maxString, back.Tabs[0].Name)
1051+
1052+ invalid := []SessionMeta{
1053+ {},
1054+ {Tabs: []SessionTabMeta{{Name: "one"}}, Active: 1},
1055+ {Tabs: []SessionTabMeta{{Index: 1, Name: "one"}}},
1056+ {Tabs: []SessionTabMeta{{Name: string(bytes.Repeat([]byte{'x'}, math.MaxUint16+1))}}, Active: 0},
1057+ {SessionName: string(bytes.Repeat([]byte{'x'}, math.MaxUint16+1)), Tabs: []SessionTabMeta{{Name: "one"}}},
1058+ }
1059+ for _, msg := range invalid {
1060+ _, err := MarshalSessionMeta(msg)
1061+ require.Error(t, err)
1062+ }
1063+
1064+ valid, err := MarshalSessionMeta(tests[1].msg)
1065+ require.NoError(t, err)
1066+ for _, tt := range []struct {
1067+ name string
1068+ mutate func([]byte)
1069+ wantError error
1070+ }{
1071+ {name: "active outside tab count", mutate: func(b []byte) { b[6] = 0; b[7] = 2 }, wantError: ErrInvalidSessionMeta},
1072+ // The first tab index is the uint16 at bytes 10–11, after the session
1073+ // name, active index, and tab count.
1074+ {name: "first tab index out of order", mutate: func(b []byte) { b[10] = 0; b[11] = 1 }, wantError: ErrInvalidSessionMeta},
1075+ {name: "attention flag malformed", mutate: func(b []byte) { b[len(b)-1] = 2 }},
1076+ } {
1077+ t.Run(tt.name, func(t *testing.T) {
1078+ malformed := append([]byte(nil), valid...)
1079+ tt.mutate(malformed)
1080+ _, err := UnmarshalSessionMeta(malformed)
1081+ if tt.wantError != nil {
1082+ require.ErrorIs(t, err, tt.wantError)
1083+ return
1084+ }
1085+ require.Error(t, err)
1086+ })
1087+ }
1088+}
@@ -73,7 +73,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
73 TrueColor: true,73 TrueColor: true,
74 MaxOutputInFlight: 8,74 MaxOutputInFlight: 8,
75 },75 },
76- 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, 0x08, 0x00, 0x00, 0x00, 0x00},76+ want: []byte{0x00, 0x01, 0x01, 0x00, 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, 0x08, 0x00, 0x00, 0x00, 0x00},
77 },77 },
78 {78 {
79 name: "empty strings",79 name: "empty strings",
@@ -86,7 +86,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
86 Cwd: "",86 Cwd: "",
87 TrueColor: false,87 TrueColor: false,
88 },88 },
89- 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, 0x00, 0x00, 0x00, 0x00},89+ 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, 0x00, 0x00, 0x00, 0x00, 0x00},
90 },90 },
91 }91 }
92 92
@@ -122,7 +122,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
122 Env: []string{"A=B", "XY=123"},122 Env: []string{"A=B", "XY=123"},
123 })123 })
124 want := []byte{124 want := []byte{
125- 0x00, 0x13, 0x00, // version, intent125+ 0x00, 0x14, 0x00, 0x00, // version, intent, proxied
126 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,126 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
127 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID127 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
128 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token128 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -336,7 +336,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
336 }336 }
337 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)337 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
338 require.Equal(t, want, MarshalTheme(cleared))338 require.Equal(t, want, MarshalTheme(cleared))
339- require.Equal(t, uint16(19), ProtocolVersion)339+ require.Equal(t, uint16(20), ProtocolVersion)
340 }340 }
341 341
342 func TestResizeGoldenAndRoundTrip(t *testing.T) {342 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -461,8 +461,10 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
461 name: "minimal",461 name: "minimal",
462 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},462 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
463 want: []byte{463 want: []byte{
464- 0x00, 0x13,464+ 0x00, 0x14,
465- 0x00,465+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
466+ 0x00, // attached
467+ 0x00, // self
466 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',468 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
467 0x00, 0x00,469 0x00, 0x00,
468 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,470 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -482,8 +484,10 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
482 JSON: true,484 JSON: true,
483 },485 },
484 want: []byte{486 want: []byte{
485- 0x00, 0x13,487+ 0x00, 0x14,
486- 0x01,488+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
489+ 0x00, // attached
490+ 0x01, // self
487 0x00, 0x05, 't', 'o', 'a', 's', 't',491 0x00, 0x05, 't', 'o', 'a', 's', 't',
488 0x00, 0x03,492 0x00, 0x03,
489 0x00, 0x00, 0x00, 0x02, '-', 'l',493 0x00, 0x00, 0x00, 0x02, '-', 'l',
@@ -532,7 +536,7 @@ func TestMarshalCommandRequestRejectsTooManyArguments(t *testing.T) {
532 func TestCommandRequestRejectsImpossibleArgumentCount(t *testing.T) {536 func TestCommandRequestRejectsImpossibleArgumentCount(t *testing.T) {
533 payload, err := MarshalCommandRequest(CommandRequest{Version: ProtocolVersion, Slug: "toast"})537 payload, err := MarshalCommandRequest(CommandRequest{Version: ProtocolVersion, Slug: "toast"})
534 require.NoError(t, err)538 require.NoError(t, err)
535- argCountOffset := 2 + 1 + 2 + len("toast")539+ argCountOffset := 2 + 8 + 1 + 1 + 2 + len("toast")
536 payload[argCountOffset] = 0xff540 payload[argCountOffset] = 0xff
537 payload[argCountOffset+1] = 0xff541 payload[argCountOffset+1] = 0xff
538 542
@@ -551,6 +555,7 @@ func TestCommandResultGoldenAndRoundTrip(t *testing.T) {
551 name: "error",555 name: "error",
552 msg: CommandResult{Code: ErrNoSuchTarget, Text: "no such session"},556 msg: CommandResult{Code: ErrNoSuchTarget, Text: "no such session"},
553 want: []byte{557 want: []byte{
558+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
554 0x00,559 0x00,
555 0x00, 0x09,560 0x00, 0x09,
556 0x00, 0x0f, 'n', 'o', ' ', 's', 'u', 'c', 'h', ' ', 's', 'e', 's', 's', 'i', 'o', 'n',561 0x00, 0x0f, 'n', 'o', ' ', 's', 'u', 'c', 'h', ' ', 's', 'e', 's', 's', 'i', 'o', 'n',
@@ -561,6 +566,7 @@ func TestCommandResultGoldenAndRoundTrip(t *testing.T) {
561 name: "success with output",566 name: "success with output",
562 msg: CommandResult{OK: true, Text: "listed", Output: "ID\tFOCUSED\n"},567 msg: CommandResult{OK: true, Text: "listed", Output: "ID\tFOCUSED\n"},
563 want: []byte{568 want: []byte{
569+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
564 0x01,570 0x01,
565 0x00, 0x00,571 0x00, 0x00,
566 0x00, 0x06, 'l', 'i', 's', 't', 'e', 'd',572 0x00, 0x06, 'l', 'i', 's', 't', 'e', 'd',
@@ -868,9 +874,9 @@ func TestHelloOutputWindowByteExactValues(t *testing.T) {
868 for _, window := range []uint8{0, 1, 8} {874 for _, window := range []uint8{0, 1, 8} {
869 t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) {875 t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) {
870 hello := Hello{Version: 14, MaxOutputInFlight: window}876 hello := Hello{Version: 14, MaxOutputInFlight: window}
871- // The empty Hello has 38 zero bytes before the negotiated output window,877+ // The empty Hello has 39 zero bytes before the negotiated output window,
872 // followed by a uint32 zero environment-entry count.878 // followed by a uint32 zero environment-entry count.
873- want := append(make([]byte, 38), window, 0, 0, 0, 0)879+ want := append(make([]byte, 39), window, 0, 0, 0, 0)
874 want[1] = 14880 want[1] = 14
875 got := MarshalHello(hello)881 got := MarshalHello(hello)
876 requireBytesEqual(t, want, got)882 requireBytesEqual(t, want, got)
@@ -889,3 +895,194 @@ func requireBytesEqual(t *testing.T, want, got []byte) {
889 t.Fatalf("bytes = %#v, want %#v", got, want)895 t.Fatalf("bytes = %#v, want %#v", got, want)
890 }896 }
891 }897 }
898+func TestProxiedHelloGoldenAndStrict(t *testing.T) {
899+ msg := Hello{
900+ Version: ProtocolVersion,
901+ Intent: IntentAttach,
902+ Proxied: true,
903+ ClientID: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16},
904+ ResumeToken: 0x0102030405060708,
905+ Name: "remote",
906+ Size: domain.Size{Cols: 80, Rows: 22},
907+ TermEnv: "xterm",
908+ Cwd: "/tmp",
909+ TrueColor: true,
910+ MaxOutputInFlight: 8,
911+ }
912+ want := []byte{
913+ 0x00, 0x14, 0x02, 0x01,
914+ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
915+ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
916+ 0x00, 0x06, 'r', 'e', 'm', 'o', 't', 'e',
917+ 0x00, 0x50, 0x00, 0x16,
918+ 0x00, 0x05, 'x', 't', 'e', 'r', 'm',
919+ 0x00, 0x04, '/', 't', 'm', 'p',
920+ 0x01, 0x08, 0x00, 0x00, 0x00, 0x00,
921+ }
922+ got := MarshalHello(msg)
923+ require.Equal(t, want, got)
924+ back, err := UnmarshalHello(got)
925+ require.NoError(t, err)
926+ require.Equal(t, msg, back)
927+ assertAllPrefixesFail(t, got, UnmarshalHello)
928+ assertTrailingGarbageFails(t, got, UnmarshalHello)
929+
930+ malformed := append([]byte(nil), got...)
931+ malformed[3] = 2
932+ _, err = UnmarshalHello(malformed)
933+ require.Error(t, err)
934+}
935+
936+func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
937+ tests := []struct {
938+ name string
939+ msg CommandRequest
940+ want []byte
941+ }{
942+ {
943+ name: "attached",
944+ msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
945+ want: []byte{
946+ 0x00, 0x14, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
947+ 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
948+ 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
949+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
950+ },
951+ },
952+ {
953+ name: "control",
954+ msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
955+ want: []byte{
956+ 0x00, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
957+ 0x00, 0x02, 'l', 's', 0x00, 0x00,
958+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
959+ },
960+ },
961+ }
962+ for _, tt := range tests {
963+ t.Run(tt.name, func(t *testing.T) {
964+ got, err := MarshalCommandRequest(tt.msg)
965+ require.NoError(t, err)
966+ require.Equal(t, tt.want, got)
967+ back, err := UnmarshalCommandRequest(got)
968+ require.NoError(t, err)
969+ require.Equal(t, tt.msg, back)
970+ assertAllPrefixesFail(t, got, UnmarshalCommandRequest)
971+ assertTrailingGarbageFails(t, got, UnmarshalCommandRequest)
972+ })
973+ }
974+
975+ payload, err := MarshalCommandRequest(tests[0].msg)
976+ require.NoError(t, err)
977+ for _, offset := range []int{10, 11, len(payload) - 1} {
978+ malformed := append([]byte(nil), payload...)
979+ malformed[offset] = 2
980+ _, err := UnmarshalCommandRequest(malformed)
981+ require.Error(t, err)
982+ }
983+}
984+
985+func TestCommandResultCorrelationGoldenAndStrict(t *testing.T) {
986+ msg := CommandResult{RequestID: 0x0102030405060708, OK: true, Code: 7, Text: "ok", Output: "result"}
987+ want := []byte{
988+ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
989+ 0x01, 0x00, 0x07, 0x00, 0x02, 'o', 'k', 0x00, 0x00, 0x00, 0x06, 'r', 'e', 's', 'u', 'l', 't',
990+ }
991+ got := MarshalCommandResult(msg)
992+ require.Equal(t, want, got)
993+ back, err := UnmarshalCommandResult(got)
994+ require.NoError(t, err)
995+ require.Equal(t, msg, back)
996+ assertAllPrefixesFail(t, got, UnmarshalCommandResult)
997+ assertTrailingGarbageFails(t, got, UnmarshalCommandResult)
998+
999+ malformed := append([]byte(nil), got...)
1000+ malformed[8] = 2
1001+ _, err = UnmarshalCommandResult(malformed)
1002+ require.Error(t, err)
1003+}
1004+
1005+func TestOutputResetRequestStrict(t *testing.T) {
1006+ got := MarshalOutputResetRequest(OutputResetRequest{})
1007+ require.Empty(t, got)
1008+ back, err := UnmarshalOutputResetRequest(got)
1009+ require.NoError(t, err)
1010+ require.Equal(t, OutputResetRequest{}, back)
1011+ assertTrailingGarbageFails(t, got, UnmarshalOutputResetRequest)
1012+}
1013+
1014+func TestSessionMetaGoldenAndStrict(t *testing.T) {
1015+ tests := []struct {
1016+ name string
1017+ msg SessionMeta
1018+ want []byte
1019+ }{
1020+ {
1021+ name: "one unicode tab",
1022+ msg: SessionMeta{SessionName: "démo", Active: 0, Tabs: []SessionTabMeta{{Index: 0, Name: "é", Attention: true}}},
1023+ want: []byte{0x00, 0x05, 'd', 0xc3, 0xa9, 'm', 'o', 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0xc3, 0xa9, 0x01},
1024+ },
1025+ {
1026+ name: "multiple tabs",
1027+ msg: SessionMeta{SessionName: "work", Active: 1, Tabs: []SessionTabMeta{{Index: 0, Name: "shell"}, {Index: 1, Name: "build", Attention: true}}},
1028+ want: []byte{0x00, 0x04, 'w', 'o', 'r', 'k', 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x05, 's', 'h', 'e', 'l', 'l', 0x00, 0x00, 0x01, 0x00, 0x05, 'b', 'u', 'i', 'l', 'd', 0x01},
1029+ },
1030+ }
1031+ for _, tt := range tests {
1032+ t.Run(tt.name, func(t *testing.T) {
1033+ got, err := MarshalSessionMeta(tt.msg)
1034+ require.NoError(t, err)
1035+ require.Equal(t, tt.want, got)
1036+ back, err := UnmarshalSessionMeta(got)
1037+ require.NoError(t, err)
1038+ require.Equal(t, tt.msg, back)
1039+ assertAllPrefixesFail(t, got, UnmarshalSessionMeta)
1040+ assertTrailingGarbageFails(t, got, UnmarshalSessionMeta)
1041+ })
1042+ }
1043+
1044+ maxString := string(bytes.Repeat([]byte{'x'}, math.MaxUint16))
1045+ payload, err := MarshalSessionMeta(SessionMeta{SessionName: maxString, Tabs: []SessionTabMeta{{Name: maxString}}})
1046+ require.NoError(t, err)
1047+ back, err := UnmarshalSessionMeta(payload)
1048+ require.NoError(t, err)
1049+ require.Equal(t, maxString, back.SessionName)
1050+ require.Equal(t, maxString, back.Tabs[0].Name)
1051+
1052+ invalid := []SessionMeta{
1053+ {},
1054+ {Tabs: []SessionTabMeta{{Name: "one"}}, Active: 1},
1055+ {Tabs: []SessionTabMeta{{Index: 1, Name: "one"}}},
1056+ {Tabs: []SessionTabMeta{{Name: string(bytes.Repeat([]byte{'x'}, math.MaxUint16+1))}}, Active: 0},
1057+ {SessionName: string(bytes.Repeat([]byte{'x'}, math.MaxUint16+1)), Tabs: []SessionTabMeta{{Name: "one"}}},
1058+ }
1059+ for _, msg := range invalid {
1060+ _, err := MarshalSessionMeta(msg)
1061+ require.Error(t, err)
1062+ }
1063+
1064+ valid, err := MarshalSessionMeta(tests[1].msg)
1065+ require.NoError(t, err)
1066+ for _, tt := range []struct {
1067+ name string
1068+ mutate func([]byte)
1069+ wantError error
1070+ }{
1071+ {name: "active outside tab count", mutate: func(b []byte) { b[6] = 0; b[7] = 2 }, wantError: ErrInvalidSessionMeta},
1072+ // The first tab index is the uint16 at bytes 10–11, after the session
1073+ // name, active index, and tab count.
1074+ {name: "first tab index out of order", mutate: func(b []byte) { b[10] = 0; b[11] = 1 }, wantError: ErrInvalidSessionMeta},
1075+ {name: "attention flag malformed", mutate: func(b []byte) { b[len(b)-1] = 2 }},
1076+ } {
1077+ t.Run(tt.name, func(t *testing.T) {
1078+ malformed := append([]byte(nil), valid...)
1079+ tt.mutate(malformed)
1080+ _, err := UnmarshalSessionMeta(malformed)
1081+ if tt.wantError != nil {
1082+ require.ErrorIs(t, err, tt.wantError)
1083+ return
1084+ }
1085+ require.Error(t, err)
1086+ })
1087+ }
1088+}
M internal/usecase/daemon/client.go +11 -2
@@ -48,7 +48,10 @@ type attachedClient struct {
4848 resumeCapable bool
4949 resumeToken uint64
5050 parked bool
51- echoAck atomic.Uint64
51+ // proxied is negotiated once by Hello and remains immutable for this
52+ // attachment, including across transport resume.
53+ proxied bool
54+ echoAck atomic.Uint64
5255 // prepareFailureFallback prevents a direct fallback paint from recursively
5356 // reporting the same failed prepare through its notice repaint. It is only
5457 // needed while no render coordinator is installed.
@@ -59,6 +62,10 @@ type attachedClient struct {
5962 pipelineCache composeCacheInput
6063 pipelineScratch composeCacheInput
6164 renderScratch renderCaptureScratch // only touched while sendMu is held
65+ // sessionMeta is the last authoritative metadata snapshot successfully sent
66+ // on a proxied attachment. It is only read or written while sendMu is held.
67+ sessionMeta ports.SessionMeta
68+ sessionMetaSent bool
6269 // captureFrames is keyed by pane ownership, not the tab-local PaneID, so
6370 // snapshots cannot leak when an attachment switches tabs or sessions.
6471 captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held
@@ -454,6 +461,7 @@ type attachClientOptions struct {
454461 clientID [16]byte
455462 resumeCapable bool
456463 maxOutputInFlight uint8
464+ proxied bool
457465 }
458466
459467 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient, error) {
@@ -500,6 +508,7 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size,
500508 clientID: opts.clientID,
501509 resumeCapable: opts.resumeCapable,
502510 resumeToken: resumeToken,
511+ proxied: opts.proxied,
503512 }
504513 ac.initOverlays()
505514 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
@@ -648,7 +657,7 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSi
648657 tb.mu.Unlock()
649658
650659 outerResizeAccepted := false
651- if clientSize.Valid() && wsz != tabSize(clientSize) {
660+ if clientSize.Valid() && wsz != contentSize(clientSize, ac.proxied) {
652661 if lease == nil {
653662 outerResizeAccepted = d.resizeForFirstPaint(sess, ac, clientSize)
654663 } else {
@@ -48,7 +48,10 @@ type attachedClient struct {
48 resumeCapable bool48 resumeCapable bool
49 resumeToken uint6449 resumeToken uint64
50 parked bool50 parked bool
51- echoAck atomic.Uint6451+ // proxied is negotiated once by Hello and remains immutable for this
52+ // attachment, including across transport resume.
53+ proxied bool
54+ echoAck atomic.Uint64
52 // prepareFailureFallback prevents a direct fallback paint from recursively55 // prepareFailureFallback prevents a direct fallback paint from recursively
53 // reporting the same failed prepare through its notice repaint. It is only56 // reporting the same failed prepare through its notice repaint. It is only
54 // needed while no render coordinator is installed.57 // needed while no render coordinator is installed.
@@ -59,6 +62,10 @@ type attachedClient struct {
59 pipelineCache composeCacheInput62 pipelineCache composeCacheInput
60 pipelineScratch composeCacheInput63 pipelineScratch composeCacheInput
61 renderScratch renderCaptureScratch // only touched while sendMu is held64 renderScratch renderCaptureScratch // only touched while sendMu is held
65+ // sessionMeta is the last authoritative metadata snapshot successfully sent
66+ // on a proxied attachment. It is only read or written while sendMu is held.
67+ sessionMeta ports.SessionMeta
68+ sessionMetaSent bool
62 // captureFrames is keyed by pane ownership, not the tab-local PaneID, so69 // captureFrames is keyed by pane ownership, not the tab-local PaneID, so
63 // snapshots cannot leak when an attachment switches tabs or sessions.70 // snapshots cannot leak when an attachment switches tabs or sessions.
64 captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held71 captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held
@@ -454,6 +461,7 @@ type attachClientOptions struct {
454 clientID [16]byte461 clientID [16]byte
455 resumeCapable bool462 resumeCapable bool
456 maxOutputInFlight uint8463 maxOutputInFlight uint8
464+ proxied bool
457 }465 }
458 466
459 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient, error) {467 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient, error) {
@@ -500,6 +508,7 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size,
500 clientID: opts.clientID,508 clientID: opts.clientID,
501 resumeCapable: opts.resumeCapable,509 resumeCapable: opts.resumeCapable,
502 resumeToken: resumeToken,510 resumeToken: resumeToken,
511+ proxied: opts.proxied,
503 }512 }
504 ac.initOverlays()513 ac.initOverlays()
505 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)514 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
@@ -648,7 +657,7 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSi
648 tb.mu.Unlock()657 tb.mu.Unlock()
649 658
650 outerResizeAccepted := false659 outerResizeAccepted := false
651- if clientSize.Valid() && wsz != tabSize(clientSize) {660+ if clientSize.Valid() && wsz != contentSize(clientSize, ac.proxied) {
652 if lease == nil {661 if lease == nil {
653 outerResizeAccepted = d.resizeForFirstPaint(sess, ac, clientSize)662 outerResizeAccepted = d.resizeForFirstPaint(sess, ac, clientSize)
654 } else {663 } else {
M internal/usecase/daemon/client_frame_routing.go +51 -0
@@ -104,6 +104,35 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram
104104 if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {
105105 d.ackActiveOutput(token, ack.AckedStateNum)
106106 }
107+ case ports.MsgOutputResetRequest:
108+ if _, derr := ports.UnmarshalOutputResetRequest(f.Payload); derr == nil {
109+ d.resetActiveOutput(token)
110+ }
111+ case ports.MsgCommand:
112+ if token.ac.proxied {
113+ request, derr := ports.UnmarshalCommandRequest(f.Payload)
114+ if derr != nil {
115+ return false
116+ }
117+ // Attached commands are intentionally admitted only on the proxied
118+ // attachment path. Phase 6 supplies execution; until then every
119+ // decoded request is rejected with its original correlation ID.
120+ valid := request.Version == ports.ProtocolVersion && request.Attached && request.RequestID != 0 &&
121+ request.TargetSession == "" && request.TargetTab == "" && request.TargetPane == ""
122+ if !valid {
123+ d.log.Warn("invalid attached command", "request_id", request.RequestID)
124+ }
125+ result := ports.CommandResult{
126+ RequestID: request.RequestID,
127+ Code: ports.ErrNotScriptable,
128+ Text: "attached command relay is not enabled",
129+ }
130+ if err := token.sendActiveControl(frameCommandResult(result)); err != nil {
131+ token.endRoleEffect()
132+ d.detachOnRoleSendError(token, token.transport.transport)
133+ return true
134+ }
135+ }
107136 case ports.MsgPing:
108137 if err := token.sendActiveControl(framePong()); err != nil {
109138 token.endRoleEffect()
@@ -117,6 +146,28 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram
117146 return false
118147 }
119148
149+// resetActiveOutput rebases only the exact proxied role and transport admitted
150+// by the frame router. sendMu serializes the rebase with every output send;
151+// transport revalidation under that lock rejects a link replaced while waiting.
152+func (d *Daemon) resetActiveOutput(token attachmentRoleToken) bool {
153+ ac := token.ac
154+ if ac == nil || !ac.proxied || token.effect == nil || token.effect.ended.Load() {
155+ return false
156+ }
157+ ac.sendMu.Lock()
158+ if token.effect.ended.Load() || !ac.proxied || !token.activeCurrent() || ac.output == nil {
159+ ac.sendMu.Unlock()
160+ return false
161+ }
162+ ac.output.rebase()
163+ ac.sendMu.Unlock()
164+
165+ rc := token.sess.core().coordinator.Load()
166+ return rc != nil && rc.invalidateForLease(ac, token.lease, renderInvalidation{
167+ class: invalidateUrgent, reset: true, producer: "output-reset-request",
168+ })
169+}
170+
120171 func (d *Daemon) ackActiveOutput(token attachmentRoleToken, state uint64) bool {
121172 ac := token.ac
122173 if token.effect == nil || token.effect.ended.Load() {
@@ -104,6 +104,35 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram
104 if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {104 if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {
105 d.ackActiveOutput(token, ack.AckedStateNum)105 d.ackActiveOutput(token, ack.AckedStateNum)
106 }106 }
107+ case ports.MsgOutputResetRequest:
108+ if _, derr := ports.UnmarshalOutputResetRequest(f.Payload); derr == nil {
109+ d.resetActiveOutput(token)
110+ }
111+ case ports.MsgCommand:
112+ if token.ac.proxied {
113+ request, derr := ports.UnmarshalCommandRequest(f.Payload)
114+ if derr != nil {
115+ return false
116+ }
117+ // Attached commands are intentionally admitted only on the proxied
118+ // attachment path. Phase 6 supplies execution; until then every
119+ // decoded request is rejected with its original correlation ID.
120+ valid := request.Version == ports.ProtocolVersion && request.Attached && request.RequestID != 0 &&
121+ request.TargetSession == "" && request.TargetTab == "" && request.TargetPane == ""
122+ if !valid {
123+ d.log.Warn("invalid attached command", "request_id", request.RequestID)
124+ }
125+ result := ports.CommandResult{
126+ RequestID: request.RequestID,
127+ Code: ports.ErrNotScriptable,
128+ Text: "attached command relay is not enabled",
129+ }
130+ if err := token.sendActiveControl(frameCommandResult(result)); err != nil {
131+ token.endRoleEffect()
132+ d.detachOnRoleSendError(token, token.transport.transport)
133+ return true
134+ }
135+ }
107 case ports.MsgPing:136 case ports.MsgPing:
108 if err := token.sendActiveControl(framePong()); err != nil {137 if err := token.sendActiveControl(framePong()); err != nil {
109 token.endRoleEffect()138 token.endRoleEffect()
@@ -117,6 +146,28 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram
117 return false146 return false
118 }147 }
119 148
149+// resetActiveOutput rebases only the exact proxied role and transport admitted
150+// by the frame router. sendMu serializes the rebase with every output send;
151+// transport revalidation under that lock rejects a link replaced while waiting.
152+func (d *Daemon) resetActiveOutput(token attachmentRoleToken) bool {
153+ ac := token.ac
154+ if ac == nil || !ac.proxied || token.effect == nil || token.effect.ended.Load() {
155+ return false
156+ }
157+ ac.sendMu.Lock()
158+ if token.effect.ended.Load() || !ac.proxied || !token.activeCurrent() || ac.output == nil {
159+ ac.sendMu.Unlock()
160+ return false
161+ }
162+ ac.output.rebase()
163+ ac.sendMu.Unlock()
164+
165+ rc := token.sess.core().coordinator.Load()
166+ return rc != nil && rc.invalidateForLease(ac, token.lease, renderInvalidation{
167+ class: invalidateUrgent, reset: true, producer: "output-reset-request",
168+ })
169+}
170+
120 func (d *Daemon) ackActiveOutput(token attachmentRoleToken, state uint64) bool {171 func (d *Daemon) ackActiveOutput(token attachmentRoleToken, state uint64) bool {
121 ac := token.ac172 ac := token.ac
122 if token.effect == nil || token.effect.ended.Load() {173 if token.effect == nil || token.effect.ended.Load() {
M internal/usecase/daemon/control.go +23 -6
@@ -17,19 +17,36 @@ import (
1717
1818 // handleCommand serves one one-shot control request. The leading version is
1919 // checked before decoding the versioned body so newer layouts fail cleanly.
20-func (d *Daemon) handleCommand(tr ports.Transport, f ports.Frame) {
20+func (d *Daemon) handleCommand(tr ports.Transport, f ports.Frame) error {
2121 defer func() { _ = tr.Close() }()
2222
2323 if version, ok := ports.PeekCommandVersion(f.Payload); !ok || version != ports.ProtocolVersion {
24- _ = tr.Send(frameCommandResult(ports.CommandResult{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"}))
25- return
24+ return d.sendCommandResult(tr, ports.CommandResult{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"})
2625 }
2726 request, err := ports.UnmarshalCommandRequest(f.Payload)
2827 if err != nil {
29- _ = tr.Send(frameCommandResult(ports.CommandResult{Code: ports.ErrInternal, Text: "malformed command request"}))
30- return
28+ return d.sendCommandResult(tr, ports.CommandResult{Code: ports.ErrInternal, Text: "malformed command request"})
3129 }
32- _ = tr.Send(frameCommandResult(d.dispatchCommand(d.serveCtx, request)))
30+ if request.Attached {
31+ return d.sendCommandResult(tr, ports.CommandResult{
32+ RequestID: request.RequestID,
33+ Code: ports.ErrNotScriptable,
34+ Text: "attached command relay is not enabled",
35+ })
36+ }
37+ result := d.dispatchCommand(d.serveCtx, request)
38+ result.RequestID = request.RequestID
39+ return d.sendCommandResult(tr, result)
40+}
41+
42+// sendCommandResult gives one-shot control responses the same observable
43+// transport-failure behavior regardless of which validation path produced it.
44+func (d *Daemon) sendCommandResult(tr ports.Transport, result ports.CommandResult) error {
45+ if err := tr.Send(frameCommandResult(result)); err != nil {
46+ d.log.Warn("command response send failed", "err", err)
47+ return err
48+ }
49+ return nil
3350 }
3451
3552 func frameCommandResult(result ports.CommandResult) ports.Frame {
@@ -17,19 +17,36 @@ import (
17 17
18 // handleCommand serves one one-shot control request. The leading version is18 // handleCommand serves one one-shot control request. The leading version is
19 // checked before decoding the versioned body so newer layouts fail cleanly.19 // checked before decoding the versioned body so newer layouts fail cleanly.
20-func (d *Daemon) handleCommand(tr ports.Transport, f ports.Frame) {20+func (d *Daemon) handleCommand(tr ports.Transport, f ports.Frame) error {
21 defer func() { _ = tr.Close() }()21 defer func() { _ = tr.Close() }()
22 22
23 if version, ok := ports.PeekCommandVersion(f.Payload); !ok || version != ports.ProtocolVersion {23 if version, ok := ports.PeekCommandVersion(f.Payload); !ok || version != ports.ProtocolVersion {
24- _ = tr.Send(frameCommandResult(ports.CommandResult{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"}))24+ return d.sendCommandResult(tr, ports.CommandResult{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"})
25- return
26 }25 }
27 request, err := ports.UnmarshalCommandRequest(f.Payload)26 request, err := ports.UnmarshalCommandRequest(f.Payload)
28 if err != nil {27 if err != nil {
29- _ = tr.Send(frameCommandResult(ports.CommandResult{Code: ports.ErrInternal, Text: "malformed command request"}))28+ return d.sendCommandResult(tr, ports.CommandResult{Code: ports.ErrInternal, Text: "malformed command request"})
30- return
31 }29 }
32- _ = tr.Send(frameCommandResult(d.dispatchCommand(d.serveCtx, request)))30+ if request.Attached {
31+ return d.sendCommandResult(tr, ports.CommandResult{
32+ RequestID: request.RequestID,
33+ Code: ports.ErrNotScriptable,
34+ Text: "attached command relay is not enabled",
35+ })
36+ }
37+ result := d.dispatchCommand(d.serveCtx, request)
38+ result.RequestID = request.RequestID
39+ return d.sendCommandResult(tr, result)
40+}
41+
42+// sendCommandResult gives one-shot control responses the same observable
43+// transport-failure behavior regardless of which validation path produced it.
44+func (d *Daemon) sendCommandResult(tr ports.Transport, result ports.CommandResult) error {
45+ if err := tr.Send(frameCommandResult(result)); err != nil {
46+ d.log.Warn("command response send failed", "err", err)
47+ return err
48+ }
49+ return nil
33 }50 }
34 51
35 func frameCommandResult(result ports.CommandResult) ports.Frame {52 func frameCommandResult(result ports.CommandResult) ports.Frame {
M internal/usecase/daemon/control_test.go +30 -0
@@ -1,8 +1,12 @@
11 package daemon
22
33 import (
4+ "bytes"
45 "context"
56 "encoding/json"
7+ "errors"
8+ "io"
9+ "log/slog"
610 "strings"
711 "sync"
812 "testing"
@@ -122,6 +126,23 @@ func (s *actionRunnerSpy) Run(request daemonActionRequest) error {
122126 return s.err
123127 }
124128
129+func TestHandleCommandAttachedRejectionReturnsSendFailure(t *testing.T) {
130+ d := newTestDaemon(t, nil, stubClock{})
131+ var logs bytes.Buffer
132+ d.log = slog.New(slog.NewTextHandler(&logs, nil))
133+ sendErr := errors.New("command result send failed")
134+ tr := &commandSendErrorTransport{err: sendErr}
135+
136+ err := d.handleCommand(tr, commandFrame(t, ports.CommandRequest{
137+ RequestID: 41,
138+ Attached: true,
139+ }))
140+
141+ require.ErrorIs(t, err, sendErr)
142+ require.Contains(t, logs.String(), "command response send failed")
143+ require.True(t, tr.closed, "the failed one-shot transport must still close")
144+}
145+
125146 func TestHandleCommandRejectsVersionBeforeDecodeOrDispatch(t *testing.T) {
126147 // The version prefix is valid and mismatched, while the remainder is not a
127148 // decodable request. The daemon must still return ErrVersionMismatch.
@@ -1222,6 +1243,15 @@ func (f *controlPTYFactory) close() {
12221243 }
12231244 }
12241245
1246+type commandSendErrorTransport struct {
1247+ err error
1248+ closed bool
1249+}
1250+
1251+func (tr *commandSendErrorTransport) Send(ports.Frame) error { return tr.err }
1252+func (*commandSendErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
1253+func (tr *commandSendErrorTransport) Close() error { tr.closed = true; return nil }
1254+
12251255 func addControlSession(d *Daemon, name, tabID, paneID string) *session {
12261256 tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
12271257 tb.ctx, tb.cancel = context.WithCancel(d.serveCtx)
@@ -1,8 +1,12 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "bytes"
4 "context"5 "context"
5 "encoding/json"6 "encoding/json"
7+ "errors"
8+ "io"
9+ "log/slog"
6 "strings"10 "strings"
7 "sync"11 "sync"
8 "testing"12 "testing"
@@ -122,6 +126,23 @@ func (s *actionRunnerSpy) Run(request daemonActionRequest) error {
122 return s.err126 return s.err
123 }127 }
124 128
129+func TestHandleCommandAttachedRejectionReturnsSendFailure(t *testing.T) {
130+ d := newTestDaemon(t, nil, stubClock{})
131+ var logs bytes.Buffer
132+ d.log = slog.New(slog.NewTextHandler(&logs, nil))
133+ sendErr := errors.New("command result send failed")
134+ tr := &commandSendErrorTransport{err: sendErr}
135+
136+ err := d.handleCommand(tr, commandFrame(t, ports.CommandRequest{
137+ RequestID: 41,
138+ Attached: true,
139+ }))
140+
141+ require.ErrorIs(t, err, sendErr)
142+ require.Contains(t, logs.String(), "command response send failed")
143+ require.True(t, tr.closed, "the failed one-shot transport must still close")
144+}
145+
125 func TestHandleCommandRejectsVersionBeforeDecodeOrDispatch(t *testing.T) {146 func TestHandleCommandRejectsVersionBeforeDecodeOrDispatch(t *testing.T) {
126 // The version prefix is valid and mismatched, while the remainder is not a147 // The version prefix is valid and mismatched, while the remainder is not a
127 // decodable request. The daemon must still return ErrVersionMismatch.148 // decodable request. The daemon must still return ErrVersionMismatch.
@@ -1222,6 +1243,15 @@ func (f *controlPTYFactory) close() {
1222 }1243 }
1223 }1244 }
1224 1245
1246+type commandSendErrorTransport struct {
1247+ err error
1248+ closed bool
1249+}
1250+
1251+func (tr *commandSendErrorTransport) Send(ports.Frame) error { return tr.err }
1252+func (*commandSendErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
1253+func (tr *commandSendErrorTransport) Close() error { tr.closed = true; return nil }
1254+
1225 func addControlSession(d *Daemon, name, tabID, paneID string) *session {1255 func addControlSession(d *Daemon, name, tabID, paneID string) *session {
1226 tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 22})1256 tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1227 tb.ctx, tb.cancel = context.WithCancel(d.serveCtx)1257 tb.ctx, tb.cancel = context.WithCancel(d.serveCtx)
M internal/usecase/daemon/daemon.go +21 -5
@@ -1149,7 +1149,22 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
11491149 return
11501150 }
11511151 postWelcomeLease := postWelcomeToken.lease
1152- if postWelcomeToken.role != attachmentActive || rc == nil || postWelcomeLease == nil || !rc.markAttachmentReady(postWelcomeLease) {
1152+ if postWelcomeToken.role != attachmentActive || rc == nil || postWelcomeLease == nil {
1153+ postWelcomeTicket.End()
1154+ d.clientGone(sess, ac, tr, false)
1155+ return
1156+ }
1157+ if ac.proxied {
1158+ meta, ok := sess.sessionMetaSnapshot()
1159+ metaFrame, metaErr := frameSessionMeta(meta)
1160+ if !ok || metaErr != nil || ac.sendExpectedTransportForRole(postWelcomeToken.transport, metaFrame, postWelcomeTicket) != nil {
1161+ postWelcomeTicket.End()
1162+ d.clientGone(sess, ac, tr, false)
1163+ return
1164+ }
1165+ ac.markSessionMetaSent(meta)
1166+ }
1167+ if !rc.markAttachmentReady(postWelcomeLease) {
11531168 postWelcomeTicket.End()
11541169 // The attachment was displaced or detached while Welcome was in flight;
11551170 // never let this stale handshake emit an Output frame.
@@ -1190,6 +1205,7 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
11901205 clientID: h.ClientID,
11911206 resumeCapable: true,
11921207 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
1208+ proxied: h.Proxied,
11931209 }
11941210 ac := d.prepareAttachedClientLocked(tr, sz, opts)
11951211 d.mu.Unlock()
@@ -1336,7 +1352,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
13361352 }
13371353 cwd := d.dirOrHome(stopped.cwd)
13381354 var err error
1339- sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term, h.Env, stopped.tabNames)
1355+ sess, err = d.createSessionLockedWithMode(h.Name, false, cwd, sz, term, h.Env, h.Proxied, stopped.tabNames)
13401356 if err != nil {
13411357 d.mu.Unlock()
13421358 return nil, nil, err
@@ -1347,7 +1363,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
13471363
13481364 case ports.IntentEphemeral:
13491365 name := d.allocEphemeralNameLocked()
1350- sess, err := d.createSessionLocked(name, true, h.Cwd, sz, term, h.Env)
1366+ sess, err := d.createSessionLockedWithMode(name, true, h.Cwd, sz, term, h.Env, h.Proxied)
13511367 if err != nil {
13521368 d.mu.Unlock()
13531369 return nil, nil, err
@@ -1368,7 +1384,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
13681384 d.mu.Unlock()
13691385 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}
13701386 }
1371- sess, err := d.createSessionLocked(h.Name, false, h.Cwd, sz, term, h.Env)
1387+ sess, err := d.createSessionLockedWithMode(h.Name, false, h.Cwd, sz, term, h.Env, h.Proxied)
13721388 if err != nil {
13731389 d.mu.Unlock()
13741390 return nil, nil, err
@@ -1386,7 +1402,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
13861402 }
13871403 cwd := d.dirOrHome(stopped.cwd)
13881404 var err error
1389- sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term, h.Env, stopped.tabNames)
1405+ sess, err = d.createSessionLockedWithMode(h.Name, false, cwd, sz, term, h.Env, h.Proxied, stopped.tabNames)
13901406 if err != nil {
13911407 d.mu.Unlock()
13921408 return nil, nil, err
@@ -1149,7 +1149,22 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
1149 return1149 return
1150 }1150 }
1151 postWelcomeLease := postWelcomeToken.lease1151 postWelcomeLease := postWelcomeToken.lease
1152- if postWelcomeToken.role != attachmentActive || rc == nil || postWelcomeLease == nil || !rc.markAttachmentReady(postWelcomeLease) {1152+ if postWelcomeToken.role != attachmentActive || rc == nil || postWelcomeLease == nil {
1153+ postWelcomeTicket.End()
1154+ d.clientGone(sess, ac, tr, false)
1155+ return
1156+ }
1157+ if ac.proxied {
1158+ meta, ok := sess.sessionMetaSnapshot()
1159+ metaFrame, metaErr := frameSessionMeta(meta)
1160+ if !ok || metaErr != nil || ac.sendExpectedTransportForRole(postWelcomeToken.transport, metaFrame, postWelcomeTicket) != nil {
1161+ postWelcomeTicket.End()
1162+ d.clientGone(sess, ac, tr, false)
1163+ return
1164+ }
1165+ ac.markSessionMetaSent(meta)
1166+ }
1167+ if !rc.markAttachmentReady(postWelcomeLease) {
1153 postWelcomeTicket.End()1168 postWelcomeTicket.End()
1154 // The attachment was displaced or detached while Welcome was in flight;1169 // The attachment was displaced or detached while Welcome was in flight;
1155 // never let this stale handshake emit an Output frame.1170 // never let this stale handshake emit an Output frame.
@@ -1190,6 +1205,7 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
1190 clientID: h.ClientID,1205 clientID: h.ClientID,
1191 resumeCapable: true,1206 resumeCapable: true,
1192 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),1207 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
1208+ proxied: h.Proxied,
1193 }1209 }
1194 ac := d.prepareAttachedClientLocked(tr, sz, opts)1210 ac := d.prepareAttachedClientLocked(tr, sz, opts)
1195 d.mu.Unlock()1211 d.mu.Unlock()
@@ -1336,7 +1352,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
1336 }1352 }
1337 cwd := d.dirOrHome(stopped.cwd)1353 cwd := d.dirOrHome(stopped.cwd)
1338 var err error1354 var err error
1339- sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term, h.Env, stopped.tabNames)1355+ sess, err = d.createSessionLockedWithMode(h.Name, false, cwd, sz, term, h.Env, h.Proxied, stopped.tabNames)
1340 if err != nil {1356 if err != nil {
1341 d.mu.Unlock()1357 d.mu.Unlock()
1342 return nil, nil, err1358 return nil, nil, err
@@ -1347,7 +1363,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
1347 1363
1348 case ports.IntentEphemeral:1364 case ports.IntentEphemeral:
1349 name := d.allocEphemeralNameLocked()1365 name := d.allocEphemeralNameLocked()
1350- sess, err := d.createSessionLocked(name, true, h.Cwd, sz, term, h.Env)1366+ sess, err := d.createSessionLockedWithMode(name, true, h.Cwd, sz, term, h.Env, h.Proxied)
1351 if err != nil {1367 if err != nil {
1352 d.mu.Unlock()1368 d.mu.Unlock()
1353 return nil, nil, err1369 return nil, nil, err
@@ -1368,7 +1384,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
1368 d.mu.Unlock()1384 d.mu.Unlock()
1369 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}1385 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}
1370 }1386 }
1371- sess, err := d.createSessionLocked(h.Name, false, h.Cwd, sz, term, h.Env)1387+ sess, err := d.createSessionLockedWithMode(h.Name, false, h.Cwd, sz, term, h.Env, h.Proxied)
1372 if err != nil {1388 if err != nil {
1373 d.mu.Unlock()1389 d.mu.Unlock()
1374 return nil, nil, err1390 return nil, nil, err
@@ -1386,7 +1402,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
1386 }1402 }
1387 cwd := d.dirOrHome(stopped.cwd)1403 cwd := d.dirOrHome(stopped.cwd)
1388 var err error1404 var err error
1389- sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term, h.Env, stopped.tabNames)1405+ sess, err = d.createSessionLockedWithMode(h.Name, false, cwd, sz, term, h.Env, h.Proxied, stopped.tabNames)
1390 if err != nil {1406 if err != nil {
1391 d.mu.Unlock()1407 d.mu.Unlock()
1392 return nil, nil, err1408 return nil, nil, err
A internal/usecase/daemon/proxied_contract_test.go +259 -0
@@ -0,0 +1,259 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/internal/usecase/layout"
10+ "github.com/bnema/vev/pkg/renderer"
11+ "github.com/bnema/vev/pkg/vt"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestProxiedHandshakeOrdersWelcomeMetadataAndBaseZeroContent(t *testing.T) {
16+ d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
17+ contentSize := domain.Size{Cols: 24, Rows: 4}
18+ hello := ports.Hello{
19+ Version: ports.ProtocolVersion,
20+ Intent: ports.IntentNew,
21+ Proxied: true,
22+ Name: "remote-work",
23+ Size: contentSize,
24+ MaxOutputInFlight: 4,
25+ }
26+ tr, sends, release := newConn(t, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})
27+ defer release()
28+
29+ var handlers sync.WaitGroup
30+ handlers.Go(func() { d.handleConn(tr) })
31+
32+ welcomeFrame := awaitFrame(t, sends, ports.MsgWelcome)
33+ welcome, err := ports.UnmarshalWelcome(welcomeFrame.Payload)
34+ require.NoError(t, err)
35+ require.Equal(t, ports.CapabilityResume|ports.CapabilityProxied, welcome.Capabilities)
36+
37+ metaFrame := awaitFrame(t, sends, ports.MsgSessionMeta)
38+ meta, err := ports.UnmarshalSessionMeta(metaFrame.Payload)
39+ require.NoError(t, err)
40+ require.Equal(t, "remote-work", meta.SessionName)
41+ require.Equal(t, uint16(0), meta.Active)
42+ require.Equal(t, []ports.SessionTabMeta{{Index: 0}}, meta.Tabs)
43+
44+ outputFrame := awaitFrame(t, sends, ports.MsgOutput)
45+ output, err := ports.UnmarshalOutput(outputFrame.Payload)
46+ require.NoError(t, err)
47+ require.Zero(t, output.BaseStateNum)
48+
49+ client := vt.NewScreen(contentSize.Cols, contentSize.Rows)
50+ client.Write(output.Data)
51+ require.Equal(t, contentSize.Rows, client.Frame.Height)
52+ for _, row := range frameRows(client.Frame) {
53+ require.NotContains(t, row, "remote-work", "proxied output must not contain remote session chrome")
54+ }
55+
56+ sess := firstSession(d)
57+ require.NotNil(t, sess)
58+ sess.activeTab().mu.Lock()
59+ actualContentSize := sess.activeTab().size
60+ sess.activeTab().mu.Unlock()
61+ require.Equal(t, contentSize, actualContentSize, "proxied geometry must not subtract chrome a second time")
62+
63+ release()
64+ handlers.Wait()
65+}
66+
67+func TestOrdinaryHandshakeRetainsChromeAndNoMetadata(t *testing.T) {
68+ d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
69+ viewport := domain.Size{Cols: 24, Rows: 6}
70+ hello := ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "ordinary", Size: viewport}
71+ tr, sends, release := newConn(t, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})
72+ defer release()
73+
74+ var handlers sync.WaitGroup
75+ handlers.Go(func() { d.handleConn(tr) })
76+
77+ welcomeFrame := awaitFrame(t, sends, ports.MsgWelcome)
78+ welcome, err := ports.UnmarshalWelcome(welcomeFrame.Payload)
79+ require.NoError(t, err)
80+ require.Equal(t, ports.CapabilityResume, welcome.Capabilities)
81+
82+ outputFrame := awaitFrame(t, sends, ports.MsgOutput)
83+ output, err := ports.UnmarshalOutput(outputFrame.Payload)
84+ require.NoError(t, err)
85+ require.Zero(t, output.BaseStateNum)
86+ client := vt.NewScreen(viewport.Cols, viewport.Rows)
87+ client.Write(output.Data)
88+ require.Contains(t, screenLineText(client, 0), "1")
89+ require.Contains(t, screenLineText(client, viewport.Rows-1), "ordinary")
90+
91+ sess := firstSession(d)
92+ require.NotNil(t, sess)
93+ sess.activeTab().mu.Lock()
94+ actualContentSize := sess.activeTab().size
95+ sess.activeTab().mu.Unlock()
96+ require.Equal(t, tabSize(viewport), actualContentSize)
97+ requireNoOutputFrame(t, sends)
98+
99+ release()
100+ handlers.Wait()
101+}
102+
103+func TestComposeFrameProxiedContentOnlyOmitsBothChromeRows(t *testing.T) {
104+ paneFrame := renderer.NewFrame(8, 2)
105+ fillOutputStateRows(paneFrame, []string{"CONTENT1", "CONTENT2"})
106+ placement := layout.Placement{ID: "pane", Content: domain.Rect{Width: 8, Height: 2}}
107+ state := capturedRenderState{
108+ contentOnly: true,
109+ reset: true,
110+ layout: capturedTabLayout{
111+ area: domain.Rect{Width: 8, Height: 2}, focus: "pane", placements: []layout.Placement{placement}, valid: true,
112+ },
113+ panes: []capturedPaneRenderState{{
114+ id: "pane", frame: paneFrame, placement: placement, focused: true, damage: []renderer.Damage{renderer.FullRedraw()},
115+ }},
116+ bars: barState{status: statusSnapshot{session: "REMOTE-CHROME"}, bottomRight: "BOTTOM-CHROME"},
117+ cursor: capturedCursorInputs{visible: true, renderable: true, content: placement.Content},
118+ }
119+
120+ proxied := composeFrame(state, composeCacheInput{})
121+ require.Equal(t, []string{"CONTENT1", "CONTENT2"}, frameRows(proxied.frame))
122+ require.Equal(t, 0, proxied.cursor.row)
123+
124+ state.contentOnly = false
125+ ordinary := composeFrame(state, composeCacheInput{})
126+ require.Equal(t, 4, ordinary.frame.Height)
127+ require.Equal(t, "CONTENT1", rowText(ordinary.frame.Row(1)))
128+ require.Contains(t, rowText(ordinary.frame.Row(3)), "REMOTE-")
129+ require.Equal(t, 1, ordinary.cursor.row)
130+}
131+
132+func TestProxiedTransactionalResizeUsesReceivedContentGeometry(t *testing.T) {
133+ d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
134+ ac.proxied = true
135+ rc := d.attachCoordinator(sess, nil, ac, true)
136+ lease := rc.attachmentLease(ac)
137+ require.NotNil(t, lease)
138+
139+ want := domain.Size{Cols: 100, Rows: 12}
140+ require.True(t, d.requestTransactionalResizeForLease(sess, ac, lease, want, true))
141+ tb := sess.activeTab()
142+ tb.mu.Lock()
143+ require.Equal(t, want, tb.size)
144+ tb.mu.Unlock()
145+ require.Equal(t, want, ac.size)
146+}
147+
148+func TestProxiedModeCannotChangeAcrossResume(t *testing.T) {
149+ d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
150+ var clientID [16]byte
151+ clientID[0] = 1
152+ originalTransport := &closeTrackingTransport{}
153+ hello := ports.Hello{
154+ Version: ports.ProtocolVersion, Intent: ports.IntentNew, Proxied: true,
155+ ClientID: clientID, Name: "immutable", Size: domain.Size{Cols: 40, Rows: 8},
156+ }
157+ sess, ac, err := d.route(hello, originalTransport)
158+ require.NoError(t, err)
159+ token := ac.resumeToken
160+ d.clientGone(sess, ac, originalTransport, false)
161+
162+ resume := hello
163+ resume.Intent = ports.IntentResume
164+ resume.ResumeToken = token
165+ resume.Proxied = false
166+ _, resumed, ok, err := d.resumeParked(resume, &closeTrackingTransport{}, resume.Size)
167+ require.Error(t, err)
168+ require.False(t, ok)
169+ require.Nil(t, resumed)
170+ require.True(t, ac.proxied)
171+ d.mu.Lock()
172+ require.NotNil(t, d.parked[token], "a mode-mismatched resume must leave the original attachment parked")
173+ d.mu.Unlock()
174+}
175+
176+func TestOutputResetRebasesFullWindowAndSchedulesBaseZeroPaint(t *testing.T) {
177+ d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
178+ ac.proxied = true
179+ rc := d.attachCoordinator(sess, nil, ac, true)
180+ lease := rc.attachmentLease(ac)
181+ require.NotNil(t, lease)
182+
183+ ac.sendMu.Lock()
184+ ac.output.next = ac.output.maxOutstanding
185+ ac.output.acked = 0
186+ ac.sendMu.Unlock()
187+
188+ token := sess.attachmentToken(ac, ac.transport())
189+ require.False(t, d.handleActiveClientFrame(token, ports.Frame{
190+ Type: ports.MsgOutputResetRequest,
191+ Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),
192+ }))
193+
194+ frame := awaitFrame(t, sends, ports.MsgOutput)
195+ out, err := ports.UnmarshalOutput(frame.Payload)
196+ require.NoError(t, err)
197+ require.Zero(t, out.BaseStateNum)
198+ ac.sendMu.Lock()
199+ require.Equal(t, ac.output.maxOutstanding, ac.output.acked, "reset must retire the previously full output window")
200+ require.Equal(t, uint64(1), ac.output.outstanding(), "only the authoritative reset paint remains outstanding")
201+ require.Equal(t, ac.output.next, out.NewStateNum)
202+ ac.sendMu.Unlock()
203+}
204+
205+func TestOutputResetRequiresProxiedActiveRoleAndStrictPayload(t *testing.T) {
206+ tests := []struct {
207+ name string
208+ proxied bool
209+ payload []byte
210+ staleRole bool
211+ }{
212+ {name: "ordinary attachment", proxied: false, payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{})},
213+ {name: "malformed request", proxied: true, payload: []byte{0}},
214+ {name: "stale active role", proxied: true, payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}), staleRole: true},
215+ }
216+ for _, tt := range tests {
217+ t.Run(tt.name, func(t *testing.T) {
218+ d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
219+ ac.proxied = tt.proxied
220+ d.attachCoordinator(sess, nil, ac, true)
221+ ac.output.next, ac.output.acked = 4, 0
222+
223+ token := sess.attachmentToken(ac, ac.transport())
224+ if tt.staleRole {
225+ token.role = attachmentSnatched
226+ }
227+ require.False(t, d.handleActiveClientFrame(token, ports.Frame{Type: ports.MsgOutputResetRequest, Payload: tt.payload}))
228+ require.Zero(t, ac.output.acked)
229+ })
230+ }
231+}
232+
233+func TestOutputResetRevalidatesTransportUnderSendLock(t *testing.T) {
234+ d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
235+ ac.proxied = true
236+ d.attachCoordinator(sess, nil, ac, true)
237+ ac.output.next, ac.output.acked = 4, 0
238+ token := sess.attachmentToken(ac, ac.transport())
239+
240+ admitted := make(chan struct{})
241+ var once sync.Once
242+ d.afterRoleEffectAdmitted = func(attachmentRoleToken) { once.Do(func() { close(admitted) }) }
243+ ac.sendMu.Lock()
244+ done := make(chan bool, 1)
245+ go func() {
246+ done <- d.handleActiveClientFrame(token, ports.Frame{
247+ Type: ports.MsgOutputResetRequest,
248+ Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),
249+ })
250+ }()
251+ awaitTestCompletion(t, admitted, "reset did not admit its role effect")
252+ ac.replaceTransport(&closeTrackingTransport{})
253+ ac.sendMu.Unlock()
254+
255+ require.False(t, awaitTestValue(t, done, "reset did not finish after send lock release"))
256+ ac.sendMu.Lock()
257+ require.Zero(t, ac.output.acked, "a stale transport must not rebase the output stream")
258+ ac.sendMu.Unlock()
259+}
@@ -0,0 +1,259 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/internal/usecase/layout"
10+ "github.com/bnema/vev/pkg/renderer"
11+ "github.com/bnema/vev/pkg/vt"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestProxiedHandshakeOrdersWelcomeMetadataAndBaseZeroContent(t *testing.T) {
16+ d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
17+ contentSize := domain.Size{Cols: 24, Rows: 4}
18+ hello := ports.Hello{
19+ Version: ports.ProtocolVersion,
20+ Intent: ports.IntentNew,
21+ Proxied: true,
22+ Name: "remote-work",
23+ Size: contentSize,
24+ MaxOutputInFlight: 4,
25+ }
26+ tr, sends, release := newConn(t, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})
27+ defer release()
28+
29+ var handlers sync.WaitGroup
30+ handlers.Go(func() { d.handleConn(tr) })
31+
32+ welcomeFrame := awaitFrame(t, sends, ports.MsgWelcome)
33+ welcome, err := ports.UnmarshalWelcome(welcomeFrame.Payload)
34+ require.NoError(t, err)
35+ require.Equal(t, ports.CapabilityResume|ports.CapabilityProxied, welcome.Capabilities)
36+
37+ metaFrame := awaitFrame(t, sends, ports.MsgSessionMeta)
38+ meta, err := ports.UnmarshalSessionMeta(metaFrame.Payload)
39+ require.NoError(t, err)
40+ require.Equal(t, "remote-work", meta.SessionName)
41+ require.Equal(t, uint16(0), meta.Active)
42+ require.Equal(t, []ports.SessionTabMeta{{Index: 0}}, meta.Tabs)
43+
44+ outputFrame := awaitFrame(t, sends, ports.MsgOutput)
45+ output, err := ports.UnmarshalOutput(outputFrame.Payload)
46+ require.NoError(t, err)
47+ require.Zero(t, output.BaseStateNum)
48+
49+ client := vt.NewScreen(contentSize.Cols, contentSize.Rows)
50+ client.Write(output.Data)
51+ require.Equal(t, contentSize.Rows, client.Frame.Height)
52+ for _, row := range frameRows(client.Frame) {
53+ require.NotContains(t, row, "remote-work", "proxied output must not contain remote session chrome")
54+ }
55+
56+ sess := firstSession(d)
57+ require.NotNil(t, sess)
58+ sess.activeTab().mu.Lock()
59+ actualContentSize := sess.activeTab().size
60+ sess.activeTab().mu.Unlock()
61+ require.Equal(t, contentSize, actualContentSize, "proxied geometry must not subtract chrome a second time")
62+
63+ release()
64+ handlers.Wait()
65+}
66+
67+func TestOrdinaryHandshakeRetainsChromeAndNoMetadata(t *testing.T) {
68+ d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
69+ viewport := domain.Size{Cols: 24, Rows: 6}
70+ hello := ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "ordinary", Size: viewport}
71+ tr, sends, release := newConn(t, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})
72+ defer release()
73+
74+ var handlers sync.WaitGroup
75+ handlers.Go(func() { d.handleConn(tr) })
76+
77+ welcomeFrame := awaitFrame(t, sends, ports.MsgWelcome)
78+ welcome, err := ports.UnmarshalWelcome(welcomeFrame.Payload)
79+ require.NoError(t, err)
80+ require.Equal(t, ports.CapabilityResume, welcome.Capabilities)
81+
82+ outputFrame := awaitFrame(t, sends, ports.MsgOutput)
83+ output, err := ports.UnmarshalOutput(outputFrame.Payload)
84+ require.NoError(t, err)
85+ require.Zero(t, output.BaseStateNum)
86+ client := vt.NewScreen(viewport.Cols, viewport.Rows)
87+ client.Write(output.Data)
88+ require.Contains(t, screenLineText(client, 0), "1")
89+ require.Contains(t, screenLineText(client, viewport.Rows-1), "ordinary")
90+
91+ sess := firstSession(d)
92+ require.NotNil(t, sess)
93+ sess.activeTab().mu.Lock()
94+ actualContentSize := sess.activeTab().size
95+ sess.activeTab().mu.Unlock()
96+ require.Equal(t, tabSize(viewport), actualContentSize)
97+ requireNoOutputFrame(t, sends)
98+
99+ release()
100+ handlers.Wait()
101+}
102+
103+func TestComposeFrameProxiedContentOnlyOmitsBothChromeRows(t *testing.T) {
104+ paneFrame := renderer.NewFrame(8, 2)
105+ fillOutputStateRows(paneFrame, []string{"CONTENT1", "CONTENT2"})
106+ placement := layout.Placement{ID: "pane", Content: domain.Rect{Width: 8, Height: 2}}
107+ state := capturedRenderState{
108+ contentOnly: true,
109+ reset: true,
110+ layout: capturedTabLayout{
111+ area: domain.Rect{Width: 8, Height: 2}, focus: "pane", placements: []layout.Placement{placement}, valid: true,
112+ },
113+ panes: []capturedPaneRenderState{{
114+ id: "pane", frame: paneFrame, placement: placement, focused: true, damage: []renderer.Damage{renderer.FullRedraw()},
115+ }},
116+ bars: barState{status: statusSnapshot{session: "REMOTE-CHROME"}, bottomRight: "BOTTOM-CHROME"},
117+ cursor: capturedCursorInputs{visible: true, renderable: true, content: placement.Content},
118+ }
119+
120+ proxied := composeFrame(state, composeCacheInput{})
121+ require.Equal(t, []string{"CONTENT1", "CONTENT2"}, frameRows(proxied.frame))
122+ require.Equal(t, 0, proxied.cursor.row)
123+
124+ state.contentOnly = false
125+ ordinary := composeFrame(state, composeCacheInput{})
126+ require.Equal(t, 4, ordinary.frame.Height)
127+ require.Equal(t, "CONTENT1", rowText(ordinary.frame.Row(1)))
128+ require.Contains(t, rowText(ordinary.frame.Row(3)), "REMOTE-")
129+ require.Equal(t, 1, ordinary.cursor.row)
130+}
131+
132+func TestProxiedTransactionalResizeUsesReceivedContentGeometry(t *testing.T) {
133+ d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
134+ ac.proxied = true
135+ rc := d.attachCoordinator(sess, nil, ac, true)
136+ lease := rc.attachmentLease(ac)
137+ require.NotNil(t, lease)
138+
139+ want := domain.Size{Cols: 100, Rows: 12}
140+ require.True(t, d.requestTransactionalResizeForLease(sess, ac, lease, want, true))
141+ tb := sess.activeTab()
142+ tb.mu.Lock()
143+ require.Equal(t, want, tb.size)
144+ tb.mu.Unlock()
145+ require.Equal(t, want, ac.size)
146+}
147+
148+func TestProxiedModeCannotChangeAcrossResume(t *testing.T) {
149+ d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
150+ var clientID [16]byte
151+ clientID[0] = 1
152+ originalTransport := &closeTrackingTransport{}
153+ hello := ports.Hello{
154+ Version: ports.ProtocolVersion, Intent: ports.IntentNew, Proxied: true,
155+ ClientID: clientID, Name: "immutable", Size: domain.Size{Cols: 40, Rows: 8},
156+ }
157+ sess, ac, err := d.route(hello, originalTransport)
158+ require.NoError(t, err)
159+ token := ac.resumeToken
160+ d.clientGone(sess, ac, originalTransport, false)
161+
162+ resume := hello
163+ resume.Intent = ports.IntentResume
164+ resume.ResumeToken = token
165+ resume.Proxied = false
166+ _, resumed, ok, err := d.resumeParked(resume, &closeTrackingTransport{}, resume.Size)
167+ require.Error(t, err)
168+ require.False(t, ok)
169+ require.Nil(t, resumed)
170+ require.True(t, ac.proxied)
171+ d.mu.Lock()
172+ require.NotNil(t, d.parked[token], "a mode-mismatched resume must leave the original attachment parked")
173+ d.mu.Unlock()
174+}
175+
176+func TestOutputResetRebasesFullWindowAndSchedulesBaseZeroPaint(t *testing.T) {
177+ d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
178+ ac.proxied = true
179+ rc := d.attachCoordinator(sess, nil, ac, true)
180+ lease := rc.attachmentLease(ac)
181+ require.NotNil(t, lease)
182+
183+ ac.sendMu.Lock()
184+ ac.output.next = ac.output.maxOutstanding
185+ ac.output.acked = 0
186+ ac.sendMu.Unlock()
187+
188+ token := sess.attachmentToken(ac, ac.transport())
189+ require.False(t, d.handleActiveClientFrame(token, ports.Frame{
190+ Type: ports.MsgOutputResetRequest,
191+ Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),
192+ }))
193+
194+ frame := awaitFrame(t, sends, ports.MsgOutput)
195+ out, err := ports.UnmarshalOutput(frame.Payload)
196+ require.NoError(t, err)
197+ require.Zero(t, out.BaseStateNum)
198+ ac.sendMu.Lock()
199+ require.Equal(t, ac.output.maxOutstanding, ac.output.acked, "reset must retire the previously full output window")
200+ require.Equal(t, uint64(1), ac.output.outstanding(), "only the authoritative reset paint remains outstanding")
201+ require.Equal(t, ac.output.next, out.NewStateNum)
202+ ac.sendMu.Unlock()
203+}
204+
205+func TestOutputResetRequiresProxiedActiveRoleAndStrictPayload(t *testing.T) {
206+ tests := []struct {
207+ name string
208+ proxied bool
209+ payload []byte
210+ staleRole bool
211+ }{
212+ {name: "ordinary attachment", proxied: false, payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{})},
213+ {name: "malformed request", proxied: true, payload: []byte{0}},
214+ {name: "stale active role", proxied: true, payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}), staleRole: true},
215+ }
216+ for _, tt := range tests {
217+ t.Run(tt.name, func(t *testing.T) {
218+ d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
219+ ac.proxied = tt.proxied
220+ d.attachCoordinator(sess, nil, ac, true)
221+ ac.output.next, ac.output.acked = 4, 0
222+
223+ token := sess.attachmentToken(ac, ac.transport())
224+ if tt.staleRole {
225+ token.role = attachmentSnatched
226+ }
227+ require.False(t, d.handleActiveClientFrame(token, ports.Frame{Type: ports.MsgOutputResetRequest, Payload: tt.payload}))
228+ require.Zero(t, ac.output.acked)
229+ })
230+ }
231+}
232+
233+func TestOutputResetRevalidatesTransportUnderSendLock(t *testing.T) {
234+ d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
235+ ac.proxied = true
236+ d.attachCoordinator(sess, nil, ac, true)
237+ ac.output.next, ac.output.acked = 4, 0
238+ token := sess.attachmentToken(ac, ac.transport())
239+
240+ admitted := make(chan struct{})
241+ var once sync.Once
242+ d.afterRoleEffectAdmitted = func(attachmentRoleToken) { once.Do(func() { close(admitted) }) }
243+ ac.sendMu.Lock()
244+ done := make(chan bool, 1)
245+ go func() {
246+ done <- d.handleActiveClientFrame(token, ports.Frame{
247+ Type: ports.MsgOutputResetRequest,
248+ Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),
249+ })
250+ }()
251+ awaitTestCompletion(t, admitted, "reset did not admit its role effect")
252+ ac.replaceTransport(&closeTrackingTransport{})
253+ ac.sendMu.Unlock()
254+
255+ require.False(t, awaitTestValue(t, done, "reset did not finish after send lock release"))
256+ ac.sendMu.Lock()
257+ require.Zero(t, ac.output.acked, "a stale transport must not rebase the output stream")
258+ ac.sendMu.Unlock()
259+}
M internal/usecase/daemon/render_capture.go +2 -1
@@ -43,6 +43,7 @@ type capturedRenderState struct {
4343 attachment *attachedClient // identity only; never dereferenced by composition
4444 lease *attachmentLease
4545 reset bool
46+ contentOnly bool
4647 layout capturedTabLayout
4748 panes []capturedPaneRenderState
4849 floating capturedFloatingRenderState
@@ -257,7 +258,7 @@ func captureLocalPrimaryRenderState(
257258 }
258259 state := &scratch.state
259260 *state = capturedRenderState{
260- attachment: ac, lease: lease, reset: reset, bars: bars, theme: bars.theme,
261+ attachment: ac, lease: lease, reset: reset, contentOnly: ac.proxied, bars: bars, theme: bars.theme,
261262 styles: request.styles, styleGeneration: request.styleGeneration,
262263 overlays: overlays, preview: preview,
263264 layout: capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},
@@ -43,6 +43,7 @@ type capturedRenderState struct {
43 attachment *attachedClient // identity only; never dereferenced by composition43 attachment *attachedClient // identity only; never dereferenced by composition
44 lease *attachmentLease44 lease *attachmentLease
45 reset bool45 reset bool
46+ contentOnly bool
46 layout capturedTabLayout47 layout capturedTabLayout
47 panes []capturedPaneRenderState48 panes []capturedPaneRenderState
48 floating capturedFloatingRenderState49 floating capturedFloatingRenderState
@@ -257,7 +258,7 @@ func captureLocalPrimaryRenderState(
257 }258 }
258 state := &scratch.state259 state := &scratch.state
259 *state = capturedRenderState{260 *state = capturedRenderState{
260- attachment: ac, lease: lease, reset: reset, bars: bars, theme: bars.theme,261+ attachment: ac, lease: lease, reset: reset, contentOnly: ac.proxied, bars: bars, theme: bars.theme,
261 styles: request.styles, styleGeneration: request.styleGeneration,262 styles: request.styles, styleGeneration: request.styleGeneration,
262 overlays: overlays, preview: preview,263 overlays: overlays, preview: preview,
263 layout: capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},264 layout: capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},
M internal/usecase/daemon/render_pipeline.go +37 -17
@@ -73,12 +73,18 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
7373 }
7474 // Compose into the alternate attachment-owned buffer. The committed frame is
7575 // copied before incremental drawing so prepare/send failures cannot change it.
76- canReuseFrame := scratch.frame.Width == width && scratch.frame.Height == rows+2
76+ contentY := 1
77+ frameHeight := rows + tabChromeRows
78+ if state.contentOnly {
79+ contentY = 0
80+ frameHeight = rows
81+ }
82+ canReuseFrame := scratch.frame.Width == width && scratch.frame.Height == frameHeight
7783 frame := scratch.frame
7884 if !canReuseFrame {
79- frame = renderer.NewFrame(width, rows+2)
85+ frame = renderer.NewFrame(width, frameHeight)
8086 }
81- if in.frame.Width == width && in.frame.Height == rows+2 {
87+ if in.frame.Width == width && in.frame.Height == frameHeight {
8288 for y := 0; y < frame.Height; y++ {
8389 copy(frame.Row(y), in.frame.Row(y))
8490 }
@@ -93,14 +99,16 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
9399 defaultDimmer := themeui.NewDimmer(state.theme)
94100 neutralBorder := styles.NeutralBorder
95101 inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming))
96- drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)
97- drawStatusBarState(frame.Row(rows+1), state.bars, styles)
98- content := domain.Rect{Y: 1, Width: width, Height: rows}
102+ if !state.contentOnly {
103+ drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)
104+ drawStatusBarState(frame.Row(rows+1), state.bars, styles)
105+ }
106+ content := domain.Rect{Y: contentY, Width: width, Height: rows}
99107 if state.layout.valid {
100108 drawDividers(frame, state.layout.dividers, content.Y, defaultDimmer.Dim(neutralBorder))
101109 }
102110
103- full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.styleGeneration != state.styleGeneration || in.floatingVisible != state.floating.visible
111+ full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != frameHeight || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.styleGeneration != state.styleGeneration || in.floatingVisible != state.floating.visible
104112 titles := scratch.titleGenerations
105113 if titles == nil {
106114 titles = make(map[layout.PaneID]uint64, len(state.panes))
@@ -108,7 +116,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
108116 clear(titles)
109117 damage := scratch.damage[:0]
110118 for _, pane := range state.panes {
111- pl := offsetPlacement(pane.placement, 0, 1)
119+ pl := offsetPlacement(pane.placement, 0, contentY)
112120 if pl.TitleBar.Height > 0 {
113121 drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, styles, neutralBorder, defaultDimmer)
114122 if !full && in.titleGenerations[pane.id] != pane.titleGeneration {
@@ -121,7 +129,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
121129 }
122130 for _, d := range pane.damage {
123131 if d.Kind != renderer.DamageFullRedraw {
124- d.Y++
132+ d.Y += contentY
125133 }
126134 damage = append(damage, d)
127135 }
@@ -158,7 +166,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
158166 frame, damage = composeCapturedCopyMode(state, frame, damage, content)
159167 frame, damage = composeCapturedOverlays(state, frame, damage)
160168 }
161- if !full {
169+ if !state.contentOnly && !full {
162170 if !sameCells(in.bars.top, baseFrame.Row(0)) {
163171 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})
164172 }
@@ -178,9 +186,13 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
178186 }
179187 cursorInputs := state.cursor
180188 cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive
181- cursor := desiredCapturedCursor(cursorInputs)
189+ cursor := desiredCapturedCursor(cursorInputs, contentY)
182190 outCache := composeCacheInput{valid: !overlaysActive, frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, toastFootprints: append(scratch.toastFootprints[:0], toastFootprints...), floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars}
183- outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1))
191+ if !state.contentOnly {
192+ outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1))
193+ } else {
194+ outCache.bars.Reset()
195+ }
184196 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}
185197 }
186198
@@ -195,7 +207,7 @@ func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title s
195207 ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style)
196208 }
197209
198-func desiredCapturedCursor(c capturedCursorInputs) cursorOut {
210+func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut {
199211 if !c.renderable || c.hiddenByOverlay || !c.visible {
200212 return cursorOut{hidden: true}
201213 }
@@ -203,7 +215,7 @@ func desiredCapturedCursor(c capturedCursorInputs) cursorOut {
203215 if !c.hasStyle {
204216 style = 1
205217 }
206- return cursorOut{row: c.content.Y + 1 + c.row, col: c.content.X + c.col, style: style, hasStyle: true}
218+ return cursorOut{row: c.content.Y + contentY + c.row, col: c.content.X + c.col, style: style, hasStyle: true}
207219 }
208220
209221 func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapshot, paletteCfg domain.PaletteConfig) {
@@ -222,7 +234,11 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
222234 if styles == (themeui.Styles{}) {
223235 styles = fallbackChromeStyles
224236 }
225- size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2}
237+ rows := state.layout.area.Height
238+ if !state.contentOnly {
239+ rows += tabChromeRows
240+ }
241+ size := domain.Size{Cols: state.layout.area.Width, Rows: rows}
226242 if snap.copySearchModel != nil {
227243 presentation := copySearchModal.Resolve(size)
228244 o.copySearch = capturedModal{active: true, title: copySearchModal.Title, presentation: presentation, focused: true}
@@ -472,10 +488,14 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
472488 if len(data) > 0 {
473489 sendTransport := ac.transportSnapshot()
474490 sendTr = sendTransport.transport
491+ if ac.proxied {
492+ sendErr = ac.sendSessionMetaIfChanged(sess, sendTransport, marks.roleEffect)
493+ }
475494 endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data)))
476- if sendTr == nil {
495+ if sendErr == nil && sendTr == nil {
477496 sendErr = errors.New("client transport is nil")
478- } else {
497+ }
498+ if sendErr == nil {
479499 send := sendTr.Send
480500 if async, ok := sendTr.(ports.AsyncTransport); ok {
481501 send = async.SendAsync
@@ -73,12 +73,18 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
73 }73 }
74 // Compose into the alternate attachment-owned buffer. The committed frame is74 // Compose into the alternate attachment-owned buffer. The committed frame is
75 // copied before incremental drawing so prepare/send failures cannot change it.75 // copied before incremental drawing so prepare/send failures cannot change it.
76- canReuseFrame := scratch.frame.Width == width && scratch.frame.Height == rows+276+ contentY := 1
77+ frameHeight := rows + tabChromeRows
78+ if state.contentOnly {
79+ contentY = 0
80+ frameHeight = rows
81+ }
82+ canReuseFrame := scratch.frame.Width == width && scratch.frame.Height == frameHeight
77 frame := scratch.frame83 frame := scratch.frame
78 if !canReuseFrame {84 if !canReuseFrame {
79- frame = renderer.NewFrame(width, rows+2)85+ frame = renderer.NewFrame(width, frameHeight)
80 }86 }
81- if in.frame.Width == width && in.frame.Height == rows+2 {87+ if in.frame.Width == width && in.frame.Height == frameHeight {
82 for y := 0; y < frame.Height; y++ {88 for y := 0; y < frame.Height; y++ {
83 copy(frame.Row(y), in.frame.Row(y))89 copy(frame.Row(y), in.frame.Row(y))
84 }90 }
@@ -93,14 +99,16 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
93 defaultDimmer := themeui.NewDimmer(state.theme)99 defaultDimmer := themeui.NewDimmer(state.theme)
94 neutralBorder := styles.NeutralBorder100 neutralBorder := styles.NeutralBorder
95 inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming))101 inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming))
96- drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)102+ if !state.contentOnly {
97- drawStatusBarState(frame.Row(rows+1), state.bars, styles)103+ drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)
98- content := domain.Rect{Y: 1, Width: width, Height: rows}104+ drawStatusBarState(frame.Row(rows+1), state.bars, styles)
105+ }
106+ content := domain.Rect{Y: contentY, Width: width, Height: rows}
99 if state.layout.valid {107 if state.layout.valid {
100 drawDividers(frame, state.layout.dividers, content.Y, defaultDimmer.Dim(neutralBorder))108 drawDividers(frame, state.layout.dividers, content.Y, defaultDimmer.Dim(neutralBorder))
101 }109 }
102 110
103- full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.styleGeneration != state.styleGeneration || in.floatingVisible != state.floating.visible111+ full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != frameHeight || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.styleGeneration != state.styleGeneration || in.floatingVisible != state.floating.visible
104 titles := scratch.titleGenerations112 titles := scratch.titleGenerations
105 if titles == nil {113 if titles == nil {
106 titles = make(map[layout.PaneID]uint64, len(state.panes))114 titles = make(map[layout.PaneID]uint64, len(state.panes))
@@ -108,7 +116,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
108 clear(titles)116 clear(titles)
109 damage := scratch.damage[:0]117 damage := scratch.damage[:0]
110 for _, pane := range state.panes {118 for _, pane := range state.panes {
111- pl := offsetPlacement(pane.placement, 0, 1)119+ pl := offsetPlacement(pane.placement, 0, contentY)
112 if pl.TitleBar.Height > 0 {120 if pl.TitleBar.Height > 0 {
113 drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, styles, neutralBorder, defaultDimmer)121 drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, styles, neutralBorder, defaultDimmer)
114 if !full && in.titleGenerations[pane.id] != pane.titleGeneration {122 if !full && in.titleGenerations[pane.id] != pane.titleGeneration {
@@ -121,7 +129,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
121 }129 }
122 for _, d := range pane.damage {130 for _, d := range pane.damage {
123 if d.Kind != renderer.DamageFullRedraw {131 if d.Kind != renderer.DamageFullRedraw {
124- d.Y++132+ d.Y += contentY
125 }133 }
126 damage = append(damage, d)134 damage = append(damage, d)
127 }135 }
@@ -158,7 +166,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
158 frame, damage = composeCapturedCopyMode(state, frame, damage, content)166 frame, damage = composeCapturedCopyMode(state, frame, damage, content)
159 frame, damage = composeCapturedOverlays(state, frame, damage)167 frame, damage = composeCapturedOverlays(state, frame, damage)
160 }168 }
161- if !full {169+ if !state.contentOnly && !full {
162 if !sameCells(in.bars.top, baseFrame.Row(0)) {170 if !sameCells(in.bars.top, baseFrame.Row(0)) {
163 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})171 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})
164 }172 }
@@ -178,9 +186,13 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
178 }186 }
179 cursorInputs := state.cursor187 cursorInputs := state.cursor
180 cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive188 cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive
181- cursor := desiredCapturedCursor(cursorInputs)189+ cursor := desiredCapturedCursor(cursorInputs, contentY)
182 outCache := composeCacheInput{valid: !overlaysActive, frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, toastFootprints: append(scratch.toastFootprints[:0], toastFootprints...), floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars}190 outCache := composeCacheInput{valid: !overlaysActive, frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, toastFootprints: append(scratch.toastFootprints[:0], toastFootprints...), floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars}
183- outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1))191+ if !state.contentOnly {
192+ outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1))
193+ } else {
194+ outCache.bars.Reset()
195+ }
184 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}196 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}
185 }197 }
186 198
@@ -195,7 +207,7 @@ func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title s
195 ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style)207 ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style)
196 }208 }
197 209
198-func desiredCapturedCursor(c capturedCursorInputs) cursorOut {210+func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut {
199 if !c.renderable || c.hiddenByOverlay || !c.visible {211 if !c.renderable || c.hiddenByOverlay || !c.visible {
200 return cursorOut{hidden: true}212 return cursorOut{hidden: true}
201 }213 }
@@ -203,7 +215,7 @@ func desiredCapturedCursor(c capturedCursorInputs) cursorOut {
203 if !c.hasStyle {215 if !c.hasStyle {
204 style = 1216 style = 1
205 }217 }
206- return cursorOut{row: c.content.Y + 1 + c.row, col: c.content.X + c.col, style: style, hasStyle: true}218+ return cursorOut{row: c.content.Y + contentY + c.row, col: c.content.X + c.col, style: style, hasStyle: true}
207 }219 }
208 220
209 func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapshot, paletteCfg domain.PaletteConfig) {221 func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapshot, paletteCfg domain.PaletteConfig) {
@@ -222,7 +234,11 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
222 if styles == (themeui.Styles{}) {234 if styles == (themeui.Styles{}) {
223 styles = fallbackChromeStyles235 styles = fallbackChromeStyles
224 }236 }
225- size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2}237+ rows := state.layout.area.Height
238+ if !state.contentOnly {
239+ rows += tabChromeRows
240+ }
241+ size := domain.Size{Cols: state.layout.area.Width, Rows: rows}
226 if snap.copySearchModel != nil {242 if snap.copySearchModel != nil {
227 presentation := copySearchModal.Resolve(size)243 presentation := copySearchModal.Resolve(size)
228 o.copySearch = capturedModal{active: true, title: copySearchModal.Title, presentation: presentation, focused: true}244 o.copySearch = capturedModal{active: true, title: copySearchModal.Title, presentation: presentation, focused: true}
@@ -472,10 +488,14 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
472 if len(data) > 0 {488 if len(data) > 0 {
473 sendTransport := ac.transportSnapshot()489 sendTransport := ac.transportSnapshot()
474 sendTr = sendTransport.transport490 sendTr = sendTransport.transport
491+ if ac.proxied {
492+ sendErr = ac.sendSessionMetaIfChanged(sess, sendTransport, marks.roleEffect)
493+ }
475 endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data)))494 endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data)))
476- if sendTr == nil {495+ if sendErr == nil && sendTr == nil {
477 sendErr = errors.New("client transport is nil")496 sendErr = errors.New("client transport is nil")
478- } else {497+ }
498+ if sendErr == nil {
479 send := sendTr.Send499 send := sendTr.Send
480 if async, ok := sendTr.(ports.AsyncTransport); ok {500 if async, ok := sendTr.(ports.AsyncTransport); ok {
481 send = async.SendAsync501 send = async.SendAsync
M internal/usecase/daemon/resume.go +15 -4
@@ -384,6 +384,7 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma
384384 oldSnap transportSnapshot
385385 credentialMatch bool
386386 clientMismatch bool
387+ modeMismatch bool
387388 )
388389 if sess != nil {
389390 sess.mu.Lock()
@@ -394,6 +395,8 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma
394395 if ac != nil && ac.resumeCapable && ac.resumeToken == h.ResumeToken {
395396 if ac.clientID != h.ClientID {
396397 clientMismatch = true
398+ } else if ac.proxied != h.Proxied {
399+ modeMismatch = true
397400 } else {
398401 oldSnap = ac.transportSnapshot()
399402 credentialMatch = oldSnap.transport != nil
@@ -408,8 +411,12 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma
408411 }
409412 d.mu.Unlock()
410413
411- if clientMismatch {
412- d.log.Warn("resume rejected", "session", sess.name, "err", "client id mismatch")
414+ if clientMismatch || modeMismatch {
415+ reason := "client id mismatch"
416+ if modeMismatch {
417+ reason = "proxied mode mismatch"
418+ }
419+ d.log.Warn("resume rejected", "session", sess.name, "err", reason)
413420 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
414421 }
415422 if !credentialMatch {
@@ -513,8 +520,12 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
513520 parked.role = attachmentSnatched
514521 }
515522 ac := parked.ac
516- if ac.clientID != h.ClientID {
517- d.log.Warn("resume rejected", "session", sess.name, "err", "client id mismatch")
523+ if ac.clientID != h.ClientID || ac.proxied != h.Proxied {
524+ reason := "client id mismatch"
525+ if ac.proxied != h.Proxied {
526+ reason = "proxied mode mismatch"
527+ }
528+ d.log.Warn("resume rejected", "session", sess.name, "err", reason)
518529 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
519530 }
520531 delete(d.parked, h.ResumeToken)
@@ -384,6 +384,7 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma
384 oldSnap transportSnapshot384 oldSnap transportSnapshot
385 credentialMatch bool385 credentialMatch bool
386 clientMismatch bool386 clientMismatch bool
387+ modeMismatch bool
387 )388 )
388 if sess != nil {389 if sess != nil {
389 sess.mu.Lock()390 sess.mu.Lock()
@@ -394,6 +395,8 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma
394 if ac != nil && ac.resumeCapable && ac.resumeToken == h.ResumeToken {395 if ac != nil && ac.resumeCapable && ac.resumeToken == h.ResumeToken {
395 if ac.clientID != h.ClientID {396 if ac.clientID != h.ClientID {
396 clientMismatch = true397 clientMismatch = true
398+ } else if ac.proxied != h.Proxied {
399+ modeMismatch = true
397 } else {400 } else {
398 oldSnap = ac.transportSnapshot()401 oldSnap = ac.transportSnapshot()
399 credentialMatch = oldSnap.transport != nil402 credentialMatch = oldSnap.transport != nil
@@ -408,8 +411,12 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma
408 }411 }
409 d.mu.Unlock()412 d.mu.Unlock()
410 413
411- if clientMismatch {414+ if clientMismatch || modeMismatch {
412- d.log.Warn("resume rejected", "session", sess.name, "err", "client id mismatch")415+ reason := "client id mismatch"
416+ if modeMismatch {
417+ reason = "proxied mode mismatch"
418+ }
419+ d.log.Warn("resume rejected", "session", sess.name, "err", reason)
413 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}420 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
414 }421 }
415 if !credentialMatch {422 if !credentialMatch {
@@ -513,8 +520,12 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
513 parked.role = attachmentSnatched520 parked.role = attachmentSnatched
514 }521 }
515 ac := parked.ac522 ac := parked.ac
516- if ac.clientID != h.ClientID {523+ if ac.clientID != h.ClientID || ac.proxied != h.Proxied {
517- d.log.Warn("resume rejected", "session", sess.name, "err", "client id mismatch")524+ reason := "client id mismatch"
525+ if ac.proxied != h.Proxied {
526+ reason = "proxied mode mismatch"
527+ }
528+ d.log.Warn("resume rejected", "session", sess.name, "err", reason)
518 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}529 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
519 }530 }
520 delete(d.parked, h.ResumeToken)531 delete(d.parked, h.ResumeToken)
M internal/usecase/daemon/session.go +18 -4
@@ -178,6 +178,10 @@ func (d *Daemon) touchMRU(sess *session) {
178178 }
179179
180180 func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) {
181+ return d.createSessionLockedWithMode(name, ephemeral, cwd, sz, term, env, false, restoredTabNames...)
182+}
183+
184+func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, proxied bool, restoredTabNames ...[]string) (*session, error) {
181185 env = copyEnvironment(env)
182186 if _, reserved := d.creating[name]; reserved {
183187 return nil, errSessionNameInUse
@@ -231,7 +235,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
231235 }
232236 }
233237
234- tbSize := tabSize(sz)
238+ tbSize := contentSize(sz, proxied)
235239 var names []string
236240 if len(restoredTabNames) > 0 {
237241 names = append([]string(nil), restoredTabNames[0]...)
@@ -412,7 +416,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
412416 }
413417 from.mu.Unlock()
414418
415- newSess, err := d.createSessionLocked(name, false, cwd, sz, term, env)
419+ newSess, err := d.createSessionLockedWithMode(name, false, cwd, sz, term, env, ac.proxied)
416420 d.mu.Unlock()
417421 if err != nil {
418422 return err
@@ -478,7 +482,7 @@ func (d *Daemon) createSessionAndSwitchForRole(token attachmentRoleToken, name s
478482 cwd, term, env := source.cwd, source.terminal, copyEnvironment(source.env)
479483 source.mu.Unlock()
480484 var createErr error
481- created, createErr = d.createSessionLocked(name, false, cwd, token.ac.size, term, env)
485+ created, createErr = d.createSessionLockedWithMode(name, false, cwd, token.ac.size, term, env, token.ac.proxied)
482486 return created, createErr
483487 },
484488 })
@@ -500,7 +504,6 @@ func (d *Daemon) createSessionAndSwitchForRole(token attachmentRoleToken, name s
500504 }
501505
502506 func (d *Daemon) createTab(sess *session, sz domain.Size) error {
503- tbSize := tabSize(sz)
504507 sess.mu.Lock()
505508 name := sess.name
506509 cwd := sess.cwd
@@ -508,6 +511,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
508511 term := sess.terminal
509512 env := copyEnvironment(sess.env)
510513 sess.mu.Unlock()
514+ tbSize := contentSize(sz, client != nil && client.proxied)
511515 tabStableID, paneStableID, err := d.newTabPaneStableIDs()
512516 if err != nil {
513517 return err
@@ -627,9 +631,19 @@ func (tb *tab) closeAllPanes() {
627631 const tabChromeRows = 2
628632
629633 func tabSize(clientSize domain.Size) domain.Size {
634+ return contentSize(clientSize, false)
635+}
636+
637+// contentSize maps the negotiated viewport to PTY geometry. A proxied client
638+// has already removed its local chrome rows, so the remote daemon must consume
639+// the received size verbatim instead of subtracting chrome a second time.
640+func contentSize(clientSize domain.Size, proxied bool) domain.Size {
630641 if !clientSize.Valid() {
631642 clientSize = defaultSize
632643 }
644+ if proxied {
645+ return clientSize
646+ }
633647 rows := max(clientSize.Rows-tabChromeRows, 1)
634648 return domain.Size{Cols: clientSize.Cols, Rows: rows}
635649 }
@@ -178,6 +178,10 @@ func (d *Daemon) touchMRU(sess *session) {
178 }178 }
179 179
180 func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) {180 func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) {
181+ return d.createSessionLockedWithMode(name, ephemeral, cwd, sz, term, env, false, restoredTabNames...)
182+}
183+
184+func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, proxied bool, restoredTabNames ...[]string) (*session, error) {
181 env = copyEnvironment(env)185 env = copyEnvironment(env)
182 if _, reserved := d.creating[name]; reserved {186 if _, reserved := d.creating[name]; reserved {
183 return nil, errSessionNameInUse187 return nil, errSessionNameInUse
@@ -231,7 +235,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
231 }235 }
232 }236 }
233 237
234- tbSize := tabSize(sz)238+ tbSize := contentSize(sz, proxied)
235 var names []string239 var names []string
236 if len(restoredTabNames) > 0 {240 if len(restoredTabNames) > 0 {
237 names = append([]string(nil), restoredTabNames[0]...)241 names = append([]string(nil), restoredTabNames[0]...)
@@ -412,7 +416,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
412 }416 }
413 from.mu.Unlock()417 from.mu.Unlock()
414 418
415- newSess, err := d.createSessionLocked(name, false, cwd, sz, term, env)419+ newSess, err := d.createSessionLockedWithMode(name, false, cwd, sz, term, env, ac.proxied)
416 d.mu.Unlock()420 d.mu.Unlock()
417 if err != nil {421 if err != nil {
418 return err422 return err
@@ -478,7 +482,7 @@ func (d *Daemon) createSessionAndSwitchForRole(token attachmentRoleToken, name s
478 cwd, term, env := source.cwd, source.terminal, copyEnvironment(source.env)482 cwd, term, env := source.cwd, source.terminal, copyEnvironment(source.env)
479 source.mu.Unlock()483 source.mu.Unlock()
480 var createErr error484 var createErr error
481- created, createErr = d.createSessionLocked(name, false, cwd, token.ac.size, term, env)485+ created, createErr = d.createSessionLockedWithMode(name, false, cwd, token.ac.size, term, env, token.ac.proxied)
482 return created, createErr486 return created, createErr
483 },487 },
484 })488 })
@@ -500,7 +504,6 @@ func (d *Daemon) createSessionAndSwitchForRole(token attachmentRoleToken, name s
500 }504 }
501 505
502 func (d *Daemon) createTab(sess *session, sz domain.Size) error {506 func (d *Daemon) createTab(sess *session, sz domain.Size) error {
503- tbSize := tabSize(sz)
504 sess.mu.Lock()507 sess.mu.Lock()
505 name := sess.name508 name := sess.name
506 cwd := sess.cwd509 cwd := sess.cwd
@@ -508,6 +511,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
508 term := sess.terminal511 term := sess.terminal
509 env := copyEnvironment(sess.env)512 env := copyEnvironment(sess.env)
510 sess.mu.Unlock()513 sess.mu.Unlock()
514+ tbSize := contentSize(sz, client != nil && client.proxied)
511 tabStableID, paneStableID, err := d.newTabPaneStableIDs()515 tabStableID, paneStableID, err := d.newTabPaneStableIDs()
512 if err != nil {516 if err != nil {
513 return err517 return err
@@ -627,9 +631,19 @@ func (tb *tab) closeAllPanes() {
627 const tabChromeRows = 2631 const tabChromeRows = 2
628 632
629 func tabSize(clientSize domain.Size) domain.Size {633 func tabSize(clientSize domain.Size) domain.Size {
634+ return contentSize(clientSize, false)
635+}
636+
637+// contentSize maps the negotiated viewport to PTY geometry. A proxied client
638+// has already removed its local chrome rows, so the remote daemon must consume
639+// the received size verbatim instead of subtracting chrome a second time.
640+func contentSize(clientSize domain.Size, proxied bool) domain.Size {
630 if !clientSize.Valid() {641 if !clientSize.Valid() {
631 clientSize = defaultSize642 clientSize = defaultSize
632 }643 }
644+ if proxied {
645+ return clientSize
646+ }
633 rows := max(clientSize.Rows-tabChromeRows, 1)647 rows := max(clientSize.Rows-tabChromeRows, 1)
634 return domain.Size{Cols: clientSize.Cols, Rows: rows}648 return domain.Size{Cols: clientSize.Cols, Rows: rows}
635 }649 }
A internal/usecase/daemon/session_meta.go +113 -0
@@ -0,0 +1,113 @@
1+package daemon
2+
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/ports"
7+)
8+
9+// sessionMetaSnapshot captures the authoritative tab order while session.mu
10+// owns every field represented on the wire.
11+func (s *session) sessionMetaSnapshot() (ports.SessionMeta, bool) {
12+ if s == nil {
13+ return ports.SessionMeta{}, false
14+ }
15+ s.mu.Lock()
16+ defer s.mu.Unlock()
17+ if len(s.tabs) == 0 || s.active < 0 || s.active >= len(s.tabs) {
18+ return ports.SessionMeta{}, false
19+ }
20+ meta := ports.SessionMeta{
21+ SessionName: s.name,
22+ Active: uint16(s.active),
23+ Tabs: make([]ports.SessionTabMeta, len(s.tabs)),
24+ }
25+ for i, tb := range s.tabs {
26+ meta.Tabs[i] = ports.SessionTabMeta{
27+ Index: uint16(i),
28+ Name: tb.name,
29+ Attention: tb.attention,
30+ }
31+ }
32+ return meta, true
33+}
34+
35+func frameSessionMeta(meta ports.SessionMeta) (ports.Frame, error) {
36+ payload, err := ports.MarshalSessionMeta(meta)
37+ if err != nil {
38+ return ports.Frame{}, err
39+ }
40+ return ports.Frame{Type: ports.MsgSessionMeta, Payload: payload}, nil
41+}
42+
43+func sameSessionMeta(a, b ports.SessionMeta) bool {
44+ if a.SessionName != b.SessionName || a.Active != b.Active || len(a.Tabs) != len(b.Tabs) {
45+ return false
46+ }
47+ for i := range a.Tabs {
48+ if a.Tabs[i] != b.Tabs[i] {
49+ return false
50+ }
51+ }
52+ return true
53+}
54+
55+func cloneSessionMeta(meta ports.SessionMeta) ports.SessionMeta {
56+ meta.Tabs = append([]ports.SessionTabMeta(nil), meta.Tabs...)
57+ return meta
58+}
59+
60+// sendSessionMetaIfChanged sends metadata before a proxied output frame. The
61+// caller holds ac.sendMu, which serializes this snapshot shadow with every
62+// output write. The shadow is committed only after the transport accepts the
63+// metadata frame.
64+func (ac *attachedClient) sendSessionMetaIfChanged(sess *session, expected transportSnapshot, ticket *roleEffectTicket) error {
65+ if ac == nil || sess == nil || !ac.proxied {
66+ return nil
67+ }
68+ meta, ok := sess.sessionMetaSnapshot()
69+ if !ok {
70+ return errors.New("session metadata is unavailable")
71+ }
72+ if ac.sessionMetaSent && sameSessionMeta(ac.sessionMeta, meta) {
73+ return nil
74+ }
75+ frame, err := frameSessionMeta(meta)
76+ if err != nil {
77+ return err
78+ }
79+ if expected.transport == nil || !ac.transportSnapshotCurrent(expected) {
80+ return errTransportReplaced
81+ }
82+ if ticket != nil && (ticket.ended.Load() || !ticket.beginTransportSend(expected)) {
83+ return errAttachmentTransition
84+ }
85+ send := expected.transport.Send
86+ if async, ok := expected.transport.(ports.AsyncTransport); ok {
87+ send = async.SendAsync
88+ }
89+ err = send(frame)
90+ if ticket != nil {
91+ if err != nil {
92+ ticket.reportTransportFailure(expected)
93+ }
94+ ticket.endTransportSend()
95+ }
96+ if err == nil {
97+ ac.sessionMeta = cloneSessionMeta(meta)
98+ ac.sessionMetaSent = true
99+ }
100+ return err
101+}
102+
103+// markSessionMetaSent records a successfully sent snapshot. It acquires
104+// ac.sendMu itself, so callers must not hold that lock.
105+func (ac *attachedClient) markSessionMetaSent(meta ports.SessionMeta) {
106+ if ac == nil {
107+ return
108+ }
109+ ac.sendMu.Lock()
110+ ac.sessionMeta = cloneSessionMeta(meta)
111+ ac.sessionMetaSent = true
112+ ac.sendMu.Unlock()
113+}
@@ -0,0 +1,113 @@
1+package daemon
2+
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/ports"
7+)
8+
9+// sessionMetaSnapshot captures the authoritative tab order while session.mu
10+// owns every field represented on the wire.
11+func (s *session) sessionMetaSnapshot() (ports.SessionMeta, bool) {
12+ if s == nil {
13+ return ports.SessionMeta{}, false
14+ }
15+ s.mu.Lock()
16+ defer s.mu.Unlock()
17+ if len(s.tabs) == 0 || s.active < 0 || s.active >= len(s.tabs) {
18+ return ports.SessionMeta{}, false
19+ }
20+ meta := ports.SessionMeta{
21+ SessionName: s.name,
22+ Active: uint16(s.active),
23+ Tabs: make([]ports.SessionTabMeta, len(s.tabs)),
24+ }
25+ for i, tb := range s.tabs {
26+ meta.Tabs[i] = ports.SessionTabMeta{
27+ Index: uint16(i),
28+ Name: tb.name,
29+ Attention: tb.attention,
30+ }
31+ }
32+ return meta, true
33+}
34+
35+func frameSessionMeta(meta ports.SessionMeta) (ports.Frame, error) {
36+ payload, err := ports.MarshalSessionMeta(meta)
37+ if err != nil {
38+ return ports.Frame{}, err
39+ }
40+ return ports.Frame{Type: ports.MsgSessionMeta, Payload: payload}, nil
41+}
42+
43+func sameSessionMeta(a, b ports.SessionMeta) bool {
44+ if a.SessionName != b.SessionName || a.Active != b.Active || len(a.Tabs) != len(b.Tabs) {
45+ return false
46+ }
47+ for i := range a.Tabs {
48+ if a.Tabs[i] != b.Tabs[i] {
49+ return false
50+ }
51+ }
52+ return true
53+}
54+
55+func cloneSessionMeta(meta ports.SessionMeta) ports.SessionMeta {
56+ meta.Tabs = append([]ports.SessionTabMeta(nil), meta.Tabs...)
57+ return meta
58+}
59+
60+// sendSessionMetaIfChanged sends metadata before a proxied output frame. The
61+// caller holds ac.sendMu, which serializes this snapshot shadow with every
62+// output write. The shadow is committed only after the transport accepts the
63+// metadata frame.
64+func (ac *attachedClient) sendSessionMetaIfChanged(sess *session, expected transportSnapshot, ticket *roleEffectTicket) error {
65+ if ac == nil || sess == nil || !ac.proxied {
66+ return nil
67+ }
68+ meta, ok := sess.sessionMetaSnapshot()
69+ if !ok {
70+ return errors.New("session metadata is unavailable")
71+ }
72+ if ac.sessionMetaSent && sameSessionMeta(ac.sessionMeta, meta) {
73+ return nil
74+ }
75+ frame, err := frameSessionMeta(meta)
76+ if err != nil {
77+ return err
78+ }
79+ if expected.transport == nil || !ac.transportSnapshotCurrent(expected) {
80+ return errTransportReplaced
81+ }
82+ if ticket != nil && (ticket.ended.Load() || !ticket.beginTransportSend(expected)) {
83+ return errAttachmentTransition
84+ }
85+ send := expected.transport.Send
86+ if async, ok := expected.transport.(ports.AsyncTransport); ok {
87+ send = async.SendAsync
88+ }
89+ err = send(frame)
90+ if ticket != nil {
91+ if err != nil {
92+ ticket.reportTransportFailure(expected)
93+ }
94+ ticket.endTransportSend()
95+ }
96+ if err == nil {
97+ ac.sessionMeta = cloneSessionMeta(meta)
98+ ac.sessionMetaSent = true
99+ }
100+ return err
101+}
102+
103+// markSessionMetaSent records a successfully sent snapshot. It acquires
104+// ac.sendMu itself, so callers must not hold that lock.
105+func (ac *attachedClient) markSessionMetaSent(meta ports.SessionMeta) {
106+ if ac == nil {
107+ return
108+ }
109+ ac.sendMu.Lock()
110+ ac.sessionMeta = cloneSessionMeta(meta)
111+ ac.sessionMetaSent = true
112+ ac.sendMu.Unlock()
113+}
A internal/usecase/daemon/session_meta_test.go +123 -0
@@ -0,0 +1,123 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "io"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+type sessionMetaFailTransport struct{}
13+
14+func (sessionMetaFailTransport) Send(ports.Frame) error { return errors.New("metadata send failed") }
15+func (sessionMetaFailTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
16+func (sessionMetaFailTransport) Close() error { return nil }
17+
18+func TestProxiedMetadataPrecedesChangedOutput(t *testing.T) {
19+ d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
20+ ac.proxied = true
21+
22+ emit := func(title string) {
23+ state := cacheBarState(title, true)
24+ state.attachment = ac
25+ composed := composeFrame(state, ac.pipelineCache, ac.pipelineScratch)
26+ ac.sendMu.Lock()
27+ require.True(t, d.emitFrame(sess, ac, &state, composed))
28+ }
29+
30+ emit("initial")
31+ firstMetaFrame := <-sends
32+ firstOutputFrame := <-sends
33+ require.Equal(t, ports.MsgSessionMeta, firstMetaFrame.Type)
34+ require.Equal(t, ports.MsgOutput, firstOutputFrame.Type)
35+ firstMeta, err := ports.UnmarshalSessionMeta(firstMetaFrame.Payload)
36+ require.NoError(t, err)
37+ require.Equal(t, "work", firstMeta.SessionName)
38+
39+ sess.mu.Lock()
40+ sess.tabs[0].name = "renamed"
41+ sess.mu.Unlock()
42+ emit("changed")
43+ changedMetaFrame := <-sends
44+ changedOutputFrame := <-sends
45+ require.Equal(t, ports.MsgSessionMeta, changedMetaFrame.Type)
46+ require.Equal(t, ports.MsgOutput, changedOutputFrame.Type)
47+ changedMeta, err := ports.UnmarshalSessionMeta(changedMetaFrame.Payload)
48+ require.NoError(t, err)
49+ require.Equal(t, "renamed", changedMeta.Tabs[0].Name)
50+}
51+
52+func TestSessionMetaSnapshotIsImmutable(t *testing.T) {
53+ _, sess, _, _ := newManualSessionWithPTYs(t, newQuietPTY())
54+ meta, ok := sess.sessionMetaSnapshot()
55+ require.True(t, ok)
56+
57+ sess.mu.Lock()
58+ sess.name = "renamed-session"
59+ sess.tabs[0].name = "renamed-tab"
60+ sess.mu.Unlock()
61+
62+ require.Equal(t, "work", meta.SessionName)
63+ require.Equal(t, "", meta.Tabs[0].Name)
64+}
65+
66+func TestSessionMetaShadowUpdatesOnlyAfterSuccessfulSend(t *testing.T) {
67+ _, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
68+ ac.proxied = true
69+ ac.replaceTransport(sessionMetaFailTransport{})
70+
71+ ac.sendMu.Lock()
72+ err := ac.sendSessionMetaIfChanged(sess, ac.transportSnapshot(), nil)
73+ ac.sendMu.Unlock()
74+ require.Error(t, err)
75+ require.False(t, ac.sessionMetaSent)
76+}
77+
78+func TestProxiedAttachedCommandRejectionPreservesRequestID(t *testing.T) {
79+ tests := []struct {
80+ name string
81+ request ports.CommandRequest
82+ }{
83+ {
84+ name: "valid attached command",
85+ request: ports.CommandRequest{
86+ Version: ports.ProtocolVersion, RequestID: 41, Attached: true, Slug: "new-tab",
87+ },
88+ },
89+ {
90+ name: "target override is rejected",
91+ request: ports.CommandRequest{
92+ Version: ports.ProtocolVersion, RequestID: 42, Attached: true, Slug: "new-tab", TargetSession: "other",
93+ },
94+ },
95+ {
96+ name: "missing attached flag is rejected",
97+ request: ports.CommandRequest{
98+ Version: ports.ProtocolVersion, RequestID: 43, Slug: "new-tab",
99+ },
100+ },
101+ }
102+ for _, tt := range tests {
103+ t.Run(tt.name, func(t *testing.T) {
104+ d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
105+ ac.proxied = true
106+ d.attachCoordinator(sess, nil, ac, true)
107+ payload, err := ports.MarshalCommandRequest(tt.request)
108+ require.NoError(t, err)
109+
110+ token := sess.attachmentToken(ac, ac.transport())
111+ require.False(t, d.handleActiveClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload}))
112+
113+ reply := <-sends
114+ require.Equal(t, ports.MsgCommandResult, reply.Type)
115+ result, err := ports.UnmarshalCommandResult(reply.Payload)
116+ require.NoError(t, err)
117+ require.Equal(t, tt.request.RequestID, result.RequestID)
118+ require.False(t, result.OK)
119+ require.Equal(t, ports.ErrNotScriptable, result.Code)
120+ require.Equal(t, "attached command relay is not enabled", result.Text)
121+ })
122+ }
123+}
@@ -0,0 +1,123 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "io"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+type sessionMetaFailTransport struct{}
13+
14+func (sessionMetaFailTransport) Send(ports.Frame) error { return errors.New("metadata send failed") }
15+func (sessionMetaFailTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
16+func (sessionMetaFailTransport) Close() error { return nil }
17+
18+func TestProxiedMetadataPrecedesChangedOutput(t *testing.T) {
19+ d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
20+ ac.proxied = true
21+
22+ emit := func(title string) {
23+ state := cacheBarState(title, true)
24+ state.attachment = ac
25+ composed := composeFrame(state, ac.pipelineCache, ac.pipelineScratch)
26+ ac.sendMu.Lock()
27+ require.True(t, d.emitFrame(sess, ac, &state, composed))
28+ }
29+
30+ emit("initial")
31+ firstMetaFrame := <-sends
32+ firstOutputFrame := <-sends
33+ require.Equal(t, ports.MsgSessionMeta, firstMetaFrame.Type)
34+ require.Equal(t, ports.MsgOutput, firstOutputFrame.Type)
35+ firstMeta, err := ports.UnmarshalSessionMeta(firstMetaFrame.Payload)
36+ require.NoError(t, err)
37+ require.Equal(t, "work", firstMeta.SessionName)
38+
39+ sess.mu.Lock()
40+ sess.tabs[0].name = "renamed"
41+ sess.mu.Unlock()
42+ emit("changed")
43+ changedMetaFrame := <-sends
44+ changedOutputFrame := <-sends
45+ require.Equal(t, ports.MsgSessionMeta, changedMetaFrame.Type)
46+ require.Equal(t, ports.MsgOutput, changedOutputFrame.Type)
47+ changedMeta, err := ports.UnmarshalSessionMeta(changedMetaFrame.Payload)
48+ require.NoError(t, err)
49+ require.Equal(t, "renamed", changedMeta.Tabs[0].Name)
50+}
51+
52+func TestSessionMetaSnapshotIsImmutable(t *testing.T) {
53+ _, sess, _, _ := newManualSessionWithPTYs(t, newQuietPTY())
54+ meta, ok := sess.sessionMetaSnapshot()
55+ require.True(t, ok)
56+
57+ sess.mu.Lock()
58+ sess.name = "renamed-session"
59+ sess.tabs[0].name = "renamed-tab"
60+ sess.mu.Unlock()
61+
62+ require.Equal(t, "work", meta.SessionName)
63+ require.Equal(t, "", meta.Tabs[0].Name)
64+}
65+
66+func TestSessionMetaShadowUpdatesOnlyAfterSuccessfulSend(t *testing.T) {
67+ _, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
68+ ac.proxied = true
69+ ac.replaceTransport(sessionMetaFailTransport{})
70+
71+ ac.sendMu.Lock()
72+ err := ac.sendSessionMetaIfChanged(sess, ac.transportSnapshot(), nil)
73+ ac.sendMu.Unlock()
74+ require.Error(t, err)
75+ require.False(t, ac.sessionMetaSent)
76+}
77+
78+func TestProxiedAttachedCommandRejectionPreservesRequestID(t *testing.T) {
79+ tests := []struct {
80+ name string
81+ request ports.CommandRequest
82+ }{
83+ {
84+ name: "valid attached command",
85+ request: ports.CommandRequest{
86+ Version: ports.ProtocolVersion, RequestID: 41, Attached: true, Slug: "new-tab",
87+ },
88+ },
89+ {
90+ name: "target override is rejected",
91+ request: ports.CommandRequest{
92+ Version: ports.ProtocolVersion, RequestID: 42, Attached: true, Slug: "new-tab", TargetSession: "other",
93+ },
94+ },
95+ {
96+ name: "missing attached flag is rejected",
97+ request: ports.CommandRequest{
98+ Version: ports.ProtocolVersion, RequestID: 43, Slug: "new-tab",
99+ },
100+ },
101+ }
102+ for _, tt := range tests {
103+ t.Run(tt.name, func(t *testing.T) {
104+ d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
105+ ac.proxied = true
106+ d.attachCoordinator(sess, nil, ac, true)
107+ payload, err := ports.MarshalCommandRequest(tt.request)
108+ require.NoError(t, err)
109+
110+ token := sess.attachmentToken(ac, ac.transport())
111+ require.False(t, d.handleActiveClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload}))
112+
113+ reply := <-sends
114+ require.Equal(t, ports.MsgCommandResult, reply.Type)
115+ result, err := ports.UnmarshalCommandResult(reply.Payload)
116+ require.NoError(t, err)
117+ require.Equal(t, tt.request.RequestID, result.RequestID)
118+ require.False(t, result.OK)
119+ require.Equal(t, ports.ErrNotScriptable, result.Code)
120+ require.Equal(t, "attached command relay is not enabled", result.Text)
121+ })
122+ }
123+}
M internal/usecase/daemon/transactional_resize.go +6 -2
@@ -538,13 +538,17 @@ func (d *Daemon) releasePreparedSessionGates(plans []*preparedTabLayout) {
538538 // coordinator admits the epoch before any tab size, rectangle, VT screen,
539539 // snapshot dirtiness, or resize telemetry becomes visible.
540540 func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, admit func() bool) ([]resizeMember, bool) {
541+ return d.applySessionLayoutWithMode(sess, size, false, current, admit)
542+}
543+
544+func (d *Daemon) applySessionLayoutWithMode(sess *session, size domain.Size, proxied bool, current, admit func() bool) ([]resizeMember, bool) {
541545 if sess == nil {
542546 return nil, false
543547 }
544548 sess.layoutApplyMu.Lock()
545549 defer sess.layoutApplyMu.Unlock()
546550
547- target := tabSize(size)
551+ target := contentSize(size, proxied)
548552 for {
549553 if sess.ctx != nil && sess.ctx.Err() != nil {
550554 return nil, false
@@ -661,7 +665,7 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *
661665 return false
662666 }
663667 d.exitCopyMode(ac)
664- failed, ok := d.applySessionLayout(sess, snap.size, current, func() bool {
668+ failed, ok := d.applySessionLayoutWithMode(sess, snap.size, ac.proxied, current, func() bool {
665669 return rc.resizeCurrentForLease(epoch, ac, lease, true)
666670 })
667671 if !ok {
@@ -538,13 +538,17 @@ func (d *Daemon) releasePreparedSessionGates(plans []*preparedTabLayout) {
538 // coordinator admits the epoch before any tab size, rectangle, VT screen,538 // coordinator admits the epoch before any tab size, rectangle, VT screen,
539 // snapshot dirtiness, or resize telemetry becomes visible.539 // snapshot dirtiness, or resize telemetry becomes visible.
540 func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, admit func() bool) ([]resizeMember, bool) {540 func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, admit func() bool) ([]resizeMember, bool) {
541+ return d.applySessionLayoutWithMode(sess, size, false, current, admit)
542+}
543+
544+func (d *Daemon) applySessionLayoutWithMode(sess *session, size domain.Size, proxied bool, current, admit func() bool) ([]resizeMember, bool) {
541 if sess == nil {545 if sess == nil {
542 return nil, false546 return nil, false
543 }547 }
544 sess.layoutApplyMu.Lock()548 sess.layoutApplyMu.Lock()
545 defer sess.layoutApplyMu.Unlock()549 defer sess.layoutApplyMu.Unlock()
546 550
547- target := tabSize(size)551+ target := contentSize(size, proxied)
548 for {552 for {
549 if sess.ctx != nil && sess.ctx.Err() != nil {553 if sess.ctx != nil && sess.ctx.Err() != nil {
550 return nil, false554 return nil, false
@@ -661,7 +665,7 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *
661 return false665 return false
662 }666 }
663 d.exitCopyMode(ac)667 d.exitCopyMode(ac)
664- failed, ok := d.applySessionLayout(sess, snap.size, current, func() bool {668+ failed, ok := d.applySessionLayoutWithMode(sess, snap.size, ac.proxied, current, func() bool {
665 return rc.resizeCurrentForLease(epoch, ac, lease, true)669 return rc.resizeCurrentForLease(epoch, ac, lease, true)
666 })670 })
667 if !ok {671 if !ok {
M internal/usecase/daemon/wire.go +5 -1
@@ -6,11 +6,15 @@ import (
66 )
77
88 func frameWelcome(s *session, ac *attachedClient) ports.Frame {
9+ capabilities := ports.CapabilityResume
10+ if ac.proxied {
11+ capabilities |= ports.CapabilityProxied
12+ }
913 w := ports.Welcome{
1014 SessionID: string(s.id),
1115 SessionName: s.name,
1216 Ephemeral: s.ephemeral,
13- Capabilities: ports.CapabilityResume,
17+ Capabilities: capabilities,
1418 ResumeToken: ac.resumeToken,
1519 }
1620 return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)}
@@ -6,11 +6,15 @@ import (
6 )6 )
7 7
8 func frameWelcome(s *session, ac *attachedClient) ports.Frame {8 func frameWelcome(s *session, ac *attachedClient) ports.Frame {
9+ capabilities := ports.CapabilityResume
10+ if ac.proxied {
11+ capabilities |= ports.CapabilityProxied
12+ }
9 w := ports.Welcome{13 w := ports.Welcome{
10 SessionID: string(s.id),14 SessionID: string(s.id),
11 SessionName: s.name,15 SessionName: s.name,
12 Ephemeral: s.ephemeral,16 Ephemeral: s.ephemeral,
13- Capabilities: ports.CapabilityResume,17+ Capabilities: capabilities,
14 ResumeToken: ac.resumeToken,18 ResumeToken: ac.resumeToken,
15 }19 }
16 return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)}20 return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)}

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