detached 4e32793

feat(client): scope attachment render state

commit
4e32793d263f5e024ec56a70ffbcf977f102b98a
author
brice · · 2026-08-03 08:04 UTC
committer
brice · · 2026-08-04 03:19 UTC
parent
4c3ccc7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +353 -86
M internal/usecase/client/client.go +52 -28
@@ -832,6 +832,8 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
832832 clip = clipboard
833833 }
834834 awaitingReconnectReset := false
835+ outputState := outputApplyState{}
836+ outputResetRequested := false
835837 if reconnect.showing {
836838 if reconnect.remote {
837839 // Before the asynchronous sender starts, preserve transport ordering by
@@ -990,19 +992,25 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
990992 if derr != nil {
991993 return welcomedResult(fmt.Errorf("vev: decoding output: %w", derr))
992994 }
993- // Dismissing or changing a local overlay requests a same-size resize,
994- // whose stateful base-zero Output is the daemon's authoritative reset.
995- // ACK intervening increments so the output window keeps moving, but do
996- // not apply diffs based on cells hidden by the client-local overlay.
997- resetFrame := isReconnectResetFrame(o)
998- if awaitingReconnectReset && !resetFrame && o.NewStateNum != 0 {
999- ackQueue.offer(o.NewStateNum)
995+ nextState, accepted := outputState.next(o)
996+ if !accepted {
997+ // A discarded frame was never written and must never be ACKed.
998+ // Ask the daemon for one authoritative full reset, coalescing
999+ // repeated gaps while that reset is in flight.
1000+ if !outputResetRequested {
1001+ select {
1002+ case sendCh <- ports.Frame{Type: ports.MsgOutputResetRequest, Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{})}:
1003+ outputResetRequested = true
1004+ case <-loopCtx.Done():
1005+ return loopCanceledResult()
1006+ }
1007+ }
10001008 continue
10011009 }
10021010 if _, werr := term.Out().Write(o.Data); werr != nil {
10031011 return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr))
10041012 }
1005- if awaitingReconnectReset && resetFrame {
1013+ if awaitingReconnectReset && o.Full && o.Base == 0 && o.New != 0 {
10061014 awaitingReconnectReset = false
10071015 }
10081016 if reconnect.showing && reconnect.remote {
@@ -1016,13 +1024,15 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
10161024 } else if ferr := term.Flush(); ferr != nil {
10171025 return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr))
10181026 }
1027+ outputState = nextState
1028+ if o.New != 0 {
1029+ ackQueue.offer(o.Epoch, o.New)
1030+ outputResetRequested = false
1031+ }
10191032 // The terminal boundary is after a successful flush and before ACK.
10201033 if observer != nil {
10211034 observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeTerminalFlushed, uint64(len(o.Data)), true))
10221035 }
1023- if o.NewStateNum != 0 {
1024- ackQueue.offer(o.NewStateNum)
1025- }
10261036 if !ms.firstOutput {
10271037 ms.firstOutput = true
10281038 log.Debug("received first output")
@@ -1049,13 +1059,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
10491059 }
10501060 }
10511061
1052-// isReconnectResetFrame identifies the authoritative stateful reset emitted
1053-// by the daemon's existing resize path. Stateless side effects can also have a
1054-// zero base, so they must not dismiss or reconcile a client-local overlay.
1055-func isReconnectResetFrame(o ports.Output) bool {
1056- return o.BaseStateNum == 0 && o.NewStateNum != 0
1057-}
1058-
10591062 // recvResult carries one framed message (or a read error) from the receive
10601063 // pump to the main loop.
10611064 type recvResult struct {
@@ -1084,27 +1087,48 @@ func runRecv(ctx context.Context, transport ports.Transport, out chan<- recvResu
10841087 // resize pumps through one goroutine keeps the transport's single-writer
10851088 // contract intact. A send failure cancels the loop and is surfaced once.
10861089 type cumulativeAckQueue struct {
1087- latest atomic.Uint64
1088- wake chan struct{}
1090+ mu sync.Mutex
1091+ latestEpoch uint64
1092+ latestState uint64
1093+ wake chan struct{}
10891094 }
10901095
10911096 func newCumulativeAckQueue() *cumulativeAckQueue {
10921097 return &cumulativeAckQueue{wake: make(chan struct{}, 1)}
10931098 }
10941099
1095-func (q *cumulativeAckQueue) offer(state uint64) {
1096- for {
1097- current := q.latest.Load()
1098- if state <= current || q.latest.CompareAndSwap(current, state) {
1099- break
1100- }
1100+func (q *cumulativeAckQueue) offer(values ...uint64) {
1101+ epoch, state := uint64(1), uint64(0)
1102+ switch len(values) {
1103+ case 1:
1104+ state = values[0]
1105+ case 2:
1106+ epoch, state = values[0], values[1]
1107+ default:
1108+ return
1109+ }
1110+ if epoch == 0 || state == 0 {
1111+ return
1112+ }
1113+ q.mu.Lock()
1114+ if epoch > q.latestEpoch || (epoch == q.latestEpoch && state > q.latestState) {
1115+ q.latestEpoch, q.latestState = epoch, state
11011116 }
1117+ q.mu.Unlock()
11021118 select {
11031119 case q.wake <- struct{}{}:
11041120 default:
11051121 }
11061122 }
11071123
1124+func (q *cumulativeAckQueue) take() (uint64, uint64) {
1125+ q.mu.Lock()
1126+ defer q.mu.Unlock()
1127+ epoch, state := q.latestEpoch, q.latestState
1128+ q.latestEpoch, q.latestState = 0, 0
1129+ return epoch, state
1130+}
1131+
11081132 func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.Transport, in <-chan ports.Frame, acks *cumulativeAckQueue, errCh chan<- error, log *slog.Logger) {
11091133 defer log.Debug("sender pump exited")
11101134 send := func(f ports.Frame) bool {
@@ -1119,8 +1143,8 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
11191143 return true
11201144 }
11211145 sendAck := func() bool {
1122- state := acks.latest.Swap(0)
1123- return state == 0 || send(ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: state})})
1146+ epoch, state := acks.take()
1147+ return state == 0 || send(ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{Epoch: epoch, State: state})})
11241148 }
11251149 for {
11261150 select {
@@ -832,6 +832,8 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
832 clip = clipboard832 clip = clipboard
833 }833 }
834 awaitingReconnectReset := false834 awaitingReconnectReset := false
835+ outputState := outputApplyState{}
836+ outputResetRequested := false
835 if reconnect.showing {837 if reconnect.showing {
836 if reconnect.remote {838 if reconnect.remote {
837 // Before the asynchronous sender starts, preserve transport ordering by839 // Before the asynchronous sender starts, preserve transport ordering by
@@ -990,19 +992,25 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
990 if derr != nil {992 if derr != nil {
991 return welcomedResult(fmt.Errorf("vev: decoding output: %w", derr))993 return welcomedResult(fmt.Errorf("vev: decoding output: %w", derr))
992 }994 }
993- // Dismissing or changing a local overlay requests a same-size resize,995+ nextState, accepted := outputState.next(o)
994- // whose stateful base-zero Output is the daemon's authoritative reset.996+ if !accepted {
995- // ACK intervening increments so the output window keeps moving, but do997+ // A discarded frame was never written and must never be ACKed.
996- // not apply diffs based on cells hidden by the client-local overlay.998+ // Ask the daemon for one authoritative full reset, coalescing
997- resetFrame := isReconnectResetFrame(o)999+ // repeated gaps while that reset is in flight.
998- if awaitingReconnectReset && !resetFrame && o.NewStateNum != 0 {1000+ if !outputResetRequested {
999- ackQueue.offer(o.NewStateNum)1001+ select {
1002+ case sendCh <- ports.Frame{Type: ports.MsgOutputResetRequest, Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{})}:
1003+ outputResetRequested = true
1004+ case <-loopCtx.Done():
1005+ return loopCanceledResult()
1006+ }
1007+ }
1000 continue1008 continue
1001 }1009 }
1002 if _, werr := term.Out().Write(o.Data); werr != nil {1010 if _, werr := term.Out().Write(o.Data); werr != nil {
1003 return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr))1011 return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr))
1004 }1012 }
1005- if awaitingReconnectReset && resetFrame {1013+ if awaitingReconnectReset && o.Full && o.Base == 0 && o.New != 0 {
1006 awaitingReconnectReset = false1014 awaitingReconnectReset = false
1007 }1015 }
1008 if reconnect.showing && reconnect.remote {1016 if reconnect.showing && reconnect.remote {
@@ -1016,13 +1024,15 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1016 } else if ferr := term.Flush(); ferr != nil {1024 } else if ferr := term.Flush(); ferr != nil {
1017 return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr))1025 return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr))
1018 }1026 }
1027+ outputState = nextState
1028+ if o.New != 0 {
1029+ ackQueue.offer(o.Epoch, o.New)
1030+ outputResetRequested = false
1031+ }
1019 // The terminal boundary is after a successful flush and before ACK.1032 // The terminal boundary is after a successful flush and before ACK.
1020 if observer != nil {1033 if observer != nil {
1021 observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeTerminalFlushed, uint64(len(o.Data)), true))1034 observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeTerminalFlushed, uint64(len(o.Data)), true))
1022 }1035 }
1023- if o.NewStateNum != 0 {
1024- ackQueue.offer(o.NewStateNum)
1025- }
1026 if !ms.firstOutput {1036 if !ms.firstOutput {
1027 ms.firstOutput = true1037 ms.firstOutput = true
1028 log.Debug("received first output")1038 log.Debug("received first output")
@@ -1049,13 +1059,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1049 }1059 }
1050 }1060 }
1051 1061
1052-// isReconnectResetFrame identifies the authoritative stateful reset emitted
1053-// by the daemon's existing resize path. Stateless side effects can also have a
1054-// zero base, so they must not dismiss or reconcile a client-local overlay.
1055-func isReconnectResetFrame(o ports.Output) bool {
1056- return o.BaseStateNum == 0 && o.NewStateNum != 0
1057-}
1058-
1059 // recvResult carries one framed message (or a read error) from the receive1062 // recvResult carries one framed message (or a read error) from the receive
1060 // pump to the main loop.1063 // pump to the main loop.
1061 type recvResult struct {1064 type recvResult struct {
@@ -1084,27 +1087,48 @@ func runRecv(ctx context.Context, transport ports.Transport, out chan<- recvResu
1084 // resize pumps through one goroutine keeps the transport's single-writer1087 // resize pumps through one goroutine keeps the transport's single-writer
1085 // contract intact. A send failure cancels the loop and is surfaced once.1088 // contract intact. A send failure cancels the loop and is surfaced once.
1086 type cumulativeAckQueue struct {1089 type cumulativeAckQueue struct {
1087- latest atomic.Uint641090+ mu sync.Mutex
1088- wake chan struct{}1091+ latestEpoch uint64
1092+ latestState uint64
1093+ wake chan struct{}
1089 }1094 }
1090 1095
1091 func newCumulativeAckQueue() *cumulativeAckQueue {1096 func newCumulativeAckQueue() *cumulativeAckQueue {
1092 return &cumulativeAckQueue{wake: make(chan struct{}, 1)}1097 return &cumulativeAckQueue{wake: make(chan struct{}, 1)}
1093 }1098 }
1094 1099
1095-func (q *cumulativeAckQueue) offer(state uint64) {1100+func (q *cumulativeAckQueue) offer(values ...uint64) {
1096- for {1101+ epoch, state := uint64(1), uint64(0)
1097- current := q.latest.Load()1102+ switch len(values) {
1098- if state <= current || q.latest.CompareAndSwap(current, state) {1103+ case 1:
1099- break1104+ state = values[0]
1100- }1105+ case 2:
1106+ epoch, state = values[0], values[1]
1107+ default:
1108+ return
1109+ }
1110+ if epoch == 0 || state == 0 {
1111+ return
1112+ }
1113+ q.mu.Lock()
1114+ if epoch > q.latestEpoch || (epoch == q.latestEpoch && state > q.latestState) {
1115+ q.latestEpoch, q.latestState = epoch, state
1101 }1116 }
1117+ q.mu.Unlock()
1102 select {1118 select {
1103 case q.wake <- struct{}{}:1119 case q.wake <- struct{}{}:
1104 default:1120 default:
1105 }1121 }
1106 }1122 }
1107 1123
1124+func (q *cumulativeAckQueue) take() (uint64, uint64) {
1125+ q.mu.Lock()
1126+ defer q.mu.Unlock()
1127+ epoch, state := q.latestEpoch, q.latestState
1128+ q.latestEpoch, q.latestState = 0, 0
1129+ return epoch, state
1130+}
1131+
1108 func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.Transport, in <-chan ports.Frame, acks *cumulativeAckQueue, errCh chan<- error, log *slog.Logger) {1132 func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.Transport, in <-chan ports.Frame, acks *cumulativeAckQueue, errCh chan<- error, log *slog.Logger) {
1109 defer log.Debug("sender pump exited")1133 defer log.Debug("sender pump exited")
1110 send := func(f ports.Frame) bool {1134 send := func(f ports.Frame) bool {
@@ -1119,8 +1143,8 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
1119 return true1143 return true
1120 }1144 }
1121 sendAck := func() bool {1145 sendAck := func() bool {
1122- state := acks.latest.Swap(0)1146+ epoch, state := acks.take()
1123- return state == 0 || send(ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: state})})1147+ return state == 0 || send(ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{Epoch: epoch, State: state})})
1124 }1148 }
1125 for {1149 for {
1126 select {1150 select {
A internal/usecase/client/output_state.go +50 -0
@@ -0,0 +1,50 @@
1+package client
2+
3+import "github.com/bnema/vev/internal/ports"
4+
5+// outputApplyState is the client-side dependency chain for one attachment.
6+// State-bearing frames are accepted only when their epoch, base, and view
7+// revision continue the last frame; a new epoch must carry a full reset.
8+type outputApplyState struct {
9+ epoch uint64
10+ state uint64
11+ viewRevision uint64
12+ initialized bool
13+}
14+
15+func (s outputApplyState) next(output ports.Output) (outputApplyState, bool) {
16+ if output.Epoch == 0 {
17+ return outputApplyState{}, false
18+ }
19+ if !s.initialized {
20+ if output.New == 0 {
21+ return s, true
22+ }
23+ if output.Base != 0 || !output.Full {
24+ return outputApplyState{}, false
25+ }
26+ return outputApplyState{epoch: output.Epoch, state: output.New, viewRevision: output.ViewRevision, initialized: true}, true
27+ }
28+ if output.Epoch < s.epoch {
29+ return outputApplyState{}, false
30+ }
31+ if output.Epoch == s.epoch {
32+ if output.ViewRevision != s.viewRevision {
33+ return outputApplyState{}, false
34+ }
35+ if output.New == 0 {
36+ if output.Base != 0 || output.Full {
37+ return outputApplyState{}, false
38+ }
39+ return s, true
40+ }
41+ if output.Full || output.Base != s.state || output.New != output.Base+1 {
42+ return outputApplyState{}, false
43+ }
44+ return outputApplyState{epoch: s.epoch, state: output.New, viewRevision: s.viewRevision, initialized: true}, true
45+ }
46+ if !output.Full || output.Base != 0 {
47+ return outputApplyState{}, false
48+ }
49+ return outputApplyState{epoch: output.Epoch, state: output.New, viewRevision: output.ViewRevision, initialized: true}, true
50+}
@@ -0,0 +1,50 @@
1+package client
2+
3+import "github.com/bnema/vev/internal/ports"
4+
5+// outputApplyState is the client-side dependency chain for one attachment.
6+// State-bearing frames are accepted only when their epoch, base, and view
7+// revision continue the last frame; a new epoch must carry a full reset.
8+type outputApplyState struct {
9+ epoch uint64
10+ state uint64
11+ viewRevision uint64
12+ initialized bool
13+}
14+
15+func (s outputApplyState) next(output ports.Output) (outputApplyState, bool) {
16+ if output.Epoch == 0 {
17+ return outputApplyState{}, false
18+ }
19+ if !s.initialized {
20+ if output.New == 0 {
21+ return s, true
22+ }
23+ if output.Base != 0 || !output.Full {
24+ return outputApplyState{}, false
25+ }
26+ return outputApplyState{epoch: output.Epoch, state: output.New, viewRevision: output.ViewRevision, initialized: true}, true
27+ }
28+ if output.Epoch < s.epoch {
29+ return outputApplyState{}, false
30+ }
31+ if output.Epoch == s.epoch {
32+ if output.ViewRevision != s.viewRevision {
33+ return outputApplyState{}, false
34+ }
35+ if output.New == 0 {
36+ if output.Base != 0 || output.Full {
37+ return outputApplyState{}, false
38+ }
39+ return s, true
40+ }
41+ if output.Full || output.Base != s.state || output.New != output.Base+1 {
42+ return outputApplyState{}, false
43+ }
44+ return outputApplyState{epoch: s.epoch, state: output.New, viewRevision: s.viewRevision, initialized: true}, true
45+ }
46+ if !output.Full || output.Base != 0 {
47+ return outputApplyState{}, false
48+ }
49+ return outputApplyState{epoch: output.Epoch, state: output.New, viewRevision: output.ViewRevision, initialized: true}, true
50+}
A internal/usecase/client/output_state_test.go +37 -0
@@ -0,0 +1,37 @@
1+package client
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
8+)
9+
10+func TestOutputApplyStateTransitions(t *testing.T) {
11+ full := ports.Output{Epoch: 1, Base: 0, New: 1, Full: true, ViewRevision: 2, Size: domain.Size{Cols: 80, Rows: 24}}
12+ tests := []struct {
13+ name string
14+ state outputApplyState
15+ output ports.Output
16+ accepted bool
17+ want outputApplyState
18+ }{
19+ {name: "first full", output: full, accepted: true, want: outputApplyState{epoch: 1, state: 1, viewRevision: 2, initialized: true}},
20+ {name: "first gap", output: ports.Output{Epoch: 1, Base: 1, New: 2, ViewRevision: 2}, accepted: false},
21+ {name: "increment", state: outputApplyState{epoch: 1, state: 1, viewRevision: 2, initialized: true}, output: ports.Output{Epoch: 1, Base: 1, New: 2, ViewRevision: 2}, accepted: true, want: outputApplyState{epoch: 1, state: 2, viewRevision: 2, initialized: true}},
22+ {name: "base gap", state: outputApplyState{epoch: 1, state: 1, viewRevision: 2, initialized: true}, output: ports.Output{Epoch: 1, Base: 0, New: 2, Full: true, ViewRevision: 2}, accepted: false},
23+ {name: "revision gap", state: outputApplyState{epoch: 1, state: 1, viewRevision: 2, initialized: true}, output: ports.Output{Epoch: 1, Base: 1, New: 2, ViewRevision: 3}, accepted: false},
24+ {name: "new epoch reset", state: outputApplyState{epoch: 1, state: 1, viewRevision: 2, initialized: true}, output: ports.Output{Epoch: 2, Base: 0, New: 1, Full: true, ViewRevision: 3}, accepted: true, want: outputApplyState{epoch: 2, state: 1, viewRevision: 3, initialized: true}},
25+ }
26+ for _, tt := range tests {
27+ t.Run(tt.name, func(t *testing.T) {
28+ got, accepted := tt.state.next(tt.output)
29+ if accepted != tt.accepted {
30+ t.Fatalf("accepted = %v, want %v", accepted, tt.accepted)
31+ }
32+ if accepted && got != tt.want {
33+ t.Fatalf("state = %+v, want %+v", got, tt.want)
34+ }
35+ })
36+ }
37+}
@@ -0,0 +1,37 @@
1+package client
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
8+)
9+
10+func TestOutputApplyStateTransitions(t *testing.T) {
11+ full := ports.Output{Epoch: 1, Base: 0, New: 1, Full: true, ViewRevision: 2, Size: domain.Size{Cols: 80, Rows: 24}}
12+ tests := []struct {
13+ name string
14+ state outputApplyState
15+ output ports.Output
16+ accepted bool
17+ want outputApplyState
18+ }{
19+ {name: "first full", output: full, accepted: true, want: outputApplyState{epoch: 1, state: 1, viewRevision: 2, initialized: true}},
20+ {name: "first gap", output: ports.Output{Epoch: 1, Base: 1, New: 2, ViewRevision: 2}, accepted: false},
21+ {name: "increment", state: outputApplyState{epoch: 1, state: 1, viewRevision: 2, initialized: true}, output: ports.Output{Epoch: 1, Base: 1, New: 2, ViewRevision: 2}, accepted: true, want: outputApplyState{epoch: 1, state: 2, viewRevision: 2, initialized: true}},
22+ {name: "base gap", state: outputApplyState{epoch: 1, state: 1, viewRevision: 2, initialized: true}, output: ports.Output{Epoch: 1, Base: 0, New: 2, Full: true, ViewRevision: 2}, accepted: false},
23+ {name: "revision gap", state: outputApplyState{epoch: 1, state: 1, viewRevision: 2, initialized: true}, output: ports.Output{Epoch: 1, Base: 1, New: 2, ViewRevision: 3}, accepted: false},
24+ {name: "new epoch reset", state: outputApplyState{epoch: 1, state: 1, viewRevision: 2, initialized: true}, output: ports.Output{Epoch: 2, Base: 0, New: 1, Full: true, ViewRevision: 3}, accepted: true, want: outputApplyState{epoch: 2, state: 1, viewRevision: 3, initialized: true}},
25+ }
26+ for _, tt := range tests {
27+ t.Run(tt.name, func(t *testing.T) {
28+ got, accepted := tt.state.next(tt.output)
29+ if accepted != tt.accepted {
30+ t.Fatalf("accepted = %v, want %v", accepted, tt.accepted)
31+ }
32+ if accepted && got != tt.want {
33+ t.Fatalf("state = %+v, want %+v", got, tt.want)
34+ }
35+ })
36+ }
37+}
M internal/usecase/daemon/attachment_registry.go +5 -3
@@ -17,6 +17,7 @@ type attachmentView struct {
1717 paneID domain.PaneStableID
1818 windowTop int
1919 windowRows int
20+ windowSet bool
2021 bookmark vt.RowID
2122 liveBottom bool
2223 revision uint64
@@ -241,6 +242,7 @@ func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachment
241242 view.paneID = ""
242243 view.windowTop = 0
243244 view.windowRows = 0
245+ view.windowSet = false
244246 view.bookmark = 0
245247 view.liveBottom = true
246248 return view
@@ -293,7 +295,7 @@ func (s *session) repairAttachmentView(ac *attachedClient) bool {
293295 }
294296 before := ac.viewSnapshot()
295297 after := s.repairAttachmentViewLocked(ac, before)
296- if before.tabID == after.tabID && before.paneID == after.paneID && before.windowTop == after.windowTop && before.windowRows == after.windowRows && before.bookmark == after.bookmark && before.liveBottom == after.liveBottom {
298+ if before.tabID == after.tabID && before.paneID == after.paneID && before.windowTop == after.windowTop && before.windowRows == after.windowRows && before.windowSet == after.windowSet && before.bookmark == after.bookmark && before.liveBottom == after.liveBottom {
297299 return false
298300 }
299301 after.revision++
@@ -369,7 +371,7 @@ func (s *session) tabForAttachment(ac *attachedClient) *tab {
369371 }
370372 view := ac.viewSnapshot()
371373 repaired := s.repairAttachmentViewLocked(ac, view)
372- if repaired.tabID != view.tabID || repaired.paneID != view.paneID || repaired.windowTop != view.windowTop || repaired.windowRows != view.windowRows || repaired.bookmark != view.bookmark || repaired.liveBottom != view.liveBottom {
374+ if repaired.tabID != view.tabID || repaired.paneID != view.paneID || repaired.windowTop != view.windowTop || repaired.windowRows != view.windowRows || repaired.windowSet != view.windowSet || repaired.bookmark != view.bookmark || repaired.liveBottom != view.liveBottom {
373375 repaired.revision++
374376 ac.publishView(repaired)
375377 }
@@ -476,7 +478,7 @@ func (s *session) updateAttachmentView(ac *attachedClient, update func(*attachme
476478 next = s.repairAttachmentViewLocked(ac, next)
477479 next.revision++
478480 ac.publishView(next)
479- changed = next.tabID != before.tabID || next.paneID != before.paneID || next.windowTop != before.windowTop || next.windowRows != before.windowRows || next.bookmark != before.bookmark || next.liveBottom != before.liveBottom
481+ changed = next.tabID != before.tabID || next.paneID != before.paneID || next.windowTop != before.windowTop || next.windowRows != before.windowRows || next.windowSet != before.windowSet || next.bookmark != before.bookmark || next.liveBottom != before.liveBottom
480482 return nil
481483 })
482484 return changed
@@ -17,6 +17,7 @@ type attachmentView struct {
17 paneID domain.PaneStableID17 paneID domain.PaneStableID
18 windowTop int18 windowTop int
19 windowRows int19 windowRows int
20+ windowSet bool
20 bookmark vt.RowID21 bookmark vt.RowID
21 liveBottom bool22 liveBottom bool
22 revision uint6423 revision uint64
@@ -241,6 +242,7 @@ func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachment
241 view.paneID = ""242 view.paneID = ""
242 view.windowTop = 0243 view.windowTop = 0
243 view.windowRows = 0244 view.windowRows = 0
245+ view.windowSet = false
244 view.bookmark = 0246 view.bookmark = 0
245 view.liveBottom = true247 view.liveBottom = true
246 return view248 return view
@@ -293,7 +295,7 @@ func (s *session) repairAttachmentView(ac *attachedClient) bool {
293 }295 }
294 before := ac.viewSnapshot()296 before := ac.viewSnapshot()
295 after := s.repairAttachmentViewLocked(ac, before)297 after := s.repairAttachmentViewLocked(ac, before)
296- if before.tabID == after.tabID && before.paneID == after.paneID && before.windowTop == after.windowTop && before.windowRows == after.windowRows && before.bookmark == after.bookmark && before.liveBottom == after.liveBottom {298+ if before.tabID == after.tabID && before.paneID == after.paneID && before.windowTop == after.windowTop && before.windowRows == after.windowRows && before.windowSet == after.windowSet && before.bookmark == after.bookmark && before.liveBottom == after.liveBottom {
297 return false299 return false
298 }300 }
299 after.revision++301 after.revision++
@@ -369,7 +371,7 @@ func (s *session) tabForAttachment(ac *attachedClient) *tab {
369 }371 }
370 view := ac.viewSnapshot()372 view := ac.viewSnapshot()
371 repaired := s.repairAttachmentViewLocked(ac, view)373 repaired := s.repairAttachmentViewLocked(ac, view)
372- if repaired.tabID != view.tabID || repaired.paneID != view.paneID || repaired.windowTop != view.windowTop || repaired.windowRows != view.windowRows || repaired.bookmark != view.bookmark || repaired.liveBottom != view.liveBottom {374+ if repaired.tabID != view.tabID || repaired.paneID != view.paneID || repaired.windowTop != view.windowTop || repaired.windowRows != view.windowRows || repaired.windowSet != view.windowSet || repaired.bookmark != view.bookmark || repaired.liveBottom != view.liveBottom {
373 repaired.revision++375 repaired.revision++
374 ac.publishView(repaired)376 ac.publishView(repaired)
375 }377 }
@@ -476,7 +478,7 @@ func (s *session) updateAttachmentView(ac *attachedClient, update func(*attachme
476 next = s.repairAttachmentViewLocked(ac, next)478 next = s.repairAttachmentViewLocked(ac, next)
477 next.revision++479 next.revision++
478 ac.publishView(next)480 ac.publishView(next)
479- changed = next.tabID != before.tabID || next.paneID != before.paneID || next.windowTop != before.windowTop || next.windowRows != before.windowRows || next.bookmark != before.bookmark || next.liveBottom != before.liveBottom481+ changed = next.tabID != before.tabID || next.paneID != before.paneID || next.windowTop != before.windowTop || next.windowRows != before.windowRows || next.windowSet != before.windowSet || next.bookmark != before.bookmark || next.liveBottom != before.liveBottom
480 return nil482 return nil
481 })483 })
482 return changed484 return changed
A internal/usecase/daemon/attachment_render_test.go +110 -0
@@ -0,0 +1,110 @@
1+package daemon
2+
3+import (
4+ "io"
5+ "sync"
6+ "sync/atomic"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+type blockedAttachmentTransport struct {
16+ entered chan struct{}
17+ release chan struct{}
18+ sends chan ports.Frame
19+ blocked atomic.Bool
20+ once sync.Once
21+}
22+
23+func (t *blockedAttachmentTransport) Send(frame ports.Frame) error {
24+ if frame.Type == ports.MsgOutput && t.blocked.Load() {
25+ t.once.Do(func() { close(t.entered) })
26+ <-t.release
27+ }
28+ t.sends <- frame
29+ return nil
30+}
31+func (*blockedAttachmentTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
32+func (*blockedAttachmentTransport) Close() error { return nil }
33+
34+func TestAttachmentResizeKeepsSessionContentAndPeersFixed(t *testing.T) {
35+ pty, releasePTY := newBlockingPTY(t)
36+ defer releasePTY()
37+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
38+ firstTransport, _ := newCapturingTransport(t)
39+ sess, first, err := d.route(ports.Hello{
40+ Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work",
41+ Size: domain.Size{Cols: 80, Rows: 24}, ClientID: [16]byte{1},
42+ }, firstTransport)
43+ require.NoError(t, err)
44+ secondTransport, _ := newCapturingTransport(t)
45+ _, second, err := d.route(ports.Hello{
46+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work",
47+ Size: domain.Size{Cols: 100, Rows: 40}, ClientID: [16]byte{2},
48+ }, secondTransport)
49+ require.NoError(t, err)
50+
51+ tb := sess.tabs[0]
52+ tb.mu.Lock()
53+ contentBefore := tb.size
54+ tb.mu.Unlock()
55+ firstSize := first.size
56+ secondRevision := second.viewSnapshot().revision
57+ secondEpoch := second.output.currentEpoch()
58+
59+ rc := sess.renderCoordinator()
60+ lease := rc.attachmentLease(second)
61+ require.True(t, rc.markAttachmentReady(lease))
62+ token := sess.attachmentToken(second, secondTransport)
63+ token.lease = lease
64+ ticket, admitted := second.beginAttachmentEffect(token)
65+ require.True(t, admitted)
66+ token.effect = ticket
67+ require.True(t, d.resizeAttachmentForLease(token, domain.Size{Cols: 120, Rows: 50}))
68+ ticket.End()
69+
70+ tb.mu.Lock()
71+ require.Equal(t, contentBefore, tb.size)
72+ tb.mu.Unlock()
73+ require.Equal(t, firstSize, first.size)
74+ require.Equal(t, domain.Size{Cols: 120, Rows: 50}, second.size)
75+ require.Equal(t, secondRevision+1, second.viewSnapshot().revision)
76+ require.Greater(t, second.output.currentEpoch(), secondEpoch)
77+}
78+
79+func TestAttachmentPaintFanoutDoesNotWaitForBlockedPeer(t *testing.T) {
80+ pty, releasePTY := newBlockingPTY(t)
81+ defer releasePTY()
82+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
83+ firstTransport := &blockedAttachmentTransport{entered: make(chan struct{}), release: make(chan struct{}), sends: make(chan ports.Frame, 8)}
84+ sess, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, ClientID: [16]byte{1}}, firstTransport)
85+ require.NoError(t, err)
86+ secondTransport := &blockedAttachmentTransport{entered: make(chan struct{}), release: make(chan struct{}), sends: make(chan ports.Frame, 8)}
87+ _, _, err = d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, ClientID: [16]byte{2}}, secondTransport)
88+ require.NoError(t, err)
89+ rc := sess.renderCoordinator()
90+ for _, ac := range sess.snapshotAttachments() {
91+ require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac)))
92+ }
93+ firstTransport.blocked.Store(true)
94+
95+ sess.tabs[0].focusedPane().screen.Write([]byte("shared"))
96+ rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "test"})
97+ rc.fireCurrent(false)
98+ select {
99+ case <-firstTransport.entered:
100+ case <-time.After(time.Second):
101+ t.Fatal("first attachment did not reach blocked send")
102+ }
103+ select {
104+ case frame := <-secondTransport.sends:
105+ require.Equal(t, ports.MsgOutput, frame.Type)
106+ case <-time.After(time.Second):
107+ t.Fatal("second attachment waited for first attachment transport")
108+ }
109+ close(firstTransport.release)
110+}
@@ -0,0 +1,110 @@
1+package daemon
2+
3+import (
4+ "io"
5+ "sync"
6+ "sync/atomic"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+type blockedAttachmentTransport struct {
16+ entered chan struct{}
17+ release chan struct{}
18+ sends chan ports.Frame
19+ blocked atomic.Bool
20+ once sync.Once
21+}
22+
23+func (t *blockedAttachmentTransport) Send(frame ports.Frame) error {
24+ if frame.Type == ports.MsgOutput && t.blocked.Load() {
25+ t.once.Do(func() { close(t.entered) })
26+ <-t.release
27+ }
28+ t.sends <- frame
29+ return nil
30+}
31+func (*blockedAttachmentTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
32+func (*blockedAttachmentTransport) Close() error { return nil }
33+
34+func TestAttachmentResizeKeepsSessionContentAndPeersFixed(t *testing.T) {
35+ pty, releasePTY := newBlockingPTY(t)
36+ defer releasePTY()
37+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
38+ firstTransport, _ := newCapturingTransport(t)
39+ sess, first, err := d.route(ports.Hello{
40+ Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work",
41+ Size: domain.Size{Cols: 80, Rows: 24}, ClientID: [16]byte{1},
42+ }, firstTransport)
43+ require.NoError(t, err)
44+ secondTransport, _ := newCapturingTransport(t)
45+ _, second, err := d.route(ports.Hello{
46+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work",
47+ Size: domain.Size{Cols: 100, Rows: 40}, ClientID: [16]byte{2},
48+ }, secondTransport)
49+ require.NoError(t, err)
50+
51+ tb := sess.tabs[0]
52+ tb.mu.Lock()
53+ contentBefore := tb.size
54+ tb.mu.Unlock()
55+ firstSize := first.size
56+ secondRevision := second.viewSnapshot().revision
57+ secondEpoch := second.output.currentEpoch()
58+
59+ rc := sess.renderCoordinator()
60+ lease := rc.attachmentLease(second)
61+ require.True(t, rc.markAttachmentReady(lease))
62+ token := sess.attachmentToken(second, secondTransport)
63+ token.lease = lease
64+ ticket, admitted := second.beginAttachmentEffect(token)
65+ require.True(t, admitted)
66+ token.effect = ticket
67+ require.True(t, d.resizeAttachmentForLease(token, domain.Size{Cols: 120, Rows: 50}))
68+ ticket.End()
69+
70+ tb.mu.Lock()
71+ require.Equal(t, contentBefore, tb.size)
72+ tb.mu.Unlock()
73+ require.Equal(t, firstSize, first.size)
74+ require.Equal(t, domain.Size{Cols: 120, Rows: 50}, second.size)
75+ require.Equal(t, secondRevision+1, second.viewSnapshot().revision)
76+ require.Greater(t, second.output.currentEpoch(), secondEpoch)
77+}
78+
79+func TestAttachmentPaintFanoutDoesNotWaitForBlockedPeer(t *testing.T) {
80+ pty, releasePTY := newBlockingPTY(t)
81+ defer releasePTY()
82+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
83+ firstTransport := &blockedAttachmentTransport{entered: make(chan struct{}), release: make(chan struct{}), sends: make(chan ports.Frame, 8)}
84+ sess, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, ClientID: [16]byte{1}}, firstTransport)
85+ require.NoError(t, err)
86+ secondTransport := &blockedAttachmentTransport{entered: make(chan struct{}), release: make(chan struct{}), sends: make(chan ports.Frame, 8)}
87+ _, _, err = d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, ClientID: [16]byte{2}}, secondTransport)
88+ require.NoError(t, err)
89+ rc := sess.renderCoordinator()
90+ for _, ac := range sess.snapshotAttachments() {
91+ require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac)))
92+ }
93+ firstTransport.blocked.Store(true)
94+
95+ sess.tabs[0].focusedPane().screen.Write([]byte("shared"))
96+ rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "test"})
97+ rc.fireCurrent(false)
98+ select {
99+ case <-firstTransport.entered:
100+ case <-time.After(time.Second):
101+ t.Fatal("first attachment did not reach blocked send")
102+ }
103+ select {
104+ case frame := <-secondTransport.sends:
105+ require.Equal(t, ports.MsgOutput, frame.Type)
106+ case <-time.After(time.Second):
107+ t.Fatal("second attachment waited for first attachment transport")
108+ }
109+ close(firstTransport.release)
110+}
M internal/usecase/daemon/client.go +49 -27
@@ -569,6 +569,7 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size,
569569 tr: tr,
570570 output: output,
571571 size: sz,
572+ view: attachmentView{windowRows: sz.Rows, windowSet: true},
572573 clientID: opts.clientID,
573574 resumeCapable: opts.resumeCapable,
574575 resumeToken: resumeToken,
@@ -724,15 +725,14 @@ func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool {
724725 return true
725726 }
726727
727-// firstPaint guarantees the freshly attached client sees the full screen: if
728-// the tab size differs from the client's it resizes first, then immediately
729-// emits a full redraw. It is retained for direct test/headless setup; active
730-// attachment transitions use firstPaintForTransition with their exact lease.
728+// firstPaint guarantees the freshly attached client sees the full screen. The
729+// session content geometry is fixed by the first valid Hello; an attachment's
730+// window is rendered without resizing shared tabs or PTYs.
731731 func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {
732732 d.firstPaintWithLease(sess, ac, clientSize, nil)
733733 }
734734
735-func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSize domain.Size, lease *attachmentLease) {
735+func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, _ domain.Size, lease *attachmentLease) {
736736 // Global notices raised while nothing was attached surface on this client.
737737 // Drained before the early return below so a session without an active tab
738738 // cannot swallow the queue.
@@ -741,29 +741,14 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSi
741741 if tb == nil {
742742 return
743743 }
744- tb.mu.Lock()
745- wsz := tb.size
746- tb.mu.Unlock()
747-
748- outerResizeAccepted := false
749- if clientSize.Valid() && wsz != contentSize(clientSize, ac.proxied) {
750- if lease == nil {
751- outerResizeAccepted = d.resizeForFirstPaint(sess, ac, clientSize)
752- } else {
753- outerResizeAccepted = d.requestTransactionalResizeForLease(sess, ac, lease, clientSize, true)
754- }
755- }
756744 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
757- // Activation can synchronously resize a retained floating pane. An accepted
758- // synchronous outer request already includes that pane and emits the reset,
759- // but activation still performs its warmup work.
760- activationResized := d.activateTabAfterResizeForLease(sess, tb, outerResizeAccepted, ac, lease)
761- if !outerResizeAccepted && !activationResized {
762- if lease == nil {
763- d.invalidateRenderNow(sess, ac, true, "client.go")
764- } else if rc := sess.renderCoordinator(); rc != nil && rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"}) {
765- rc.fireCurrentForLease(lease)
766- }
745+ // Activation still performs attachment-local warmup, but it must not turn a
746+ // new window into a shared session resize.
747+ d.ensureFloatingWarm(sess, tb)
748+ if lease == nil {
749+ d.invalidateRenderNow(sess, ac, true, "client.go")
750+ } else if rc := sess.renderCoordinator(); rc != nil && rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"}) {
751+ rc.fireCurrentForLease(lease)
767752 }
768753 }
769754
@@ -817,6 +802,43 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
817802 d.requestTransactionalResize(sess, ac, sz, false)
818803 }
819804
805+// resizeAttachmentForLease changes only the sender's window/view. Session tab
806+// geometry and PTY sizes are deliberately outside this mutation boundary.
807+// Callers hold the attachment effect admission; output state is rebased before
808+// the targeted redraw so every subsequent frame starts a new epoch.
809+func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size domain.Size) bool {
810+ if token.ac == nil || !size.Valid() || !token.attachmentEffectCurrent() {
811+ return false
812+ }
813+ ac := token.ac
814+ ac.sendMu.Lock()
815+ if !token.attachmentEffectCurrent() || !ac.transportSnapshotCurrent(token.transport) {
816+ ac.sendMu.Unlock()
817+ return false
818+ }
819+ if ac.size == size {
820+ ac.sendMu.Unlock()
821+ return false
822+ }
823+ ac.size = size
824+ view := ac.viewSnapshot()
825+ view.windowRows = size.Rows
826+ view.windowSet = true
827+ view.windowTop = 0
828+ view.revision++
829+ ac.publishView(view)
830+ ac.rebaseOutput()
831+ ac.pipelineCache = composeCacheInput{}
832+ ac.pipelineScratch = composeCacheInput{}
833+ ac.sendMu.Unlock()
834+
835+ entry := token.sess
836+ // Rendering is attachment-scoped. Run it independently of the connection
837+ // reader so a blocked transport cannot hold session dispatch or peers.
838+ go d.paint(entry, ac, true, token.lease)
839+ return true
840+}
841+
820842 // handleClientNotice maps the closed client-event enum to daemon-owned notice
821843 // content. routingMu makes ownership validation and toast mutation one atomic
822844 // attachment-routing operation: attachment publication also takes routingMu
@@ -569,6 +569,7 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size,
569 tr: tr,569 tr: tr,
570 output: output,570 output: output,
571 size: sz,571 size: sz,
572+ view: attachmentView{windowRows: sz.Rows, windowSet: true},
572 clientID: opts.clientID,573 clientID: opts.clientID,
573 resumeCapable: opts.resumeCapable,574 resumeCapable: opts.resumeCapable,
574 resumeToken: resumeToken,575 resumeToken: resumeToken,
@@ -724,15 +725,14 @@ func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool {
724 return true725 return true
725 }726 }
726 727
727-// firstPaint guarantees the freshly attached client sees the full screen: if728+// firstPaint guarantees the freshly attached client sees the full screen. The
728-// the tab size differs from the client's it resizes first, then immediately729+// session content geometry is fixed by the first valid Hello; an attachment's
729-// emits a full redraw. It is retained for direct test/headless setup; active730+// window is rendered without resizing shared tabs or PTYs.
730-// attachment transitions use firstPaintForTransition with their exact lease.
731 func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {731 func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {
732 d.firstPaintWithLease(sess, ac, clientSize, nil)732 d.firstPaintWithLease(sess, ac, clientSize, nil)
733 }733 }
734 734
735-func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSize domain.Size, lease *attachmentLease) {735+func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, _ domain.Size, lease *attachmentLease) {
736 // Global notices raised while nothing was attached surface on this client.736 // Global notices raised while nothing was attached surface on this client.
737 // Drained before the early return below so a session without an active tab737 // Drained before the early return below so a session without an active tab
738 // cannot swallow the queue.738 // cannot swallow the queue.
@@ -741,29 +741,14 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSi
741 if tb == nil {741 if tb == nil {
742 return742 return
743 }743 }
744- tb.mu.Lock()
745- wsz := tb.size
746- tb.mu.Unlock()
747-
748- outerResizeAccepted := false
749- if clientSize.Valid() && wsz != contentSize(clientSize, ac.proxied) {
750- if lease == nil {
751- outerResizeAccepted = d.resizeForFirstPaint(sess, ac, clientSize)
752- } else {
753- outerResizeAccepted = d.requestTransactionalResizeForLease(sess, ac, lease, clientSize, true)
754- }
755- }
756 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)744 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
757- // Activation can synchronously resize a retained floating pane. An accepted745+ // Activation still performs attachment-local warmup, but it must not turn a
758- // synchronous outer request already includes that pane and emits the reset,746+ // new window into a shared session resize.
759- // but activation still performs its warmup work.747+ d.ensureFloatingWarm(sess, tb)
760- activationResized := d.activateTabAfterResizeForLease(sess, tb, outerResizeAccepted, ac, lease)748+ if lease == nil {
761- if !outerResizeAccepted && !activationResized {749+ d.invalidateRenderNow(sess, ac, true, "client.go")
762- if lease == nil {750+ } else if rc := sess.renderCoordinator(); rc != nil && rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"}) {
763- d.invalidateRenderNow(sess, ac, true, "client.go")751+ rc.fireCurrentForLease(lease)
764- } else if rc := sess.renderCoordinator(); rc != nil && rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"}) {
765- rc.fireCurrentForLease(lease)
766- }
767 }752 }
768 }753 }
769 754
@@ -817,6 +802,43 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
817 d.requestTransactionalResize(sess, ac, sz, false)802 d.requestTransactionalResize(sess, ac, sz, false)
818 }803 }
819 804
805+// resizeAttachmentForLease changes only the sender's window/view. Session tab
806+// geometry and PTY sizes are deliberately outside this mutation boundary.
807+// Callers hold the attachment effect admission; output state is rebased before
808+// the targeted redraw so every subsequent frame starts a new epoch.
809+func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size domain.Size) bool {
810+ if token.ac == nil || !size.Valid() || !token.attachmentEffectCurrent() {
811+ return false
812+ }
813+ ac := token.ac
814+ ac.sendMu.Lock()
815+ if !token.attachmentEffectCurrent() || !ac.transportSnapshotCurrent(token.transport) {
816+ ac.sendMu.Unlock()
817+ return false
818+ }
819+ if ac.size == size {
820+ ac.sendMu.Unlock()
821+ return false
822+ }
823+ ac.size = size
824+ view := ac.viewSnapshot()
825+ view.windowRows = size.Rows
826+ view.windowSet = true
827+ view.windowTop = 0
828+ view.revision++
829+ ac.publishView(view)
830+ ac.rebaseOutput()
831+ ac.pipelineCache = composeCacheInput{}
832+ ac.pipelineScratch = composeCacheInput{}
833+ ac.sendMu.Unlock()
834+
835+ entry := token.sess
836+ // Rendering is attachment-scoped. Run it independently of the connection
837+ // reader so a blocked transport cannot hold session dispatch or peers.
838+ go d.paint(entry, ac, true, token.lease)
839+ return true
840+}
841+
820 // handleClientNotice maps the closed client-event enum to daemon-owned notice842 // handleClientNotice maps the closed client-event enum to daemon-owned notice
821 // content. routingMu makes ownership validation and toast mutation one atomic843 // content. routingMu makes ownership validation and toast mutation one atomic
822 // attachment-routing operation: attachment publication also takes routingMu844 // attachment-routing operation: attachment publication also takes routingMu
M internal/usecase/daemon/client_frame_routing.go +7 -12
@@ -102,11 +102,7 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f
102102 }
103103 case ports.MsgResize:
104104 if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.attachmentEffectCurrent() {
105- if sess, ok := localSession(token.sess); ok {
106- d.requestTransactionalResizeForLease(sess, token.ac, token.lease, rz.Size, false)
107- } else if proxy, ok := token.sess.(*proxySession); ok {
108- d.resizeProxyForLease(proxy, token.ac, token.lease, rz.Size)
109- }
105+ d.resizeAttachmentForLease(token, rz.Size)
110106 }
111107 case ports.MsgTheme:
112108 if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {
@@ -246,21 +242,20 @@ func proxyAttachedCommandOwnedRemotely(slug string) bool {
246242 // transport revalidation under that lock rejects a link replaced while waiting.
247243 func (d *Daemon) resetOutput(token attachmentConnectionToken) bool {
248244 ac := token.ac
249- if ac == nil || !ac.proxied || token.effect == nil || token.effect.ended.Load() {
245+ if ac == nil || token.effect == nil || token.effect.ended.Load() {
250246 return false
251247 }
252248 ac.sendMu.Lock()
253- if token.effect.ended.Load() || !ac.proxied || !token.attachmentCurrent() || ac.output == nil {
249+ if token.effect.ended.Load() || !token.attachmentCurrent() || ac.output == nil {
254250 ac.sendMu.Unlock()
255251 return false
256252 }
257253 ac.rebaseOutput()
254+ ac.pipelineCache = composeCacheInput{}
255+ ac.pipelineScratch = composeCacheInput{}
258256 ac.sendMu.Unlock()
259-
260- rc := token.sess.core().coordinator.Load()
261- return rc != nil && rc.invalidateForLease(ac, token.lease, renderInvalidation{
262- class: invalidateUrgent, reset: true, producer: "output-reset-request",
263- })
257+ go d.paint(token.sess, ac, true, token.lease)
258+ return true
264259 }
265260
266261 func (d *Daemon) ackOutput(token attachmentConnectionToken, values ...uint64) bool {
@@ -102,11 +102,7 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f
102 }102 }
103 case ports.MsgResize:103 case ports.MsgResize:
104 if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.attachmentEffectCurrent() {104 if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.attachmentEffectCurrent() {
105- if sess, ok := localSession(token.sess); ok {105+ d.resizeAttachmentForLease(token, rz.Size)
106- d.requestTransactionalResizeForLease(sess, token.ac, token.lease, rz.Size, false)
107- } else if proxy, ok := token.sess.(*proxySession); ok {
108- d.resizeProxyForLease(proxy, token.ac, token.lease, rz.Size)
109- }
110 }106 }
111 case ports.MsgTheme:107 case ports.MsgTheme:
112 if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {108 if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {
@@ -246,21 +242,20 @@ func proxyAttachedCommandOwnedRemotely(slug string) bool {
246 // transport revalidation under that lock rejects a link replaced while waiting.242 // transport revalidation under that lock rejects a link replaced while waiting.
247 func (d *Daemon) resetOutput(token attachmentConnectionToken) bool {243 func (d *Daemon) resetOutput(token attachmentConnectionToken) bool {
248 ac := token.ac244 ac := token.ac
249- if ac == nil || !ac.proxied || token.effect == nil || token.effect.ended.Load() {245+ if ac == nil || token.effect == nil || token.effect.ended.Load() {
250 return false246 return false
251 }247 }
252 ac.sendMu.Lock()248 ac.sendMu.Lock()
253- if token.effect.ended.Load() || !ac.proxied || !token.attachmentCurrent() || ac.output == nil {249+ if token.effect.ended.Load() || !token.attachmentCurrent() || ac.output == nil {
254 ac.sendMu.Unlock()250 ac.sendMu.Unlock()
255 return false251 return false
256 }252 }
257 ac.rebaseOutput()253 ac.rebaseOutput()
254+ ac.pipelineCache = composeCacheInput{}
255+ ac.pipelineScratch = composeCacheInput{}
258 ac.sendMu.Unlock()256 ac.sendMu.Unlock()
259-257+ go d.paint(token.sess, ac, true, token.lease)
260- rc := token.sess.core().coordinator.Load()258+ return true
261- return rc != nil && rc.invalidateForLease(ac, token.lease, renderInvalidation{
262- class: invalidateUrgent, reset: true, producer: "output-reset-request",
263- })
264 }259 }
265 260
266 func (d *Daemon) ackOutput(token attachmentConnectionToken, values ...uint64) bool {261 func (d *Daemon) ackOutput(token attachmentConnectionToken, values ...uint64) bool {
M internal/usecase/daemon/daemon.go +5 -4
@@ -64,7 +64,8 @@ func normalizeOutputWindow(window uint8) uint8 {
6464
6565 const defaultResumeParkGrace = 15 * time.Minute
6666
67-// defaultSize is used when a client's Hello carries no valid dimensions.
67+// defaultSize is retained for headless layout helpers that have no client
68+// window; Hello routing rejects invalid dimensions instead of using it.
6869 var defaultSize = domain.Size{Cols: 80, Rows: 24}
6970
7071 type Daemon struct {
@@ -1266,9 +1267,6 @@ func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error {
12661267 // the session for ephemeral/new intents.
12671268 func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) {
12681269 sz := h.Size
1269- if !sz.Valid() {
1270- sz = defaultSize
1271- }
12721270 term := terminalEnv{TrueColor: h.TrueColor}
12731271
12741272 // A non-zero token is an authoritative resume credential. If it is unknown,
@@ -1317,6 +1315,9 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
13171315 return nil, nil, err
13181316 }
13191317 }
1318+ if !sz.Valid() {
1319+ return nil, nil, &protoErr{ports.ErrInternal, "invalid terminal size"}
1320+ }
13201321
13211322 d.mu.Lock()
13221323 // Shutdown/create interlock: once shutdown has begun (last session removed,
@@ -64,7 +64,8 @@ func normalizeOutputWindow(window uint8) uint8 {
64 64
65 const defaultResumeParkGrace = 15 * time.Minute65 const defaultResumeParkGrace = 15 * time.Minute
66 66
67-// defaultSize is used when a client's Hello carries no valid dimensions.67+// defaultSize is retained for headless layout helpers that have no client
68+// window; Hello routing rejects invalid dimensions instead of using it.
68 var defaultSize = domain.Size{Cols: 80, Rows: 24}69 var defaultSize = domain.Size{Cols: 80, Rows: 24}
69 70
70 type Daemon struct {71 type Daemon struct {
@@ -1266,9 +1267,6 @@ func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error {
1266 // the session for ephemeral/new intents.1267 // the session for ephemeral/new intents.
1267 func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) {1268 func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) {
1268 sz := h.Size1269 sz := h.Size
1269- if !sz.Valid() {
1270- sz = defaultSize
1271- }
1272 term := terminalEnv{TrueColor: h.TrueColor}1270 term := terminalEnv{TrueColor: h.TrueColor}
1273 1271
1274 // A non-zero token is an authoritative resume credential. If it is unknown,1272 // A non-zero token is an authoritative resume credential. If it is unknown,
@@ -1317,6 +1315,9 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
1317 return nil, nil, err1315 return nil, nil, err
1318 }1316 }
1319 }1317 }
1318+ if !sz.Valid() {
1319+ return nil, nil, &protoErr{ports.ErrInternal, "invalid terminal size"}
1320+ }
1320 1321
1321 d.mu.Lock()1322 d.mu.Lock()
1322 // Shutdown/create interlock: once shutdown has begun (last session removed,1323 // Shutdown/create interlock: once shutdown has begun (last session removed,
M internal/usecase/daemon/render.go +20 -9
@@ -479,15 +479,23 @@ func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement {
479479 }
480480
481481 func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, dimmer themeui.Dimmer) {
482- rows := min(r.Height, src.Height)
483- cols := min(r.Width, src.Width)
484- for y := range rows {
485- for x := range cols {
486- cell := src.At(x, y)
482+ startX, startY := max(r.X, 0), max(r.Y, 0)
483+ endX, endY := min(r.X+r.Width, dst.Width), min(r.Y+r.Height, dst.Height)
484+ for y := startY; y < endY; y++ {
485+ sy := y - r.Y
486+ if sy < 0 || sy >= src.Height {
487+ continue
488+ }
489+ for x := startX; x < endX; x++ {
490+ sx := x - r.X
491+ if sx < 0 || sx >= src.Width {
492+ continue
493+ }
494+ cell := src.At(sx, sy)
487495 if dim {
488496 cell.Style = dimmer.Dim(cell.Style)
489497 }
490- dst.Set(r.X+x, r.Y+y, cell)
498+ dst.Set(x, y, cell)
491499 }
492500 }
493501 }
@@ -502,9 +510,12 @@ func drawDividers(frame renderer.Frame, dividers []layout.Divider, dy int, style
502510 if d.Dir == layout.Vertical {
503511 glyph = '─'
504512 }
505- y0 := d.Rect.Y + dy
506- for y := y0; y < y0+d.Rect.Height; y++ {
507- for x := d.Rect.X; x < d.Rect.X+d.Rect.Width; x++ {
513+ y0 := max(d.Rect.Y+dy, 0)
514+ y1 := min(d.Rect.Y+dy+d.Rect.Height, frame.Height)
515+ x0 := max(d.Rect.X, 0)
516+ x1 := min(d.Rect.X+d.Rect.Width, frame.Width)
517+ for y := y0; y < y1; y++ {
518+ for x := x0; x < x1; x++ {
508519 frame.Set(x, y, renderer.Cell{Rune: glyph, Style: style})
509520 }
510521 }
@@ -479,15 +479,23 @@ func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement {
479 }479 }
480 480
481 func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, dimmer themeui.Dimmer) {481 func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, dimmer themeui.Dimmer) {
482- rows := min(r.Height, src.Height)482+ startX, startY := max(r.X, 0), max(r.Y, 0)
483- cols := min(r.Width, src.Width)483+ endX, endY := min(r.X+r.Width, dst.Width), min(r.Y+r.Height, dst.Height)
484- for y := range rows {484+ for y := startY; y < endY; y++ {
485- for x := range cols {485+ sy := y - r.Y
486- cell := src.At(x, y)486+ if sy < 0 || sy >= src.Height {
487+ continue
488+ }
489+ for x := startX; x < endX; x++ {
490+ sx := x - r.X
491+ if sx < 0 || sx >= src.Width {
492+ continue
493+ }
494+ cell := src.At(sx, sy)
487 if dim {495 if dim {
488 cell.Style = dimmer.Dim(cell.Style)496 cell.Style = dimmer.Dim(cell.Style)
489 }497 }
490- dst.Set(r.X+x, r.Y+y, cell)498+ dst.Set(x, y, cell)
491 }499 }
492 }500 }
493 }501 }
@@ -502,9 +510,12 @@ func drawDividers(frame renderer.Frame, dividers []layout.Divider, dy int, style
502 if d.Dir == layout.Vertical {510 if d.Dir == layout.Vertical {
503 glyph = '─'511 glyph = '─'
504 }512 }
505- y0 := d.Rect.Y + dy513+ y0 := max(d.Rect.Y+dy, 0)
506- for y := y0; y < y0+d.Rect.Height; y++ {514+ y1 := min(d.Rect.Y+dy+d.Rect.Height, frame.Height)
507- for x := d.Rect.X; x < d.Rect.X+d.Rect.Width; x++ {515+ x0 := max(d.Rect.X, 0)
516+ x1 := min(d.Rect.X+d.Rect.Width, frame.Width)
517+ for y := y0; y < y1; y++ {
518+ for x := x0; x < x1; x++ {
508 frame.Set(x, y, renderer.Cell{Rune: glyph, Style: style})519 frame.Set(x, y, renderer.Cell{Rune: glyph, Style: style})
509 }520 }
510 }521 }
M internal/usecase/daemon/render_capture.go +9 -1
@@ -44,6 +44,8 @@ type damageReceipt struct {
4444 type capturedRenderState struct {
4545 attachment *attachedClient // identity only; never dereferenced by composition
4646 lease *attachmentLease
47+ view attachmentView
48+ window domain.Size
4749 reset bool
4850 contentOnly bool
4951 layout capturedTabLayout
@@ -266,8 +268,14 @@ func captureLocalRenderState(
266268 layoutSnap.dividers = scratch.dividers
267269 }
268270 state := &scratch.state
271+ view := ac.viewSnapshot()
272+ window := domain.Size{}
273+ if view.windowSet {
274+ window = ac.size
275+ }
269276 *state = capturedRenderState{
270- attachment: ac, lease: lease, reset: reset, contentOnly: ac.proxied, bars: bars, theme: bars.theme,
277+ attachment: ac, lease: lease, view: view, window: window,
278+ reset: reset, contentOnly: ac.proxied, bars: bars, theme: bars.theme,
271279 styles: request.styles, styleGeneration: request.styleGeneration,
272280 overlays: overlays, preview: preview,
273281 layout: capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},
@@ -44,6 +44,8 @@ type damageReceipt struct {
44 type capturedRenderState struct {44 type capturedRenderState struct {
45 attachment *attachedClient // identity only; never dereferenced by composition45 attachment *attachedClient // identity only; never dereferenced by composition
46 lease *attachmentLease46 lease *attachmentLease
47+ view attachmentView
48+ window domain.Size
47 reset bool49 reset bool
48 contentOnly bool50 contentOnly bool
49 layout capturedTabLayout51 layout capturedTabLayout
@@ -266,8 +268,14 @@ func captureLocalRenderState(
266 layoutSnap.dividers = scratch.dividers268 layoutSnap.dividers = scratch.dividers
267 }269 }
268 state := &scratch.state270 state := &scratch.state
271+ view := ac.viewSnapshot()
272+ window := domain.Size{}
273+ if view.windowSet {
274+ window = ac.size
275+ }
269 *state = capturedRenderState{276 *state = capturedRenderState{
270- attachment: ac, lease: lease, reset: reset, contentOnly: ac.proxied, bars: bars, theme: bars.theme,277+ attachment: ac, lease: lease, view: view, window: window,
278+ reset: reset, contentOnly: ac.proxied, bars: bars, theme: bars.theme,
271 styles: request.styles, styleGeneration: request.styleGeneration,279 styles: request.styles, styleGeneration: request.styleGeneration,
272 overlays: overlays, preview: preview,280 overlays: overlays, preview: preview,
273 layout: capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},281 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 +9 -2
@@ -68,6 +68,10 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
6868 scratch = scratchIn[0]
6969 }
7070 width, rows := state.layout.area.Width, state.layout.area.Height
71+ if state.window.Valid() {
72+ window := contentSize(state.window, state.contentOnly)
73+ width, rows = window.Cols, window.Rows
74+ }
7175 if width <= 0 || rows < 0 {
7276 return composedRenderFrame{frame: renderer.NewFrame(0, 0), cursor: cursorOut{hidden: true}, reset: state.reset}
7377 }
@@ -197,14 +201,17 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
197201 }
198202
199203 func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, neutralBorder renderer.Style, dimmer themeui.Dimmer) {
204+ if pl.TitleBar.Y < 0 || pl.TitleBar.Y >= frame.Height {
205+ return
206+ }
200207 style := styles.StatusBar
201208 if !focused {
202209 style = dimmer.Dim(neutralBorder)
203210 }
204- for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {
211+ for x := max(pl.TitleBar.X, 0); x < min(pl.TitleBar.X+pl.TitleBar.Width, frame.Width); x++ {
205212 frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
206213 }
207- ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style)
214+ ui.DrawText(frame, max(pl.TitleBar.X, 0), pl.TitleBar.Y, min(pl.TitleBar.X+pl.TitleBar.Width, frame.Width), title, style)
208215 }
209216
210217 func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut {
@@ -68,6 +68,10 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
68 scratch = scratchIn[0]68 scratch = scratchIn[0]
69 }69 }
70 width, rows := state.layout.area.Width, state.layout.area.Height70 width, rows := state.layout.area.Width, state.layout.area.Height
71+ if state.window.Valid() {
72+ window := contentSize(state.window, state.contentOnly)
73+ width, rows = window.Cols, window.Rows
74+ }
71 if width <= 0 || rows < 0 {75 if width <= 0 || rows < 0 {
72 return composedRenderFrame{frame: renderer.NewFrame(0, 0), cursor: cursorOut{hidden: true}, reset: state.reset}76 return composedRenderFrame{frame: renderer.NewFrame(0, 0), cursor: cursorOut{hidden: true}, reset: state.reset}
73 }77 }
@@ -197,14 +201,17 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
197 }201 }
198 202
199 func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, neutralBorder renderer.Style, dimmer themeui.Dimmer) {203 func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, neutralBorder renderer.Style, dimmer themeui.Dimmer) {
204+ if pl.TitleBar.Y < 0 || pl.TitleBar.Y >= frame.Height {
205+ return
206+ }
200 style := styles.StatusBar207 style := styles.StatusBar
201 if !focused {208 if !focused {
202 style = dimmer.Dim(neutralBorder)209 style = dimmer.Dim(neutralBorder)
203 }210 }
204- for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {211+ for x := max(pl.TitleBar.X, 0); x < min(pl.TitleBar.X+pl.TitleBar.Width, frame.Width); x++ {
205 frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})212 frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
206 }213 }
207- ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style)214+ ui.DrawText(frame, max(pl.TitleBar.X, 0), pl.TitleBar.Y, min(pl.TitleBar.X+pl.TitleBar.Width, frame.Width), title, style)
208 }215 }
209 216
210 func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut {217 func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut {

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 2

Tags 13

↑↓ moveenter openesc close