detached f65f2e3

feat(client): keep attachment live under the picker overlay

SSP no longer detaches the attachment. The daemon sends a navigation
PickerOffer and opens no interaction; the attached worker asks the
supervisor for its client-owned picker, which enters the new
Attached+Picker presentation (ADR 001 Amendment 1):

- the foreground overlay slot, set under the output lease, suppresses
  attachment output (frames are still applied and acknowledged) and
  diverts every authorized input delivery to the picker, so the single
  terminal reader is kept and no picker byte reaches the session;
- broker publications, previews, and resizes repaint the overlay;
- Escape, q, and Ctrl+C, and a commit to the attached session, release
  the slot and the worker requests an authoritative repaint
  (OutputResetRequest); the attachment is never reconnected;
- a commit to another target sends a clean Detach and swaps through
  Connecting without passing through the plain picker.

The worker also presents the daemon's move-destination offers (MFP/MAT)
through the same overlay slot with the existing typed PickerSelection
and PickerClose replies, and declines a move offer it cannot present so
the daemon never keeps swallowing input.

Decision for a picker with no attachment (initial, or after loss): only
the explicit exit key ends the process. Ctrl+C is the explicit exit;
Escape and q are cancels, and with nothing to cancel back to they keep
the picker and show a "press Ctrl+C to quit" notice. Over a live
attachment all three cancel back to it. A lone Escape keypress now
resolves on its disambiguation window through ports.Clock instead of
waiting for another byte.
commit
f65f2e3773db5bc33f7632c2b3e5b48faacfbdde
author
brice · · 2026-09-22 19:44 UTC
parent
fff93b8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +1660 -26
M internal/usecase/client/attachment_session.go +82 -3
@@ -68,6 +68,9 @@ type sessionAttachmentConfig struct {
6868 // BeforeAttached runs after the initial frame commits but before attached
6969 // presentation becomes observable. A failure keeps the run unattached.
7070 BeforeAttached func() error
71+ // Clock bounds the move picker's lone-escape window. A nil clock uses the
72+ // system clock.
73+ Clock ports.Clock
7174 }
7275
7376 // sessionAttachmentWorker implements AttachmentWorker for one broker logical
@@ -87,6 +90,9 @@ func newSessionAttachmentWorker(cfg sessionAttachmentConfig) (*sessionAttachment
8790 }
8891 cfg.Request.Env = append([]string(nil), cfg.Request.Env...)
8992 cfg.SessionEnvironment = cfg.SessionEnvironment.Clone()
93+ if supervisorNil(cfg.Clock) {
94+ cfg.Clock = systemClock{}
95+ }
9096 return &sessionAttachmentWorker{cfg: cfg}, nil
9197 }
9298
@@ -188,6 +194,7 @@ func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, f
188194 return state, &event
189195 }
190196 state = next
197+ attachmentNoteCommitted(fg, state.context.Route.Target)
191198 // The frame transaction is the pre-attach publication: it commits the first
192199 // session bytes with the Connecting presentation and, through the foreground
193200 // shape rule, a public generation of zero. Attached is published only after
@@ -246,6 +253,18 @@ type attachmentClientEvent struct {
246253 action AttachmentLifecycleActionKind
247254 }
248255
256+// attachmentOverlay returns the optional overlay seam of fg, or nil.
257+func attachmentOverlay(fg AttachmentForeground) attachmentOverlayForeground {
258+ overlay, _ := fg.(attachmentOverlayForeground)
259+ return overlay
260+}
261+
262+func attachmentNoteCommitted(fg AttachmentForeground, target protocol.ExactSessionTarget) {
263+ if overlay := attachmentOverlay(fg); overlay != nil {
264+ overlay.noteCommitted(target)
265+ }
266+}
267+
249268 var (
250269 errDetachToPicker = errors.New("client: detach to picker")
251270 errDetachAndExit = errors.New("client: detach and exit")
@@ -261,8 +280,30 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
261280 go pumpAttachmentGeometry(pumpCtx, fg, outgoing)
262281 go pumpAttachmentLifecycle(pumpCtx, fg, outgoing)
263282 outputResetRequested := false
283+ overlay := attachmentOverlay(fg)
284+ var repaint <-chan struct{}
285+ if overlay != nil {
286+ repaint = overlay.overlayRepaint()
287+ }
288+ picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay()}
289+ defer picker.stopEscape()
290+ var inputSeq uint64
264291 for {
265292 select {
293+ case <-repaint:
294+ // An overlay released the terminal: the picker box is still on screen
295+ // and suppressed frames were never written, so ask the daemon for an
296+ // authoritative full repaint.
297+ if err := w.send(ctx, fg, stream, protocol.OutputResetRequest{}); err != nil {
298+ return w.settle(ctx, fg, stream, token, err)
299+ }
300+ outputResetRequested = true
301+ case <-picker.escape():
302+ picker.escapeFired()
303+ op, changed := picker.move.flush()
304+ if err := picker.applyOp(ctx, state, op, changed); err != nil {
305+ return w.settle(ctx, fg, stream, token, err)
306+ }
266307 case result := <-incoming:
267308 if result.err != nil {
268309 return w.settle(ctx, fg, stream, token, result.err)
@@ -275,6 +316,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
275316 }
276317 state = next
277318 outputResetRequested = false
319+ attachmentNoteCommitted(fg, state.context.Route.Target)
278320 if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), typed.Data); err != nil {
279321 return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing output: %w", err)}
280322 }
@@ -298,6 +340,16 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
298340 }
299341 case protocol.UIReceipt:
300342 attachmentUIReceipt(fg, typed)
343+ case protocol.PickerOffer:
344+ if err := picker.offer(ctx, typed); err != nil {
345+ return w.settle(ctx, fg, stream, token, err)
346+ }
347+ case protocol.PickerSnapshot:
348+ if err := picker.snapshot(ctx, state, typed); err != nil {
349+ return w.settle(ctx, fg, stream, token, err)
350+ }
351+ case protocol.PickerClosed:
352+ picker.closed(typed.InteractionID)
301353 case protocol.ErrorMsg:
302354 return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: typed.Code, Text: typed.Text}}
303355 case protocol.Detached:
@@ -311,6 +363,32 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
311363 fg.PreserveInput(event.input.Data)
312364 return w.settle(ctx, fg, stream, token, event.input.Err)
313365 }
366+ if event.input != nil {
367+ consumed, err := picker.consumeInput(ctx, state, *event.input)
368+ if err != nil {
369+ fg.PreserveInput(event.input.Data)
370+ return w.settle(ctx, fg, stream, token, err)
371+ }
372+ if consumed {
373+ // The overlay owned these bytes: nothing reaches the session.
374+ // A driver action still fences through the daemon so it
375+ // completes after the overlay applied it.
376+ fg.AckInput()
377+ if event.input.actionID != 0 {
378+ if err := w.send(ctx, fg, stream, protocol.UIFence{ActionID: event.input.actionID}); err != nil {
379+ return w.settle(ctx, fg, stream, token, err)
380+ }
381+ }
382+ continue
383+ }
384+ inputSeq++
385+ event.message = protocol.Input{InputSeq: inputSeq, ActionID: event.input.actionID, Data: append([]byte(nil), event.input.Data...)}
386+ }
387+ if resize, ok := event.message.(protocol.Resize); ok {
388+ if err := picker.resize(ctx, state, resize.Size); err != nil {
389+ return w.settle(ctx, fg, stream, token, err)
390+ }
391+ }
314392 if err := w.send(ctx, fg, stream, event.message); err != nil {
315393 if event.input != nil {
316394 fg.PreserveInput(event.input.Data)
@@ -364,8 +442,10 @@ func attachmentUIReceipt(fg AttachmentForeground, receipt protocol.UIReceipt) {
364442 }
365443 }
366444
445+// pumpAttachmentInput forwards each authorized delivery to the attached loop.
446+// The loop decides whether an overlay owns it or it becomes session Input, and
447+// sequences only the deliveries that actually reach the session.
367448 func pumpAttachmentInput(ctx context.Context, fg AttachmentForeground, out chan<- attachmentClientEvent) {
368- var sequence uint64
369449 for {
370450 event, ok := fg.Input(ctx)
371451 if !ok {
@@ -379,10 +459,9 @@ func pumpAttachmentInput(ctx context.Context, fg AttachmentForeground, out chan<
379459 }
380460 return
381461 }
382- sequence++
383462 copyEvent := event
384463 select {
385- case out <- attachmentClientEvent{message: protocol.Input{InputSeq: sequence, ActionID: event.actionID, Data: append([]byte(nil), event.Data...)}, input: &copyEvent}:
464+ case out <- attachmentClientEvent{input: &copyEvent}:
386465 case <-ctx.Done():
387466 fg.PreserveInput(event.Data)
388467 return
@@ -68,6 +68,9 @@ type sessionAttachmentConfig struct {
68 // BeforeAttached runs after the initial frame commits but before attached68 // BeforeAttached runs after the initial frame commits but before attached
69 // presentation becomes observable. A failure keeps the run unattached.69 // presentation becomes observable. A failure keeps the run unattached.
70 BeforeAttached func() error70 BeforeAttached func() error
71+ // Clock bounds the move picker's lone-escape window. A nil clock uses the
72+ // system clock.
73+ Clock ports.Clock
71 }74 }
72 75
73 // sessionAttachmentWorker implements AttachmentWorker for one broker logical76 // sessionAttachmentWorker implements AttachmentWorker for one broker logical
@@ -87,6 +90,9 @@ func newSessionAttachmentWorker(cfg sessionAttachmentConfig) (*sessionAttachment
87 }90 }
88 cfg.Request.Env = append([]string(nil), cfg.Request.Env...)91 cfg.Request.Env = append([]string(nil), cfg.Request.Env...)
89 cfg.SessionEnvironment = cfg.SessionEnvironment.Clone()92 cfg.SessionEnvironment = cfg.SessionEnvironment.Clone()
93+ if supervisorNil(cfg.Clock) {
94+ cfg.Clock = systemClock{}
95+ }
90 return &sessionAttachmentWorker{cfg: cfg}, nil96 return &sessionAttachmentWorker{cfg: cfg}, nil
91 }97 }
92 98
@@ -188,6 +194,7 @@ func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, f
188 return state, &event194 return state, &event
189 }195 }
190 state = next196 state = next
197+ attachmentNoteCommitted(fg, state.context.Route.Target)
191 // The frame transaction is the pre-attach publication: it commits the first198 // The frame transaction is the pre-attach publication: it commits the first
192 // session bytes with the Connecting presentation and, through the foreground199 // session bytes with the Connecting presentation and, through the foreground
193 // shape rule, a public generation of zero. Attached is published only after200 // shape rule, a public generation of zero. Attached is published only after
@@ -246,6 +253,18 @@ type attachmentClientEvent struct {
246 action AttachmentLifecycleActionKind253 action AttachmentLifecycleActionKind
247 }254 }
248 255
256+// attachmentOverlay returns the optional overlay seam of fg, or nil.
257+func attachmentOverlay(fg AttachmentForeground) attachmentOverlayForeground {
258+ overlay, _ := fg.(attachmentOverlayForeground)
259+ return overlay
260+}
261+
262+func attachmentNoteCommitted(fg AttachmentForeground, target protocol.ExactSessionTarget) {
263+ if overlay := attachmentOverlay(fg); overlay != nil {
264+ overlay.noteCommitted(target)
265+ }
266+}
267+
249 var (268 var (
250 errDetachToPicker = errors.New("client: detach to picker")269 errDetachToPicker = errors.New("client: detach to picker")
251 errDetachAndExit = errors.New("client: detach and exit")270 errDetachAndExit = errors.New("client: detach and exit")
@@ -261,8 +280,30 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
261 go pumpAttachmentGeometry(pumpCtx, fg, outgoing)280 go pumpAttachmentGeometry(pumpCtx, fg, outgoing)
262 go pumpAttachmentLifecycle(pumpCtx, fg, outgoing)281 go pumpAttachmentLifecycle(pumpCtx, fg, outgoing)
263 outputResetRequested := false282 outputResetRequested := false
283+ overlay := attachmentOverlay(fg)
284+ var repaint <-chan struct{}
285+ if overlay != nil {
286+ repaint = overlay.overlayRepaint()
287+ }
288+ picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay()}
289+ defer picker.stopEscape()
290+ var inputSeq uint64
264 for {291 for {
265 select {292 select {
293+ case <-repaint:
294+ // An overlay released the terminal: the picker box is still on screen
295+ // and suppressed frames were never written, so ask the daemon for an
296+ // authoritative full repaint.
297+ if err := w.send(ctx, fg, stream, protocol.OutputResetRequest{}); err != nil {
298+ return w.settle(ctx, fg, stream, token, err)
299+ }
300+ outputResetRequested = true
301+ case <-picker.escape():
302+ picker.escapeFired()
303+ op, changed := picker.move.flush()
304+ if err := picker.applyOp(ctx, state, op, changed); err != nil {
305+ return w.settle(ctx, fg, stream, token, err)
306+ }
266 case result := <-incoming:307 case result := <-incoming:
267 if result.err != nil {308 if result.err != nil {
268 return w.settle(ctx, fg, stream, token, result.err)309 return w.settle(ctx, fg, stream, token, result.err)
@@ -275,6 +316,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
275 }316 }
276 state = next317 state = next
277 outputResetRequested = false318 outputResetRequested = false
319+ attachmentNoteCommitted(fg, state.context.Route.Target)
278 if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), typed.Data); err != nil {320 if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), typed.Data); err != nil {
279 return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing output: %w", err)}321 return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing output: %w", err)}
280 }322 }
@@ -298,6 +340,16 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
298 }340 }
299 case protocol.UIReceipt:341 case protocol.UIReceipt:
300 attachmentUIReceipt(fg, typed)342 attachmentUIReceipt(fg, typed)
343+ case protocol.PickerOffer:
344+ if err := picker.offer(ctx, typed); err != nil {
345+ return w.settle(ctx, fg, stream, token, err)
346+ }
347+ case protocol.PickerSnapshot:
348+ if err := picker.snapshot(ctx, state, typed); err != nil {
349+ return w.settle(ctx, fg, stream, token, err)
350+ }
351+ case protocol.PickerClosed:
352+ picker.closed(typed.InteractionID)
301 case protocol.ErrorMsg:353 case protocol.ErrorMsg:
302 return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: typed.Code, Text: typed.Text}}354 return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: typed.Code, Text: typed.Text}}
303 case protocol.Detached:355 case protocol.Detached:
@@ -311,6 +363,32 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
311 fg.PreserveInput(event.input.Data)363 fg.PreserveInput(event.input.Data)
312 return w.settle(ctx, fg, stream, token, event.input.Err)364 return w.settle(ctx, fg, stream, token, event.input.Err)
313 }365 }
366+ if event.input != nil {
367+ consumed, err := picker.consumeInput(ctx, state, *event.input)
368+ if err != nil {
369+ fg.PreserveInput(event.input.Data)
370+ return w.settle(ctx, fg, stream, token, err)
371+ }
372+ if consumed {
373+ // The overlay owned these bytes: nothing reaches the session.
374+ // A driver action still fences through the daemon so it
375+ // completes after the overlay applied it.
376+ fg.AckInput()
377+ if event.input.actionID != 0 {
378+ if err := w.send(ctx, fg, stream, protocol.UIFence{ActionID: event.input.actionID}); err != nil {
379+ return w.settle(ctx, fg, stream, token, err)
380+ }
381+ }
382+ continue
383+ }
384+ inputSeq++
385+ event.message = protocol.Input{InputSeq: inputSeq, ActionID: event.input.actionID, Data: append([]byte(nil), event.input.Data...)}
386+ }
387+ if resize, ok := event.message.(protocol.Resize); ok {
388+ if err := picker.resize(ctx, state, resize.Size); err != nil {
389+ return w.settle(ctx, fg, stream, token, err)
390+ }
391+ }
314 if err := w.send(ctx, fg, stream, event.message); err != nil {392 if err := w.send(ctx, fg, stream, event.message); err != nil {
315 if event.input != nil {393 if event.input != nil {
316 fg.PreserveInput(event.input.Data)394 fg.PreserveInput(event.input.Data)
@@ -364,8 +442,10 @@ func attachmentUIReceipt(fg AttachmentForeground, receipt protocol.UIReceipt) {
364 }442 }
365 }443 }
366 444
445+// pumpAttachmentInput forwards each authorized delivery to the attached loop.
446+// The loop decides whether an overlay owns it or it becomes session Input, and
447+// sequences only the deliveries that actually reach the session.
367 func pumpAttachmentInput(ctx context.Context, fg AttachmentForeground, out chan<- attachmentClientEvent) {448 func pumpAttachmentInput(ctx context.Context, fg AttachmentForeground, out chan<- attachmentClientEvent) {
368- var sequence uint64
369 for {449 for {
370 event, ok := fg.Input(ctx)450 event, ok := fg.Input(ctx)
371 if !ok {451 if !ok {
@@ -379,10 +459,9 @@ func pumpAttachmentInput(ctx context.Context, fg AttachmentForeground, out chan<
379 }459 }
380 return460 return
381 }461 }
382- sequence++
383 copyEvent := event462 copyEvent := event
384 select {463 select {
385- case out <- attachmentClientEvent{message: protocol.Input{InputSeq: sequence, ActionID: event.actionID, Data: append([]byte(nil), event.Data...)}, input: &copyEvent}:464+ case out <- attachmentClientEvent{input: &copyEvent}:
386 case <-ctx.Done():465 case <-ctx.Done():
387 fg.PreserveInput(event.Data)466 fg.PreserveInput(event.Data)
388 return467 return
M internal/usecase/client/attachment_worker.go +284 -0
@@ -392,6 +392,14 @@ type attachmentHost struct {
392392 geometryCancel context.CancelFunc
393393 geometryDone chan struct{}
394394 ownerBoundary bool
395+
396+ // navigationRequests carries a coalesced request from the attached worker
397+ // to compose the client's session picker over the live attachment (the
398+ // daemon's navigation PickerOffer). Only the supervisor consumes it.
399+ navigationRequests chan struct{}
400+ // internalActions carries supervisor-originated lifecycle decisions for the
401+ // current foreground, such as the detach that precedes an attachment swap.
402+ internalActions chan AttachmentLifecycleAction
395403 }
396404
397405 // newAttachmentHost builds the reusable foreground host. A nil clock falls back
@@ -429,6 +437,8 @@ func newAttachmentHost(cfg attachmentHostConfig) *attachmentHost {
429437 geometryValid: initialGeometryValid,
430438 geometryUpdate: make(chan struct{}, 1),
431439 presentationUpdate: make(chan struct{}, 1),
440+ navigationRequests: make(chan struct{}, 1),
441+ internalActions: make(chan AttachmentLifecycleAction, 1),
432442 }
433443 }
434444
@@ -460,6 +470,9 @@ func (h *attachmentHost) Begin(ctx context.Context, token AttachmentToken, worke
460470 cancel()
461471 return nil, false
462472 }
473+ // A request or action left behind by a previous foreground never applies
474+ // to this one.
475+ h.drainForegroundSignals()
463476 if h.actionUI != nil {
464477 fg.uiGeneration = h.actionUI.bindForeground(workerCtx, fg.input, fg.consumer)
465478 }
@@ -595,7 +608,245 @@ func (h *attachmentHost) newForeground(token AttachmentToken, stream ports.Broke
595608 done: make(chan struct{}),
596609 decided: true,
597610 onAttached: h.onAttach,
611+ repaint: make(chan struct{}, 1),
612+ }
613+}
614+
615+// drainForegroundSignals drops any navigation request or internal lifecycle
616+// action that belonged to an earlier foreground.
617+func (h *attachmentHost) drainForegroundSignals() {
618+ for {
619+ select {
620+ case <-h.navigationRequests:
621+ case <-h.internalActions:
622+ default:
623+ return
624+ }
625+ }
626+}
627+
628+// NavigationRequests wakes the supervisor when the attached worker asked for
629+// the client picker over the live attachment.
630+func (h *attachmentHost) NavigationRequests() <-chan struct{} {
631+ if h == nil {
632+ return nil
633+ }
634+ return h.navigationRequests
635+}
636+
637+// beginNavigationOverlay composes the supervisor-owned picker over the current
638+// foreground: from its return, attachment output is applied and acknowledged
639+// but never written, and every authorized input delivery is handed to sink. It
640+// reports false when no active foreground can host the overlay.
641+func (h *attachmentHost) beginNavigationOverlay(sink pickerInputConsumer) bool {
642+ if h == nil || sink == nil {
643+ return false
644+ }
645+ fg := h.authority.foreground()
646+ if fg == nil {
647+ return false
648+ }
649+ return fg.setOverlay(attachmentOverlayNavigation, sink)
650+}
651+
652+// endNavigationOverlay returns the terminal to the current foreground and asks
653+// its worker for an authoritative repaint over the picker box.
654+func (h *attachmentHost) endNavigationOverlay() {
655+ if h == nil {
656+ return
657+ }
658+ if fg := h.authority.foreground(); fg != nil {
659+ fg.clearOverlay(attachmentOverlayNavigation)
660+ }
661+}
662+
663+// committedTarget reports the session the current foreground last committed
664+// output for, so the supervisor can tell a commit to the attached session from
665+// a commit elsewhere.
666+func (h *attachmentHost) committedTarget() (protocol.ExactSessionTarget, bool) {
667+ if h == nil {
668+ return protocol.ExactSessionTarget{}, false
669+ }
670+ fg := h.authority.foreground()
671+ if fg == nil {
672+ return protocol.ExactSessionTarget{}, false
673+ }
674+ fg.overlayMu.Lock()
675+ defer fg.overlayMu.Unlock()
676+ return fg.committed, fg.committedKnown
677+}
678+
679+// committedTargetOrZero is committedTarget with an unknown target as zero.
680+func (h *attachmentHost) committedTargetOrZero() protocol.ExactSessionTarget {
681+ target, ok := h.committedTarget()
682+ if !ok {
683+ return protocol.ExactSessionTarget{}
684+ }
685+ return target
686+}
687+
688+// requestDetach asks the current foreground's worker to detach explicitly, so
689+// the daemon observes a clean Detach before the supervisor swaps attachments.
690+func (h *attachmentHost) requestDetach(token AttachmentToken) {
691+ if h == nil {
692+ return
693+ }
694+ select {
695+ case h.internalActions <- AttachmentLifecycleAction{Token: token, Kind: AttachmentDetachToPicker}:
696+ default:
697+ }
698+}
699+
700+// attachmentOverlayKind names the client-composed overlay that owns the
701+// terminal over a live attachment.
702+type attachmentOverlayKind uint8
703+
704+const (
705+ attachmentOverlayNone attachmentOverlayKind = iota
706+ // attachmentOverlayNavigation is the supervisor-owned session picker.
707+ attachmentOverlayNavigation
708+ // attachmentOverlayMove is the worker-owned move-destination picker the
709+ // serving daemon offered.
710+ attachmentOverlayMove
711+)
712+
713+// attachmentOverlayForeground is the optional overlay seam of the real
714+// foreground. A worker reaches it by assertion so scripted foregrounds may
715+// omit it; without it, overlays are simply never presented.
716+type attachmentOverlayForeground interface {
717+ requestNavigationPicker()
718+ setOverlay(kind attachmentOverlayKind, sink pickerInputConsumer) bool
719+ clearOverlay(kind attachmentOverlayKind)
720+ overlayKind() attachmentOverlayKind
721+ divertInput(data []byte) bool
722+ overlayRepaint() <-chan struct{}
723+ overlayOutput(uiContext ports.UIContext, data []byte) error
724+ noteCommitted(target protocol.ExactSessionTarget)
725+}
726+
727+var _ attachmentOverlayForeground = (*attachmentForeground)(nil)
728+
729+// requestNavigationPicker records the daemon's navigation offer for the
730+// supervisor. The request is coalesced; the supervisor decides whether the
731+// current presentation can host the overlay.
732+func (f *attachmentForeground) requestNavigationPicker() {
733+ if f == nil || f.host == nil || !f.authority.actionAuthorized(f) {
734+ return
735+ }
736+ select {
737+ case f.host.navigationRequests <- struct{}{}:
738+ default:
739+ }
740+}
741+
742+// setOverlay installs kind under the output lease, so no attachment frame is
743+// mid-write when the overlay takes the terminal. An occupied slot refuses a
744+// different overlay.
745+func (f *attachmentForeground) setOverlay(kind attachmentOverlayKind, sink pickerInputConsumer) bool {
746+ if f == nil || kind == attachmentOverlayNone {
747+ return false
748+ }
749+ installed := false
750+ f.lease.send(func() bool {
751+ if !f.authority.actionAuthorized(f) {
752+ return false
753+ }
754+ f.overlayMu.Lock()
755+ defer f.overlayMu.Unlock()
756+ if f.overlay != attachmentOverlayNone && f.overlay != kind {
757+ return true
758+ }
759+ f.overlay = kind
760+ f.overlaySink = sink
761+ installed = true
762+ return true
763+ })
764+ return installed
765+}
766+
767+// clearOverlay releases kind under the output lease and asks the worker for an
768+// authoritative repaint: the terminal still shows the picker box and any
769+// attachment frames it suppressed were never written.
770+func (f *attachmentForeground) clearOverlay(kind attachmentOverlayKind) {
771+ if f == nil {
772+ return
773+ }
774+ cleared := false
775+ f.lease.send(func() bool {
776+ f.overlayMu.Lock()
777+ defer f.overlayMu.Unlock()
778+ if f.overlay != kind {
779+ return true
780+ }
781+ f.overlay = attachmentOverlayNone
782+ f.overlaySink = nil
783+ cleared = true
784+ return true
785+ })
786+ if !cleared {
787+ // The lease stops at finalization; the overlay dies with the grant.
788+ f.overlayMu.Lock()
789+ if f.overlay == kind {
790+ f.overlay = attachmentOverlayNone
791+ f.overlaySink = nil
792+ }
793+ f.overlayMu.Unlock()
794+ return
795+ }
796+ select {
797+ case f.repaint <- struct{}{}:
798+ default:
799+ }
800+}
801+
802+func (f *attachmentForeground) overlayKind() attachmentOverlayKind {
803+ if f == nil {
804+ return attachmentOverlayNone
598805 }
806+ f.overlayMu.Lock()
807+ defer f.overlayMu.Unlock()
808+ return f.overlay
809+}
810+
811+// divertInput hands one authorized delivery to the supervisor-owned overlay.
812+// It reports false when no navigation overlay owns input, so the worker keeps
813+// its session path.
814+func (f *attachmentForeground) divertInput(data []byte) bool {
815+ if f == nil {
816+ return false
817+ }
818+ f.overlayMu.Lock()
819+ sink := f.overlaySink
820+ kind := f.overlay
821+ f.overlayMu.Unlock()
822+ if kind != attachmentOverlayNavigation || sink == nil {
823+ return false
824+ }
825+ sink.ConsumeTerminalRead(data)
826+ return true
827+}
828+
829+func (f *attachmentForeground) overlayRepaint() <-chan struct{} {
830+ if f == nil {
831+ return nil
832+ }
833+ return f.repaint
834+}
835+
836+func (f *attachmentForeground) noteCommitted(target protocol.ExactSessionTarget) {
837+ if f == nil {
838+ return
839+ }
840+ f.overlayMu.Lock()
841+ f.committed = target
842+ f.committedKnown = true
843+ f.overlayMu.Unlock()
844+}
845+
846+// overlayOutput writes one overlay frame through the same lease and UI
847+// transaction as attachment output, bypassing only the overlay suppression.
848+func (f *attachmentForeground) overlayOutput(uiContext ports.UIContext, data []byte) error {
849+ return f.write(uiContext, data, true)
599850 }
600851
601852 // attachmentRun is one in-flight worker generation owned by the supervisor.
@@ -726,6 +977,18 @@ type attachmentForeground struct {
726977 geometrySeq uint64
727978 attached atomic.Bool
728979 onAttached func(AttachmentToken)
980+
981+ // overlayMu guards the overlay slot and the committed target. The slot is
982+ // changed only inside the output lease, so an overlay never takes the
983+ // terminal while an attachment frame is mid-write.
984+ overlayMu sync.Mutex
985+ overlay attachmentOverlayKind
986+ overlaySink pickerInputConsumer
987+ committed protocol.ExactSessionTarget
988+ committedKnown bool
989+ // repaint wakes the worker to request an authoritative repaint once an
990+ // overlay released the terminal.
991+ repaint chan struct{}
729992 }
730993
731994 // Token is the generation/attempt identity of this grant.
@@ -904,6 +1167,10 @@ func (f *attachmentForeground) Lifecycle(ctx context.Context) (AttachmentLifecyc
9041167 if !f.authority.actionAuthorized(f) {
9051168 return 0, false
9061169 }
1170+ var internal <-chan AttachmentLifecycleAction
1171+ if f.host != nil {
1172+ internal = f.host.internalActions
1173+ }
9071174 select {
9081175 case action, ok := <-f.actions:
9091176 if !ok || !f.authority.actionAuthorized(f) {
@@ -913,6 +1180,11 @@ func (f *attachmentForeground) Lifecycle(ctx context.Context) (AttachmentLifecyc
9131180 continue
9141181 }
9151182 return action.Kind, action.Kind == AttachmentDetachToPicker || action.Kind == AttachmentDetachAndExit
1183+ case action := <-internal:
1184+ if action.Token != f.token || !f.authority.actionAuthorized(f) {
1185+ continue
1186+ }
1187+ return action.Kind, action.Kind == AttachmentDetachToPicker || action.Kind == AttachmentDetachAndExit
9161188 case <-ctx.Done():
9171189 return 0, false
9181190 case <-f.done:
@@ -973,6 +1245,15 @@ func (f *attachmentForeground) Resize(ctx context.Context) (domain.Geometry, boo
9731245 // flush, and EndOutput(true) commit the frame; any failed or short write or
9741246 // failed flush leaves EndOutput(false).
9751247 func (f *attachmentForeground) Output(uiContext ports.UIContext, data []byte) error {
1248+ return f.write(uiContext, data, false)
1249+}
1250+
1251+// write is the shared output transaction. While an overlay owns the terminal an
1252+// attachment frame (overlay=false) is accepted without being written or
1253+// published: the caller still applies and acknowledges it, and the overlay's
1254+// release requests an authoritative repaint. Overlay frames bypass only that
1255+// suppression.
1256+func (f *attachmentForeground) write(uiContext ports.UIContext, data []byte, overlay bool) error {
9761257 if f == nil {
9771258 return errAttachmentForegroundRevoked
9781259 }
@@ -981,6 +1262,9 @@ func (f *attachmentForeground) Output(uiContext ports.UIContext, data []byte) er
9811262 if !f.authority.actionAuthorized(f) {
9821263 return false
9831264 }
1265+ if !overlay && f.overlayKind() != attachmentOverlayNone {
1266+ return true
1267+ }
9841268 if supervisorNil(f.term) {
9851269 outputErr = ports.ErrUIUnavailable
9861270 return true
@@ -392,6 +392,14 @@ type attachmentHost struct {
392 geometryCancel context.CancelFunc392 geometryCancel context.CancelFunc
393 geometryDone chan struct{}393 geometryDone chan struct{}
394 ownerBoundary bool394 ownerBoundary bool
395+
396+ // navigationRequests carries a coalesced request from the attached worker
397+ // to compose the client's session picker over the live attachment (the
398+ // daemon's navigation PickerOffer). Only the supervisor consumes it.
399+ navigationRequests chan struct{}
400+ // internalActions carries supervisor-originated lifecycle decisions for the
401+ // current foreground, such as the detach that precedes an attachment swap.
402+ internalActions chan AttachmentLifecycleAction
395 }403 }
396 404
397 // newAttachmentHost builds the reusable foreground host. A nil clock falls back405 // newAttachmentHost builds the reusable foreground host. A nil clock falls back
@@ -429,6 +437,8 @@ func newAttachmentHost(cfg attachmentHostConfig) *attachmentHost {
429 geometryValid: initialGeometryValid,437 geometryValid: initialGeometryValid,
430 geometryUpdate: make(chan struct{}, 1),438 geometryUpdate: make(chan struct{}, 1),
431 presentationUpdate: make(chan struct{}, 1),439 presentationUpdate: make(chan struct{}, 1),
440+ navigationRequests: make(chan struct{}, 1),
441+ internalActions: make(chan AttachmentLifecycleAction, 1),
432 }442 }
433 }443 }
434 444
@@ -460,6 +470,9 @@ func (h *attachmentHost) Begin(ctx context.Context, token AttachmentToken, worke
460 cancel()470 cancel()
461 return nil, false471 return nil, false
462 }472 }
473+ // A request or action left behind by a previous foreground never applies
474+ // to this one.
475+ h.drainForegroundSignals()
463 if h.actionUI != nil {476 if h.actionUI != nil {
464 fg.uiGeneration = h.actionUI.bindForeground(workerCtx, fg.input, fg.consumer)477 fg.uiGeneration = h.actionUI.bindForeground(workerCtx, fg.input, fg.consumer)
465 }478 }
@@ -595,7 +608,245 @@ func (h *attachmentHost) newForeground(token AttachmentToken, stream ports.Broke
595 done: make(chan struct{}),608 done: make(chan struct{}),
596 decided: true,609 decided: true,
597 onAttached: h.onAttach,610 onAttached: h.onAttach,
611+ repaint: make(chan struct{}, 1),
612+ }
613+}
614+
615+// drainForegroundSignals drops any navigation request or internal lifecycle
616+// action that belonged to an earlier foreground.
617+func (h *attachmentHost) drainForegroundSignals() {
618+ for {
619+ select {
620+ case <-h.navigationRequests:
621+ case <-h.internalActions:
622+ default:
623+ return
624+ }
625+ }
626+}
627+
628+// NavigationRequests wakes the supervisor when the attached worker asked for
629+// the client picker over the live attachment.
630+func (h *attachmentHost) NavigationRequests() <-chan struct{} {
631+ if h == nil {
632+ return nil
633+ }
634+ return h.navigationRequests
635+}
636+
637+// beginNavigationOverlay composes the supervisor-owned picker over the current
638+// foreground: from its return, attachment output is applied and acknowledged
639+// but never written, and every authorized input delivery is handed to sink. It
640+// reports false when no active foreground can host the overlay.
641+func (h *attachmentHost) beginNavigationOverlay(sink pickerInputConsumer) bool {
642+ if h == nil || sink == nil {
643+ return false
644+ }
645+ fg := h.authority.foreground()
646+ if fg == nil {
647+ return false
648+ }
649+ return fg.setOverlay(attachmentOverlayNavigation, sink)
650+}
651+
652+// endNavigationOverlay returns the terminal to the current foreground and asks
653+// its worker for an authoritative repaint over the picker box.
654+func (h *attachmentHost) endNavigationOverlay() {
655+ if h == nil {
656+ return
657+ }
658+ if fg := h.authority.foreground(); fg != nil {
659+ fg.clearOverlay(attachmentOverlayNavigation)
660+ }
661+}
662+
663+// committedTarget reports the session the current foreground last committed
664+// output for, so the supervisor can tell a commit to the attached session from
665+// a commit elsewhere.
666+func (h *attachmentHost) committedTarget() (protocol.ExactSessionTarget, bool) {
667+ if h == nil {
668+ return protocol.ExactSessionTarget{}, false
669+ }
670+ fg := h.authority.foreground()
671+ if fg == nil {
672+ return protocol.ExactSessionTarget{}, false
673+ }
674+ fg.overlayMu.Lock()
675+ defer fg.overlayMu.Unlock()
676+ return fg.committed, fg.committedKnown
677+}
678+
679+// committedTargetOrZero is committedTarget with an unknown target as zero.
680+func (h *attachmentHost) committedTargetOrZero() protocol.ExactSessionTarget {
681+ target, ok := h.committedTarget()
682+ if !ok {
683+ return protocol.ExactSessionTarget{}
684+ }
685+ return target
686+}
687+
688+// requestDetach asks the current foreground's worker to detach explicitly, so
689+// the daemon observes a clean Detach before the supervisor swaps attachments.
690+func (h *attachmentHost) requestDetach(token AttachmentToken) {
691+ if h == nil {
692+ return
693+ }
694+ select {
695+ case h.internalActions <- AttachmentLifecycleAction{Token: token, Kind: AttachmentDetachToPicker}:
696+ default:
697+ }
698+}
699+
700+// attachmentOverlayKind names the client-composed overlay that owns the
701+// terminal over a live attachment.
702+type attachmentOverlayKind uint8
703+
704+const (
705+ attachmentOverlayNone attachmentOverlayKind = iota
706+ // attachmentOverlayNavigation is the supervisor-owned session picker.
707+ attachmentOverlayNavigation
708+ // attachmentOverlayMove is the worker-owned move-destination picker the
709+ // serving daemon offered.
710+ attachmentOverlayMove
711+)
712+
713+// attachmentOverlayForeground is the optional overlay seam of the real
714+// foreground. A worker reaches it by assertion so scripted foregrounds may
715+// omit it; without it, overlays are simply never presented.
716+type attachmentOverlayForeground interface {
717+ requestNavigationPicker()
718+ setOverlay(kind attachmentOverlayKind, sink pickerInputConsumer) bool
719+ clearOverlay(kind attachmentOverlayKind)
720+ overlayKind() attachmentOverlayKind
721+ divertInput(data []byte) bool
722+ overlayRepaint() <-chan struct{}
723+ overlayOutput(uiContext ports.UIContext, data []byte) error
724+ noteCommitted(target protocol.ExactSessionTarget)
725+}
726+
727+var _ attachmentOverlayForeground = (*attachmentForeground)(nil)
728+
729+// requestNavigationPicker records the daemon's navigation offer for the
730+// supervisor. The request is coalesced; the supervisor decides whether the
731+// current presentation can host the overlay.
732+func (f *attachmentForeground) requestNavigationPicker() {
733+ if f == nil || f.host == nil || !f.authority.actionAuthorized(f) {
734+ return
735+ }
736+ select {
737+ case f.host.navigationRequests <- struct{}{}:
738+ default:
739+ }
740+}
741+
742+// setOverlay installs kind under the output lease, so no attachment frame is
743+// mid-write when the overlay takes the terminal. An occupied slot refuses a
744+// different overlay.
745+func (f *attachmentForeground) setOverlay(kind attachmentOverlayKind, sink pickerInputConsumer) bool {
746+ if f == nil || kind == attachmentOverlayNone {
747+ return false
748+ }
749+ installed := false
750+ f.lease.send(func() bool {
751+ if !f.authority.actionAuthorized(f) {
752+ return false
753+ }
754+ f.overlayMu.Lock()
755+ defer f.overlayMu.Unlock()
756+ if f.overlay != attachmentOverlayNone && f.overlay != kind {
757+ return true
758+ }
759+ f.overlay = kind
760+ f.overlaySink = sink
761+ installed = true
762+ return true
763+ })
764+ return installed
765+}
766+
767+// clearOverlay releases kind under the output lease and asks the worker for an
768+// authoritative repaint: the terminal still shows the picker box and any
769+// attachment frames it suppressed were never written.
770+func (f *attachmentForeground) clearOverlay(kind attachmentOverlayKind) {
771+ if f == nil {
772+ return
773+ }
774+ cleared := false
775+ f.lease.send(func() bool {
776+ f.overlayMu.Lock()
777+ defer f.overlayMu.Unlock()
778+ if f.overlay != kind {
779+ return true
780+ }
781+ f.overlay = attachmentOverlayNone
782+ f.overlaySink = nil
783+ cleared = true
784+ return true
785+ })
786+ if !cleared {
787+ // The lease stops at finalization; the overlay dies with the grant.
788+ f.overlayMu.Lock()
789+ if f.overlay == kind {
790+ f.overlay = attachmentOverlayNone
791+ f.overlaySink = nil
792+ }
793+ f.overlayMu.Unlock()
794+ return
795+ }
796+ select {
797+ case f.repaint <- struct{}{}:
798+ default:
799+ }
800+}
801+
802+func (f *attachmentForeground) overlayKind() attachmentOverlayKind {
803+ if f == nil {
804+ return attachmentOverlayNone
598 }805 }
806+ f.overlayMu.Lock()
807+ defer f.overlayMu.Unlock()
808+ return f.overlay
809+}
810+
811+// divertInput hands one authorized delivery to the supervisor-owned overlay.
812+// It reports false when no navigation overlay owns input, so the worker keeps
813+// its session path.
814+func (f *attachmentForeground) divertInput(data []byte) bool {
815+ if f == nil {
816+ return false
817+ }
818+ f.overlayMu.Lock()
819+ sink := f.overlaySink
820+ kind := f.overlay
821+ f.overlayMu.Unlock()
822+ if kind != attachmentOverlayNavigation || sink == nil {
823+ return false
824+ }
825+ sink.ConsumeTerminalRead(data)
826+ return true
827+}
828+
829+func (f *attachmentForeground) overlayRepaint() <-chan struct{} {
830+ if f == nil {
831+ return nil
832+ }
833+ return f.repaint
834+}
835+
836+func (f *attachmentForeground) noteCommitted(target protocol.ExactSessionTarget) {
837+ if f == nil {
838+ return
839+ }
840+ f.overlayMu.Lock()
841+ f.committed = target
842+ f.committedKnown = true
843+ f.overlayMu.Unlock()
844+}
845+
846+// overlayOutput writes one overlay frame through the same lease and UI
847+// transaction as attachment output, bypassing only the overlay suppression.
848+func (f *attachmentForeground) overlayOutput(uiContext ports.UIContext, data []byte) error {
849+ return f.write(uiContext, data, true)
599 }850 }
600 851
601 // attachmentRun is one in-flight worker generation owned by the supervisor.852 // attachmentRun is one in-flight worker generation owned by the supervisor.
@@ -726,6 +977,18 @@ type attachmentForeground struct {
726 geometrySeq uint64977 geometrySeq uint64
727 attached atomic.Bool978 attached atomic.Bool
728 onAttached func(AttachmentToken)979 onAttached func(AttachmentToken)
980+
981+ // overlayMu guards the overlay slot and the committed target. The slot is
982+ // changed only inside the output lease, so an overlay never takes the
983+ // terminal while an attachment frame is mid-write.
984+ overlayMu sync.Mutex
985+ overlay attachmentOverlayKind
986+ overlaySink pickerInputConsumer
987+ committed protocol.ExactSessionTarget
988+ committedKnown bool
989+ // repaint wakes the worker to request an authoritative repaint once an
990+ // overlay released the terminal.
991+ repaint chan struct{}
729 }992 }
730 993
731 // Token is the generation/attempt identity of this grant.994 // Token is the generation/attempt identity of this grant.
@@ -904,6 +1167,10 @@ func (f *attachmentForeground) Lifecycle(ctx context.Context) (AttachmentLifecyc
904 if !f.authority.actionAuthorized(f) {1167 if !f.authority.actionAuthorized(f) {
905 return 0, false1168 return 0, false
906 }1169 }
1170+ var internal <-chan AttachmentLifecycleAction
1171+ if f.host != nil {
1172+ internal = f.host.internalActions
1173+ }
907 select {1174 select {
908 case action, ok := <-f.actions:1175 case action, ok := <-f.actions:
909 if !ok || !f.authority.actionAuthorized(f) {1176 if !ok || !f.authority.actionAuthorized(f) {
@@ -913,6 +1180,11 @@ func (f *attachmentForeground) Lifecycle(ctx context.Context) (AttachmentLifecyc
913 continue1180 continue
914 }1181 }
915 return action.Kind, action.Kind == AttachmentDetachToPicker || action.Kind == AttachmentDetachAndExit1182 return action.Kind, action.Kind == AttachmentDetachToPicker || action.Kind == AttachmentDetachAndExit
1183+ case action := <-internal:
1184+ if action.Token != f.token || !f.authority.actionAuthorized(f) {
1185+ continue
1186+ }
1187+ return action.Kind, action.Kind == AttachmentDetachToPicker || action.Kind == AttachmentDetachAndExit
916 case <-ctx.Done():1188 case <-ctx.Done():
917 return 0, false1189 return 0, false
918 case <-f.done:1190 case <-f.done:
@@ -973,6 +1245,15 @@ func (f *attachmentForeground) Resize(ctx context.Context) (domain.Geometry, boo
973 // flush, and EndOutput(true) commit the frame; any failed or short write or1245 // flush, and EndOutput(true) commit the frame; any failed or short write or
974 // failed flush leaves EndOutput(false).1246 // failed flush leaves EndOutput(false).
975 func (f *attachmentForeground) Output(uiContext ports.UIContext, data []byte) error {1247 func (f *attachmentForeground) Output(uiContext ports.UIContext, data []byte) error {
1248+ return f.write(uiContext, data, false)
1249+}
1250+
1251+// write is the shared output transaction. While an overlay owns the terminal an
1252+// attachment frame (overlay=false) is accepted without being written or
1253+// published: the caller still applies and acknowledges it, and the overlay's
1254+// release requests an authoritative repaint. Overlay frames bypass only that
1255+// suppression.
1256+func (f *attachmentForeground) write(uiContext ports.UIContext, data []byte, overlay bool) error {
976 if f == nil {1257 if f == nil {
977 return errAttachmentForegroundRevoked1258 return errAttachmentForegroundRevoked
978 }1259 }
@@ -981,6 +1262,9 @@ func (f *attachmentForeground) Output(uiContext ports.UIContext, data []byte) er
981 if !f.authority.actionAuthorized(f) {1262 if !f.authority.actionAuthorized(f) {
982 return false1263 return false
983 }1264 }
1265+ if !overlay && f.overlayKind() != attachmentOverlayNone {
1266+ return true
1267+ }
984 if supervisorNil(f.term) {1268 if supervisorNil(f.term) {
985 outputErr = ports.ErrUIUnavailable1269 outputErr = ports.ErrUIUnavailable
986 return true1270 return true
A internal/usecase/client/move_picker_overlay.go +318 -0
@@ -0,0 +1,318 @@
1+package client
2+
3+import (
4+ "context"
5+ "fmt"
6+ "time"
7+
8+ ansirenderer "github.com/bnema/vev-vt/ansi"
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/protocol"
12+)
13+
14+// Move-destination picker over a live attachment (Plan 003 A2).
15+//
16+// The serving daemon still owns move semantics: it captures the source when
17+// MFP/MAT runs, publishes the destination rows in a PickerSnapshot, resolves
18+// the opaque key of a typed PickerSelection, and confirms the end of the
19+// interaction with PickerClosed. The attachment worker only presents it: it
20+// composes the box over the attachment through the foreground's overlay slot,
21+// decodes the input the overlay owns, and answers with the existing typed
22+// PickerSelection/PickerClose replies. Zero picker bytes reach the session.
23+//
24+// Every method runs on the worker goroutine, which is the overlay's only
25+// owner, so it holds no lock of its own.
26+
27+// movePickerInputGeneration is the fixed input generation of the worker-owned
28+// move overlay; the interaction ID alone names the namespace.
29+const movePickerInputGeneration = uint64(1)
30+
31+type movePickerOverlay struct {
32+ namespace pickerInteraction
33+ intent protocol.PickerIntent
34+ loop *pickerLoop
35+ consumer pickerConsumer
36+ renderer *pickerRenderer
37+ // presenting reports that the foreground overlay slot is held for this
38+ // interaction.
39+ presenting bool
40+}
41+
42+func newMovePickerOverlay() *movePickerOverlay {
43+ return &movePickerOverlay{renderer: newPickerRenderer(ansirenderer.ColorProfileTrueColor)}
44+}
45+
46+// offer opens the namespace a move offer names. A superseding offer retires
47+// the previous interaction, whose rows can no longer be committed; the caller
48+// releases the overlay when superseded reports true.
49+func (m *movePickerOverlay) offer(offer protocol.PickerOffer) (superseded bool) {
50+ superseded = m.presenting && m.namespace.interaction != offer.InteractionID
51+ if superseded {
52+ m.retire()
53+ }
54+ m.namespace.setOpen(true, offer.InteractionID)
55+ m.namespace.setIntent(offer.InteractionID, offer.Intent)
56+ m.intent = offer.Intent
57+ m.loop = nil
58+ return superseded
59+}
60+
61+// admit folds one daemon publication into the model. It reports whether the
62+// snapshot belongs to the open interaction and is newer than what is shown.
63+func (m *movePickerOverlay) admit(snapshot protocol.PickerSnapshot) bool {
64+ if !m.namespace.open || m.intent == protocol.PickerIntentNavigation || m.intent == 0 {
65+ return false
66+ }
67+ if !m.namespace.admitSnapshot(snapshot, true) {
68+ return false
69+ }
70+ if m.loop == nil {
71+ m.loop = pickerLoopFromSnapshot(snapshot, m.intent, defaultPickerSort())
72+ m.consumer.setOwned(snapshot.InteractionID, movePickerInputGeneration)
73+ return true
74+ }
75+ m.loop.replaceLines(snapshot)
76+ return true
77+}
78+
79+// interaction names the open namespace, or zero.
80+func (m *movePickerOverlay) interaction() uint64 {
81+ if !m.namespace.open {
82+ return 0
83+ }
84+ return m.namespace.interaction
85+}
86+
87+// closedBy reports whether a daemon close retires the open interaction.
88+func (m *movePickerOverlay) closedBy(interaction uint64) bool {
89+ return m.namespace.open && m.namespace.interaction == interaction
90+}
91+
92+// retire ends the interaction locally. A late snapshot for it is refused.
93+func (m *movePickerOverlay) retire() {
94+ if m.namespace.open {
95+ m.namespace.setOpen(false, m.namespace.interaction)
96+ }
97+ m.loop = nil
98+ m.presenting = false
99+ m.consumer.clear()
100+}
101+
102+// input decodes one owned delivery and applies it to the model.
103+func (m *movePickerOverlay) input(data []byte, actionID uint64) (pickerOp, bool) {
104+ outcome, consumed := m.consumer.consume(terminalReadResult{data: data, actionID: actionID})
105+ return m.apply(outcome, consumed)
106+}
107+
108+// flush resolves a withheld lone escape once its window elapsed.
109+func (m *movePickerOverlay) flush() (pickerOp, bool) {
110+ outcome, consumed := m.consumer.flushPending()
111+ return m.apply(outcome, consumed)
112+}
113+
114+func (m *movePickerOverlay) apply(outcome pickerConsumeOutcome, consumed bool) (pickerOp, bool) {
115+ if !consumed || m.loop == nil || !outcome.acceptOutcome(m.namespace.interaction, movePickerInputGeneration) {
116+ return pickerOp{}, false
117+ }
118+ return applyPickerBatch(m.loop, outcome.events)
119+}
120+
121+// selection builds the typed move for the cursor row, refusing rows the daemon
122+// did not authorise for a move.
123+func (m *movePickerOverlay) selection(causeActionID uint64) (protocol.PickerSelection, bool) {
124+ action, ok := m.loop.selectedAction()
125+ if !ok || action != protocol.PickerActionMove {
126+ return protocol.PickerSelection{}, false
127+ }
128+ return commitSelection(m.loop, action, causeActionID)
129+}
130+
131+// render composes the box for one terminal size.
132+func (m *movePickerOverlay) render(size domain.Size) []byte {
133+ if m.loop == nil {
134+ return nil
135+ }
136+ return m.renderer.render(m.loop, size, emptyPickerPreview())
137+}
138+
139+// attachmentMovePicker binds the move overlay and the navigation request to
140+// one attached worker loop: it owns the foreground overlay slot while a move
141+// interaction is presented and answers the daemon on the worker's stream.
142+type attachmentMovePicker struct {
143+ worker *sessionAttachmentWorker
144+ fg AttachmentForeground
145+ overlay attachmentOverlayForeground
146+ stream ports.BrokerLogicalConnection
147+ size domain.Size
148+ move *movePickerOverlay
149+ // escapeTimer bounds a withheld lone escape while the move overlay owns
150+ // input; nil while nothing is withheld.
151+ escapeTimer ports.Timer
152+}
153+
154+// offer handles one daemon PickerOffer. A navigation offer asks the
155+// supervisor for the client's session picker over this attachment; a move
156+// offer opens the namespace whose snapshot the overlay will present.
157+func (p *attachmentMovePicker) offer(ctx context.Context, offer protocol.PickerOffer) error {
158+ if p.overlay == nil {
159+ // No overlay seam: never leave the daemon swallowing input for an
160+ // interaction nothing presents.
161+ return p.decline(ctx, offer)
162+ }
163+ if offer.Intent == protocol.PickerIntentNavigation {
164+ if p.move.presenting {
165+ return nil
166+ }
167+ p.overlay.requestNavigationPicker()
168+ return nil
169+ }
170+ if p.overlay.overlayKind() == attachmentOverlayNavigation {
171+ // The session picker owns the terminal; a move picker cannot be
172+ // presented under it, so release the daemon's interaction at once.
173+ return p.decline(ctx, offer)
174+ }
175+ if p.move.offer(offer) {
176+ p.stopEscape()
177+ p.overlay.clearOverlay(attachmentOverlayMove)
178+ }
179+ return nil
180+}
181+
182+// decline closes a move interaction the client will not present.
183+func (p *attachmentMovePicker) decline(ctx context.Context, offer protocol.PickerOffer) error {
184+ if offer.Intent == protocol.PickerIntentNavigation {
185+ return nil
186+ }
187+ return p.worker.send(ctx, p.fg, p.stream, protocol.PickerClose{InteractionID: offer.InteractionID})
188+}
189+
190+// snapshot presents one daemon publication. The first admitted snapshot takes
191+// the foreground overlay slot; a refused slot declines the interaction.
192+func (p *attachmentMovePicker) snapshot(ctx context.Context, state outputApplyState, snapshot protocol.PickerSnapshot) error {
193+ if p.overlay == nil || !p.move.admit(snapshot) {
194+ return nil
195+ }
196+ if !p.move.presenting {
197+ if !p.overlay.setOverlay(attachmentOverlayMove, nil) {
198+ interaction := p.move.interaction()
199+ p.move.retire()
200+ return p.worker.send(ctx, p.fg, p.stream, protocol.PickerClose{InteractionID: interaction})
201+ }
202+ p.move.presenting = true
203+ p.move.renderer.invalidate()
204+ }
205+ return p.paint(state)
206+}
207+
208+// closed retires the interaction the daemon closed and returns the terminal to
209+// the attachment.
210+func (p *attachmentMovePicker) closed(interaction uint64) {
211+ if !p.move.closedBy(interaction) {
212+ return
213+ }
214+ p.release()
215+}
216+
217+func (p *attachmentMovePicker) release() {
218+ presenting := p.move.presenting
219+ p.move.retire()
220+ p.stopEscape()
221+ if presenting && p.overlay != nil {
222+ p.overlay.clearOverlay(attachmentOverlayMove)
223+ }
224+}
225+
226+// consumeInput offers one authorized delivery to the overlay that owns input.
227+// It reports true when the bytes were consumed and must not reach the session.
228+func (p *attachmentMovePicker) consumeInput(ctx context.Context, state outputApplyState, input AttachmentInputEvent) (bool, error) {
229+ if p.move.presenting {
230+ p.stopEscape()
231+ op, changed := p.move.input(input.Data, input.actionID)
232+ if err := p.applyOp(ctx, state, op, changed); err != nil {
233+ return true, err
234+ }
235+ p.armEscape()
236+ return true, nil
237+ }
238+ if p.overlay != nil && p.overlay.divertInput(input.Data) {
239+ return true, nil
240+ }
241+ return false, nil
242+}
243+
244+// applyOp carries out one decoded decision: close sends the typed close and
245+// releases the terminal, commit sends the typed move, and a changed model
246+// repaints the box.
247+func (p *attachmentMovePicker) applyOp(ctx context.Context, state outputApplyState, op pickerOp, changed bool) error {
248+ if !p.move.presenting {
249+ return nil
250+ }
251+ if op.close {
252+ interaction := p.move.interaction()
253+ p.release()
254+ return p.worker.send(ctx, p.fg, p.stream, protocol.PickerClose{InteractionID: interaction})
255+ }
256+ if op.commit {
257+ if selection, ok := p.move.selection(0); ok {
258+ if err := p.worker.send(ctx, p.fg, p.stream, selection); err != nil {
259+ return err
260+ }
261+ }
262+ }
263+ if changed {
264+ return p.paint(state)
265+ }
266+ return nil
267+}
268+
269+// resize repaints the presented box at the new terminal size.
270+func (p *attachmentMovePicker) resize(_ context.Context, state outputApplyState, size domain.Size) error {
271+ p.size = size
272+ if !p.move.presenting {
273+ return nil
274+ }
275+ return p.paint(state)
276+}
277+
278+func (p *attachmentMovePicker) paint(state outputApplyState) error {
279+ frame := p.move.render(p.size)
280+ if len(frame) == 0 {
281+ return nil
282+ }
283+ uiContext := state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(p.fg, p.fg.Token())}, ports.UIStatusAttached)
284+ if err := p.overlay.overlayOutput(uiContext, frame); err != nil {
285+ return fmt.Errorf("vev: publishing move picker: %w", err)
286+ }
287+ return nil
288+}
289+
290+// escape is the armed lone-escape window, or nil.
291+func (p *attachmentMovePicker) escape() <-chan time.Time {
292+ if p.escapeTimer == nil {
293+ return nil
294+ }
295+ return p.escapeTimer.C()
296+}
297+
298+func (p *attachmentMovePicker) armEscape() {
299+ if p.escapeTimer != nil || !p.move.presenting || !p.move.consumer.hasPending() {
300+ return
301+ }
302+ timer := p.worker.cfg.Clock.NewTimer(pickerEscapeDeadline)
303+ if supervisorNil(timer) {
304+ return
305+ }
306+ p.escapeTimer = timer
307+}
308+
309+// escapeFired forgets the timer whose window just elapsed.
310+func (p *attachmentMovePicker) escapeFired() { p.escapeTimer = nil }
311+
312+func (p *attachmentMovePicker) stopEscape() {
313+ if p.escapeTimer == nil {
314+ return
315+ }
316+ stopSupervisorTimer(p.escapeTimer)
317+ p.escapeTimer = nil
318+}
@@ -0,0 +1,318 @@
1+package client
2+
3+import (
4+ "context"
5+ "fmt"
6+ "time"
7+
8+ ansirenderer "github.com/bnema/vev-vt/ansi"
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/protocol"
12+)
13+
14+// Move-destination picker over a live attachment (Plan 003 A2).
15+//
16+// The serving daemon still owns move semantics: it captures the source when
17+// MFP/MAT runs, publishes the destination rows in a PickerSnapshot, resolves
18+// the opaque key of a typed PickerSelection, and confirms the end of the
19+// interaction with PickerClosed. The attachment worker only presents it: it
20+// composes the box over the attachment through the foreground's overlay slot,
21+// decodes the input the overlay owns, and answers with the existing typed
22+// PickerSelection/PickerClose replies. Zero picker bytes reach the session.
23+//
24+// Every method runs on the worker goroutine, which is the overlay's only
25+// owner, so it holds no lock of its own.
26+
27+// movePickerInputGeneration is the fixed input generation of the worker-owned
28+// move overlay; the interaction ID alone names the namespace.
29+const movePickerInputGeneration = uint64(1)
30+
31+type movePickerOverlay struct {
32+ namespace pickerInteraction
33+ intent protocol.PickerIntent
34+ loop *pickerLoop
35+ consumer pickerConsumer
36+ renderer *pickerRenderer
37+ // presenting reports that the foreground overlay slot is held for this
38+ // interaction.
39+ presenting bool
40+}
41+
42+func newMovePickerOverlay() *movePickerOverlay {
43+ return &movePickerOverlay{renderer: newPickerRenderer(ansirenderer.ColorProfileTrueColor)}
44+}
45+
46+// offer opens the namespace a move offer names. A superseding offer retires
47+// the previous interaction, whose rows can no longer be committed; the caller
48+// releases the overlay when superseded reports true.
49+func (m *movePickerOverlay) offer(offer protocol.PickerOffer) (superseded bool) {
50+ superseded = m.presenting && m.namespace.interaction != offer.InteractionID
51+ if superseded {
52+ m.retire()
53+ }
54+ m.namespace.setOpen(true, offer.InteractionID)
55+ m.namespace.setIntent(offer.InteractionID, offer.Intent)
56+ m.intent = offer.Intent
57+ m.loop = nil
58+ return superseded
59+}
60+
61+// admit folds one daemon publication into the model. It reports whether the
62+// snapshot belongs to the open interaction and is newer than what is shown.
63+func (m *movePickerOverlay) admit(snapshot protocol.PickerSnapshot) bool {
64+ if !m.namespace.open || m.intent == protocol.PickerIntentNavigation || m.intent == 0 {
65+ return false
66+ }
67+ if !m.namespace.admitSnapshot(snapshot, true) {
68+ return false
69+ }
70+ if m.loop == nil {
71+ m.loop = pickerLoopFromSnapshot(snapshot, m.intent, defaultPickerSort())
72+ m.consumer.setOwned(snapshot.InteractionID, movePickerInputGeneration)
73+ return true
74+ }
75+ m.loop.replaceLines(snapshot)
76+ return true
77+}
78+
79+// interaction names the open namespace, or zero.
80+func (m *movePickerOverlay) interaction() uint64 {
81+ if !m.namespace.open {
82+ return 0
83+ }
84+ return m.namespace.interaction
85+}
86+
87+// closedBy reports whether a daemon close retires the open interaction.
88+func (m *movePickerOverlay) closedBy(interaction uint64) bool {
89+ return m.namespace.open && m.namespace.interaction == interaction
90+}
91+
92+// retire ends the interaction locally. A late snapshot for it is refused.
93+func (m *movePickerOverlay) retire() {
94+ if m.namespace.open {
95+ m.namespace.setOpen(false, m.namespace.interaction)
96+ }
97+ m.loop = nil
98+ m.presenting = false
99+ m.consumer.clear()
100+}
101+
102+// input decodes one owned delivery and applies it to the model.
103+func (m *movePickerOverlay) input(data []byte, actionID uint64) (pickerOp, bool) {
104+ outcome, consumed := m.consumer.consume(terminalReadResult{data: data, actionID: actionID})
105+ return m.apply(outcome, consumed)
106+}
107+
108+// flush resolves a withheld lone escape once its window elapsed.
109+func (m *movePickerOverlay) flush() (pickerOp, bool) {
110+ outcome, consumed := m.consumer.flushPending()
111+ return m.apply(outcome, consumed)
112+}
113+
114+func (m *movePickerOverlay) apply(outcome pickerConsumeOutcome, consumed bool) (pickerOp, bool) {
115+ if !consumed || m.loop == nil || !outcome.acceptOutcome(m.namespace.interaction, movePickerInputGeneration) {
116+ return pickerOp{}, false
117+ }
118+ return applyPickerBatch(m.loop, outcome.events)
119+}
120+
121+// selection builds the typed move for the cursor row, refusing rows the daemon
122+// did not authorise for a move.
123+func (m *movePickerOverlay) selection(causeActionID uint64) (protocol.PickerSelection, bool) {
124+ action, ok := m.loop.selectedAction()
125+ if !ok || action != protocol.PickerActionMove {
126+ return protocol.PickerSelection{}, false
127+ }
128+ return commitSelection(m.loop, action, causeActionID)
129+}
130+
131+// render composes the box for one terminal size.
132+func (m *movePickerOverlay) render(size domain.Size) []byte {
133+ if m.loop == nil {
134+ return nil
135+ }
136+ return m.renderer.render(m.loop, size, emptyPickerPreview())
137+}
138+
139+// attachmentMovePicker binds the move overlay and the navigation request to
140+// one attached worker loop: it owns the foreground overlay slot while a move
141+// interaction is presented and answers the daemon on the worker's stream.
142+type attachmentMovePicker struct {
143+ worker *sessionAttachmentWorker
144+ fg AttachmentForeground
145+ overlay attachmentOverlayForeground
146+ stream ports.BrokerLogicalConnection
147+ size domain.Size
148+ move *movePickerOverlay
149+ // escapeTimer bounds a withheld lone escape while the move overlay owns
150+ // input; nil while nothing is withheld.
151+ escapeTimer ports.Timer
152+}
153+
154+// offer handles one daemon PickerOffer. A navigation offer asks the
155+// supervisor for the client's session picker over this attachment; a move
156+// offer opens the namespace whose snapshot the overlay will present.
157+func (p *attachmentMovePicker) offer(ctx context.Context, offer protocol.PickerOffer) error {
158+ if p.overlay == nil {
159+ // No overlay seam: never leave the daemon swallowing input for an
160+ // interaction nothing presents.
161+ return p.decline(ctx, offer)
162+ }
163+ if offer.Intent == protocol.PickerIntentNavigation {
164+ if p.move.presenting {
165+ return nil
166+ }
167+ p.overlay.requestNavigationPicker()
168+ return nil
169+ }
170+ if p.overlay.overlayKind() == attachmentOverlayNavigation {
171+ // The session picker owns the terminal; a move picker cannot be
172+ // presented under it, so release the daemon's interaction at once.
173+ return p.decline(ctx, offer)
174+ }
175+ if p.move.offer(offer) {
176+ p.stopEscape()
177+ p.overlay.clearOverlay(attachmentOverlayMove)
178+ }
179+ return nil
180+}
181+
182+// decline closes a move interaction the client will not present.
183+func (p *attachmentMovePicker) decline(ctx context.Context, offer protocol.PickerOffer) error {
184+ if offer.Intent == protocol.PickerIntentNavigation {
185+ return nil
186+ }
187+ return p.worker.send(ctx, p.fg, p.stream, protocol.PickerClose{InteractionID: offer.InteractionID})
188+}
189+
190+// snapshot presents one daemon publication. The first admitted snapshot takes
191+// the foreground overlay slot; a refused slot declines the interaction.
192+func (p *attachmentMovePicker) snapshot(ctx context.Context, state outputApplyState, snapshot protocol.PickerSnapshot) error {
193+ if p.overlay == nil || !p.move.admit(snapshot) {
194+ return nil
195+ }
196+ if !p.move.presenting {
197+ if !p.overlay.setOverlay(attachmentOverlayMove, nil) {
198+ interaction := p.move.interaction()
199+ p.move.retire()
200+ return p.worker.send(ctx, p.fg, p.stream, protocol.PickerClose{InteractionID: interaction})
201+ }
202+ p.move.presenting = true
203+ p.move.renderer.invalidate()
204+ }
205+ return p.paint(state)
206+}
207+
208+// closed retires the interaction the daemon closed and returns the terminal to
209+// the attachment.
210+func (p *attachmentMovePicker) closed(interaction uint64) {
211+ if !p.move.closedBy(interaction) {
212+ return
213+ }
214+ p.release()
215+}
216+
217+func (p *attachmentMovePicker) release() {
218+ presenting := p.move.presenting
219+ p.move.retire()
220+ p.stopEscape()
221+ if presenting && p.overlay != nil {
222+ p.overlay.clearOverlay(attachmentOverlayMove)
223+ }
224+}
225+
226+// consumeInput offers one authorized delivery to the overlay that owns input.
227+// It reports true when the bytes were consumed and must not reach the session.
228+func (p *attachmentMovePicker) consumeInput(ctx context.Context, state outputApplyState, input AttachmentInputEvent) (bool, error) {
229+ if p.move.presenting {
230+ p.stopEscape()
231+ op, changed := p.move.input(input.Data, input.actionID)
232+ if err := p.applyOp(ctx, state, op, changed); err != nil {
233+ return true, err
234+ }
235+ p.armEscape()
236+ return true, nil
237+ }
238+ if p.overlay != nil && p.overlay.divertInput(input.Data) {
239+ return true, nil
240+ }
241+ return false, nil
242+}
243+
244+// applyOp carries out one decoded decision: close sends the typed close and
245+// releases the terminal, commit sends the typed move, and a changed model
246+// repaints the box.
247+func (p *attachmentMovePicker) applyOp(ctx context.Context, state outputApplyState, op pickerOp, changed bool) error {
248+ if !p.move.presenting {
249+ return nil
250+ }
251+ if op.close {
252+ interaction := p.move.interaction()
253+ p.release()
254+ return p.worker.send(ctx, p.fg, p.stream, protocol.PickerClose{InteractionID: interaction})
255+ }
256+ if op.commit {
257+ if selection, ok := p.move.selection(0); ok {
258+ if err := p.worker.send(ctx, p.fg, p.stream, selection); err != nil {
259+ return err
260+ }
261+ }
262+ }
263+ if changed {
264+ return p.paint(state)
265+ }
266+ return nil
267+}
268+
269+// resize repaints the presented box at the new terminal size.
270+func (p *attachmentMovePicker) resize(_ context.Context, state outputApplyState, size domain.Size) error {
271+ p.size = size
272+ if !p.move.presenting {
273+ return nil
274+ }
275+ return p.paint(state)
276+}
277+
278+func (p *attachmentMovePicker) paint(state outputApplyState) error {
279+ frame := p.move.render(p.size)
280+ if len(frame) == 0 {
281+ return nil
282+ }
283+ uiContext := state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(p.fg, p.fg.Token())}, ports.UIStatusAttached)
284+ if err := p.overlay.overlayOutput(uiContext, frame); err != nil {
285+ return fmt.Errorf("vev: publishing move picker: %w", err)
286+ }
287+ return nil
288+}
289+
290+// escape is the armed lone-escape window, or nil.
291+func (p *attachmentMovePicker) escape() <-chan time.Time {
292+ if p.escapeTimer == nil {
293+ return nil
294+ }
295+ return p.escapeTimer.C()
296+}
297+
298+func (p *attachmentMovePicker) armEscape() {
299+ if p.escapeTimer != nil || !p.move.presenting || !p.move.consumer.hasPending() {
300+ return
301+ }
302+ timer := p.worker.cfg.Clock.NewTimer(pickerEscapeDeadline)
303+ if supervisorNil(timer) {
304+ return
305+ }
306+ p.escapeTimer = timer
307+}
308+
309+// escapeFired forgets the timer whose window just elapsed.
310+func (p *attachmentMovePicker) escapeFired() { p.escapeTimer = nil }
311+
312+func (p *attachmentMovePicker) stopEscape() {
313+ if p.escapeTimer == nil {
314+ return
315+ }
316+ stopSupervisorTimer(p.escapeTimer)
317+ p.escapeTimer = nil
318+}
M internal/usecase/client/picker_composition.go +18 -2
@@ -22,7 +22,9 @@ import (
2222 // invariants or consume a pending user decision before the supervisor handles
2323 // it.
2424 //
25-// Painting is allowed only while PickerPresentation reports true. In
25+// Painting is allowed only while PickerPresentation reports true: the plain
26+// picker, or the picker overlay over a live attachment whose output the
27+// attachment foreground suppresses while the overlay is open. In
2628 // particular, PresentConnecting is not safe: once the attachment foreground is
2729 // admitted it owns the same terminal writer before MarkAttached or the initial
2830 // publication. Nothing serializes picker painting with that foreground.
@@ -42,7 +44,7 @@ var _ pickerHost = (*Picker)(nil)
4244 // admitted foreground can write while the public presentation is still
4345 // PresentConnecting.
4446 func PickerPresentation(state State) bool {
45- return state.Presentation == PresentPicker
47+ return state.Presentation == PresentPicker || state.Presentation == PresentAttachedPicker
4648 }
4749
4850 // NewPicker composes one client-owned picker. A nil clock uses the system
@@ -102,6 +104,20 @@ func (p *Picker) SetOwnsInput(owns bool) {
102104 p.controller.SetOwnsInput(owns)
103105 }
104106
107+// offerNotice shows one bounded client-local notice on the picker.
108+func (p *Picker) offerNotice(id, message string) {
109+ if p != nil && p.controller != nil {
110+ p.controller.offerNotice(id, message)
111+ }
112+}
113+
114+// invalidatePresentation forces the next frame to redraw the whole box.
115+func (p *Picker) invalidatePresentation() {
116+ if p != nil && p.controller != nil {
117+ p.controller.invalidatePresentation()
118+ }
119+}
120+
105121 // OpsReady returns the supervisor's coalescing operation wake channel.
106122 func (p *Picker) OpsReady() <-chan struct{} {
107123 if p == nil || p.controller == nil {
@@ -22,7 +22,9 @@ import (
22 // invariants or consume a pending user decision before the supervisor handles22 // invariants or consume a pending user decision before the supervisor handles
23 // it.23 // it.
24 //24 //
25-// Painting is allowed only while PickerPresentation reports true. In25+// Painting is allowed only while PickerPresentation reports true: the plain
26+// picker, or the picker overlay over a live attachment whose output the
27+// attachment foreground suppresses while the overlay is open. In
26 // particular, PresentConnecting is not safe: once the attachment foreground is28 // particular, PresentConnecting is not safe: once the attachment foreground is
27 // admitted it owns the same terminal writer before MarkAttached or the initial29 // admitted it owns the same terminal writer before MarkAttached or the initial
28 // publication. Nothing serializes picker painting with that foreground.30 // publication. Nothing serializes picker painting with that foreground.
@@ -42,7 +44,7 @@ var _ pickerHost = (*Picker)(nil)
42 // admitted foreground can write while the public presentation is still44 // admitted foreground can write while the public presentation is still
43 // PresentConnecting.45 // PresentConnecting.
44 func PickerPresentation(state State) bool {46 func PickerPresentation(state State) bool {
45- return state.Presentation == PresentPicker47+ return state.Presentation == PresentPicker || state.Presentation == PresentAttachedPicker
46 }48 }
47 49
48 // NewPicker composes one client-owned picker. A nil clock uses the system50 // NewPicker composes one client-owned picker. A nil clock uses the system
@@ -102,6 +104,20 @@ func (p *Picker) SetOwnsInput(owns bool) {
102 p.controller.SetOwnsInput(owns)104 p.controller.SetOwnsInput(owns)
103 }105 }
104 106
107+// offerNotice shows one bounded client-local notice on the picker.
108+func (p *Picker) offerNotice(id, message string) {
109+ if p != nil && p.controller != nil {
110+ p.controller.offerNotice(id, message)
111+ }
112+}
113+
114+// invalidatePresentation forces the next frame to redraw the whole box.
115+func (p *Picker) invalidatePresentation() {
116+ if p != nil && p.controller != nil {
117+ p.controller.invalidatePresentation()
118+ }
119+}
120+
105 // OpsReady returns the supervisor's coalescing operation wake channel.121 // OpsReady returns the supervisor's coalescing operation wake channel.
106 func (p *Picker) OpsReady() <-chan struct{} {122 func (p *Picker) OpsReady() <-chan struct{} {
107 if p == nil || p.controller == nil {123 if p == nil || p.controller == nil {
M internal/usecase/client/picker_controller.go +67 -2
@@ -104,6 +104,9 @@ type pickerController struct {
104104 // decision, so TakeOp can hand a driver the exact committed row.
105105 lastCommitKey string
106106 preview pickerusecase.Preview
107+ // flushStop cancels the armed lone-escape flush. It is non-nil exactly
108+ // while one flush is armed for the currently withheld prefix.
109+ flushStop chan struct{}
107110 }
108111
109112 // newPickerController builds a picker over an empty catalogue. The picker owns
@@ -265,8 +268,67 @@ func (p *pickerController) FlushPending() {
265268 if outcome.interaction != pickerGeneration || outcome.generation != pickerGeneration {
266269 return
267270 }
268- op, _ := applyPickerBatch(p.loop, outcome.events)
271+ op, changed := applyPickerBatch(p.loop, outcome.events)
269272 p.recordOpLocked(op)
273+ if changed {
274+ select {
275+ case p.opsReady <- struct{}{}:
276+ default:
277+ }
278+ }
279+}
280+
281+// armFlushLocked bounds a withheld escape or UTF-8 prefix with the picker's
282+// disambiguation window on the controller clock, so a lone Escape keypress
283+// resolves without waiting for another key. A later read disarms it first.
284+// Callers hold p.mu.
285+func (p *pickerController) armFlushLocked() {
286+ if p.flushStop != nil || !p.consumer.hasPending() {
287+ return
288+ }
289+ timer := p.clock.NewTimer(pickerEscapeDeadline)
290+ if supervisorNil(timer) {
291+ return
292+ }
293+ stop := make(chan struct{})
294+ p.flushStop = stop
295+ go func() {
296+ select {
297+ case <-timer.C():
298+ case <-stop:
299+ stopSupervisorTimer(timer)
300+ return
301+ }
302+ p.mu.Lock()
303+ if p.flushStop != stop {
304+ p.mu.Unlock()
305+ return
306+ }
307+ p.flushStop = nil
308+ p.mu.Unlock()
309+ p.FlushPending()
310+ }()
311+}
312+
313+// disarmFlushLocked cancels an armed flush: the next read resolves the
314+// withheld prefix itself. Callers hold p.mu.
315+func (p *pickerController) disarmFlushLocked() {
316+ if p.flushStop == nil {
317+ return
318+ }
319+ close(p.flushStop)
320+ p.flushStop = nil
321+}
322+
323+// invalidatePresentation makes the next Render redraw the whole box, because
324+// another owner wrote the terminal since the last picker frame.
325+func (p *pickerController) invalidatePresentation() {
326+ if p == nil {
327+ return
328+ }
329+ p.mu.Lock()
330+ p.renderer.invalidate()
331+ p.mu.Unlock()
270332 }
271333
272334 // OwnsInput reports whether the picker currently owns terminal input.
@@ -485,10 +547,12 @@ func (p *pickerController) handleRead(data []byte) bool {
485547 if len(data) != 0 {
486548 data = append([]byte(nil), data...)
487549 }
550+ p.disarmFlushLocked()
488551 outcome, consumed := p.consumer.consume(terminalReadResult{data: data})
489552 if !consumed {
490553 return false
491554 }
555+ defer p.armFlushLocked()
492556 if outcome.interaction != pickerGeneration || outcome.generation != pickerGeneration {
493557 return true
494558 }
@@ -497,7 +561,7 @@ func (p *pickerController) handleRead(data []byte) bool {
497561 // Use decoded events so a pasted Ctrl-C is not mistaken for an exit.
498562 for _, event := range outcome.events {
499563 if event.kind == pickerEventKey && event.key == "Ctrl+C" {
500- p.recordOpLocked(pickerOp{close: true})
564+ p.recordOpLocked(pickerOp{close: true, exit: true})
501565 }
502566 }
503567 return true
@@ -569,5 +633,6 @@ func mergePickerOps(a, b pickerOp) pickerOp {
569633 commit: a.commit || b.commit,
570634 kill: a.kill || b.kill,
571635 close: a.close || b.close,
636+ exit: a.exit || b.exit,
572637 }
573638 }
@@ -104,6 +104,9 @@ type pickerController struct {
104 // decision, so TakeOp can hand a driver the exact committed row.104 // decision, so TakeOp can hand a driver the exact committed row.
105 lastCommitKey string105 lastCommitKey string
106 preview pickerusecase.Preview106 preview pickerusecase.Preview
107+ // flushStop cancels the armed lone-escape flush. It is non-nil exactly
108+ // while one flush is armed for the currently withheld prefix.
109+ flushStop chan struct{}
107 }110 }
108 111
109 // newPickerController builds a picker over an empty catalogue. The picker owns112 // newPickerController builds a picker over an empty catalogue. The picker owns
@@ -265,8 +268,67 @@ func (p *pickerController) FlushPending() {
265 if outcome.interaction != pickerGeneration || outcome.generation != pickerGeneration {268 if outcome.interaction != pickerGeneration || outcome.generation != pickerGeneration {
266 return269 return
267 }270 }
268- op, _ := applyPickerBatch(p.loop, outcome.events)271+ op, changed := applyPickerBatch(p.loop, outcome.events)
269 p.recordOpLocked(op)272 p.recordOpLocked(op)
273+ if changed {
274+ select {
275+ case p.opsReady <- struct{}{}:
276+ default:
277+ }
278+ }
279+}
280+
281+// armFlushLocked bounds a withheld escape or UTF-8 prefix with the picker's
282+// disambiguation window on the controller clock, so a lone Escape keypress
283+// resolves without waiting for another key. A later read disarms it first.
284+// Callers hold p.mu.
285+func (p *pickerController) armFlushLocked() {
286+ if p.flushStop != nil || !p.consumer.hasPending() {
287+ return
288+ }
289+ timer := p.clock.NewTimer(pickerEscapeDeadline)
290+ if supervisorNil(timer) {
291+ return
292+ }
293+ stop := make(chan struct{})
294+ p.flushStop = stop
295+ go func() {
296+ select {
297+ case <-timer.C():
298+ case <-stop:
299+ stopSupervisorTimer(timer)
300+ return
301+ }
302+ p.mu.Lock()
303+ if p.flushStop != stop {
304+ p.mu.Unlock()
305+ return
306+ }
307+ p.flushStop = nil
308+ p.mu.Unlock()
309+ p.FlushPending()
310+ }()
311+}
312+
313+// disarmFlushLocked cancels an armed flush: the next read resolves the
314+// withheld prefix itself. Callers hold p.mu.
315+func (p *pickerController) disarmFlushLocked() {
316+ if p.flushStop == nil {
317+ return
318+ }
319+ close(p.flushStop)
320+ p.flushStop = nil
321+}
322+
323+// invalidatePresentation makes the next Render redraw the whole box, because
324+// another owner wrote the terminal since the last picker frame.
325+func (p *pickerController) invalidatePresentation() {
326+ if p == nil {
327+ return
328+ }
329+ p.mu.Lock()
330+ p.renderer.invalidate()
331+ p.mu.Unlock()
270 }332 }
271 333
272 // OwnsInput reports whether the picker currently owns terminal input.334 // OwnsInput reports whether the picker currently owns terminal input.
@@ -485,10 +547,12 @@ func (p *pickerController) handleRead(data []byte) bool {
485 if len(data) != 0 {547 if len(data) != 0 {
486 data = append([]byte(nil), data...)548 data = append([]byte(nil), data...)
487 }549 }
550+ p.disarmFlushLocked()
488 outcome, consumed := p.consumer.consume(terminalReadResult{data: data})551 outcome, consumed := p.consumer.consume(terminalReadResult{data: data})
489 if !consumed {552 if !consumed {
490 return false553 return false
491 }554 }
555+ defer p.armFlushLocked()
492 if outcome.interaction != pickerGeneration || outcome.generation != pickerGeneration {556 if outcome.interaction != pickerGeneration || outcome.generation != pickerGeneration {
493 return true557 return true
494 }558 }
@@ -497,7 +561,7 @@ func (p *pickerController) handleRead(data []byte) bool {
497 // Use decoded events so a pasted Ctrl-C is not mistaken for an exit.561 // Use decoded events so a pasted Ctrl-C is not mistaken for an exit.
498 for _, event := range outcome.events {562 for _, event := range outcome.events {
499 if event.kind == pickerEventKey && event.key == "Ctrl+C" {563 if event.kind == pickerEventKey && event.key == "Ctrl+C" {
500- p.recordOpLocked(pickerOp{close: true})564+ p.recordOpLocked(pickerOp{close: true, exit: true})
501 }565 }
502 }566 }
503 return true567 return true
@@ -569,5 +633,6 @@ func mergePickerOps(a, b pickerOp) pickerOp {
569 commit: a.commit || b.commit,633 commit: a.commit || b.commit,
570 kill: a.kill || b.kill,634 kill: a.kill || b.kill,
571 close: a.close || b.close,635 close: a.close || b.close,
636+ exit: a.exit || b.exit,
572 }637 }
573 }638 }
M internal/usecase/client/picker_interaction.go +21 -3
@@ -499,6 +499,20 @@ func (r *pickerRenderer) reset() {
499499 r.prevBounds = nil
500500 }
501501
502+// invalidate forgets everything the renderer believes is on screen. The next
503+// render re-primes an empty shadow and writes the whole box again: someone
504+// else (an attachment's output or its authoritative repaint) owned the
505+// terminal since the last picker frame, so a diff against the old shadow would
506+// leave parts of the box missing.
507+func (r *pickerRenderer) invalidate() {
508+ if r == nil {
509+ return
510+ }
511+ r.renderer = nil
512+ r.primed = false
513+ r.prevBounds = nil
514+}
515+
502516 // damageRect is the damage one rectangle of a composed frame produces.
503517 func damageRect(rect domain.Rect) ansirenderer.Damage {
504518 return ansirenderer.Damage{
@@ -591,6 +605,10 @@ type pickerOp struct {
591605 commit bool
592606 kill bool
593607 close bool
608+ // exit marks the explicit exit key (Ctrl+C). It always travels with close:
609+ // over a live attachment it cancels back to that attachment like any close,
610+ // and only a picker with no attachment to return to ends the process on it.
611+ exit bool
594612 }
595613
596614 // pickerDriverOp is one ui-driver operation applied to an open loop. Keys
@@ -668,8 +686,8 @@ func pickerDriverOp(loop *pickerLoop, keys []string, text string) (op pickerOp,
668686 }
669687 case "Ctrl+C":
670688 // The overlay exits on Ctrl-C in both modes; the byte
671- // itself never reaches the query.
672- return pickerOp{close: true}, changed
689+ // itself never reaches the query. It is the explicit exit key.
690+ return pickerOp{close: true, exit: true}, changed
673691 default:
674692 if len(key) == 1 {
675693 insertLit(rune(key[0]))
@@ -734,7 +752,7 @@ func applyPickerBatch(loop *pickerLoop, events []pickerEvent) (op pickerOp, chan
734752 eventOp, eventChanged := pickerDriverOp(loop, []string{event.key}, "")
735753 changed = changed || eventChanged
736754 if eventOp.close {
737- return pickerOp{close: true}, changed
755+ return pickerOp{close: true, exit: eventOp.exit}, changed
738756 }
739757 op.commit = op.commit || eventOp.commit
740758 op.kill = op.kill || eventOp.kill
@@ -499,6 +499,20 @@ func (r *pickerRenderer) reset() {
499 r.prevBounds = nil499 r.prevBounds = nil
500 }500 }
501 501
502+// invalidate forgets everything the renderer believes is on screen. The next
503+// render re-primes an empty shadow and writes the whole box again: someone
504+// else (an attachment's output or its authoritative repaint) owned the
505+// terminal since the last picker frame, so a diff against the old shadow would
506+// leave parts of the box missing.
507+func (r *pickerRenderer) invalidate() {
508+ if r == nil {
509+ return
510+ }
511+ r.renderer = nil
512+ r.primed = false
513+ r.prevBounds = nil
514+}
515+
502 // damageRect is the damage one rectangle of a composed frame produces.516 // damageRect is the damage one rectangle of a composed frame produces.
503 func damageRect(rect domain.Rect) ansirenderer.Damage {517 func damageRect(rect domain.Rect) ansirenderer.Damage {
504 return ansirenderer.Damage{518 return ansirenderer.Damage{
@@ -591,6 +605,10 @@ type pickerOp struct {
591 commit bool605 commit bool
592 kill bool606 kill bool
593 close bool607 close bool
608+ // exit marks the explicit exit key (Ctrl+C). It always travels with close:
609+ // over a live attachment it cancels back to that attachment like any close,
610+ // and only a picker with no attachment to return to ends the process on it.
611+ exit bool
594 }612 }
595 613
596 // pickerDriverOp is one ui-driver operation applied to an open loop. Keys614 // pickerDriverOp is one ui-driver operation applied to an open loop. Keys
@@ -668,8 +686,8 @@ func pickerDriverOp(loop *pickerLoop, keys []string, text string) (op pickerOp,
668 }686 }
669 case "Ctrl+C":687 case "Ctrl+C":
670 // The overlay exits on Ctrl-C in both modes; the byte688 // The overlay exits on Ctrl-C in both modes; the byte
671- // itself never reaches the query.689+ // itself never reaches the query. It is the explicit exit key.
672- return pickerOp{close: true}, changed690+ return pickerOp{close: true, exit: true}, changed
673 default:691 default:
674 if len(key) == 1 {692 if len(key) == 1 {
675 insertLit(rune(key[0]))693 insertLit(rune(key[0]))
@@ -734,7 +752,7 @@ func applyPickerBatch(loop *pickerLoop, events []pickerEvent) (op pickerOp, chan
734 eventOp, eventChanged := pickerDriverOp(loop, []string{event.key}, "")752 eventOp, eventChanged := pickerDriverOp(loop, []string{event.key}, "")
735 changed = changed || eventChanged753 changed = changed || eventChanged
736 if eventOp.close {754 if eventOp.close {
737- return pickerOp{close: true}, changed755+ return pickerOp{close: true, exit: eventOp.exit}, changed
738 }756 }
739 op.commit = op.commit || eventOp.commit757 op.commit = op.commit || eventOp.commit
740 op.kill = op.kill || eventOp.kill758 op.kill = op.kill || eventOp.kill
A internal/usecase/client/picker_overlay_test.go +460 -0
@@ -0,0 +1,460 @@
1+package client
2+
3+import (
4+ "context"
5+ "strings"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/protocol"
15+)
16+
17+// Picker overlay and move picker tests (Plan 003 A1/A2). They drive the real
18+// supervisor, host, foreground, and worker over the in-package broker fakes
19+// with the scripted test clock: no sleeps, and every wait is bounded.
20+
21+// recordOp records one picker decision on the scripted picker, exactly like
22+// the controller's coalesced wake.
23+func (p *attachTestPicker) recordOp(op pickerOp, key string, request ports.BrokerOpenStreamRequest) {
24+ p.mu.Lock()
25+ p.op = op
26+ p.key = key
27+ p.request = request
28+ p.mu.Unlock()
29+ select {
30+ case p.opsReady <- struct{}{}:
31+ default:
32+ }
33+}
34+
35+// opTaken reports whether the supervisor consumed the recorded decision.
36+func (p *attachTestPicker) opTaken() bool {
37+ p.mu.Lock()
38+ defer p.mu.Unlock()
39+ return p.op == (pickerOp{})
40+}
41+
42+// attachLiveSession commits one attachment on the harness and returns its
43+// stream once the attachment is presented.
44+func attachLiveSession(t *testing.T, harness *attachTestHarness, picker *attachTestPicker) *sessionTestStream {
45+ t.Helper()
46+ stream := newSessionTestStream()
47+ harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
48+ return stream, nil
49+ })
50+ picker.commit(sessionTestRequest(true))
51+ awaitStreamHello(t, &sync.Mutex{}, &stream)
52+ deliverReadyStream(t, stream)
53+ awaitAttachedState(t, harness.sup)
54+ return stream
55+}
56+
57+func awaitPresentation(t *testing.T, sup *Supervisor, want Presentation) {
58+ t.Helper()
59+ require.Eventually(t, func() bool { return sup.State().Presentation == want }, 5*time.Second, time.Millisecond, "presentation never became %s", want)
60+}
61+
62+// awaitSent waits for the first client message matching match.
63+func awaitSent(t *testing.T, stream *sessionTestStream, what string, match func(protocol.ClientMessage) bool) protocol.ClientMessage {
64+ t.Helper()
65+ var found protocol.ClientMessage
66+ require.Eventually(t, func() bool {
67+ for _, message := range stream.messages() {
68+ if match(message) {
69+ found = message
70+ return true
71+ }
72+ }
73+ return false
74+ }, 5*time.Second, time.Millisecond, "client never sent %s", what)
75+ return found
76+}
77+
78+func countSent[T protocol.ClientMessage](stream *sessionTestStream) int {
79+ count := 0
80+ for _, message := range stream.messages() {
81+ if _, ok := message.(T); ok {
82+ count++
83+ }
84+ }
85+ return count
86+}
87+
88+func isSent[T protocol.ClientMessage](message protocol.ClientMessage) bool {
89+ _, ok := message.(T)
90+ return ok
91+}
92+
93+// laterOutput is a full frame on a later epoch, which the attachment accepts
94+// after its initial publication.
95+func laterOutput(epoch, publication uint64, data string) protocol.Output {
96+ output := sessionTestOutput(publication, data)
97+ output.Epoch = epoch
98+ return output
99+}
100+
101+func navigationOffer(id uint64) protocol.PickerOffer {
102+ return protocol.PickerOffer{InteractionID: id, Intent: protocol.PickerIntentNavigation, Title: " Sessions · recent "}
103+}
104+
105+// TestPickerOverlayReturnsToLiveAttachment pins PA1: SSP composes the picker
106+// over the live attachment, its input and output stay with the picker, and
107+// every cancel (and a commit to the attached session) returns to the very same
108+// attachment without reconnecting, then asks for an authoritative repaint.
109+func TestPickerOverlayReturnsToLiveAttachment(t *testing.T) {
110+ tests := []struct {
111+ name string
112+ op pickerOp
113+ key string
114+ }{
115+ {name: "escape or q cancels", op: pickerOp{close: true}},
116+ {name: "ctrl+c cancels without exiting", op: pickerOp{close: true, exit: true}},
117+ {name: "commit to the attached session closes", op: pickerOp{commit: true}, key: "row"},
118+ }
119+ for _, tt := range tests {
120+ t.Run(tt.name, func(t *testing.T) {
121+ picker := newAttachTestPicker()
122+ harness := startAttachHarness(t, picker)
123+ stream := attachLiveSession(t, harness, picker)
124+ foreground := harness.sup.attachments.authority.foreground()
125+ require.NotNil(t, foreground)
126+ require.False(t, picker.owns())
127+
128+ stream.deliver(navigationOffer(1))
129+ awaitPresentation(t, harness.sup, PresentAttachedPicker)
130+ require.True(t, picker.owns(), "the picker owns input while it is open")
131+
132+ // Input belongs to the picker: it is consumed, never sent as session
133+ // Input.
134+ harness.reader.push([]byte("j"))
135+ require.Eventually(t, func() bool { return picker.consumedCount() == 1 }, 5*time.Second, time.Millisecond)
136+ require.Zero(t, countSent[protocol.Input](stream), "picker input must never reach the session")
137+
138+ // Attachment output is applied and acknowledged, never written over
139+ // the picker.
140+ acks := countSent[protocol.Ack](stream)
141+ stream.deliver(laterOutput(2, 2, "hidden-frame"))
142+ require.Eventually(t, func() bool { return countSent[protocol.Ack](stream) > acks }, 5*time.Second, time.Millisecond)
143+ require.NotContains(t, harness.terminal.written(), "hidden-frame")
144+
145+ picker.recordOp(tt.op, tt.key, sessionTestRequest(true))
146+ awaitPresentation(t, harness.sup, PresentAttached)
147+ awaitSent(t, stream, "OutputResetRequest", isSent[protocol.OutputResetRequest])
148+
149+ require.Same(t, foreground, harness.sup.attachments.authority.foreground(), "cancel must keep the same live attachment")
150+ require.False(t, stream.closedNow())
151+ require.Len(t, harness.service.openedRequests(), 1, "cancel must not reconnect")
152+ require.Zero(t, countSent[protocol.Detach](stream))
153+ require.False(t, picker.owns())
154+
155+ // The session owns input again.
156+ harness.reader.push([]byte("z"))
157+ input := awaitSent(t, stream, "session Input", isSent[protocol.Input]).(protocol.Input)
158+ require.Equal(t, []byte("z"), input.Data)
159+ select {
160+ case <-harness.runDone:
161+ t.Fatal("cancelling the overlay ended the process")
162+ default:
163+ }
164+ })
165+ }
166+}
167+
168+// TestPickerOverlayCommitElsewhereSwapsAttachment pins the swap: a commit to
169+// another session detaches the live attachment cleanly and attaches the
170+// committed target through Connecting, without passing through the plain
171+// picker.
172+func TestPickerOverlayCommitElsewhereSwapsAttachment(t *testing.T) {
173+ picker := newAttachTestPicker()
174+ notices := make(chan LifecycleNotice, 8)
175+ harness := startAttachHarnessConfig(t, picker, func(cfg *SupervisorConfig) {
176+ cfg.NotifyLifecycle = func(notice LifecycleNotice) { notices <- notice }
177+ })
178+ first := attachLiveSession(t, harness, picker)
179+ first.deliver(navigationOffer(1))
180+ awaitPresentation(t, harness.sup, PresentAttachedPicker)
181+
182+ var mu sync.Mutex
183+ second := newSessionTestStream()
184+ harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
185+ return second, nil
186+ })
187+ other := sessionTestRequest(true)
188+ other.Target = protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "beta"}
189+ picker.recordOp(pickerOp{commit: true}, "row", other)
190+
191+ awaitSent(t, first, "Detach", isSent[protocol.Detach])
192+ awaitStreamHello(t, &mu, &second)
193+ second.deliver(protocol.Welcome{SessionName: "beta"})
194+ output := sessionTestOutput(1, "\x1b[Hbeta")
195+ output.Context.Route.Target = other.Target
196+ second.deliver(output)
197+ awaitAttachedState(t, harness.sup)
198+
199+ opened := harness.service.openedRequests()
200+ require.Len(t, opened, 2)
201+ require.Equal(t, other.Target, opened[1].Target)
202+ require.True(t, first.closedNow(), "the previous attachment is released")
203+ select {
204+ case notice := <-notices:
205+ t.Fatalf("a swap must not report a lifecycle transition to the picker: %+v", notice)
206+ default:
207+ }
208+}
209+
210+// TestPickerCloseWithoutAttachment pins the unattached decision: with nothing
211+// to return to, Escape and q keep the picker (and the process) alive; only the
212+// explicit Ctrl+C exit ends the run.
213+func TestPickerCloseWithoutAttachment(t *testing.T) {
214+ tests := []struct {
215+ name string
216+ op pickerOp
217+ wantExit bool
218+ }{
219+ {name: "escape or q keeps the picker", op: pickerOp{close: true}},
220+ {name: "ctrl+c exits explicitly", op: pickerOp{close: true, exit: true}, wantExit: true},
221+ }
222+ for _, tt := range tests {
223+ t.Run(tt.name, func(t *testing.T) {
224+ picker := newAttachTestPicker()
225+ harness := startAttachHarness(t, picker)
226+ picker.recordOp(tt.op, "", ports.BrokerOpenStreamRequest{})
227+ if tt.wantExit {
228+ require.NoError(t, harness.waitRun(t))
229+ return
230+ }
231+ // Prove the close was consumed, then that the run survived it: a
232+ // later commit is still admitted.
233+ require.Eventually(t, picker.opTaken, 5*time.Second, time.Millisecond)
234+ stream := newSessionTestStream()
235+ harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
236+ return stream, nil
237+ })
238+ picker.commit(sessionTestRequest(true))
239+ awaitStreamHello(t, &sync.Mutex{}, &stream)
240+ select {
241+ case <-harness.runDone:
242+ t.Fatal("a cancel with no attachment ended the process")
243+ default:
244+ }
245+ })
246+ }
247+}
248+
249+// moveSnapshot is the two-destination publication a move interaction opens
250+// with; both rows authorise a move.
251+func moveSnapshot(interaction, revision uint64) protocol.PickerSnapshot {
252+ return protocol.PickerSnapshot{
253+ InteractionID: interaction, SourceID: "serving", SourceRevision: revision, Status: protocol.PickerSourceOK,
254+ Lines: []protocol.PickerLine{
255+ {Key: "aa/first", Kind: protocol.PickerLineSession, Label: "first", Focusable: true, Actions: protocol.PickerCanMove},
256+ {Key: "bb/second", Kind: protocol.PickerLineSession, Label: "second", Focusable: true, Actions: protocol.PickerCanMove},
257+ },
258+ Cursor: protocol.PickerCursor{Key: "aa/first", Index: 0},
259+ }
260+}
261+
262+func moveOffer(interaction uint64) protocol.PickerOffer {
263+ return protocol.PickerOffer{InteractionID: interaction, Intent: protocol.PickerIntentMovePane, Title: " Move "}
264+}
265+
266+// openMovePicker opens one move interaction on a live attachment and waits
267+// until its box is drawn.
268+func openMovePicker(t *testing.T, harness *attachTestHarness, stream *sessionTestStream, interaction uint64) {
269+ t.Helper()
270+ stream.deliver(moveOffer(interaction))
271+ stream.deliver(moveSnapshot(interaction, 1))
272+ require.Eventually(t, func() bool { return strings.Contains(harness.terminal.written(), "second") }, 5*time.Second, time.Millisecond, "the move picker was never drawn")
273+}
274+
275+// TestMovePickerOfferIsPresentedAndAnswered pins PA3: the daemon's move offer
276+// opens a working destination picker whose input never reaches the session,
277+// and every exit path answers with the typed replies and hands input back.
278+func TestMovePickerOfferIsPresentedAndAnswered(t *testing.T) {
279+ tests := []struct {
280+ name string
281+ keys []string
282+ check func(t *testing.T, harness *attachTestHarness, stream *sessionTestStream)
283+ }{
284+ {
285+ name: "enter commits the cursor destination",
286+ keys: []string{"\x1b[B", "\r"},
287+ check: func(t *testing.T, harness *attachTestHarness, stream *sessionTestStream) {
288+ selection := awaitSent(t, stream, "PickerSelection", isSent[protocol.PickerSelection]).(protocol.PickerSelection)
289+ require.Equal(t, uint64(7), selection.InteractionID)
290+ require.Equal(t, "serving", selection.SourceID)
291+ require.Equal(t, uint64(1), selection.SourceRevision)
292+ require.Equal(t, "bb/second", selection.Key)
293+ require.Equal(t, protocol.PickerActionMove, selection.Action)
294+ // The daemon closes the interaction; the terminal returns to the
295+ // attachment with an authoritative repaint.
296+ stream.deliver(protocol.PickerClosed{InteractionID: 7})
297+ awaitSent(t, stream, "OutputResetRequest", isSent[protocol.OutputResetRequest])
298+ },
299+ },
300+ {
301+ name: "q cancels with a typed close",
302+ keys: []string{"q"},
303+ check: func(t *testing.T, harness *attachTestHarness, stream *sessionTestStream) {
304+ closed := awaitSent(t, stream, "PickerClose", isSent[protocol.PickerClose]).(protocol.PickerClose)
305+ require.Equal(t, uint64(7), closed.InteractionID)
306+ awaitSent(t, stream, "OutputResetRequest", isSent[protocol.OutputResetRequest])
307+ },
308+ },
309+ {
310+ name: "a lone escape cancels once its window elapses",
311+ keys: []string{"\x1b"},
312+ check: func(t *testing.T, harness *attachTestHarness, stream *sessionTestStream) {
313+ var escape *supervisorTestTimer
314+ require.Eventually(t, func() bool {
315+ for _, timer := range harness.clock.snapshotTimers() {
316+ if timer.delay == pickerEscapeDeadline && !timer.stopped() {
317+ escape = timer
318+ return true
319+ }
320+ }
321+ return false
322+ }, 5*time.Second, time.Millisecond, "the escape window was never armed")
323+ require.Zero(t, countSent[protocol.PickerClose](stream), "a withheld escape must not close early")
324+ escape.fire()
325+ closed := awaitSent(t, stream, "PickerClose", isSent[protocol.PickerClose]).(protocol.PickerClose)
326+ require.Equal(t, uint64(7), closed.InteractionID)
327+ },
328+ },
329+ }
330+ for _, tt := range tests {
331+ t.Run(tt.name, func(t *testing.T) {
332+ picker := newAttachTestPicker()
333+ harness := startAttachHarness(t, picker)
334+ stream := attachLiveSession(t, harness, picker)
335+ openMovePicker(t, harness, stream, 7)
336+
337+ // Output under the box is applied and acknowledged, never written.
338+ acks := countSent[protocol.Ack](stream)
339+ stream.deliver(laterOutput(2, 2, "hidden-frame"))
340+ require.Eventually(t, func() bool { return countSent[protocol.Ack](stream) > acks }, 5*time.Second, time.Millisecond)
341+ require.NotContains(t, harness.terminal.written(), "hidden-frame")
342+
343+ for _, key := range tt.keys {
344+ harness.reader.push([]byte(key))
345+ }
346+ tt.check(t, harness, stream)
347+ require.Zero(t, countSent[protocol.Input](stream), "move picker input must never reach the session")
348+
349+ // Input is never swallowed after the picker ended: the next key is
350+ // session Input again.
351+ harness.reader.push([]byte("z"))
352+ input := awaitSent(t, stream, "session Input", isSent[protocol.Input]).(protocol.Input)
353+ require.Equal(t, []byte("z"), input.Data)
354+ require.Equal(t, PresentAttached, harness.sup.State().Presentation)
355+ })
356+ }
357+}
358+
359+// TestMovePickerOfferDeclinedUnderSessionPicker pins the exit path when the
360+// move picker cannot be presented: the daemon's interaction is closed at once
361+// instead of swallowing input with nothing on screen.
362+func TestMovePickerOfferDeclinedUnderSessionPicker(t *testing.T) {
363+ picker := newAttachTestPicker()
364+ harness := startAttachHarness(t, picker)
365+ stream := attachLiveSession(t, harness, picker)
366+ stream.deliver(navigationOffer(1))
367+ awaitPresentation(t, harness.sup, PresentAttachedPicker)
368+
369+ stream.deliver(moveOffer(9))
370+ closed := awaitSent(t, stream, "PickerClose", isSent[protocol.PickerClose]).(protocol.PickerClose)
371+ require.Equal(t, uint64(9), closed.InteractionID)
372+ require.Equal(t, PresentAttachedPicker, harness.sup.State().Presentation)
373+}
374+
375+// TestSupervisorReducerPickerOverlay pins the Attached+Picker transitions and
376+// the paint fence: only a committed attachment hosts the overlay, cancel
377+// returns to Attached, and a settled attachment falls back to the plain
378+// picker.
379+func TestSupervisorReducerPickerOverlay(t *testing.T) {
380+ tests := []struct {
381+ name string
382+ from Presentation
383+ event supervisorEventKind
384+ want Presentation
385+ wantPaint bool
386+ }{
387+ {name: "attached opens the overlay", from: PresentAttached, event: supervisorOverlayOpened, want: PresentAttachedPicker, wantPaint: true},
388+ {name: "connecting cannot host the overlay", from: PresentConnecting, event: supervisorOverlayOpened, want: PresentConnecting},
389+ {name: "plain picker ignores an overlay request", from: PresentPicker, event: supervisorOverlayOpened, want: PresentPicker, wantPaint: true},
390+ {name: "cancel returns to the attachment", from: PresentAttachedPicker, event: supervisorOverlayClosed, want: PresentAttached},
391+ {name: "close outside the overlay is inert", from: PresentAttached, event: supervisorOverlayClosed, want: PresentAttached},
392+ {name: "attachment end falls back to the picker", from: PresentAttachedPicker, event: supervisorAttachEnded, want: PresentPicker, wantPaint: true},
393+ {name: "swap goes through connecting", from: PresentAttachedPicker, event: supervisorAttachBegin, want: PresentConnecting},
394+ }
395+ for _, tt := range tests {
396+ t.Run(tt.name, func(t *testing.T) {
397+ state := reduceSupervisor(State{Presentation: tt.from, Connectivity: ConnectivityReady}, supervisorEvent{kind: tt.event})
398+ require.Equal(t, tt.want, state.Presentation)
399+ require.Equal(t, ConnectivityReady, state.Connectivity)
400+ require.Equal(t, tt.wantPaint, PickerPresentation(state))
401+ })
402+ }
403+}
404+
405+// TestMovePickerDrawsAFloatingModalOverTheSession ports main's modal
406+// presentation pin: the move picker is a bordered box over the session, never
407+// a full-screen takeover, so it neither clears the screen nor spans it.
408+func TestMovePickerDrawsAFloatingModalOverTheSession(t *testing.T) {
409+ picker := newAttachTestPicker()
410+ harness := startAttachHarness(t, picker)
411+ stream := attachLiveSession(t, harness, picker)
412+ before := len(harness.terminal.written())
413+ openMovePicker(t, harness, stream, 7)
414+
415+ frame := harness.terminal.written()[before:]
416+ require.Contains(t, frame, "first", "the destination rows must be drawn")
417+ require.Contains(t, frame, "─", "the modal border must be drawn")
418+ require.NotContains(t, frame, "\x1b[2J", "the box must not clear the session under it")
419+ require.Contains(t, harness.terminal.written(), "ready", "the session frame stays under the box")
420+}
421+
422+// TestPickerControllerLoneEscapeResolvesOnItsWindow pins that a real Escape
423+// keypress (one lone byte) cancels once the disambiguation window elapses on
424+// the controller clock, and that a following byte disarms the window instead.
425+func TestPickerControllerLoneEscapeResolvesOnItsWindow(t *testing.T) {
426+ tests := []struct {
427+ name string
428+ follow []byte
429+ wantClose bool
430+ }{
431+ {name: "window elapses into Escape", wantClose: true},
432+ {name: "a following byte resolves the prefix itself", follow: []byte("[B")},
433+ }
434+ for _, tt := range tests {
435+ t.Run(tt.name, func(t *testing.T) {
436+ controller, clock := pickerTestController(t)
437+ controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))
438+
439+ require.True(t, controller.ConsumeTerminalRead([]byte("\x1b")))
440+ escape := clock.awaitTimer(t)
441+ require.Equal(t, pickerEscapeDeadline, escape.delay)
442+ if tt.follow != nil {
443+ require.True(t, controller.ConsumeTerminalRead(tt.follow))
444+ require.Eventually(t, escape.stopped, 5*time.Second, time.Millisecond, "a resolving read must disarm the window")
445+ op, _ := controller.TakeOp()
446+ require.False(t, op.close)
447+ return
448+ }
449+ escape.fire()
450+ select {
451+ case <-controller.OpsReady():
452+ case <-time.After(5 * time.Second):
453+ t.Fatal("the elapsed window never woke the supervisor")
454+ }
455+ op, _ := controller.TakeOp()
456+ require.Equal(t, tt.wantClose, op.close)
457+ require.False(t, op.exit, "Escape is a cancel, never the explicit exit")
458+ })
459+ }
460+}
@@ -0,0 +1,460 @@
1+package client
2+
3+import (
4+ "context"
5+ "strings"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/protocol"
15+)
16+
17+// Picker overlay and move picker tests (Plan 003 A1/A2). They drive the real
18+// supervisor, host, foreground, and worker over the in-package broker fakes
19+// with the scripted test clock: no sleeps, and every wait is bounded.
20+
21+// recordOp records one picker decision on the scripted picker, exactly like
22+// the controller's coalesced wake.
23+func (p *attachTestPicker) recordOp(op pickerOp, key string, request ports.BrokerOpenStreamRequest) {
24+ p.mu.Lock()
25+ p.op = op
26+ p.key = key
27+ p.request = request
28+ p.mu.Unlock()
29+ select {
30+ case p.opsReady <- struct{}{}:
31+ default:
32+ }
33+}
34+
35+// opTaken reports whether the supervisor consumed the recorded decision.
36+func (p *attachTestPicker) opTaken() bool {
37+ p.mu.Lock()
38+ defer p.mu.Unlock()
39+ return p.op == (pickerOp{})
40+}
41+
42+// attachLiveSession commits one attachment on the harness and returns its
43+// stream once the attachment is presented.
44+func attachLiveSession(t *testing.T, harness *attachTestHarness, picker *attachTestPicker) *sessionTestStream {
45+ t.Helper()
46+ stream := newSessionTestStream()
47+ harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
48+ return stream, nil
49+ })
50+ picker.commit(sessionTestRequest(true))
51+ awaitStreamHello(t, &sync.Mutex{}, &stream)
52+ deliverReadyStream(t, stream)
53+ awaitAttachedState(t, harness.sup)
54+ return stream
55+}
56+
57+func awaitPresentation(t *testing.T, sup *Supervisor, want Presentation) {
58+ t.Helper()
59+ require.Eventually(t, func() bool { return sup.State().Presentation == want }, 5*time.Second, time.Millisecond, "presentation never became %s", want)
60+}
61+
62+// awaitSent waits for the first client message matching match.
63+func awaitSent(t *testing.T, stream *sessionTestStream, what string, match func(protocol.ClientMessage) bool) protocol.ClientMessage {
64+ t.Helper()
65+ var found protocol.ClientMessage
66+ require.Eventually(t, func() bool {
67+ for _, message := range stream.messages() {
68+ if match(message) {
69+ found = message
70+ return true
71+ }
72+ }
73+ return false
74+ }, 5*time.Second, time.Millisecond, "client never sent %s", what)
75+ return found
76+}
77+
78+func countSent[T protocol.ClientMessage](stream *sessionTestStream) int {
79+ count := 0
80+ for _, message := range stream.messages() {
81+ if _, ok := message.(T); ok {
82+ count++
83+ }
84+ }
85+ return count
86+}
87+
88+func isSent[T protocol.ClientMessage](message protocol.ClientMessage) bool {
89+ _, ok := message.(T)
90+ return ok
91+}
92+
93+// laterOutput is a full frame on a later epoch, which the attachment accepts
94+// after its initial publication.
95+func laterOutput(epoch, publication uint64, data string) protocol.Output {
96+ output := sessionTestOutput(publication, data)
97+ output.Epoch = epoch
98+ return output
99+}
100+
101+func navigationOffer(id uint64) protocol.PickerOffer {
102+ return protocol.PickerOffer{InteractionID: id, Intent: protocol.PickerIntentNavigation, Title: " Sessions · recent "}
103+}
104+
105+// TestPickerOverlayReturnsToLiveAttachment pins PA1: SSP composes the picker
106+// over the live attachment, its input and output stay with the picker, and
107+// every cancel (and a commit to the attached session) returns to the very same
108+// attachment without reconnecting, then asks for an authoritative repaint.
109+func TestPickerOverlayReturnsToLiveAttachment(t *testing.T) {
110+ tests := []struct {
111+ name string
112+ op pickerOp
113+ key string
114+ }{
115+ {name: "escape or q cancels", op: pickerOp{close: true}},
116+ {name: "ctrl+c cancels without exiting", op: pickerOp{close: true, exit: true}},
117+ {name: "commit to the attached session closes", op: pickerOp{commit: true}, key: "row"},
118+ }
119+ for _, tt := range tests {
120+ t.Run(tt.name, func(t *testing.T) {
121+ picker := newAttachTestPicker()
122+ harness := startAttachHarness(t, picker)
123+ stream := attachLiveSession(t, harness, picker)
124+ foreground := harness.sup.attachments.authority.foreground()
125+ require.NotNil(t, foreground)
126+ require.False(t, picker.owns())
127+
128+ stream.deliver(navigationOffer(1))
129+ awaitPresentation(t, harness.sup, PresentAttachedPicker)
130+ require.True(t, picker.owns(), "the picker owns input while it is open")
131+
132+ // Input belongs to the picker: it is consumed, never sent as session
133+ // Input.
134+ harness.reader.push([]byte("j"))
135+ require.Eventually(t, func() bool { return picker.consumedCount() == 1 }, 5*time.Second, time.Millisecond)
136+ require.Zero(t, countSent[protocol.Input](stream), "picker input must never reach the session")
137+
138+ // Attachment output is applied and acknowledged, never written over
139+ // the picker.
140+ acks := countSent[protocol.Ack](stream)
141+ stream.deliver(laterOutput(2, 2, "hidden-frame"))
142+ require.Eventually(t, func() bool { return countSent[protocol.Ack](stream) > acks }, 5*time.Second, time.Millisecond)
143+ require.NotContains(t, harness.terminal.written(), "hidden-frame")
144+
145+ picker.recordOp(tt.op, tt.key, sessionTestRequest(true))
146+ awaitPresentation(t, harness.sup, PresentAttached)
147+ awaitSent(t, stream, "OutputResetRequest", isSent[protocol.OutputResetRequest])
148+
149+ require.Same(t, foreground, harness.sup.attachments.authority.foreground(), "cancel must keep the same live attachment")
150+ require.False(t, stream.closedNow())
151+ require.Len(t, harness.service.openedRequests(), 1, "cancel must not reconnect")
152+ require.Zero(t, countSent[protocol.Detach](stream))
153+ require.False(t, picker.owns())
154+
155+ // The session owns input again.
156+ harness.reader.push([]byte("z"))
157+ input := awaitSent(t, stream, "session Input", isSent[protocol.Input]).(protocol.Input)
158+ require.Equal(t, []byte("z"), input.Data)
159+ select {
160+ case <-harness.runDone:
161+ t.Fatal("cancelling the overlay ended the process")
162+ default:
163+ }
164+ })
165+ }
166+}
167+
168+// TestPickerOverlayCommitElsewhereSwapsAttachment pins the swap: a commit to
169+// another session detaches the live attachment cleanly and attaches the
170+// committed target through Connecting, without passing through the plain
171+// picker.
172+func TestPickerOverlayCommitElsewhereSwapsAttachment(t *testing.T) {
173+ picker := newAttachTestPicker()
174+ notices := make(chan LifecycleNotice, 8)
175+ harness := startAttachHarnessConfig(t, picker, func(cfg *SupervisorConfig) {
176+ cfg.NotifyLifecycle = func(notice LifecycleNotice) { notices <- notice }
177+ })
178+ first := attachLiveSession(t, harness, picker)
179+ first.deliver(navigationOffer(1))
180+ awaitPresentation(t, harness.sup, PresentAttachedPicker)
181+
182+ var mu sync.Mutex
183+ second := newSessionTestStream()
184+ harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
185+ return second, nil
186+ })
187+ other := sessionTestRequest(true)
188+ other.Target = protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "beta"}
189+ picker.recordOp(pickerOp{commit: true}, "row", other)
190+
191+ awaitSent(t, first, "Detach", isSent[protocol.Detach])
192+ awaitStreamHello(t, &mu, &second)
193+ second.deliver(protocol.Welcome{SessionName: "beta"})
194+ output := sessionTestOutput(1, "\x1b[Hbeta")
195+ output.Context.Route.Target = other.Target
196+ second.deliver(output)
197+ awaitAttachedState(t, harness.sup)
198+
199+ opened := harness.service.openedRequests()
200+ require.Len(t, opened, 2)
201+ require.Equal(t, other.Target, opened[1].Target)
202+ require.True(t, first.closedNow(), "the previous attachment is released")
203+ select {
204+ case notice := <-notices:
205+ t.Fatalf("a swap must not report a lifecycle transition to the picker: %+v", notice)
206+ default:
207+ }
208+}
209+
210+// TestPickerCloseWithoutAttachment pins the unattached decision: with nothing
211+// to return to, Escape and q keep the picker (and the process) alive; only the
212+// explicit Ctrl+C exit ends the run.
213+func TestPickerCloseWithoutAttachment(t *testing.T) {
214+ tests := []struct {
215+ name string
216+ op pickerOp
217+ wantExit bool
218+ }{
219+ {name: "escape or q keeps the picker", op: pickerOp{close: true}},
220+ {name: "ctrl+c exits explicitly", op: pickerOp{close: true, exit: true}, wantExit: true},
221+ }
222+ for _, tt := range tests {
223+ t.Run(tt.name, func(t *testing.T) {
224+ picker := newAttachTestPicker()
225+ harness := startAttachHarness(t, picker)
226+ picker.recordOp(tt.op, "", ports.BrokerOpenStreamRequest{})
227+ if tt.wantExit {
228+ require.NoError(t, harness.waitRun(t))
229+ return
230+ }
231+ // Prove the close was consumed, then that the run survived it: a
232+ // later commit is still admitted.
233+ require.Eventually(t, picker.opTaken, 5*time.Second, time.Millisecond)
234+ stream := newSessionTestStream()
235+ harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
236+ return stream, nil
237+ })
238+ picker.commit(sessionTestRequest(true))
239+ awaitStreamHello(t, &sync.Mutex{}, &stream)
240+ select {
241+ case <-harness.runDone:
242+ t.Fatal("a cancel with no attachment ended the process")
243+ default:
244+ }
245+ })
246+ }
247+}
248+
249+// moveSnapshot is the two-destination publication a move interaction opens
250+// with; both rows authorise a move.
251+func moveSnapshot(interaction, revision uint64) protocol.PickerSnapshot {
252+ return protocol.PickerSnapshot{
253+ InteractionID: interaction, SourceID: "serving", SourceRevision: revision, Status: protocol.PickerSourceOK,
254+ Lines: []protocol.PickerLine{
255+ {Key: "aa/first", Kind: protocol.PickerLineSession, Label: "first", Focusable: true, Actions: protocol.PickerCanMove},
256+ {Key: "bb/second", Kind: protocol.PickerLineSession, Label: "second", Focusable: true, Actions: protocol.PickerCanMove},
257+ },
258+ Cursor: protocol.PickerCursor{Key: "aa/first", Index: 0},
259+ }
260+}
261+
262+func moveOffer(interaction uint64) protocol.PickerOffer {
263+ return protocol.PickerOffer{InteractionID: interaction, Intent: protocol.PickerIntentMovePane, Title: " Move "}
264+}
265+
266+// openMovePicker opens one move interaction on a live attachment and waits
267+// until its box is drawn.
268+func openMovePicker(t *testing.T, harness *attachTestHarness, stream *sessionTestStream, interaction uint64) {
269+ t.Helper()
270+ stream.deliver(moveOffer(interaction))
271+ stream.deliver(moveSnapshot(interaction, 1))
272+ require.Eventually(t, func() bool { return strings.Contains(harness.terminal.written(), "second") }, 5*time.Second, time.Millisecond, "the move picker was never drawn")
273+}
274+
275+// TestMovePickerOfferIsPresentedAndAnswered pins PA3: the daemon's move offer
276+// opens a working destination picker whose input never reaches the session,
277+// and every exit path answers with the typed replies and hands input back.
278+func TestMovePickerOfferIsPresentedAndAnswered(t *testing.T) {
279+ tests := []struct {
280+ name string
281+ keys []string
282+ check func(t *testing.T, harness *attachTestHarness, stream *sessionTestStream)
283+ }{
284+ {
285+ name: "enter commits the cursor destination",
286+ keys: []string{"\x1b[B", "\r"},
287+ check: func(t *testing.T, harness *attachTestHarness, stream *sessionTestStream) {
288+ selection := awaitSent(t, stream, "PickerSelection", isSent[protocol.PickerSelection]).(protocol.PickerSelection)
289+ require.Equal(t, uint64(7), selection.InteractionID)
290+ require.Equal(t, "serving", selection.SourceID)
291+ require.Equal(t, uint64(1), selection.SourceRevision)
292+ require.Equal(t, "bb/second", selection.Key)
293+ require.Equal(t, protocol.PickerActionMove, selection.Action)
294+ // The daemon closes the interaction; the terminal returns to the
295+ // attachment with an authoritative repaint.
296+ stream.deliver(protocol.PickerClosed{InteractionID: 7})
297+ awaitSent(t, stream, "OutputResetRequest", isSent[protocol.OutputResetRequest])
298+ },
299+ },
300+ {
301+ name: "q cancels with a typed close",
302+ keys: []string{"q"},
303+ check: func(t *testing.T, harness *attachTestHarness, stream *sessionTestStream) {
304+ closed := awaitSent(t, stream, "PickerClose", isSent[protocol.PickerClose]).(protocol.PickerClose)
305+ require.Equal(t, uint64(7), closed.InteractionID)
306+ awaitSent(t, stream, "OutputResetRequest", isSent[protocol.OutputResetRequest])
307+ },
308+ },
309+ {
310+ name: "a lone escape cancels once its window elapses",
311+ keys: []string{"\x1b"},
312+ check: func(t *testing.T, harness *attachTestHarness, stream *sessionTestStream) {
313+ var escape *supervisorTestTimer
314+ require.Eventually(t, func() bool {
315+ for _, timer := range harness.clock.snapshotTimers() {
316+ if timer.delay == pickerEscapeDeadline && !timer.stopped() {
317+ escape = timer
318+ return true
319+ }
320+ }
321+ return false
322+ }, 5*time.Second, time.Millisecond, "the escape window was never armed")
323+ require.Zero(t, countSent[protocol.PickerClose](stream), "a withheld escape must not close early")
324+ escape.fire()
325+ closed := awaitSent(t, stream, "PickerClose", isSent[protocol.PickerClose]).(protocol.PickerClose)
326+ require.Equal(t, uint64(7), closed.InteractionID)
327+ },
328+ },
329+ }
330+ for _, tt := range tests {
331+ t.Run(tt.name, func(t *testing.T) {
332+ picker := newAttachTestPicker()
333+ harness := startAttachHarness(t, picker)
334+ stream := attachLiveSession(t, harness, picker)
335+ openMovePicker(t, harness, stream, 7)
336+
337+ // Output under the box is applied and acknowledged, never written.
338+ acks := countSent[protocol.Ack](stream)
339+ stream.deliver(laterOutput(2, 2, "hidden-frame"))
340+ require.Eventually(t, func() bool { return countSent[protocol.Ack](stream) > acks }, 5*time.Second, time.Millisecond)
341+ require.NotContains(t, harness.terminal.written(), "hidden-frame")
342+
343+ for _, key := range tt.keys {
344+ harness.reader.push([]byte(key))
345+ }
346+ tt.check(t, harness, stream)
347+ require.Zero(t, countSent[protocol.Input](stream), "move picker input must never reach the session")
348+
349+ // Input is never swallowed after the picker ended: the next key is
350+ // session Input again.
351+ harness.reader.push([]byte("z"))
352+ input := awaitSent(t, stream, "session Input", isSent[protocol.Input]).(protocol.Input)
353+ require.Equal(t, []byte("z"), input.Data)
354+ require.Equal(t, PresentAttached, harness.sup.State().Presentation)
355+ })
356+ }
357+}
358+
359+// TestMovePickerOfferDeclinedUnderSessionPicker pins the exit path when the
360+// move picker cannot be presented: the daemon's interaction is closed at once
361+// instead of swallowing input with nothing on screen.
362+func TestMovePickerOfferDeclinedUnderSessionPicker(t *testing.T) {
363+ picker := newAttachTestPicker()
364+ harness := startAttachHarness(t, picker)
365+ stream := attachLiveSession(t, harness, picker)
366+ stream.deliver(navigationOffer(1))
367+ awaitPresentation(t, harness.sup, PresentAttachedPicker)
368+
369+ stream.deliver(moveOffer(9))
370+ closed := awaitSent(t, stream, "PickerClose", isSent[protocol.PickerClose]).(protocol.PickerClose)
371+ require.Equal(t, uint64(9), closed.InteractionID)
372+ require.Equal(t, PresentAttachedPicker, harness.sup.State().Presentation)
373+}
374+
375+// TestSupervisorReducerPickerOverlay pins the Attached+Picker transitions and
376+// the paint fence: only a committed attachment hosts the overlay, cancel
377+// returns to Attached, and a settled attachment falls back to the plain
378+// picker.
379+func TestSupervisorReducerPickerOverlay(t *testing.T) {
380+ tests := []struct {
381+ name string
382+ from Presentation
383+ event supervisorEventKind
384+ want Presentation
385+ wantPaint bool
386+ }{
387+ {name: "attached opens the overlay", from: PresentAttached, event: supervisorOverlayOpened, want: PresentAttachedPicker, wantPaint: true},
388+ {name: "connecting cannot host the overlay", from: PresentConnecting, event: supervisorOverlayOpened, want: PresentConnecting},
389+ {name: "plain picker ignores an overlay request", from: PresentPicker, event: supervisorOverlayOpened, want: PresentPicker, wantPaint: true},
390+ {name: "cancel returns to the attachment", from: PresentAttachedPicker, event: supervisorOverlayClosed, want: PresentAttached},
391+ {name: "close outside the overlay is inert", from: PresentAttached, event: supervisorOverlayClosed, want: PresentAttached},
392+ {name: "attachment end falls back to the picker", from: PresentAttachedPicker, event: supervisorAttachEnded, want: PresentPicker, wantPaint: true},
393+ {name: "swap goes through connecting", from: PresentAttachedPicker, event: supervisorAttachBegin, want: PresentConnecting},
394+ }
395+ for _, tt := range tests {
396+ t.Run(tt.name, func(t *testing.T) {
397+ state := reduceSupervisor(State{Presentation: tt.from, Connectivity: ConnectivityReady}, supervisorEvent{kind: tt.event})
398+ require.Equal(t, tt.want, state.Presentation)
399+ require.Equal(t, ConnectivityReady, state.Connectivity)
400+ require.Equal(t, tt.wantPaint, PickerPresentation(state))
401+ })
402+ }
403+}
404+
405+// TestMovePickerDrawsAFloatingModalOverTheSession ports main's modal
406+// presentation pin: the move picker is a bordered box over the session, never
407+// a full-screen takeover, so it neither clears the screen nor spans it.
408+func TestMovePickerDrawsAFloatingModalOverTheSession(t *testing.T) {
409+ picker := newAttachTestPicker()
410+ harness := startAttachHarness(t, picker)
411+ stream := attachLiveSession(t, harness, picker)
412+ before := len(harness.terminal.written())
413+ openMovePicker(t, harness, stream, 7)
414+
415+ frame := harness.terminal.written()[before:]
416+ require.Contains(t, frame, "first", "the destination rows must be drawn")
417+ require.Contains(t, frame, "─", "the modal border must be drawn")
418+ require.NotContains(t, frame, "\x1b[2J", "the box must not clear the session under it")
419+ require.Contains(t, harness.terminal.written(), "ready", "the session frame stays under the box")
420+}
421+
422+// TestPickerControllerLoneEscapeResolvesOnItsWindow pins that a real Escape
423+// keypress (one lone byte) cancels once the disambiguation window elapses on
424+// the controller clock, and that a following byte disarms the window instead.
425+func TestPickerControllerLoneEscapeResolvesOnItsWindow(t *testing.T) {
426+ tests := []struct {
427+ name string
428+ follow []byte
429+ wantClose bool
430+ }{
431+ {name: "window elapses into Escape", wantClose: true},
432+ {name: "a following byte resolves the prefix itself", follow: []byte("[B")},
433+ }
434+ for _, tt := range tests {
435+ t.Run(tt.name, func(t *testing.T) {
436+ controller, clock := pickerTestController(t)
437+ controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))
438+
439+ require.True(t, controller.ConsumeTerminalRead([]byte("\x1b")))
440+ escape := clock.awaitTimer(t)
441+ require.Equal(t, pickerEscapeDeadline, escape.delay)
442+ if tt.follow != nil {
443+ require.True(t, controller.ConsumeTerminalRead(tt.follow))
444+ require.Eventually(t, escape.stopped, 5*time.Second, time.Millisecond, "a resolving read must disarm the window")
445+ op, _ := controller.TakeOp()
446+ require.False(t, op.close)
447+ return
448+ }
449+ escape.fire()
450+ select {
451+ case <-controller.OpsReady():
452+ case <-time.After(5 * time.Second):
453+ t.Fatal("the elapsed window never woke the supervisor")
454+ }
455+ op, _ := controller.TakeOp()
456+ require.Equal(t, tt.wantClose, op.close)
457+ require.False(t, op.exit, "Escape is a cancel, never the explicit exit")
458+ })
459+ }
460+}
M internal/usecase/client/supervisor.go +76 -4
@@ -70,6 +70,12 @@ const (
7070 // PresentTerminating is the terminal state: raw mode is being restored and
7171 // the process is leaving.
7272 PresentTerminating
73+ // PresentAttachedPicker composes the client picker over a live attachment
74+ // (ADR 001 Amendment 1). The logical attachment stays attached: its output
75+ // is applied and acknowledged but not written while the picker owns the
76+ // terminal and its input. Cancel returns to the same attachment without
77+ // reconnecting.
78+ PresentAttachedPicker
7379 )
7480
7581 func (p Presentation) String() string {
@@ -82,6 +88,8 @@ func (p Presentation) String() string {
8288 return "attached"
8389 case PresentTerminating:
8490 return "terminating"
91+ case PresentAttachedPicker:
92+ return "attached_picker"
8593 default:
8694 return "unknown"
8795 }
@@ -173,6 +181,11 @@ const (
173181 // supervisorTerminal ends the process: terminal EOF, process cancellation,
174182 // or a terminal broker failure.
175183 supervisorTerminal
184+ // supervisorOverlayOpened composes the picker over the live attachment.
185+ supervisorOverlayOpened
186+ // supervisorOverlayClosed returns from the picker overlay to the same live
187+ // attachment.
188+ supervisorOverlayClosed
176189 )
177190
178191 type supervisorEvent struct {
@@ -221,6 +234,17 @@ func reduceSupervisor(state State, event supervisorEvent) State {
221234 case supervisorNonRetryable:
222235 state.Connectivity = ConnectivityDisconnected
223236 state.Err = event.err
237+ case supervisorOverlayOpened:
238+ // Only a committed attachment can host the overlay: a connecting
239+ // foreground has not proven its first frame yet.
240+ if state.Presentation == PresentAttached {
241+ state.Presentation = PresentAttachedPicker
242+ state.Err = nil
243+ }
244+ case supervisorOverlayClosed:
245+ if state.Presentation == PresentAttachedPicker {
246+ state.Presentation = PresentAttached
247+ }
224248 case supervisorTerminal:
225249 state.Presentation = PresentTerminating
226250 state.Connectivity = ConnectivityDisconnected
@@ -375,6 +399,14 @@ type Supervisor struct {
375399 // clientID is the stable client identity carried in every Hello this
376400 // supervisor sends, across reconnects and attachments.
377401 clientID [16]byte
402+ // readySub is the adopted connection's subscription while the ready phase
403+ // runs, so the picker overlay over a live attachment keeps folding broker
404+ // publications. It is only touched from the run goroutine.
405+ readySub ports.BrokerSubscription
406+ // pendingSwap is the request the picker overlay committed to another
407+ // target while an attachment was live. It is only touched from the run
408+ // goroutine and consumed by runResolvedAttachment.
409+ pendingSwap *ports.BrokerOpenStreamRequest
378410 }
379411
380412 // NewSupervisor validates the required dependencies and returns a supervisor
@@ -474,6 +506,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) {
474506 )
475507 retire := func() {
476508 s.preview.close(s.cfg.Picker)
509+ s.readySub = nil
477510 if sub != nil {
478511 sub.Close()
479512 sub = nil
@@ -542,6 +575,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) {
542575 }
543576
544577 service, sub = result.service, result.sub
578+ s.readySub = sub
545579 if s.cfg.Picker != nil {
546580 // Render the first committed publication immediately, before waiting
547581 // for the next one; the picker never shows a stale empty catalogue
@@ -796,8 +830,17 @@ func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetim
796830 continue
797831 }
798832 if op.close {
799- s.preview.close(s.cfg.Picker)
800- return readyOutcome{terminated: true}
833+ if op.exit {
834+ // Ctrl+C is the explicit exit. With no attachment to
835+ // return to, it is the only close that ends the process.
836+ s.preview.close(s.cfg.Picker)
837+ return readyOutcome{terminated: true}
838+ }
839+ // Escape and q cancel; with no attachment there is nothing to
840+ // cancel back to, so the picker stays and says how to leave.
841+ s.offerPickerNotice("picker-exit-hint", "no session attached: press Ctrl+C to quit")
842+ s.renderCurrent()
843+ continue
801844 }
802845 if op.commit && key != "" {
803846 s.preview.close(s.cfg.Picker)
@@ -834,13 +877,42 @@ func (s *Supervisor) pickerOps() <-chan struct{} {
834877 return s.cfg.Picker.OpsReady()
835878 }
836879
837-// takePickerClose honors exit while offline without losing a racing commit.
880+// takePickerClose honors the explicit exit while offline without losing a
881+// racing commit. A plain cancel (Escape, q) has no attachment to return to and
882+// never ends the process.
838883 func (s *Supervisor) takePickerClose() bool {
839884 op, key := s.cfg.Picker.TakeOp()
840885 if op.commit && key != "" {
841886 s.pendingPickerKey = key
842887 }
843- return op.close
888+ if op.close && !op.exit {
889+ s.offerPickerNotice("picker-exit-hint", "no session attached: press Ctrl+C to quit")
890+ s.renderCurrent()
891+ }
892+ return op.close && op.exit
893+}
894+
895+// pickerPresentationHost is the optional presentation surface of the real
896+// picker. Scripted pickers may omit it.
897+type pickerPresentationHost interface {
898+ offerNotice(id, message string)
899+ invalidatePresentation()
900+}
901+
902+// offerPickerNotice shows one bounded client-local notice when the picker
903+// supports it.
904+func (s *Supervisor) offerPickerNotice(id, message string) {
905+ if host, ok := s.cfg.Picker.(pickerPresentationHost); ok {
906+ host.offerNotice(id, message)
907+ }
908+}
909+
910+// invalidatePickerPresentation forces the next picker frame to redraw the
911+// whole box after another owner wrote the terminal.
912+func (s *Supervisor) invalidatePickerPresentation() {
913+ if host, ok := s.cfg.Picker.(pickerPresentationHost); ok {
914+ host.invalidatePresentation()
915+ }
844916 }
845917
846918 // awaitTermination parks the supervisor on the picker after a non-retryable
@@ -70,6 +70,12 @@ const (
70 // PresentTerminating is the terminal state: raw mode is being restored and70 // PresentTerminating is the terminal state: raw mode is being restored and
71 // the process is leaving.71 // the process is leaving.
72 PresentTerminating72 PresentTerminating
73+ // PresentAttachedPicker composes the client picker over a live attachment
74+ // (ADR 001 Amendment 1). The logical attachment stays attached: its output
75+ // is applied and acknowledged but not written while the picker owns the
76+ // terminal and its input. Cancel returns to the same attachment without
77+ // reconnecting.
78+ PresentAttachedPicker
73 )79 )
74 80
75 func (p Presentation) String() string {81 func (p Presentation) String() string {
@@ -82,6 +88,8 @@ func (p Presentation) String() string {
82 return "attached"88 return "attached"
83 case PresentTerminating:89 case PresentTerminating:
84 return "terminating"90 return "terminating"
91+ case PresentAttachedPicker:
92+ return "attached_picker"
85 default:93 default:
86 return "unknown"94 return "unknown"
87 }95 }
@@ -173,6 +181,11 @@ const (
173 // supervisorTerminal ends the process: terminal EOF, process cancellation,181 // supervisorTerminal ends the process: terminal EOF, process cancellation,
174 // or a terminal broker failure.182 // or a terminal broker failure.
175 supervisorTerminal183 supervisorTerminal
184+ // supervisorOverlayOpened composes the picker over the live attachment.
185+ supervisorOverlayOpened
186+ // supervisorOverlayClosed returns from the picker overlay to the same live
187+ // attachment.
188+ supervisorOverlayClosed
176 )189 )
177 190
178 type supervisorEvent struct {191 type supervisorEvent struct {
@@ -221,6 +234,17 @@ func reduceSupervisor(state State, event supervisorEvent) State {
221 case supervisorNonRetryable:234 case supervisorNonRetryable:
222 state.Connectivity = ConnectivityDisconnected235 state.Connectivity = ConnectivityDisconnected
223 state.Err = event.err236 state.Err = event.err
237+ case supervisorOverlayOpened:
238+ // Only a committed attachment can host the overlay: a connecting
239+ // foreground has not proven its first frame yet.
240+ if state.Presentation == PresentAttached {
241+ state.Presentation = PresentAttachedPicker
242+ state.Err = nil
243+ }
244+ case supervisorOverlayClosed:
245+ if state.Presentation == PresentAttachedPicker {
246+ state.Presentation = PresentAttached
247+ }
224 case supervisorTerminal:248 case supervisorTerminal:
225 state.Presentation = PresentTerminating249 state.Presentation = PresentTerminating
226 state.Connectivity = ConnectivityDisconnected250 state.Connectivity = ConnectivityDisconnected
@@ -375,6 +399,14 @@ type Supervisor struct {
375 // clientID is the stable client identity carried in every Hello this399 // clientID is the stable client identity carried in every Hello this
376 // supervisor sends, across reconnects and attachments.400 // supervisor sends, across reconnects and attachments.
377 clientID [16]byte401 clientID [16]byte
402+ // readySub is the adopted connection's subscription while the ready phase
403+ // runs, so the picker overlay over a live attachment keeps folding broker
404+ // publications. It is only touched from the run goroutine.
405+ readySub ports.BrokerSubscription
406+ // pendingSwap is the request the picker overlay committed to another
407+ // target while an attachment was live. It is only touched from the run
408+ // goroutine and consumed by runResolvedAttachment.
409+ pendingSwap *ports.BrokerOpenStreamRequest
378 }410 }
379 411
380 // NewSupervisor validates the required dependencies and returns a supervisor412 // NewSupervisor validates the required dependencies and returns a supervisor
@@ -474,6 +506,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) {
474 )506 )
475 retire := func() {507 retire := func() {
476 s.preview.close(s.cfg.Picker)508 s.preview.close(s.cfg.Picker)
509+ s.readySub = nil
477 if sub != nil {510 if sub != nil {
478 sub.Close()511 sub.Close()
479 sub = nil512 sub = nil
@@ -542,6 +575,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) {
542 }575 }
543 576
544 service, sub = result.service, result.sub577 service, sub = result.service, result.sub
578+ s.readySub = sub
545 if s.cfg.Picker != nil {579 if s.cfg.Picker != nil {
546 // Render the first committed publication immediately, before waiting580 // Render the first committed publication immediately, before waiting
547 // for the next one; the picker never shows a stale empty catalogue581 // for the next one; the picker never shows a stale empty catalogue
@@ -796,8 +830,17 @@ func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetim
796 continue830 continue
797 }831 }
798 if op.close {832 if op.close {
799- s.preview.close(s.cfg.Picker)833+ if op.exit {
800- return readyOutcome{terminated: true}834+ // Ctrl+C is the explicit exit. With no attachment to
835+ // return to, it is the only close that ends the process.
836+ s.preview.close(s.cfg.Picker)
837+ return readyOutcome{terminated: true}
838+ }
839+ // Escape and q cancel; with no attachment there is nothing to
840+ // cancel back to, so the picker stays and says how to leave.
841+ s.offerPickerNotice("picker-exit-hint", "no session attached: press Ctrl+C to quit")
842+ s.renderCurrent()
843+ continue
801 }844 }
802 if op.commit && key != "" {845 if op.commit && key != "" {
803 s.preview.close(s.cfg.Picker)846 s.preview.close(s.cfg.Picker)
@@ -834,13 +877,42 @@ func (s *Supervisor) pickerOps() <-chan struct{} {
834 return s.cfg.Picker.OpsReady()877 return s.cfg.Picker.OpsReady()
835 }878 }
836 879
837-// takePickerClose honors exit while offline without losing a racing commit.880+// takePickerClose honors the explicit exit while offline without losing a
881+// racing commit. A plain cancel (Escape, q) has no attachment to return to and
882+// never ends the process.
838 func (s *Supervisor) takePickerClose() bool {883 func (s *Supervisor) takePickerClose() bool {
839 op, key := s.cfg.Picker.TakeOp()884 op, key := s.cfg.Picker.TakeOp()
840 if op.commit && key != "" {885 if op.commit && key != "" {
841 s.pendingPickerKey = key886 s.pendingPickerKey = key
842 }887 }
843- return op.close888+ if op.close && !op.exit {
889+ s.offerPickerNotice("picker-exit-hint", "no session attached: press Ctrl+C to quit")
890+ s.renderCurrent()
891+ }
892+ return op.close && op.exit
893+}
894+
895+// pickerPresentationHost is the optional presentation surface of the real
896+// picker. Scripted pickers may omit it.
897+type pickerPresentationHost interface {
898+ offerNotice(id, message string)
899+ invalidatePresentation()
900+}
901+
902+// offerPickerNotice shows one bounded client-local notice when the picker
903+// supports it.
904+func (s *Supervisor) offerPickerNotice(id, message string) {
905+ if host, ok := s.cfg.Picker.(pickerPresentationHost); ok {
906+ host.offerNotice(id, message)
907+ }
908+}
909+
910+// invalidatePickerPresentation forces the next picker frame to redraw the
911+// whole box after another owner wrote the terminal.
912+func (s *Supervisor) invalidatePickerPresentation() {
913+ if host, ok := s.cfg.Picker.(pickerPresentationHost); ok {
914+ host.invalidatePresentation()
915+ }
844 }916 }
845 917
846 // awaitTermination parks the supervisor on the picker after a non-retryable918 // awaitTermination parks the supervisor on the picker after a non-retryable
M internal/usecase/client/supervisor_attach.go +59 -4
@@ -306,6 +306,7 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest,
306306 TrueColor: environment.TrueColor,
307307 SessionEnvironment: sessionEnv,
308308 BeforeAttached: beforeAttached,
309+ Clock: s.cfg.Clock,
309310 })
310311 if err != nil {
311312 return nil, err
@@ -411,9 +412,37 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI
411412 picker.SetOwnsInput(false)
412413 input.releasePicker()
413414 defer func() {
415+ // Whoever owned the terminal since the last picker frame, the next
416+ // frame must draw the whole box again.
417+ s.invalidatePickerPresentation()
414418 picker.SetOwnsInput(true)
415419 input.acquirePicker()
416420 }()
421+ for {
422+ terminated, termErr, swap := s.attachOnce(ctx, input, service, request, localProvenance)
423+ if terminated || swap == nil {
424+ return terminated, termErr
425+ }
426+ // A commit from the picker overlay to another target: the previous
427+ // attachment detached cleanly, and the swap goes through Connecting
428+ // exactly like any committed selection.
429+ request, localProvenance = *swap, SessionEnvironmentLocalPicker
430+ }
431+}
432+
433+// attachOnce runs one resolved attachment end to end. A non-nil swap names the
434+// next request the picker overlay committed while this attachment was live.
435+func (s *Supervisor) attachOnce(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, request ports.BrokerOpenStreamRequest, localProvenance SessionEnvironmentProvenance) (bool, error, *ports.BrokerOpenStreamRequest) {
436+ terminated, termErr := s.attachResolved(ctx, input, service, request, localProvenance)
437+ swap := s.pendingSwap
438+ s.pendingSwap = nil
439+ if terminated {
440+ return terminated, termErr, nil
441+ }
442+ return false, nil, swap
443+}
444+
445+func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, request ports.BrokerOpenStreamRequest, localProvenance SessionEnvironmentProvenance) (bool, error) {
417446 s.transition(supervisorEvent{kind: supervisorAttachBegin})
418447 deadline := startAttachmentDeadline(ctx, s.cfg.Clock)
419448 defer deadline.finish()
@@ -471,20 +500,23 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI
471500 s.transition(supervisorEvent{kind: supervisorAttachEnded})
472501 return false, nil
473502 }
474- return s.settleAttachment(ctx, input, service, deadline, run)
503+ return s.settleAttachment(ctx, input, service, deadline, run, request)
475504 }
476505
477506 // settleAttachment joins one admitted attachment while watching the parent
478507 // context, terminal EOF, and broker-connection loss. The deadline is the only
479508 // other stop: when it fires before the worker settled, the attachment ends as
480509 // a typed timeout without claiming attachment.
481-func (s *Supervisor) settleAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, deadline *attachmentDeadline, run *attachmentRun) (bool, error) {
510+func (s *Supervisor) settleAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, deadline *attachmentDeadline, run *attachmentRun, request ports.BrokerOpenStreamRequest) (bool, error) {
482511 settled := make(chan attachmentSettlement, 1)
483512 go func() {
484513 event, adopted := run.Wait(deadline.Context())
485514 settled <- attachmentSettlement{event: event, adopted: adopted}
486515 }()
487516
517+ overlay := &attachmentPickerOverlay{sup: s, run: run, service: service, request: request}
518+ defer overlay.drop()
519+
488520 var result attachmentSettlement
489521 var brokerLost bool
490522 var terminated bool
@@ -493,11 +525,22 @@ settlement:
493525 for {
494526 // Begin has admitted the foreground, which can write before MarkAttached
495527 // and before the initial publication. Keep presentation invalidation
496- // buffered for the next picker state; consuming it here could overlay
497- // session output with a second terminal writer.
528+ // buffered for the next picker state unless the picker overlay owns the
529+ // terminal; consuming it otherwise could overlay session output with a
530+ // second terminal writer.
498531 select {
499532 case result = <-settled:
500533 break settlement
534+ case <-s.attachments.NavigationRequests():
535+ overlay.enter()
536+ case <-overlay.changed():
537+ overlay.applyPublication()
538+ case <-overlay.previewChanged():
539+ overlay.publishPreview()
540+ case <-overlay.ops():
541+ overlay.takeOp()
542+ case <-overlay.invalidation():
543+ overlay.resize()
501544 case <-service.Done():
502545 brokerLost = true
503546 // The broker connection is gone; the supervisor cancels the run and
@@ -518,9 +561,21 @@ settlement:
518561 }
519562 }
520563
564+ // The attachment settled, so its foreground (and overlay slot) is gone.
565+ overlay.drop()
521566 if terminated {
567+ s.pendingSwap = nil
522568 return true, termErr
523569 }
570+ if s.pendingSwap != nil {
571+ if !brokerLost {
572+ // The overlay committed another target and this attachment
573+ // detached for it: go straight to Connecting for the swap, never
574+ // through the plain picker.
575+ return false, nil
576+ }
577+ s.pendingSwap = nil
578+ }
524579 if deadline.TimedOut() && !result.adopted {
525580 timeout := attachmentTimeoutError(errAttachmentDeadline)
526581 s.transition(supervisorEvent{kind: supervisorAttachEnded, err: timeout})
@@ -306,6 +306,7 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest,
306 TrueColor: environment.TrueColor,306 TrueColor: environment.TrueColor,
307 SessionEnvironment: sessionEnv,307 SessionEnvironment: sessionEnv,
308 BeforeAttached: beforeAttached,308 BeforeAttached: beforeAttached,
309+ Clock: s.cfg.Clock,
309 })310 })
310 if err != nil {311 if err != nil {
311 return nil, err312 return nil, err
@@ -411,9 +412,37 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI
411 picker.SetOwnsInput(false)412 picker.SetOwnsInput(false)
412 input.releasePicker()413 input.releasePicker()
413 defer func() {414 defer func() {
415+ // Whoever owned the terminal since the last picker frame, the next
416+ // frame must draw the whole box again.
417+ s.invalidatePickerPresentation()
414 picker.SetOwnsInput(true)418 picker.SetOwnsInput(true)
415 input.acquirePicker()419 input.acquirePicker()
416 }()420 }()
421+ for {
422+ terminated, termErr, swap := s.attachOnce(ctx, input, service, request, localProvenance)
423+ if terminated || swap == nil {
424+ return terminated, termErr
425+ }
426+ // A commit from the picker overlay to another target: the previous
427+ // attachment detached cleanly, and the swap goes through Connecting
428+ // exactly like any committed selection.
429+ request, localProvenance = *swap, SessionEnvironmentLocalPicker
430+ }
431+}
432+
433+// attachOnce runs one resolved attachment end to end. A non-nil swap names the
434+// next request the picker overlay committed while this attachment was live.
435+func (s *Supervisor) attachOnce(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, request ports.BrokerOpenStreamRequest, localProvenance SessionEnvironmentProvenance) (bool, error, *ports.BrokerOpenStreamRequest) {
436+ terminated, termErr := s.attachResolved(ctx, input, service, request, localProvenance)
437+ swap := s.pendingSwap
438+ s.pendingSwap = nil
439+ if terminated {
440+ return terminated, termErr, nil
441+ }
442+ return false, nil, swap
443+}
444+
445+func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, request ports.BrokerOpenStreamRequest, localProvenance SessionEnvironmentProvenance) (bool, error) {
417 s.transition(supervisorEvent{kind: supervisorAttachBegin})446 s.transition(supervisorEvent{kind: supervisorAttachBegin})
418 deadline := startAttachmentDeadline(ctx, s.cfg.Clock)447 deadline := startAttachmentDeadline(ctx, s.cfg.Clock)
419 defer deadline.finish()448 defer deadline.finish()
@@ -471,20 +500,23 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI
471 s.transition(supervisorEvent{kind: supervisorAttachEnded})500 s.transition(supervisorEvent{kind: supervisorAttachEnded})
472 return false, nil501 return false, nil
473 }502 }
474- return s.settleAttachment(ctx, input, service, deadline, run)503+ return s.settleAttachment(ctx, input, service, deadline, run, request)
475 }504 }
476 505
477 // settleAttachment joins one admitted attachment while watching the parent506 // settleAttachment joins one admitted attachment while watching the parent
478 // context, terminal EOF, and broker-connection loss. The deadline is the only507 // context, terminal EOF, and broker-connection loss. The deadline is the only
479 // other stop: when it fires before the worker settled, the attachment ends as508 // other stop: when it fires before the worker settled, the attachment ends as
480 // a typed timeout without claiming attachment.509 // a typed timeout without claiming attachment.
481-func (s *Supervisor) settleAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, deadline *attachmentDeadline, run *attachmentRun) (bool, error) {510+func (s *Supervisor) settleAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, deadline *attachmentDeadline, run *attachmentRun, request ports.BrokerOpenStreamRequest) (bool, error) {
482 settled := make(chan attachmentSettlement, 1)511 settled := make(chan attachmentSettlement, 1)
483 go func() {512 go func() {
484 event, adopted := run.Wait(deadline.Context())513 event, adopted := run.Wait(deadline.Context())
485 settled <- attachmentSettlement{event: event, adopted: adopted}514 settled <- attachmentSettlement{event: event, adopted: adopted}
486 }()515 }()
487 516
517+ overlay := &attachmentPickerOverlay{sup: s, run: run, service: service, request: request}
518+ defer overlay.drop()
519+
488 var result attachmentSettlement520 var result attachmentSettlement
489 var brokerLost bool521 var brokerLost bool
490 var terminated bool522 var terminated bool
@@ -493,11 +525,22 @@ settlement:
493 for {525 for {
494 // Begin has admitted the foreground, which can write before MarkAttached526 // Begin has admitted the foreground, which can write before MarkAttached
495 // and before the initial publication. Keep presentation invalidation527 // and before the initial publication. Keep presentation invalidation
496- // buffered for the next picker state; consuming it here could overlay528+ // buffered for the next picker state unless the picker overlay owns the
497- // session output with a second terminal writer.529+ // terminal; consuming it otherwise could overlay session output with a
530+ // second terminal writer.
498 select {531 select {
499 case result = <-settled:532 case result = <-settled:
500 break settlement533 break settlement
534+ case <-s.attachments.NavigationRequests():
535+ overlay.enter()
536+ case <-overlay.changed():
537+ overlay.applyPublication()
538+ case <-overlay.previewChanged():
539+ overlay.publishPreview()
540+ case <-overlay.ops():
541+ overlay.takeOp()
542+ case <-overlay.invalidation():
543+ overlay.resize()
501 case <-service.Done():544 case <-service.Done():
502 brokerLost = true545 brokerLost = true
503 // The broker connection is gone; the supervisor cancels the run and546 // The broker connection is gone; the supervisor cancels the run and
@@ -518,9 +561,21 @@ settlement:
518 }561 }
519 }562 }
520 563
564+ // The attachment settled, so its foreground (and overlay slot) is gone.
565+ overlay.drop()
521 if terminated {566 if terminated {
567+ s.pendingSwap = nil
522 return true, termErr568 return true, termErr
523 }569 }
570+ if s.pendingSwap != nil {
571+ if !brokerLost {
572+ // The overlay committed another target and this attachment
573+ // detached for it: go straight to Connecting for the swap, never
574+ // through the plain picker.
575+ return false, nil
576+ }
577+ s.pendingSwap = nil
578+ }
524 if deadline.TimedOut() && !result.adopted {579 if deadline.TimedOut() && !result.adopted {
525 timeout := attachmentTimeoutError(errAttachmentDeadline)580 timeout := attachmentTimeoutError(errAttachmentDeadline)
526 s.transition(supervisorEvent{kind: supervisorAttachEnded, err: timeout})581 s.transition(supervisorEvent{kind: supervisorAttachEnded, err: timeout})
A internal/usecase/client/supervisor_overlay.go +186 -0
@@ -0,0 +1,186 @@
1+package client
2+
3+import (
4+ "github.com/bnema/vev/internal/ports"
5+ "github.com/bnema/vev/internal/protocol"
6+)
7+
8+// Picker overlay over a live attachment (ADR 001 Amendment 1, Plan 003 A1).
9+//
10+// The daemon's session-picker command no longer ends the attachment: it sends
11+// a navigation PickerOffer and the attached worker asks the supervisor for the
12+// client picker. The supervisor then composes the same client-owned picker it
13+// presents with no attachment over the live one:
14+//
15+// - The foreground's overlay slot suppresses attachment output (frames are
16+// still applied and acknowledged) and diverts every authorized input
17+// delivery to the picker, so no picker byte reaches the session and the
18+// single terminal reader is never duplicated.
19+// - Broker publications, previews, and resizes repaint the picker while the
20+// overlay is open, exactly as in the plain picker.
21+// - Cancel (Escape, q, Ctrl+C) and a commit to the attached session release
22+// the slot; the worker then asks the daemon for an authoritative repaint.
23+// The attachment is never reconnected.
24+// - A commit to another target detaches the live attachment cleanly and
25+// swaps to the committed request through Connecting.
26+//
27+// Every method runs on the supervisor's run goroutine.
28+type attachmentPickerOverlay struct {
29+ sup *Supervisor
30+ run *attachmentRun
31+ service ports.BrokerService
32+ request ports.BrokerOpenStreamRequest
33+ active bool
34+ // swapping is set once a commit elsewhere asked the live attachment to
35+ // detach; the overlay keeps the terminal until the attachment settles.
36+ swapping bool
37+}
38+
39+func (o *attachmentPickerOverlay) picker() pickerHost { return o.sup.cfg.Picker }
40+
41+// changed is the broker publication wake while the overlay is presented.
42+func (o *attachmentPickerOverlay) changed() <-chan struct{} {
43+ if !o.active || supervisorNil(o.sup.readySub) {
44+ return nil
45+ }
46+ return o.sup.readySub.Changed()
47+}
48+
49+func (o *attachmentPickerOverlay) previewChanged() <-chan struct{} {
50+ if !o.active {
51+ return nil
52+ }
53+ return o.sup.preview.changed()
54+}
55+
56+func (o *attachmentPickerOverlay) ops() <-chan struct{} {
57+ if !o.active || o.swapping {
58+ return nil
59+ }
60+ return o.picker().OpsReady()
61+}
62+
63+// invalidation is consumed only while the overlay owns the terminal; otherwise
64+// the attachment foreground does and the signal stays buffered.
65+func (o *attachmentPickerOverlay) invalidation() <-chan struct{} {
66+ if !o.active {
67+ return nil
68+ }
69+ return o.sup.presentationInvalidation()
70+}
71+
72+// enter composes the picker over the attachment. It is refused unless the
73+// attachment is committed and presented, and while a swap is in progress.
74+func (o *attachmentPickerOverlay) enter() {
75+ s := o.sup
76+ picker := o.picker()
77+ if o.active || o.swapping || picker == nil || s.State().Presentation != PresentAttached {
78+ return
79+ }
80+ picker.ApplySnapshot(o.service.Snapshot())
81+ // A decision recorded before the overlay existed never applies to it.
82+ picker.TakeOp()
83+ picker.SetOwnsInput(true)
84+ if !s.attachments.beginNavigationOverlay(picker) {
85+ picker.SetOwnsInput(false)
86+ return
87+ }
88+ o.active = true
89+ s.invalidatePickerPresentation()
90+ s.transition(supervisorEvent{kind: supervisorOverlayOpened})
91+ s.refreshPreview(o.service)
92+ s.renderCurrent()
93+}
94+
95+// exit returns the terminal to the same live attachment.
96+func (o *attachmentPickerOverlay) exit() {
97+ if !o.active {
98+ return
99+ }
100+ // Release the slot before the picker stops owning input, so a delivery
101+ // racing the cancel reaches the session instead of a picker that no
102+ // longer consumes it.
103+ o.sup.attachments.endNavigationOverlay()
104+ o.drop()
105+ o.sup.transition(supervisorEvent{kind: supervisorOverlayClosed})
106+}
107+
108+// drop forgets the overlay without touching the foreground: the attachment
109+// settled, so its grant (and overlay slot) is already gone.
110+func (o *attachmentPickerOverlay) drop() {
111+ if !o.active {
112+ return
113+ }
114+ o.active = false
115+ o.sup.preview.close(o.picker())
116+ o.picker().SetOwnsInput(false)
117+}
118+
119+func (o *attachmentPickerOverlay) applyPublication() {
120+ o.picker().ApplySnapshot(o.service.Snapshot())
121+ o.sup.refreshPreview(o.service)
122+ o.sup.renderCurrent()
123+}
124+
125+func (o *attachmentPickerOverlay) publishPreview() {
126+ if o.sup.preview.publish(o.picker()) {
127+ o.sup.renderCurrent()
128+ }
129+}
130+
131+func (o *attachmentPickerOverlay) resize() {
132+ o.sup.refreshPreview(o.service)
133+ o.sup.renderResizeInvalidation()
134+}
135+
136+// takeOp carries out one picker decision over the live attachment.
137+func (o *attachmentPickerOverlay) takeOp() {
138+ s := o.sup
139+ op, key := o.picker().TakeOp()
140+ switch {
141+ case op.close:
142+ // Escape, q, and Ctrl+C all cancel back to the attachment: there is an
143+ // attachment to return to, so nothing here ends the process.
144+ o.exit()
145+ case op.commit && key != "":
146+ request, err := s.resolveCommittedStreamRequest(o.service, key)
147+ if err != nil {
148+ // The picker already shows the bounded refusal; the attachment
149+ // stays live under the overlay.
150+ s.renderCurrent()
151+ return
152+ }
153+ if o.sameTarget(request) {
154+ o.exit()
155+ return
156+ }
157+ s.pendingSwap = &request
158+ o.swapping = true
159+ s.preview.close(o.picker())
160+ s.attachments.requestDetach(o.run.token)
161+ default:
162+ s.refreshPreview(o.service)
163+ s.renderCurrent()
164+ }
165+}
166+
167+// sameTarget reports whether request names the session this attachment is
168+// already showing, in which case committing it is a close.
169+func (o *attachmentPickerOverlay) sameTarget(request ports.BrokerOpenStreamRequest) bool {
170+ return sameAttachmentTarget(o.request, o.sup.attachments.committedTargetOrZero(), request)
171+}
172+
173+// sameAttachmentTarget compares one committed selection with the live
174+// attachment: same daemon authority and same session lifecycle.
175+func sameAttachmentTarget(current ports.BrokerOpenStreamRequest, committed protocol.ExactSessionTarget, next ports.BrokerOpenStreamRequest) bool {
176+ if next.Admission != ports.BrokerAdmissionExact {
177+ return false
178+ }
179+ if current.Local != next.Local || current.Endpoint != next.Endpoint {
180+ return false
181+ }
182+ if committed != (protocol.ExactSessionTarget{}) {
183+ return committed.LifecycleID == next.Target.LifecycleID
184+ }
185+ return current.Admission == ports.BrokerAdmissionExact && current.Target.LifecycleID == next.Target.LifecycleID
186+}
@@ -0,0 +1,186 @@
1+package client
2+
3+import (
4+ "github.com/bnema/vev/internal/ports"
5+ "github.com/bnema/vev/internal/protocol"
6+)
7+
8+// Picker overlay over a live attachment (ADR 001 Amendment 1, Plan 003 A1).
9+//
10+// The daemon's session-picker command no longer ends the attachment: it sends
11+// a navigation PickerOffer and the attached worker asks the supervisor for the
12+// client picker. The supervisor then composes the same client-owned picker it
13+// presents with no attachment over the live one:
14+//
15+// - The foreground's overlay slot suppresses attachment output (frames are
16+// still applied and acknowledged) and diverts every authorized input
17+// delivery to the picker, so no picker byte reaches the session and the
18+// single terminal reader is never duplicated.
19+// - Broker publications, previews, and resizes repaint the picker while the
20+// overlay is open, exactly as in the plain picker.
21+// - Cancel (Escape, q, Ctrl+C) and a commit to the attached session release
22+// the slot; the worker then asks the daemon for an authoritative repaint.
23+// The attachment is never reconnected.
24+// - A commit to another target detaches the live attachment cleanly and
25+// swaps to the committed request through Connecting.
26+//
27+// Every method runs on the supervisor's run goroutine.
28+type attachmentPickerOverlay struct {
29+ sup *Supervisor
30+ run *attachmentRun
31+ service ports.BrokerService
32+ request ports.BrokerOpenStreamRequest
33+ active bool
34+ // swapping is set once a commit elsewhere asked the live attachment to
35+ // detach; the overlay keeps the terminal until the attachment settles.
36+ swapping bool
37+}
38+
39+func (o *attachmentPickerOverlay) picker() pickerHost { return o.sup.cfg.Picker }
40+
41+// changed is the broker publication wake while the overlay is presented.
42+func (o *attachmentPickerOverlay) changed() <-chan struct{} {
43+ if !o.active || supervisorNil(o.sup.readySub) {
44+ return nil
45+ }
46+ return o.sup.readySub.Changed()
47+}
48+
49+func (o *attachmentPickerOverlay) previewChanged() <-chan struct{} {
50+ if !o.active {
51+ return nil
52+ }
53+ return o.sup.preview.changed()
54+}
55+
56+func (o *attachmentPickerOverlay) ops() <-chan struct{} {
57+ if !o.active || o.swapping {
58+ return nil
59+ }
60+ return o.picker().OpsReady()
61+}
62+
63+// invalidation is consumed only while the overlay owns the terminal; otherwise
64+// the attachment foreground does and the signal stays buffered.
65+func (o *attachmentPickerOverlay) invalidation() <-chan struct{} {
66+ if !o.active {
67+ return nil
68+ }
69+ return o.sup.presentationInvalidation()
70+}
71+
72+// enter composes the picker over the attachment. It is refused unless the
73+// attachment is committed and presented, and while a swap is in progress.
74+func (o *attachmentPickerOverlay) enter() {
75+ s := o.sup
76+ picker := o.picker()
77+ if o.active || o.swapping || picker == nil || s.State().Presentation != PresentAttached {
78+ return
79+ }
80+ picker.ApplySnapshot(o.service.Snapshot())
81+ // A decision recorded before the overlay existed never applies to it.
82+ picker.TakeOp()
83+ picker.SetOwnsInput(true)
84+ if !s.attachments.beginNavigationOverlay(picker) {
85+ picker.SetOwnsInput(false)
86+ return
87+ }
88+ o.active = true
89+ s.invalidatePickerPresentation()
90+ s.transition(supervisorEvent{kind: supervisorOverlayOpened})
91+ s.refreshPreview(o.service)
92+ s.renderCurrent()
93+}
94+
95+// exit returns the terminal to the same live attachment.
96+func (o *attachmentPickerOverlay) exit() {
97+ if !o.active {
98+ return
99+ }
100+ // Release the slot before the picker stops owning input, so a delivery
101+ // racing the cancel reaches the session instead of a picker that no
102+ // longer consumes it.
103+ o.sup.attachments.endNavigationOverlay()
104+ o.drop()
105+ o.sup.transition(supervisorEvent{kind: supervisorOverlayClosed})
106+}
107+
108+// drop forgets the overlay without touching the foreground: the attachment
109+// settled, so its grant (and overlay slot) is already gone.
110+func (o *attachmentPickerOverlay) drop() {
111+ if !o.active {
112+ return
113+ }
114+ o.active = false
115+ o.sup.preview.close(o.picker())
116+ o.picker().SetOwnsInput(false)
117+}
118+
119+func (o *attachmentPickerOverlay) applyPublication() {
120+ o.picker().ApplySnapshot(o.service.Snapshot())
121+ o.sup.refreshPreview(o.service)
122+ o.sup.renderCurrent()
123+}
124+
125+func (o *attachmentPickerOverlay) publishPreview() {
126+ if o.sup.preview.publish(o.picker()) {
127+ o.sup.renderCurrent()
128+ }
129+}
130+
131+func (o *attachmentPickerOverlay) resize() {
132+ o.sup.refreshPreview(o.service)
133+ o.sup.renderResizeInvalidation()
134+}
135+
136+// takeOp carries out one picker decision over the live attachment.
137+func (o *attachmentPickerOverlay) takeOp() {
138+ s := o.sup
139+ op, key := o.picker().TakeOp()
140+ switch {
141+ case op.close:
142+ // Escape, q, and Ctrl+C all cancel back to the attachment: there is an
143+ // attachment to return to, so nothing here ends the process.
144+ o.exit()
145+ case op.commit && key != "":
146+ request, err := s.resolveCommittedStreamRequest(o.service, key)
147+ if err != nil {
148+ // The picker already shows the bounded refusal; the attachment
149+ // stays live under the overlay.
150+ s.renderCurrent()
151+ return
152+ }
153+ if o.sameTarget(request) {
154+ o.exit()
155+ return
156+ }
157+ s.pendingSwap = &request
158+ o.swapping = true
159+ s.preview.close(o.picker())
160+ s.attachments.requestDetach(o.run.token)
161+ default:
162+ s.refreshPreview(o.service)
163+ s.renderCurrent()
164+ }
165+}
166+
167+// sameTarget reports whether request names the session this attachment is
168+// already showing, in which case committing it is a close.
169+func (o *attachmentPickerOverlay) sameTarget(request ports.BrokerOpenStreamRequest) bool {
170+ return sameAttachmentTarget(o.request, o.sup.attachments.committedTargetOrZero(), request)
171+}
172+
173+// sameAttachmentTarget compares one committed selection with the live
174+// attachment: same daemon authority and same session lifecycle.
175+func sameAttachmentTarget(current ports.BrokerOpenStreamRequest, committed protocol.ExactSessionTarget, next ports.BrokerOpenStreamRequest) bool {
176+ if next.Admission != ports.BrokerAdmissionExact {
177+ return false
178+ }
179+ if current.Local != next.Local || current.Endpoint != next.Endpoint {
180+ return false
181+ }
182+ if committed != (protocol.ExactSessionTarget{}) {
183+ return committed.LifecycleID == next.Target.LifecycleID
184+ }
185+ return current.Admission == ports.BrokerAdmissionExact && current.Target.LifecycleID == next.Target.LifecycleID
186+}
M internal/usecase/daemon/overlay_runtime.go +5 -0
@@ -49,6 +49,11 @@ type overlayRuntime struct {
4949 pickerIntent protocol.PickerIntent
5050 pickerMoveSource moveSourceLocator
5151 pickerRequestID uint64
52+ // pickerNavigationOffers numbers the fire-and-forget navigation offers
53+ // that ask the client to open its own session picker over this live
54+ // attachment. They open no daemon interaction, so they never touch
55+ // pickerInteraction.
56+ pickerNavigationOffers uint64
5257 // pickerPreview* track the row the client asked to preview. The generation
5358 // supersedes an in-flight capture and names the render subscription, so a
5459 // delayed preview can never replace the row the user is displaying.
@@ -49,6 +49,11 @@ type overlayRuntime struct {
49 pickerIntent protocol.PickerIntent49 pickerIntent protocol.PickerIntent
50 pickerMoveSource moveSourceLocator50 pickerMoveSource moveSourceLocator
51 pickerRequestID uint6451 pickerRequestID uint64
52+ // pickerNavigationOffers numbers the fire-and-forget navigation offers
53+ // that ask the client to open its own session picker over this live
54+ // attachment. They open no daemon interaction, so they never touch
55+ // pickerInteraction.
56+ pickerNavigationOffers uint64
52 // pickerPreview* track the row the client asked to preview. The generation57 // pickerPreview* track the row the client asked to preview. The generation
53 // supersedes an in-flight capture and names the render subscription, so a58 // supersedes an in-flight capture and names the render subscription, so a
54 // delayed preview can never replace the row the user is displaying.59 // delayed preview can never replace the row the user is displaying.
M internal/usecase/daemon/palette.go +5 -6
@@ -1206,12 +1206,11 @@ func (e paletteExec) OpenSessionPicker() error {
12061206 defer fresh.End()
12071207 effect = fresh
12081208 }
1209- // End the attachment; the client supervisor, not an interaction on this
1210- // connection, owns the next picker presentation and its input.
1211- if !e.d.clientGoneForAttachmentReason(effect, true, protocol.ReasonDetachToPicker) {
1212- return errAttachmentTransition
1213- }
1214- return nil
1209+ // Keep the attachment live: the client supervisor composes its own picker
1210+ // over this attachment and owns its input until the user commits or
1211+ // cancels. Detach-to-picker is reserved for attachment loss, session end,
1212+ // and explicit detach.
1213+ return e.d.offerClientNavigationPicker(e.ac, effect)
12151214 }
12161215
12171216 func (e paletteExec) OpenNotifications() error {
@@ -1206,12 +1206,11 @@ func (e paletteExec) OpenSessionPicker() error {
1206 defer fresh.End()1206 defer fresh.End()
1207 effect = fresh1207 effect = fresh
1208 }1208 }
1209- // End the attachment; the client supervisor, not an interaction on this1209+ // Keep the attachment live: the client supervisor composes its own picker
1210- // connection, owns the next picker presentation and its input.1210+ // over this attachment and owns its input until the user commits or
1211- if !e.d.clientGoneForAttachmentReason(effect, true, protocol.ReasonDetachToPicker) {1211+ // cancels. Detach-to-picker is reserved for attachment loss, session end,
1212- return errAttachmentTransition1212+ // and explicit detach.
1213- }1213+ return e.d.offerClientNavigationPicker(e.ac, effect)
1214- return nil
1215 }1214 }
1216 1215
1217 func (e paletteExec) OpenNotifications() error {1216 func (e paletteExec) OpenNotifications() error {
M internal/usecase/daemon/picker_interaction.go +27 -2
@@ -11,14 +11,39 @@ import (
1111 )
1212
1313 // This file retains the serving-daemon move-destination interaction. It must
14-// never admit a navigation intent: session-picker ends the attachment and the
15-// client supervisor owns all subsequent navigation and terminal input.
14+// never admit a navigation interaction: session-picker only offers the client
15+// its own picker over the live attachment (offerClientNavigationPicker), and
16+// the client supervisor owns all navigation and terminal input while it is
17+// open.
1618
1719 // servingPickerSourceID names the source the serving daemon itself owns.
1820 const servingPickerSourceID = protocol.PickerServingSourceID
1921
2022 var errClientOwnedNavigation = errors.New("session navigation is client-owned")
2123
24+// offerClientNavigationPicker asks the client to open its own session picker
25+// over this live attachment. The daemon opens no interaction: navigation rows
26+// come from the client's broker catalogue, the client picker consumes the
27+// input it owns locally, and the attachment stays attached so cancelling the
28+// picker returns to it without reconnecting.
29+func (d *Daemon) offerClientNavigationPicker(ac *attachedClient, effect *attachmentEffect) error {
30+ if ac == nil || ac.overlays == nil || effect == nil || !effect.current() {
31+ return errAttachmentTransition
32+ }
33+ ac.overlays.pickerMu.Lock()
34+ ac.overlays.pickerNavigationOffers++
35+ if ac.overlays.pickerNavigationOffers == 0 {
36+ ac.overlays.pickerNavigationOffers = 1
37+ }
38+ offerID := ac.overlays.pickerNavigationOffers
39+ ac.overlays.pickerMu.Unlock()
40+ offer := protocol.PickerOffer{InteractionID: offerID, Intent: protocol.PickerIntentNavigation, Title: picker.SortRecent.Title()}
41+ if err := protocol.ValidatePickerOffer(offer); err != nil {
42+ return err
43+ }
44+ return effect.sendControl(offer)
45+}
46+
2247 // pickerDuplicateRows reports the first repeated row key with a readable
2348 // identity for both rows. A refused snapshot otherwise says only that a key
2449 // repeated, which is not enough to find the two rows that collided.
@@ -11,14 +11,39 @@ import (
11 )11 )
12 12
13 // This file retains the serving-daemon move-destination interaction. It must13 // This file retains the serving-daemon move-destination interaction. It must
14-// never admit a navigation intent: session-picker ends the attachment and the14+// never admit a navigation interaction: session-picker only offers the client
15-// client supervisor owns all subsequent navigation and terminal input.15+// its own picker over the live attachment (offerClientNavigationPicker), and
16+// the client supervisor owns all navigation and terminal input while it is
17+// open.
16 18
17 // servingPickerSourceID names the source the serving daemon itself owns.19 // servingPickerSourceID names the source the serving daemon itself owns.
18 const servingPickerSourceID = protocol.PickerServingSourceID20 const servingPickerSourceID = protocol.PickerServingSourceID
19 21
20 var errClientOwnedNavigation = errors.New("session navigation is client-owned")22 var errClientOwnedNavigation = errors.New("session navigation is client-owned")
21 23
24+// offerClientNavigationPicker asks the client to open its own session picker
25+// over this live attachment. The daemon opens no interaction: navigation rows
26+// come from the client's broker catalogue, the client picker consumes the
27+// input it owns locally, and the attachment stays attached so cancelling the
28+// picker returns to it without reconnecting.
29+func (d *Daemon) offerClientNavigationPicker(ac *attachedClient, effect *attachmentEffect) error {
30+ if ac == nil || ac.overlays == nil || effect == nil || !effect.current() {
31+ return errAttachmentTransition
32+ }
33+ ac.overlays.pickerMu.Lock()
34+ ac.overlays.pickerNavigationOffers++
35+ if ac.overlays.pickerNavigationOffers == 0 {
36+ ac.overlays.pickerNavigationOffers = 1
37+ }
38+ offerID := ac.overlays.pickerNavigationOffers
39+ ac.overlays.pickerMu.Unlock()
40+ offer := protocol.PickerOffer{InteractionID: offerID, Intent: protocol.PickerIntentNavigation, Title: picker.SortRecent.Title()}
41+ if err := protocol.ValidatePickerOffer(offer); err != nil {
42+ return err
43+ }
44+ return effect.sendControl(offer)
45+}
46+
22 // pickerDuplicateRows reports the first repeated row key with a readable47 // pickerDuplicateRows reports the first repeated row key with a readable
23 // identity for both rows. A refused snapshot otherwise says only that a key48 // identity for both rows. A refused snapshot otherwise says only that a key
24 // repeated, which is not enough to find the two rows that collided.49 // repeated, which is not enough to find the two rows that collided.
A internal/usecase/daemon/session_picker_offer_test.go +52 -0
@@ -0,0 +1,52 @@
1+package daemon
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/protocol"
10+ "github.com/bnema/vev/internal/protocol/wire"
11+)
12+
13+// awaitSentServerMessage drains captured frames until one satisfies match.
14+func awaitSentServerMessage(t *testing.T, sends <-chan wire.Envelope, what string, match func(protocol.ServerMessage) bool) protocol.ServerMessage {
15+ t.Helper()
16+ deadline := time.After(2 * time.Second)
17+ for {
18+ select {
19+ case frame := <-sends:
20+ message := decodeServerMessage(t, frame)
21+ if match(message) {
22+ return message
23+ }
24+ case <-deadline:
25+ t.Fatalf("daemon sent no %s", what)
26+ return nil
27+ }
28+ }
29+}
30+
31+// TestSessionPickerCommandOffersClientPickerOverLiveAttachment pins PA1 on the
32+// daemon side: SSP keeps the attachment, sends a navigation offer the client
33+// composes its own picker for, and opens no daemon interaction that could
34+// swallow input.
35+func TestSessionPickerCommandOffersClientPickerOverLiveAttachment(t *testing.T) {
36+ d, source, ac, _, _, releases, sends := setupMovePickerSessionsCore(t, stubClock{}, 0)
37+ defer releaseAll(releases)
38+
39+ d.handleInput(source, ac, []byte("\x1b "))
40+ require.True(t, ac.overlays.paletteActive())
41+ d.handleInput(source, ac, []byte("SSP\r"))
42+
43+ offer := awaitSentServerMessage(t, sends, "navigation PickerOffer", func(message protocol.ServerMessage) bool {
44+ _, ok := message.(protocol.PickerOffer)
45+ return ok
46+ }).(protocol.PickerOffer)
47+ require.Equal(t, protocol.PickerIntentNavigation, offer.Intent)
48+ require.NotZero(t, offer.InteractionID)
49+ require.False(t, ac.overlays.pickerClientActive(), "a navigation offer must not open a daemon interaction")
50+ require.Same(t, source, ac.currentAttachmentSession(), "SSP must keep the attachment live")
51+ assertNoFurtherFrame(t, sends, "Detached", "SSP must not detach the attachment")
52+}
@@ -0,0 +1,52 @@
1+package daemon
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/protocol"
10+ "github.com/bnema/vev/internal/protocol/wire"
11+)
12+
13+// awaitSentServerMessage drains captured frames until one satisfies match.
14+func awaitSentServerMessage(t *testing.T, sends <-chan wire.Envelope, what string, match func(protocol.ServerMessage) bool) protocol.ServerMessage {
15+ t.Helper()
16+ deadline := time.After(2 * time.Second)
17+ for {
18+ select {
19+ case frame := <-sends:
20+ message := decodeServerMessage(t, frame)
21+ if match(message) {
22+ return message
23+ }
24+ case <-deadline:
25+ t.Fatalf("daemon sent no %s", what)
26+ return nil
27+ }
28+ }
29+}
30+
31+// TestSessionPickerCommandOffersClientPickerOverLiveAttachment pins PA1 on the
32+// daemon side: SSP keeps the attachment, sends a navigation offer the client
33+// composes its own picker for, and opens no daemon interaction that could
34+// swallow input.
35+func TestSessionPickerCommandOffersClientPickerOverLiveAttachment(t *testing.T) {
36+ d, source, ac, _, _, releases, sends := setupMovePickerSessionsCore(t, stubClock{}, 0)
37+ defer releaseAll(releases)
38+
39+ d.handleInput(source, ac, []byte("\x1b "))
40+ require.True(t, ac.overlays.paletteActive())
41+ d.handleInput(source, ac, []byte("SSP\r"))
42+
43+ offer := awaitSentServerMessage(t, sends, "navigation PickerOffer", func(message protocol.ServerMessage) bool {
44+ _, ok := message.(protocol.PickerOffer)
45+ return ok
46+ }).(protocol.PickerOffer)
47+ require.Equal(t, protocol.PickerIntentNavigation, offer.Intent)
48+ require.NotZero(t, offer.InteractionID)
49+ require.False(t, ac.overlays.pickerClientActive(), "a navigation offer must not open a daemon interaction")
50+ require.Same(t, source, ac.currentAttachmentSession(), "SSP must keep the attachment live")
51+ assertNoFurtherFrame(t, sends, "Detached", "SSP must not detach the attachment")
52+}

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