detached 87a5a2a

fix(client): settle ui-driver actions across the picker overlay

The client picker composed over a live attachment published an
unattached Picker context and suppressed attachment output, so the
action that opened it never matched its daemon receipt, and later driver
actions were refused or never settled.

- The overlay frame keeps the live attachment's context (UI.OverlayContext).
- Suppressed attachment output records its committed boundary, so a
  receipt fenced on it settles its action.
- Input the navigation picker consumes is settled by the supervisor at its
  event-loop boundary; a commit elsewhere follows the action across the
  swap to the destination's first publication.
- The swap allocates its stream identity at open time: route streams
  opened meanwhile could move the anti-replay window past one reserved at
  commit, refusing the swap as stale.
commit
87a5a2ac755b211f421373de58ebdc17793ff669
author
brice · · 2026-09-23 02:57 UTC
parent
2e0d9a3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +371 -19
M internal/app/broker_client.go +8 -1
@@ -148,8 +148,15 @@ func (p *brokerClientPresentation) Render(state client.State) {
148148 }
149149 transaction, _ := p.terminal.(ports.UIOutputTransaction)
150150 context := p.pickerContext()
151- if state.Presentation == client.PresentConnecting {
151+ switch state.Presentation {
152+ case client.PresentConnecting:
152153 context.Status = ports.UIStatusConnecting
154+ case client.PresentAttachedPicker:
155+ // The picker is composed over a live attachment: the attachment stays
156+ // the actionable generation, so the frame keeps its attached context.
157+ if attached, ok := p.ui.OverlayContext(); ok {
158+ context = attached
159+ }
153160 }
154161 if transaction != nil {
155162 transaction.BeginOutput(context)
@@ -148,8 +148,15 @@ func (p *brokerClientPresentation) Render(state client.State) {
148 }148 }
149 transaction, _ := p.terminal.(ports.UIOutputTransaction)149 transaction, _ := p.terminal.(ports.UIOutputTransaction)
150 context := p.pickerContext()150 context := p.pickerContext()
151- if state.Presentation == client.PresentConnecting {151+ switch state.Presentation {
152+ case client.PresentConnecting:
152 context.Status = ports.UIStatusConnecting153 context.Status = ports.UIStatusConnecting
154+ case client.PresentAttachedPicker:
155+ // The picker is composed over a live attachment: the attachment stays
156+ // the actionable generation, so the frame keeps its attached context.
157+ if attached, ok := p.ui.OverlayContext(); ok {
158+ context = attached
159+ }
153 }160 }
154 if transaction != nil {161 if transaction != nil {
155 transaction.BeginOutput(context)162 transaction.BeginOutput(context)
M internal/usecase/client/attachment_input.go +1 -1
@@ -281,7 +281,7 @@ func (in *attachmentInput) route(ctx context.Context, state outputApplyState, da
281281 if len(data) == 0 {
282282 return nil
283283 }
284- consumed, err := in.picker.consumeInput(ctx, state, AttachmentInputEvent{Data: data})
284+ consumed, _, err := in.picker.consumeInput(ctx, state, AttachmentInputEvent{Data: data})
285285 if err != nil || consumed {
286286 return err
287287 }
@@ -281,7 +281,7 @@ func (in *attachmentInput) route(ctx context.Context, state outputApplyState, da
281 if len(data) == 0 {281 if len(data) == 0 {
282 return nil282 return nil
283 }283 }
284- consumed, err := in.picker.consumeInput(ctx, state, AttachmentInputEvent{Data: data})284+ consumed, _, err := in.picker.consumeInput(ctx, state, AttachmentInputEvent{Data: data})
285 if err != nil || consumed {285 if err != nil || consumed {
286 return err286 return err
287 }287 }
M internal/usecase/client/attachment_session.go +5 -4
@@ -480,17 +480,18 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
480480 fg.PreserveInput(event.input.Data)
481481 return w.settle(ctx, fg, stream, token, err)
482482 }
483- consumed, err := picker.consumeInput(ctx, state, *event.input)
483+ consumed, supervised, err := picker.consumeInput(ctx, state, *event.input)
484484 if err != nil {
485485 fg.PreserveInput(event.input.Data)
486486 return w.settle(ctx, fg, stream, token, err)
487487 }
488488 if consumed {
489489 // The overlay owned these bytes: nothing reaches the session.
490- // A driver action still fences through the daemon so it
491- // completes after the overlay applied it.
490+ // A move-picker action still fences through the daemon so it
491+ // completes after the overlay applied it; the supervisor
492+ // settles a navigation-picker action itself.
492493 fg.AckInput()
493- if event.input.actionID != 0 {
494+ if event.input.actionID != 0 && !supervised {
494495 if err := w.send(ctx, fg, stream, protocol.UIFence{ActionID: event.input.actionID}); err != nil {
495496 return w.settle(ctx, fg, stream, token, err)
496497 }
@@ -480,17 +480,18 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
480 fg.PreserveInput(event.input.Data)480 fg.PreserveInput(event.input.Data)
481 return w.settle(ctx, fg, stream, token, err)481 return w.settle(ctx, fg, stream, token, err)
482 }482 }
483- consumed, err := picker.consumeInput(ctx, state, *event.input)483+ consumed, supervised, err := picker.consumeInput(ctx, state, *event.input)
484 if err != nil {484 if err != nil {
485 fg.PreserveInput(event.input.Data)485 fg.PreserveInput(event.input.Data)
486 return w.settle(ctx, fg, stream, token, err)486 return w.settle(ctx, fg, stream, token, err)
487 }487 }
488 if consumed {488 if consumed {
489 // The overlay owned these bytes: nothing reaches the session.489 // The overlay owned these bytes: nothing reaches the session.
490- // A driver action still fences through the daemon so it490+ // A move-picker action still fences through the daemon so it
491- // completes after the overlay applied it.491+ // completes after the overlay applied it; the supervisor
492+ // settles a navigation-picker action itself.
492 fg.AckInput()493 fg.AckInput()
493- if event.input.actionID != 0 {494+ if event.input.actionID != 0 && !supervised {
494 if err := w.send(ctx, fg, stream, protocol.UIFence{ActionID: event.input.actionID}); err != nil {495 if err := w.send(ctx, fg, stream, protocol.UIFence{ActionID: event.input.actionID}); err != nil {
495 return w.settle(ctx, fg, stream, token, err)496 return w.settle(ctx, fg, stream, token, err)
496 }497 }
M internal/usecase/client/attachment_worker.go +56 -3
@@ -393,6 +393,9 @@ type attachmentHost struct {
393393 // (Plan 003 C4/C5). Only the supervisor consumes them.
394394 routeDemand chan struct{}
395395 navigations chan protocol.ServerMessage
396+ // overlayActions carries driver actions the client picker overlay
397+ // consumed to the supervisor, which settles them.
398+ overlayActions chan overlayActionConsumed
396399 }
397400
398401 // newAttachmentHost builds the reusable foreground host. A nil clock falls back
@@ -434,6 +437,7 @@ func newAttachmentHost(cfg attachmentHostConfig) *attachmentHost {
434437 internalActions: make(chan AttachmentLifecycleAction, 1),
435438 routeDemand: make(chan struct{}, 1),
436439 navigations: make(chan protocol.ServerMessage, 1),
440+ overlayActions: make(chan overlayActionConsumed, 1),
437441 }
438442 }
439443
@@ -619,6 +623,7 @@ func (h *attachmentHost) drainForegroundSignals() {
619623 case <-h.internalActions:
620624 case <-h.routeDemand:
621625 case <-h.navigations:
626+ case <-h.overlayActions:
622627 default:
623628 return
624629 }
@@ -748,7 +753,7 @@ type attachmentOverlayForeground interface {
748753 setOverlay(kind attachmentOverlayKind, sink pickerInputConsumer) bool
749754 clearOverlay(kind attachmentOverlayKind)
750755 overlayKind() attachmentOverlayKind
751- divertInput(data []byte) bool
756+ divertInput(data []byte, actionID uint64) bool
752757 overlayRepaint() <-chan struct{}
753758 overlayOutput(uiContext ports.UIContext, data []byte) error
754759 noteCommitted(target protocol.ExactSessionTarget, tab domain.TabStableID)
@@ -844,8 +849,11 @@ func (f *attachmentForeground) overlayKind() attachmentOverlayKind {
844849
845850 // divertInput hands one authorized delivery to the supervisor-owned overlay.
846851 // It reports false when no navigation overlay owns input, so the worker keeps
847-// its session path.
848-func (f *attachmentForeground) divertInput(data []byte) bool {
852+// its session path. A driver action consumed here never reaches the daemon, so
853+// it is marked before the picker sees the bytes and handed to the supervisor
854+// afterwards, which settles it at its own event-loop boundary (or follows it
855+// across the swap a commit starts).
856+func (f *attachmentForeground) divertInput(data []byte, actionID uint64) bool {
849857 if f == nil {
850858 return false
851859 }
@@ -856,10 +864,50 @@ func (f *attachmentForeground) divertInput(data []byte) bool {
856864 if kind != attachmentOverlayNavigation || sink == nil {
857865 return false
858866 }
867+ tracked := actionID != 0 && f.uiGeneration != 0 && f.host != nil && f.host.actionUI != nil
868+ if tracked {
869+ f.host.actionUI.markOverlayInput(f.uiGeneration, actionID)
870+ }
859871 sink.ConsumeTerminalRead(data)
872+ if tracked {
873+ f.host.offerOverlayAction(overlayActionConsumed{generation: f.uiGeneration, actionID: actionID})
874+ }
860875 return true
861876 }
862877
878+// overlayActionConsumed names one driver action the client picker overlay
879+// consumed on the given UI generation.
880+type overlayActionConsumed struct {
881+ generation uint64
882+ actionID uint64
883+}
884+
885+// offerOverlayAction hands the latest overlay-consumed action to the
886+// supervisor. Automation admits one action at a time and the worker is the only
887+// producer, so replacing an unread stale entry never drops a live one.
888+func (h *attachmentHost) offerOverlayAction(consumed overlayActionConsumed) {
889+ for {
890+ select {
891+ case h.overlayActions <- consumed:
892+ return
893+ default:
894+ }
895+ select {
896+ case <-h.overlayActions:
897+ default:
898+ }
899+ }
900+}
901+
902+// OverlayActions wakes the supervisor for each driver action the picker
903+// overlay consumed.
904+func (h *attachmentHost) OverlayActions() <-chan overlayActionConsumed {
905+ if h == nil {
906+ return nil
907+ }
908+ return h.overlayActions
909+}
910+
863911 func (f *attachmentForeground) overlayRepaint() <-chan struct{} {
864912 if f == nil {
865913 return nil
@@ -1380,6 +1428,11 @@ func (f *attachmentForeground) write(uiContext ports.UIContext, data []byte, ove
13801428 return false
13811429 }
13821430 if !overlay && f.overlayKind() != attachmentOverlayNone {
1431+ // Applied, not written: remember its committed boundary so a
1432+ // daemon receipt fenced on it still settles its action.
1433+ if f.uiGeneration != 0 && f.host != nil && f.host.actionUI != nil {
1434+ f.host.actionUI.suppressedOutput(f.uiGeneration, f.presentationContext(uiContext))
1435+ }
13831436 return true
13841437 }
13851438 if supervisorNil(f.term) {
@@ -393,6 +393,9 @@ type attachmentHost struct {
393 // (Plan 003 C4/C5). Only the supervisor consumes them.393 // (Plan 003 C4/C5). Only the supervisor consumes them.
394 routeDemand chan struct{}394 routeDemand chan struct{}
395 navigations chan protocol.ServerMessage395 navigations chan protocol.ServerMessage
396+ // overlayActions carries driver actions the client picker overlay
397+ // consumed to the supervisor, which settles them.
398+ overlayActions chan overlayActionConsumed
396 }399 }
397 400
398 // newAttachmentHost builds the reusable foreground host. A nil clock falls back401 // newAttachmentHost builds the reusable foreground host. A nil clock falls back
@@ -434,6 +437,7 @@ func newAttachmentHost(cfg attachmentHostConfig) *attachmentHost {
434 internalActions: make(chan AttachmentLifecycleAction, 1),437 internalActions: make(chan AttachmentLifecycleAction, 1),
435 routeDemand: make(chan struct{}, 1),438 routeDemand: make(chan struct{}, 1),
436 navigations: make(chan protocol.ServerMessage, 1),439 navigations: make(chan protocol.ServerMessage, 1),
440+ overlayActions: make(chan overlayActionConsumed, 1),
437 }441 }
438 }442 }
439 443
@@ -619,6 +623,7 @@ func (h *attachmentHost) drainForegroundSignals() {
619 case <-h.internalActions:623 case <-h.internalActions:
620 case <-h.routeDemand:624 case <-h.routeDemand:
621 case <-h.navigations:625 case <-h.navigations:
626+ case <-h.overlayActions:
622 default:627 default:
623 return628 return
624 }629 }
@@ -748,7 +753,7 @@ type attachmentOverlayForeground interface {
748 setOverlay(kind attachmentOverlayKind, sink pickerInputConsumer) bool753 setOverlay(kind attachmentOverlayKind, sink pickerInputConsumer) bool
749 clearOverlay(kind attachmentOverlayKind)754 clearOverlay(kind attachmentOverlayKind)
750 overlayKind() attachmentOverlayKind755 overlayKind() attachmentOverlayKind
751- divertInput(data []byte) bool756+ divertInput(data []byte, actionID uint64) bool
752 overlayRepaint() <-chan struct{}757 overlayRepaint() <-chan struct{}
753 overlayOutput(uiContext ports.UIContext, data []byte) error758 overlayOutput(uiContext ports.UIContext, data []byte) error
754 noteCommitted(target protocol.ExactSessionTarget, tab domain.TabStableID)759 noteCommitted(target protocol.ExactSessionTarget, tab domain.TabStableID)
@@ -844,8 +849,11 @@ func (f *attachmentForeground) overlayKind() attachmentOverlayKind {
844 849
845 // divertInput hands one authorized delivery to the supervisor-owned overlay.850 // divertInput hands one authorized delivery to the supervisor-owned overlay.
846 // It reports false when no navigation overlay owns input, so the worker keeps851 // It reports false when no navigation overlay owns input, so the worker keeps
847-// its session path.852+// its session path. A driver action consumed here never reaches the daemon, so
848-func (f *attachmentForeground) divertInput(data []byte) bool {853+// it is marked before the picker sees the bytes and handed to the supervisor
854+// afterwards, which settles it at its own event-loop boundary (or follows it
855+// across the swap a commit starts).
856+func (f *attachmentForeground) divertInput(data []byte, actionID uint64) bool {
849 if f == nil {857 if f == nil {
850 return false858 return false
851 }859 }
@@ -856,10 +864,50 @@ func (f *attachmentForeground) divertInput(data []byte) bool {
856 if kind != attachmentOverlayNavigation || sink == nil {864 if kind != attachmentOverlayNavigation || sink == nil {
857 return false865 return false
858 }866 }
867+ tracked := actionID != 0 && f.uiGeneration != 0 && f.host != nil && f.host.actionUI != nil
868+ if tracked {
869+ f.host.actionUI.markOverlayInput(f.uiGeneration, actionID)
870+ }
859 sink.ConsumeTerminalRead(data)871 sink.ConsumeTerminalRead(data)
872+ if tracked {
873+ f.host.offerOverlayAction(overlayActionConsumed{generation: f.uiGeneration, actionID: actionID})
874+ }
860 return true875 return true
861 }876 }
862 877
878+// overlayActionConsumed names one driver action the client picker overlay
879+// consumed on the given UI generation.
880+type overlayActionConsumed struct {
881+ generation uint64
882+ actionID uint64
883+}
884+
885+// offerOverlayAction hands the latest overlay-consumed action to the
886+// supervisor. Automation admits one action at a time and the worker is the only
887+// producer, so replacing an unread stale entry never drops a live one.
888+func (h *attachmentHost) offerOverlayAction(consumed overlayActionConsumed) {
889+ for {
890+ select {
891+ case h.overlayActions <- consumed:
892+ return
893+ default:
894+ }
895+ select {
896+ case <-h.overlayActions:
897+ default:
898+ }
899+ }
900+}
901+
902+// OverlayActions wakes the supervisor for each driver action the picker
903+// overlay consumed.
904+func (h *attachmentHost) OverlayActions() <-chan overlayActionConsumed {
905+ if h == nil {
906+ return nil
907+ }
908+ return h.overlayActions
909+}
910+
863 func (f *attachmentForeground) overlayRepaint() <-chan struct{} {911 func (f *attachmentForeground) overlayRepaint() <-chan struct{} {
864 if f == nil {912 if f == nil {
865 return nil913 return nil
@@ -1380,6 +1428,11 @@ func (f *attachmentForeground) write(uiContext ports.UIContext, data []byte, ove
1380 return false1428 return false
1381 }1429 }
1382 if !overlay && f.overlayKind() != attachmentOverlayNone {1430 if !overlay && f.overlayKind() != attachmentOverlayNone {
1431+ // Applied, not written: remember its committed boundary so a
1432+ // daemon receipt fenced on it still settles its action.
1433+ if f.uiGeneration != 0 && f.host != nil && f.host.actionUI != nil {
1434+ f.host.actionUI.suppressedOutput(f.uiGeneration, f.presentationContext(uiContext))
1435+ }
1383 return true1436 return true
1384 }1437 }
1385 if supervisorNil(f.term) {1438 if supervisorNil(f.term) {
M internal/usecase/client/move_picker_overlay.go +9 -7
@@ -223,21 +223,23 @@ func (p *attachmentMovePicker) release() {
223223 }
224224
225225 // consumeInput offers one authorized delivery to the overlay that owns input.
226-// It reports true when the bytes were consumed and must not reach the session.
227-func (p *attachmentMovePicker) consumeInput(ctx context.Context, state outputApplyState, input AttachmentInputEvent) (bool, error) {
226+// It reports consumed when the bytes must not reach the session, and
227+// supervised when the supervisor-owned navigation picker consumed them: its
228+// action is then settled by the supervisor, never fenced through the daemon.
229+func (p *attachmentMovePicker) consumeInput(ctx context.Context, state outputApplyState, input AttachmentInputEvent) (consumed, supervised bool, err error) {
228230 if p.move.presenting {
229231 p.stopEscape()
230232 op, changed := p.move.input(input.Data, input.actionID)
231233 if err := p.applyOp(ctx, state, op, changed); err != nil {
232- return true, err
234+ return true, false, err
233235 }
234236 p.armEscape()
235- return true, nil
237+ return true, false, nil
236238 }
237- if p.overlay != nil && p.overlay.divertInput(input.Data) {
238- return true, nil
239+ if p.overlay != nil && p.overlay.divertInput(input.Data, input.actionID) {
240+ return true, true, nil
239241 }
240- return false, nil
242+ return false, false, nil
241243 }
242244
243245 // applyOp carries out one decoded decision: close sends the typed close and
@@ -223,21 +223,23 @@ func (p *attachmentMovePicker) release() {
223 }223 }
224 224
225 // consumeInput offers one authorized delivery to the overlay that owns input.225 // consumeInput offers one authorized delivery to the overlay that owns input.
226-// It reports true when the bytes were consumed and must not reach the session.226+// It reports consumed when the bytes must not reach the session, and
227-func (p *attachmentMovePicker) consumeInput(ctx context.Context, state outputApplyState, input AttachmentInputEvent) (bool, error) {227+// supervised when the supervisor-owned navigation picker consumed them: its
228+// action is then settled by the supervisor, never fenced through the daemon.
229+func (p *attachmentMovePicker) consumeInput(ctx context.Context, state outputApplyState, input AttachmentInputEvent) (consumed, supervised bool, err error) {
228 if p.move.presenting {230 if p.move.presenting {
229 p.stopEscape()231 p.stopEscape()
230 op, changed := p.move.input(input.Data, input.actionID)232 op, changed := p.move.input(input.Data, input.actionID)
231 if err := p.applyOp(ctx, state, op, changed); err != nil {233 if err := p.applyOp(ctx, state, op, changed); err != nil {
232- return true, err234+ return true, false, err
233 }235 }
234 p.armEscape()236 p.armEscape()
235- return true, nil237+ return true, false, nil
236 }238 }
237- if p.overlay != nil && p.overlay.divertInput(input.Data) {239+ if p.overlay != nil && p.overlay.divertInput(input.Data, input.actionID) {
238- return true, nil240+ return true, true, nil
239 }241 }
240- return false, nil242+ return false, false, nil
241 }243 }
242 244
243 // applyOp carries out one decoded decision: close sends the typed close and245 // applyOp carries out one decoded decision: close sends the typed close and
M internal/usecase/client/picker_overlay_test.go +130 -0
@@ -207,6 +207,136 @@ func TestPickerOverlayCommitElsewhereSwapsAttachment(t *testing.T) {
207207 }
208208 }
209209
210+// TestPickerOverlaySettlesUIActions pins UI-driver settlement across the
211+// client picker overlay: the action that opened it settles on the daemon
212+// receipt fenced on output the overlay applied without writing, an action the
213+// picker consumes settles at the supervisor boundary without a daemon fence,
214+// and a picker commit elsewhere follows the swap and settles on the
215+// destination's first committed publication.
216+func TestPickerOverlaySettlesUIActions(t *testing.T) {
217+ alpha := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "alpha"}
218+ beta := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "beta"}
219+ type actionOutcome struct {
220+ result ports.UIActionResult
221+ err error
222+ }
223+ tests := []struct {
224+ name string
225+ // overlayFirst opens the overlay before the action is admitted.
226+ overlayFirst bool
227+ keys []string
228+ // commit scripts a picker commit elsewhere for the consumed read.
229+ commit bool
230+ wantTarget protocol.ExactSessionTarget
231+ wantFence bool
232+ }{
233+ {name: "the opening action settles on its suppressed receipt", keys: []string{"Enter"}, wantTarget: alpha, wantFence: true},
234+ {name: "a picker-consumed action settles without a daemon fence", overlayFirst: true, keys: []string{"j"}, wantTarget: alpha},
235+ {name: "a picker commit elsewhere settles on the destination", overlayFirst: true, keys: []string{"Enter"}, commit: true, wantTarget: beta},
236+ }
237+ for _, tt := range tests {
238+ t.Run(tt.name, func(t *testing.T) {
239+ picker := newAttachTestPicker()
240+ var ui *UI
241+ harness := startAttachHarnessConfig(t, picker, func(cfg *SupervisorConfig) {
242+ ui = NewUI(cfg.Terminal.(ports.UIState), cfg.Clock)
243+ cfg.UI = ui
244+ })
245+ stream := attachLiveSession(t, harness, picker)
246+ attached := sessionTestOutput(2, "\x1b[Hattached")
247+ attached.Full, attached.Base, attached.New = false, 1, 2
248+ stream.deliver(attached)
249+ var snapshot ports.UISnapshot
250+ require.Eventually(t, func() bool {
251+ var err error
252+ snapshot, err = ui.Capture(ui.Handle())
253+ return err == nil && snapshot.Context.Status == ports.UIStatusAttached && snapshot.Context.ViewPublication == 2
254+ }, 5*time.Second, time.Millisecond)
255+ if tt.overlayFirst {
256+ stream.deliver(navigationOffer(1))
257+ awaitPresentation(t, harness.sup, PresentAttachedPicker)
258+ // The composition paints the overlay with the live attachment's
259+ // context, so the attachment stays the actionable generation.
260+ overlayContext, ok := ui.OverlayContext()
261+ require.True(t, ok)
262+ require.Equal(t, ports.UIStatusAttached, overlayContext.Status)
263+ require.Equal(t, snapshot.Context.Generation, overlayContext.Generation)
264+ require.Equal(t, alpha, overlayContext.Route.Target)
265+ }
266+ var mu sync.Mutex
267+ second := newSessionTestStream()
268+ if tt.commit {
269+ harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
270+ return second, nil
271+ })
272+ other := sessionTestRequest(true)
273+ other.Target = beta
274+ picker.decideOnConsume(pickerOp{commit: true}, other)
275+ }
276+ fences := countSent[protocol.UIFence](stream)
277+ consumed := picker.consumedCount()
278+
279+ done := make(chan actionOutcome, 1)
280+ go func() {
281+ result, err := ui.Action(t.Context(), ports.UIActionRequest{Attachment: ui.Handle(), Generation: snapshot.Context.Generation, Keys: tt.keys})
282+ done <- actionOutcome{result: result, err: err}
283+ }()
284+
285+ if !tt.overlayFirst {
286+ // The Enter reaches the daemon, whose command offers the picker;
287+ // the palette-close output then arrives under the overlay.
288+ fence := awaitSent(t, stream, "UIFence", isSent[protocol.UIFence]).(protocol.UIFence)
289+ stream.deliver(navigationOffer(1))
290+ awaitPresentation(t, harness.sup, PresentAttachedPicker)
291+ hidden := sessionTestOutput(3, "\x1b[Hhidden")
292+ hidden.Full, hidden.Base, hidden.New = false, 2, 3
293+ stream.deliver(hidden)
294+ require.Eventually(t, func() bool {
295+ ui.mu.Lock()
296+ defer ui.mu.Unlock()
297+ return ui.suppressed.ViewPublication == 3
298+ }, 5*time.Second, time.Millisecond, "the suppressed output was never applied")
299+ stream.deliver(protocol.UIReceipt{ActionID: fence.ActionID, Epoch: 1, State: 3, ViewPublication: 3, Outcome: protocol.UIReceiptProcessed})
300+ }
301+ if tt.commit {
302+ awaitSent(t, stream, "Detach", isSent[protocol.Detach])
303+ awaitStreamHello(t, &mu, &second)
304+ second.deliver(protocol.Welcome{SessionName: "beta"})
305+ output := sessionTestOutput(1, "\x1b[Hbeta")
306+ output.Context.Route.Target = beta
307+ second.deliver(output)
308+ }
309+
310+ var outcome actionOutcome
311+ select {
312+ case outcome = <-done:
313+ case <-time.After(5 * time.Second):
314+ t.Fatal("the UI action never settled across the picker overlay")
315+ }
316+ require.NoError(t, outcome.err)
317+ require.Equal(t, ports.UIActionProcessed, outcome.result.Status)
318+ require.Equal(t, ports.UIStatusAttached, outcome.result.Context.Status)
319+ require.Equal(t, tt.wantTarget, outcome.result.Context.Route.Target)
320+ require.NotContains(t, harness.terminal.written(), "hidden", "suppressed output is never written over the picker")
321+ if tt.overlayFirst {
322+ require.Equal(t, consumed+1, picker.consumedCount(), "the picker consumed the action's input")
323+ }
324+ if !tt.wantFence {
325+ require.Equal(t, fences, countSent[protocol.UIFence](stream), "a picker-consumed action never fences through the daemon")
326+ require.Zero(t, countSent[protocol.Input](stream), "picker input never reaches the session")
327+ }
328+ if tt.commit {
329+ require.Greater(t, outcome.result.Context.Generation, snapshot.Context.Generation, "the swap commits a new generation")
330+ opened := harness.service.openedRequests()
331+ require.Len(t, opened, 2)
332+ require.Equal(t, beta, opened[1].Target)
333+ require.Greater(t, opened[1].Stream, picker.lastResolvedStream(),
334+ "the swap opens a stream identity allocated at open time, not the one reserved at commit")
335+ }
336+ })
337+ }
338+}
339+
210340 // TestPickerCloseWithoutAttachment pins the unattached decision: with nothing
211341 // to return to, Escape and q keep the picker (and the process) alive; only the
212342 // explicit Ctrl+C exit ends the run.
@@ -207,6 +207,136 @@ func TestPickerOverlayCommitElsewhereSwapsAttachment(t *testing.T) {
207 }207 }
208 }208 }
209 209
210+// TestPickerOverlaySettlesUIActions pins UI-driver settlement across the
211+// client picker overlay: the action that opened it settles on the daemon
212+// receipt fenced on output the overlay applied without writing, an action the
213+// picker consumes settles at the supervisor boundary without a daemon fence,
214+// and a picker commit elsewhere follows the swap and settles on the
215+// destination's first committed publication.
216+func TestPickerOverlaySettlesUIActions(t *testing.T) {
217+ alpha := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "alpha"}
218+ beta := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "beta"}
219+ type actionOutcome struct {
220+ result ports.UIActionResult
221+ err error
222+ }
223+ tests := []struct {
224+ name string
225+ // overlayFirst opens the overlay before the action is admitted.
226+ overlayFirst bool
227+ keys []string
228+ // commit scripts a picker commit elsewhere for the consumed read.
229+ commit bool
230+ wantTarget protocol.ExactSessionTarget
231+ wantFence bool
232+ }{
233+ {name: "the opening action settles on its suppressed receipt", keys: []string{"Enter"}, wantTarget: alpha, wantFence: true},
234+ {name: "a picker-consumed action settles without a daemon fence", overlayFirst: true, keys: []string{"j"}, wantTarget: alpha},
235+ {name: "a picker commit elsewhere settles on the destination", overlayFirst: true, keys: []string{"Enter"}, commit: true, wantTarget: beta},
236+ }
237+ for _, tt := range tests {
238+ t.Run(tt.name, func(t *testing.T) {
239+ picker := newAttachTestPicker()
240+ var ui *UI
241+ harness := startAttachHarnessConfig(t, picker, func(cfg *SupervisorConfig) {
242+ ui = NewUI(cfg.Terminal.(ports.UIState), cfg.Clock)
243+ cfg.UI = ui
244+ })
245+ stream := attachLiveSession(t, harness, picker)
246+ attached := sessionTestOutput(2, "\x1b[Hattached")
247+ attached.Full, attached.Base, attached.New = false, 1, 2
248+ stream.deliver(attached)
249+ var snapshot ports.UISnapshot
250+ require.Eventually(t, func() bool {
251+ var err error
252+ snapshot, err = ui.Capture(ui.Handle())
253+ return err == nil && snapshot.Context.Status == ports.UIStatusAttached && snapshot.Context.ViewPublication == 2
254+ }, 5*time.Second, time.Millisecond)
255+ if tt.overlayFirst {
256+ stream.deliver(navigationOffer(1))
257+ awaitPresentation(t, harness.sup, PresentAttachedPicker)
258+ // The composition paints the overlay with the live attachment's
259+ // context, so the attachment stays the actionable generation.
260+ overlayContext, ok := ui.OverlayContext()
261+ require.True(t, ok)
262+ require.Equal(t, ports.UIStatusAttached, overlayContext.Status)
263+ require.Equal(t, snapshot.Context.Generation, overlayContext.Generation)
264+ require.Equal(t, alpha, overlayContext.Route.Target)
265+ }
266+ var mu sync.Mutex
267+ second := newSessionTestStream()
268+ if tt.commit {
269+ harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
270+ return second, nil
271+ })
272+ other := sessionTestRequest(true)
273+ other.Target = beta
274+ picker.decideOnConsume(pickerOp{commit: true}, other)
275+ }
276+ fences := countSent[protocol.UIFence](stream)
277+ consumed := picker.consumedCount()
278+
279+ done := make(chan actionOutcome, 1)
280+ go func() {
281+ result, err := ui.Action(t.Context(), ports.UIActionRequest{Attachment: ui.Handle(), Generation: snapshot.Context.Generation, Keys: tt.keys})
282+ done <- actionOutcome{result: result, err: err}
283+ }()
284+
285+ if !tt.overlayFirst {
286+ // The Enter reaches the daemon, whose command offers the picker;
287+ // the palette-close output then arrives under the overlay.
288+ fence := awaitSent(t, stream, "UIFence", isSent[protocol.UIFence]).(protocol.UIFence)
289+ stream.deliver(navigationOffer(1))
290+ awaitPresentation(t, harness.sup, PresentAttachedPicker)
291+ hidden := sessionTestOutput(3, "\x1b[Hhidden")
292+ hidden.Full, hidden.Base, hidden.New = false, 2, 3
293+ stream.deliver(hidden)
294+ require.Eventually(t, func() bool {
295+ ui.mu.Lock()
296+ defer ui.mu.Unlock()
297+ return ui.suppressed.ViewPublication == 3
298+ }, 5*time.Second, time.Millisecond, "the suppressed output was never applied")
299+ stream.deliver(protocol.UIReceipt{ActionID: fence.ActionID, Epoch: 1, State: 3, ViewPublication: 3, Outcome: protocol.UIReceiptProcessed})
300+ }
301+ if tt.commit {
302+ awaitSent(t, stream, "Detach", isSent[protocol.Detach])
303+ awaitStreamHello(t, &mu, &second)
304+ second.deliver(protocol.Welcome{SessionName: "beta"})
305+ output := sessionTestOutput(1, "\x1b[Hbeta")
306+ output.Context.Route.Target = beta
307+ second.deliver(output)
308+ }
309+
310+ var outcome actionOutcome
311+ select {
312+ case outcome = <-done:
313+ case <-time.After(5 * time.Second):
314+ t.Fatal("the UI action never settled across the picker overlay")
315+ }
316+ require.NoError(t, outcome.err)
317+ require.Equal(t, ports.UIActionProcessed, outcome.result.Status)
318+ require.Equal(t, ports.UIStatusAttached, outcome.result.Context.Status)
319+ require.Equal(t, tt.wantTarget, outcome.result.Context.Route.Target)
320+ require.NotContains(t, harness.terminal.written(), "hidden", "suppressed output is never written over the picker")
321+ if tt.overlayFirst {
322+ require.Equal(t, consumed+1, picker.consumedCount(), "the picker consumed the action's input")
323+ }
324+ if !tt.wantFence {
325+ require.Equal(t, fences, countSent[protocol.UIFence](stream), "a picker-consumed action never fences through the daemon")
326+ require.Zero(t, countSent[protocol.Input](stream), "picker input never reaches the session")
327+ }
328+ if tt.commit {
329+ require.Greater(t, outcome.result.Context.Generation, snapshot.Context.Generation, "the swap commits a new generation")
330+ opened := harness.service.openedRequests()
331+ require.Len(t, opened, 2)
332+ require.Equal(t, beta, opened[1].Target)
333+ require.Greater(t, opened[1].Stream, picker.lastResolvedStream(),
334+ "the swap opens a stream identity allocated at open time, not the one reserved at commit")
335+ }
336+ })
337+ }
338+}
339+
210 // TestPickerCloseWithoutAttachment pins the unattached decision: with nothing340 // TestPickerCloseWithoutAttachment pins the unattached decision: with nothing
211 // to return to, Escape and q keep the picker (and the process) alive; only the341 // to return to, Escape and q keep the picker (and the process) alive; only the
212 // explicit Ctrl+C exit ends the run.342 // explicit Ctrl+C exit ends the run.
M internal/usecase/client/supervisor_attach.go +12 -1
@@ -509,7 +509,16 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI
509509 }
510510 // A commit from the picker overlay to another target: the previous
511511 // attachment detached cleanly, and the swap goes through Connecting
512- // exactly like any committed selection.
512+ // exactly like any committed selection. The stream identity is
513+ // allocated now, not at commit: streams opened while the source was
514+ // live (route publications) may have moved the connection's
515+ // anti-replay window past an identity reserved earlier.
516+ stream, err := service.NextStreamID()
517+ if err != nil {
518+ s.reportAttachmentFailure(err)
519+ return false, nil
520+ }
521+ swap.request.Stream = stream
513522 target, localProvenance = *swap, SessionEnvironmentLocalPicker
514523 }
515524 }
@@ -633,6 +642,8 @@ settlement:
633642 overlay.publishPreview()
634643 case <-overlay.ops():
635644 overlay.takeOp()
645+ case consumed := <-s.attachments.OverlayActions():
646+ overlay.settleAction(consumed)
636647 case <-overlay.invalidation():
637648 overlay.resize()
638649 case outcome := <-s.kills.results():
@@ -509,7 +509,16 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI
509 }509 }
510 // A commit from the picker overlay to another target: the previous510 // A commit from the picker overlay to another target: the previous
511 // attachment detached cleanly, and the swap goes through Connecting511 // attachment detached cleanly, and the swap goes through Connecting
512- // exactly like any committed selection.512+ // exactly like any committed selection. The stream identity is
513+ // allocated now, not at commit: streams opened while the source was
514+ // live (route publications) may have moved the connection's
515+ // anti-replay window past an identity reserved earlier.
516+ stream, err := service.NextStreamID()
517+ if err != nil {
518+ s.reportAttachmentFailure(err)
519+ return false, nil
520+ }
521+ swap.request.Stream = stream
513 target, localProvenance = *swap, SessionEnvironmentLocalPicker522 target, localProvenance = *swap, SessionEnvironmentLocalPicker
514 }523 }
515 }524 }
@@ -633,6 +642,8 @@ settlement:
633 overlay.publishPreview()642 overlay.publishPreview()
634 case <-overlay.ops():643 case <-overlay.ops():
635 overlay.takeOp()644 overlay.takeOp()
645+ case consumed := <-s.attachments.OverlayActions():
646+ overlay.settleAction(consumed)
636 case <-overlay.invalidation():647 case <-overlay.invalidation():
637 overlay.resize()648 overlay.resize()
638 case outcome := <-s.kills.results():649 case outcome := <-s.kills.results():
M internal/usecase/client/supervisor_attach_test.go +28 -0
@@ -268,6 +268,12 @@ type attachTestPicker struct {
268268 resolveErr error
269269 request ports.BrokerOpenStreamRequest
270270 resolved int
271+ // consumeOp, when set, is the decision the next consumed read records,
272+ // exactly like the controller deciding a commit from an Enter keypress.
273+ consumeOp pickerOp
274+ consumeRequest ports.BrokerOpenStreamRequest
275+ // resolvedStream is the stream identity the last resolution reserved.
276+ resolvedStream ports.BrokerStreamID
271277 }
272278
273279 func newAttachTestPicker() *attachTestPicker {
@@ -283,9 +289,24 @@ func (p *attachTestPicker) ConsumeTerminalRead([]byte) bool {
283289 return false
284290 }
285291 p.consumed++
292+ if p.consumeOp != (pickerOp{}) {
293+ p.op, p.key, p.request = p.consumeOp, "row", p.consumeRequest
294+ p.consumeOp = pickerOp{}
295+ select {
296+ case p.opsReady <- struct{}{}:
297+ default:
298+ }
299+ }
286300 return true
287301 }
288302
303+// decideOnConsume scripts the decision the next consumed read records.
304+func (p *attachTestPicker) decideOnConsume(op pickerOp, request ports.BrokerOpenStreamRequest) {
305+ p.mu.Lock()
306+ defer p.mu.Unlock()
307+ p.consumeOp, p.consumeRequest = op, request
308+}
309+
289310 func (p *attachTestPicker) TakeOp() (pickerOp, string) {
290311 p.mu.Lock()
291312 defer p.mu.Unlock()
@@ -304,6 +325,7 @@ func (p *attachTestPicker) ResolveKey(key string, base pickerResolveBase) (ports
304325 request := p.request
305326 request.Connection = base.Connection
306327 request.Stream = base.Stream
328+ p.resolvedStream = base.Stream
307329 return request, nil
308330 }
309331
@@ -333,6 +355,12 @@ func (p *attachTestPicker) owns() bool {
333355 return p.ownsInput
334356 }
335357
358+func (p *attachTestPicker) lastResolvedStream() ports.BrokerStreamID {
359+ p.mu.Lock()
360+ defer p.mu.Unlock()
361+ return p.resolvedStream
362+}
363+
336364 func (p *attachTestPicker) consumedCount() int {
337365 p.mu.Lock()
338366 defer p.mu.Unlock()
@@ -268,6 +268,12 @@ type attachTestPicker struct {
268 resolveErr error268 resolveErr error
269 request ports.BrokerOpenStreamRequest269 request ports.BrokerOpenStreamRequest
270 resolved int270 resolved int
271+ // consumeOp, when set, is the decision the next consumed read records,
272+ // exactly like the controller deciding a commit from an Enter keypress.
273+ consumeOp pickerOp
274+ consumeRequest ports.BrokerOpenStreamRequest
275+ // resolvedStream is the stream identity the last resolution reserved.
276+ resolvedStream ports.BrokerStreamID
271 }277 }
272 278
273 func newAttachTestPicker() *attachTestPicker {279 func newAttachTestPicker() *attachTestPicker {
@@ -283,9 +289,24 @@ func (p *attachTestPicker) ConsumeTerminalRead([]byte) bool {
283 return false289 return false
284 }290 }
285 p.consumed++291 p.consumed++
292+ if p.consumeOp != (pickerOp{}) {
293+ p.op, p.key, p.request = p.consumeOp, "row", p.consumeRequest
294+ p.consumeOp = pickerOp{}
295+ select {
296+ case p.opsReady <- struct{}{}:
297+ default:
298+ }
299+ }
286 return true300 return true
287 }301 }
288 302
303+// decideOnConsume scripts the decision the next consumed read records.
304+func (p *attachTestPicker) decideOnConsume(op pickerOp, request ports.BrokerOpenStreamRequest) {
305+ p.mu.Lock()
306+ defer p.mu.Unlock()
307+ p.consumeOp, p.consumeRequest = op, request
308+}
309+
289 func (p *attachTestPicker) TakeOp() (pickerOp, string) {310 func (p *attachTestPicker) TakeOp() (pickerOp, string) {
290 p.mu.Lock()311 p.mu.Lock()
291 defer p.mu.Unlock()312 defer p.mu.Unlock()
@@ -304,6 +325,7 @@ func (p *attachTestPicker) ResolveKey(key string, base pickerResolveBase) (ports
304 request := p.request325 request := p.request
305 request.Connection = base.Connection326 request.Connection = base.Connection
306 request.Stream = base.Stream327 request.Stream = base.Stream
328+ p.resolvedStream = base.Stream
307 return request, nil329 return request, nil
308 }330 }
309 331
@@ -333,6 +355,12 @@ func (p *attachTestPicker) owns() bool {
333 return p.ownsInput355 return p.ownsInput
334 }356 }
335 357
358+func (p *attachTestPicker) lastResolvedStream() ports.BrokerStreamID {
359+ p.mu.Lock()
360+ defer p.mu.Unlock()
361+ return p.resolvedStream
362+}
363+
336 func (p *attachTestPicker) consumedCount() int {364 func (p *attachTestPicker) consumedCount() int {
337 p.mu.Lock()365 p.mu.Lock()
338 defer p.mu.Unlock()366 defer p.mu.Unlock()
M internal/usecase/client/supervisor_overlay.go +19 -0
@@ -155,6 +155,11 @@ func (o *attachmentPickerOverlay) takeOp() {
155155 o.exit()
156156 return
157157 }
158+ // The driver action that committed follows the swap and settles on
159+ // the destination's first committed publication.
160+ if s.cfg.UI != nil && o.run != nil && o.run.fg != nil {
161+ s.cfg.UI.followOverlay(o.run.fg.uiGeneration)
162+ }
158163 s.pendingSwap = &pickerAttachmentTarget{request: request, tab: tab}
159164 o.swapping = true
160165 s.preview.close(o.picker())
@@ -168,6 +173,20 @@ func (o *attachmentPickerOverlay) takeOp() {
168173 }
169174 }
170175
176+// settleAction completes one driver action the overlay consumed. The read's
177+// decision was recorded before the worker handed the action over, so any
178+// pending decision is carried out first: a commit elsewhere follows the action
179+// across the swap, and everything else settles it at this boundary after the
180+// picker repainted.
181+func (o *attachmentPickerOverlay) settleAction(consumed overlayActionConsumed) {
182+ if o.active && !o.swapping {
183+ o.takeOp()
184+ }
185+ if o.sup.cfg.UI != nil {
186+ o.sup.cfg.UI.settleOverlay(consumed.generation, consumed.actionID)
187+ }
188+}
189+
171190 // current names the attachment this overlay is presented over, so the picker
172191 // opens on its session and tab.
173192 func (o *attachmentPickerOverlay) current() pickerCurrent {
@@ -155,6 +155,11 @@ func (o *attachmentPickerOverlay) takeOp() {
155 o.exit()155 o.exit()
156 return156 return
157 }157 }
158+ // The driver action that committed follows the swap and settles on
159+ // the destination's first committed publication.
160+ if s.cfg.UI != nil && o.run != nil && o.run.fg != nil {
161+ s.cfg.UI.followOverlay(o.run.fg.uiGeneration)
162+ }
158 s.pendingSwap = &pickerAttachmentTarget{request: request, tab: tab}163 s.pendingSwap = &pickerAttachmentTarget{request: request, tab: tab}
159 o.swapping = true164 o.swapping = true
160 s.preview.close(o.picker())165 s.preview.close(o.picker())
@@ -168,6 +173,20 @@ func (o *attachmentPickerOverlay) takeOp() {
168 }173 }
169 }174 }
170 175
176+// settleAction completes one driver action the overlay consumed. The read's
177+// decision was recorded before the worker handed the action over, so any
178+// pending decision is carried out first: a commit elsewhere follows the action
179+// across the swap, and everything else settles it at this boundary after the
180+// picker repainted.
181+func (o *attachmentPickerOverlay) settleAction(consumed overlayActionConsumed) {
182+ if o.active && !o.swapping {
183+ o.takeOp()
184+ }
185+ if o.sup.cfg.UI != nil {
186+ o.sup.cfg.UI.settleOverlay(consumed.generation, consumed.actionID)
187+ }
188+}
189+
171 // current names the attachment this overlay is presented over, so the picker190 // current names the attachment this overlay is presented over, so the picker
172 // opens on its session and tab.191 // opens on its session and tab.
173 func (o *attachmentPickerOverlay) current() pickerCurrent {192 func (o *attachmentPickerOverlay) current() pickerCurrent {
M internal/usecase/client/ui.go +6 -0
@@ -38,6 +38,12 @@ type UI struct {
3838 dispatched map[uint64]bool
3939 completion map[uint64]chan struct{}
4040 handoff *uiActionHandoff
41+ // suppressed is the newest attached context the foreground applied but
42+ // did not write because the client picker overlay owned the terminal.
43+ suppressed ports.UIContext
44+ // overlayAction is the admitted action whose input the client picker
45+ // overlay consumed; the supervisor settles it at its event-loop boundary.
46+ overlayAction uint64
4147 }
4248
4349 type uiActionHandoff struct {
@@ -38,6 +38,12 @@ type UI struct {
38 dispatched map[uint64]bool38 dispatched map[uint64]bool
39 completion map[uint64]chan struct{}39 completion map[uint64]chan struct{}
40 handoff *uiActionHandoff40 handoff *uiActionHandoff
41+ // suppressed is the newest attached context the foreground applied but
42+ // did not write because the client picker overlay owned the terminal.
43+ suppressed ports.UIContext
44+ // overlayAction is the admitted action whose input the client picker
45+ // overlay consumed; the supervisor settles it at its event-loop boundary.
46+ overlayAction uint64
41 }47 }
42 48
43 type uiActionHandoff struct {49 type uiActionHandoff struct {
M internal/usecase/client/ui_actions.go +97 -2
@@ -17,6 +17,7 @@ func (u *UI) bindForeground(ctx context.Context, input *terminalInputPump, consu
1717 u.consumer = consumer
1818 u.foreground = ctx
1919 u.boundary = ports.UIActionResult{}
20+ u.suppressed = ports.UIContext{}
2021 if u.handoff != nil && u.handoff.destinationGeneration == 0 {
2122 u.handoff.destinationGeneration = u.generation
2223 } else if u.pending != 0 {
@@ -138,6 +139,9 @@ func (u *UI) finishLocked(id uint64, status ports.UIActionStatus, boundary ports
138139 if u.pending == id {
139140 u.pending = 0
140141 }
142+ if u.overlayAction == id {
143+ u.overlayAction = 0
144+ }
141145 u.signalLocked()
142146 }
143147
@@ -154,6 +158,7 @@ func (u *UI) published(generation uint64) {
154158 return
155159 }
156160 u.boundary = ports.UIActionResult{Revision: snapshot.Revision, Context: snapshot.Context}
161+ u.suppressed = ports.UIContext{}
157162 if u.handoff != nil && u.handoff.destinationGeneration == generation && snapshot.Context.Status == ports.UIStatusAttached {
158163 u.handoff.boundary = u.boundary
159164 if u.dispatched[u.handoff.actionID] {
@@ -178,10 +183,100 @@ func (u *UI) receipt(generation uint64, receipt protocol.UIReceipt) {
178183 return
179184 }
180185 boundary := u.boundary
181- if boundary.Context.OutputEpoch != receipt.Epoch || boundary.Context.OutputState != receipt.State || boundary.Context.ViewPublication != receipt.ViewPublication || boundary.Revision == 0 {
186+ if boundary.Revision == 0 {
187+ return
188+ }
189+ if receiptMatches(boundary.Context, receipt) {
190+ u.finishLocked(receipt.ActionID, ports.UIActionProcessed, boundary)
191+ return
192+ }
193+ // The client picker overlay suppressed the output that reached this
194+ // boundary: it was applied, not written. Its committed context is the
195+ // boundary, and the last written revision keeps later waits eligible.
196+ if u.suppressed.Generation == generation && receiptMatches(u.suppressed, receipt) {
197+ u.finishLocked(receipt.ActionID, ports.UIActionProcessed, ports.UIActionResult{Revision: boundary.Revision, Context: u.suppressed})
198+ }
199+}
200+
201+func receiptMatches(context ports.UIContext, receipt protocol.UIReceipt) bool {
202+ return context.OutputEpoch == receipt.Epoch && context.OutputState == receipt.State && context.ViewPublication == receipt.ViewPublication
203+}
204+
205+// suppressedOutput records the committed context of attachment output the
206+// client picker overlay applied without writing, so a daemon receipt fenced
207+// on it still settles its action.
208+func (u *UI) suppressedOutput(generation uint64, context ports.UIContext) {
209+ u.mu.Lock()
210+ defer u.mu.Unlock()
211+ if generation == 0 || generation != u.generation || context.Generation != generation || context.Status != ports.UIStatusAttached {
212+ return
213+ }
214+ u.suppressed = context
215+}
216+
217+// OverlayContext is the attached context a composition publishes while the
218+// client picker is composed over the live attachment. The attachment is still
219+// the actionable generation, so the picker frame keeps its session identity and
220+// generation instead of an unattached Picker presentation. It reports false when
221+// no attached boundary of the current foreground exists.
222+func (u *UI) OverlayContext() (ports.UIContext, bool) {
223+ if u == nil {
224+ return ports.UIContext{}, false
225+ }
226+ u.mu.Lock()
227+ defer u.mu.Unlock()
228+ context := u.boundary.Context
229+ if u.suppressed.Generation != 0 {
230+ context = u.suppressed
231+ }
232+ if u.input == nil || context.Generation == 0 || context.Generation != u.generation || context.Status != ports.UIStatusAttached {
233+ return ports.UIContext{}, false
234+ }
235+ return context, true
236+}
237+
238+// markOverlayInput records that the admitted action id is about to be consumed
239+// by the client picker overlay. It runs before the picker sees the bytes, so a
240+// commit the read decides can always find the action to follow.
241+func (u *UI) markOverlayInput(generation, id uint64) {
242+ u.mu.Lock()
243+ defer u.mu.Unlock()
244+ if id == 0 || generation != u.generation || u.pending != id {
182245 return
183246 }
184- u.finishLocked(receipt.ActionID, ports.UIActionProcessed, boundary)
247+ u.overlayAction = id
248+}
249+
250+// followOverlay retains the overlay-consumed action across the attachment swap
251+// a picker commit started, so it settles on the destination's first committed
252+// publication.
253+func (u *UI) followOverlay(generation uint64) {
254+ u.mu.Lock()
255+ id := u.overlayAction
256+ u.mu.Unlock()
257+ if id != 0 {
258+ u.follow(generation, id)
259+ }
260+}
261+
262+// settleOverlay completes one overlay-consumed action at the supervisor's
263+// event-loop boundary: the picker applied its input and repainted. A followed
264+// action is left to the destination publication.
265+func (u *UI) settleOverlay(generation, id uint64) {
266+ snapshot, err := u.state.Snapshot()
267+ u.mu.Lock()
268+ defer u.mu.Unlock()
269+ if id == 0 || u.overlayAction != id || u.pending != id || generation != u.generation {
270+ return
271+ }
272+ if u.handoff != nil && u.handoff.actionID == id {
273+ return
274+ }
275+ boundary := u.boundary
276+ if err == nil && snapshot.Context.Generation == generation && snapshot.Context.Status == ports.UIStatusAttached {
277+ boundary = ports.UIActionResult{Revision: snapshot.Revision, Context: snapshot.Context}
278+ }
279+ u.finishLocked(id, ports.UIActionProcessed, boundary)
185280 }
186281
187282 func (u *UI) Action(ctx context.Context, request ports.UIActionRequest) (ports.UIActionResult, error) {
@@ -17,6 +17,7 @@ func (u *UI) bindForeground(ctx context.Context, input *terminalInputPump, consu
17 u.consumer = consumer17 u.consumer = consumer
18 u.foreground = ctx18 u.foreground = ctx
19 u.boundary = ports.UIActionResult{}19 u.boundary = ports.UIActionResult{}
20+ u.suppressed = ports.UIContext{}
20 if u.handoff != nil && u.handoff.destinationGeneration == 0 {21 if u.handoff != nil && u.handoff.destinationGeneration == 0 {
21 u.handoff.destinationGeneration = u.generation22 u.handoff.destinationGeneration = u.generation
22 } else if u.pending != 0 {23 } else if u.pending != 0 {
@@ -138,6 +139,9 @@ func (u *UI) finishLocked(id uint64, status ports.UIActionStatus, boundary ports
138 if u.pending == id {139 if u.pending == id {
139 u.pending = 0140 u.pending = 0
140 }141 }
142+ if u.overlayAction == id {
143+ u.overlayAction = 0
144+ }
141 u.signalLocked()145 u.signalLocked()
142 }146 }
143 147
@@ -154,6 +158,7 @@ func (u *UI) published(generation uint64) {
154 return158 return
155 }159 }
156 u.boundary = ports.UIActionResult{Revision: snapshot.Revision, Context: snapshot.Context}160 u.boundary = ports.UIActionResult{Revision: snapshot.Revision, Context: snapshot.Context}
161+ u.suppressed = ports.UIContext{}
157 if u.handoff != nil && u.handoff.destinationGeneration == generation && snapshot.Context.Status == ports.UIStatusAttached {162 if u.handoff != nil && u.handoff.destinationGeneration == generation && snapshot.Context.Status == ports.UIStatusAttached {
158 u.handoff.boundary = u.boundary163 u.handoff.boundary = u.boundary
159 if u.dispatched[u.handoff.actionID] {164 if u.dispatched[u.handoff.actionID] {
@@ -178,10 +183,100 @@ func (u *UI) receipt(generation uint64, receipt protocol.UIReceipt) {
178 return183 return
179 }184 }
180 boundary := u.boundary185 boundary := u.boundary
181- if boundary.Context.OutputEpoch != receipt.Epoch || boundary.Context.OutputState != receipt.State || boundary.Context.ViewPublication != receipt.ViewPublication || boundary.Revision == 0 {186+ if boundary.Revision == 0 {
187+ return
188+ }
189+ if receiptMatches(boundary.Context, receipt) {
190+ u.finishLocked(receipt.ActionID, ports.UIActionProcessed, boundary)
191+ return
192+ }
193+ // The client picker overlay suppressed the output that reached this
194+ // boundary: it was applied, not written. Its committed context is the
195+ // boundary, and the last written revision keeps later waits eligible.
196+ if u.suppressed.Generation == generation && receiptMatches(u.suppressed, receipt) {
197+ u.finishLocked(receipt.ActionID, ports.UIActionProcessed, ports.UIActionResult{Revision: boundary.Revision, Context: u.suppressed})
198+ }
199+}
200+
201+func receiptMatches(context ports.UIContext, receipt protocol.UIReceipt) bool {
202+ return context.OutputEpoch == receipt.Epoch && context.OutputState == receipt.State && context.ViewPublication == receipt.ViewPublication
203+}
204+
205+// suppressedOutput records the committed context of attachment output the
206+// client picker overlay applied without writing, so a daemon receipt fenced
207+// on it still settles its action.
208+func (u *UI) suppressedOutput(generation uint64, context ports.UIContext) {
209+ u.mu.Lock()
210+ defer u.mu.Unlock()
211+ if generation == 0 || generation != u.generation || context.Generation != generation || context.Status != ports.UIStatusAttached {
212+ return
213+ }
214+ u.suppressed = context
215+}
216+
217+// OverlayContext is the attached context a composition publishes while the
218+// client picker is composed over the live attachment. The attachment is still
219+// the actionable generation, so the picker frame keeps its session identity and
220+// generation instead of an unattached Picker presentation. It reports false when
221+// no attached boundary of the current foreground exists.
222+func (u *UI) OverlayContext() (ports.UIContext, bool) {
223+ if u == nil {
224+ return ports.UIContext{}, false
225+ }
226+ u.mu.Lock()
227+ defer u.mu.Unlock()
228+ context := u.boundary.Context
229+ if u.suppressed.Generation != 0 {
230+ context = u.suppressed
231+ }
232+ if u.input == nil || context.Generation == 0 || context.Generation != u.generation || context.Status != ports.UIStatusAttached {
233+ return ports.UIContext{}, false
234+ }
235+ return context, true
236+}
237+
238+// markOverlayInput records that the admitted action id is about to be consumed
239+// by the client picker overlay. It runs before the picker sees the bytes, so a
240+// commit the read decides can always find the action to follow.
241+func (u *UI) markOverlayInput(generation, id uint64) {
242+ u.mu.Lock()
243+ defer u.mu.Unlock()
244+ if id == 0 || generation != u.generation || u.pending != id {
182 return245 return
183 }246 }
184- u.finishLocked(receipt.ActionID, ports.UIActionProcessed, boundary)247+ u.overlayAction = id
248+}
249+
250+// followOverlay retains the overlay-consumed action across the attachment swap
251+// a picker commit started, so it settles on the destination's first committed
252+// publication.
253+func (u *UI) followOverlay(generation uint64) {
254+ u.mu.Lock()
255+ id := u.overlayAction
256+ u.mu.Unlock()
257+ if id != 0 {
258+ u.follow(generation, id)
259+ }
260+}
261+
262+// settleOverlay completes one overlay-consumed action at the supervisor's
263+// event-loop boundary: the picker applied its input and repainted. A followed
264+// action is left to the destination publication.
265+func (u *UI) settleOverlay(generation, id uint64) {
266+ snapshot, err := u.state.Snapshot()
267+ u.mu.Lock()
268+ defer u.mu.Unlock()
269+ if id == 0 || u.overlayAction != id || u.pending != id || generation != u.generation {
270+ return
271+ }
272+ if u.handoff != nil && u.handoff.actionID == id {
273+ return
274+ }
275+ boundary := u.boundary
276+ if err == nil && snapshot.Context.Generation == generation && snapshot.Context.Status == ports.UIStatusAttached {
277+ boundary = ports.UIActionResult{Revision: snapshot.Revision, Context: snapshot.Context}
278+ }
279+ u.finishLocked(id, ports.UIActionProcessed, boundary)
185 }280 }
186 281
187 func (u *UI) Action(ctx context.Context, request ports.UIActionRequest) (ports.UIActionResult, error) {282 func (u *UI) Action(ctx context.Context, request ports.UIActionRequest) (ports.UIActionResult, error) {

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