detached 09d92d3

feat(client): restore picker parity with main

Port the daemon-built picker behavior from main onto the client-owned
catalogue (Plan 003 B1-B4, C1):

- Tab rows: a session with tabs is a non-focusable header; each tab row
  is a destination with its pane-title detail, searchable by tab name
  and pane title, resolving to its exact tab (live: preferred stable
  tab; stopped remote: exact TabSelector). Committing another tab of the
  attached session sends SelectTab on the live attachment instead of
  reconnecting.
- Projections: Recent is flat (live local by LastUsedSeq, then remote,
  then stopped by recency); Grouped is sectioned per daemon; `s`
  toggles between them. The cursor starts on the attached tab, else the
  attached session's active tab, else the most recent session's.
- Kill: `x` on a local row kills the session (or deletes a stopped
  history record) through BrokerOperations on its own control stream,
  off the run goroutine, with a typed outcome notice and a local
  reconcile. An unknown outcome is reported and never replayed.
- Hosts: failure toasts once per failure episode; a remote host the
  broker observed failing refuses instantly with main's notice; host
  status rows only appear for a host with no session that is not
  reachable.
- Attention: a session header rings when any tab does, tab rows carry
  their own bell, and the picker model draws both.
commit
09d92d35d005c7444efd4c4c1a92228877786222
author
brice · · 2026-09-22 20:24 UTC
parent
8e35ea9
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
18 files · +2160 -271
M internal/usecase/client/attachment_session.go +24 -4
@@ -76,6 +76,9 @@ type sessionAttachmentConfig struct {
7676 // the worker queries the terminal palette and sends protocol.Theme; when
7777 // nil, replies are still stripped from input but no query is written.
7878 Theme *terminalThemeState
79+ // Tab is the exact tab a picker tab row committed; its zero value
80+ // attaches at session level.
81+ Tab attachmentTab
7982 }
8083
8184 // sessionAttachmentWorker implements AttachmentWorker for one broker logical
@@ -199,7 +202,7 @@ func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, f
199202 return state, &event
200203 }
201204 state = next
202- attachmentNoteCommitted(fg, state.context.Route.Target)
205+ attachmentNoteCommitted(fg, state.context)
203206 // The frame transaction is the pre-attach publication: it commits the first
204207 // session bytes with the Connecting presentation and, through the foreground
205208 // shape rule, a public generation of zero. Attached is published only after
@@ -264,9 +267,9 @@ func attachmentOverlay(fg AttachmentForeground) attachmentOverlayForeground {
264267 return overlay
265268 }
266269
267-func attachmentNoteCommitted(fg AttachmentForeground, target protocol.ExactSessionTarget) {
270+func attachmentNoteCommitted(fg AttachmentForeground, view protocol.ViewContext) {
268271 if overlay := attachmentOverlay(fg); overlay != nil {
269- overlay.noteCommitted(target)
272+ overlay.noteCommitted(view.Route.Target, view.TabID)
270273 }
271274 }
272275
@@ -287,8 +290,10 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
287290 outputResetRequested := false
288291 overlay := attachmentOverlay(fg)
289292 var repaint <-chan struct{}
293+ var tabSelections <-chan domain.TabStableID
290294 if overlay != nil {
291295 repaint = overlay.overlayRepaint()
296+ tabSelections = overlay.tabSelections()
292297 }
293298 picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.TrueColor)}
294299 defer picker.stopEscape()
@@ -323,6 +328,12 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
323328 return w.settle(ctx, fg, stream, token, err)
324329 }
325330 outputResetRequested = true
331+ case tab := <-tabSelections:
332+ // The picker overlay committed another tab of this session: switch
333+ // the attachment's view in place instead of reconnecting.
334+ if err := w.send(ctx, fg, stream, protocol.SelectTab{TabID: tab}); err != nil {
335+ return w.settle(ctx, fg, stream, token, err)
336+ }
326337 case <-picker.escape():
327338 picker.escapeFired()
328339 op, changed := picker.move.flush()
@@ -341,7 +352,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
341352 }
342353 state = next
343354 outputResetRequested = false
344- attachmentNoteCommitted(fg, state.context.Route.Target)
355+ attachmentNoteCommitted(fg, state.context)
345356 if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), typed.Data); err != nil {
346357 return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing output: %w", err)}
347358 }
@@ -360,6 +371,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
360371 continue
361372 }
362373 state = next
374+ attachmentNoteCommitted(fg, state.context)
363375 if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), nil); err != nil {
364376 return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing view update: %w", err)}
365377 }
@@ -618,6 +630,14 @@ func (w *sessionAttachmentWorker) hello(stream ports.BrokerLogicalConnection) pr
618630 // the name is carried rather than left empty. It is copied from the
619631 // request's own validated target, never invented.
620632 hello.Name = target.SessionName
633+ switch {
634+ case w.cfg.Tab.stopped != nil && !request.Local:
635+ // A stopped remote tab is restored through its exact selector.
636+ selector := *w.cfg.Tab.stopped
637+ hello.SessionTarget = &selector
638+ case w.cfg.Tab.preferred != "":
639+ hello.PreferredTabID = w.cfg.Tab.preferred
640+ }
621641 case ports.BrokerAdmissionCreateNamed:
622642 hello.Intent = protocol.IntentNew
623643 hello.Name = request.Name
@@ -76,6 +76,9 @@ type sessionAttachmentConfig struct {
76 // the worker queries the terminal palette and sends protocol.Theme; when76 // the worker queries the terminal palette and sends protocol.Theme; when
77 // nil, replies are still stripped from input but no query is written.77 // nil, replies are still stripped from input but no query is written.
78 Theme *terminalThemeState78 Theme *terminalThemeState
79+ // Tab is the exact tab a picker tab row committed; its zero value
80+ // attaches at session level.
81+ Tab attachmentTab
79 }82 }
80 83
81 // sessionAttachmentWorker implements AttachmentWorker for one broker logical84 // sessionAttachmentWorker implements AttachmentWorker for one broker logical
@@ -199,7 +202,7 @@ func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, f
199 return state, &event202 return state, &event
200 }203 }
201 state = next204 state = next
202- attachmentNoteCommitted(fg, state.context.Route.Target)205+ attachmentNoteCommitted(fg, state.context)
203 // The frame transaction is the pre-attach publication: it commits the first206 // The frame transaction is the pre-attach publication: it commits the first
204 // session bytes with the Connecting presentation and, through the foreground207 // session bytes with the Connecting presentation and, through the foreground
205 // shape rule, a public generation of zero. Attached is published only after208 // shape rule, a public generation of zero. Attached is published only after
@@ -264,9 +267,9 @@ func attachmentOverlay(fg AttachmentForeground) attachmentOverlayForeground {
264 return overlay267 return overlay
265 }268 }
266 269
267-func attachmentNoteCommitted(fg AttachmentForeground, target protocol.ExactSessionTarget) {270+func attachmentNoteCommitted(fg AttachmentForeground, view protocol.ViewContext) {
268 if overlay := attachmentOverlay(fg); overlay != nil {271 if overlay := attachmentOverlay(fg); overlay != nil {
269- overlay.noteCommitted(target)272+ overlay.noteCommitted(view.Route.Target, view.TabID)
270 }273 }
271 }274 }
272 275
@@ -287,8 +290,10 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
287 outputResetRequested := false290 outputResetRequested := false
288 overlay := attachmentOverlay(fg)291 overlay := attachmentOverlay(fg)
289 var repaint <-chan struct{}292 var repaint <-chan struct{}
293+ var tabSelections <-chan domain.TabStableID
290 if overlay != nil {294 if overlay != nil {
291 repaint = overlay.overlayRepaint()295 repaint = overlay.overlayRepaint()
296+ tabSelections = overlay.tabSelections()
292 }297 }
293 picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.TrueColor)}298 picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.TrueColor)}
294 defer picker.stopEscape()299 defer picker.stopEscape()
@@ -323,6 +328,12 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
323 return w.settle(ctx, fg, stream, token, err)328 return w.settle(ctx, fg, stream, token, err)
324 }329 }
325 outputResetRequested = true330 outputResetRequested = true
331+ case tab := <-tabSelections:
332+ // The picker overlay committed another tab of this session: switch
333+ // the attachment's view in place instead of reconnecting.
334+ if err := w.send(ctx, fg, stream, protocol.SelectTab{TabID: tab}); err != nil {
335+ return w.settle(ctx, fg, stream, token, err)
336+ }
326 case <-picker.escape():337 case <-picker.escape():
327 picker.escapeFired()338 picker.escapeFired()
328 op, changed := picker.move.flush()339 op, changed := picker.move.flush()
@@ -341,7 +352,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
341 }352 }
342 state = next353 state = next
343 outputResetRequested = false354 outputResetRequested = false
344- attachmentNoteCommitted(fg, state.context.Route.Target)355+ attachmentNoteCommitted(fg, state.context)
345 if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), typed.Data); err != nil {356 if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), typed.Data); err != nil {
346 return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing output: %w", err)}357 return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing output: %w", err)}
347 }358 }
@@ -360,6 +371,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
360 continue371 continue
361 }372 }
362 state = next373 state = next
374+ attachmentNoteCommitted(fg, state.context)
363 if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), nil); err != nil {375 if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), nil); err != nil {
364 return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing view update: %w", err)}376 return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing view update: %w", err)}
365 }377 }
@@ -618,6 +630,14 @@ func (w *sessionAttachmentWorker) hello(stream ports.BrokerLogicalConnection) pr
618 // the name is carried rather than left empty. It is copied from the630 // the name is carried rather than left empty. It is copied from the
619 // request's own validated target, never invented.631 // request's own validated target, never invented.
620 hello.Name = target.SessionName632 hello.Name = target.SessionName
633+ switch {
634+ case w.cfg.Tab.stopped != nil && !request.Local:
635+ // A stopped remote tab is restored through its exact selector.
636+ selector := *w.cfg.Tab.stopped
637+ hello.SessionTarget = &selector
638+ case w.cfg.Tab.preferred != "":
639+ hello.PreferredTabID = w.cfg.Tab.preferred
640+ }
621 case ports.BrokerAdmissionCreateNamed:641 case ports.BrokerAdmissionCreateNamed:
622 hello.Intent = protocol.IntentNew642 hello.Intent = protocol.IntentNew
623 hello.Name = request.Name643 hello.Name = request.Name
M internal/usecase/client/attachment_session_test.go +1 -1
@@ -509,7 +509,7 @@ func TestSessionAttachmentWorkerHelloEnvironment(t *testing.T) {
509509 attached := newSessionTestAttachments()
510510 supervisor := &Supervisor{cfg: SupervisorConfig{Terminal: terminal, AttachmentEnvironment: tt.env}, clientID: [16]byte{1}}
511511 request := sessionTestRequest(true)
512- worker, err := supervisor.newAttachmentWorker(request, nil, SessionEnvironment{Provenance: SessionEnvironmentLocalCLI, Cwd: tt.env.Cwd, Env: append([]string(nil), request.Env...)})
512+ worker, err := supervisor.newAttachmentWorker(request, attachmentTab{}, nil, SessionEnvironment{Provenance: SessionEnvironmentLocalCLI, Cwd: tt.env.Cwd, Env: append([]string(nil), request.Env...)})
513513 require.NoError(t, err)
514514 events := make(chan AttachmentEvent, 1)
515515 host := newWorkerTestHost(terminal, nil, attached.record)
@@ -509,7 +509,7 @@ func TestSessionAttachmentWorkerHelloEnvironment(t *testing.T) {
509 attached := newSessionTestAttachments()509 attached := newSessionTestAttachments()
510 supervisor := &Supervisor{cfg: SupervisorConfig{Terminal: terminal, AttachmentEnvironment: tt.env}, clientID: [16]byte{1}}510 supervisor := &Supervisor{cfg: SupervisorConfig{Terminal: terminal, AttachmentEnvironment: tt.env}, clientID: [16]byte{1}}
511 request := sessionTestRequest(true)511 request := sessionTestRequest(true)
512- worker, err := supervisor.newAttachmentWorker(request, nil, SessionEnvironment{Provenance: SessionEnvironmentLocalCLI, Cwd: tt.env.Cwd, Env: append([]string(nil), request.Env...)})512+ worker, err := supervisor.newAttachmentWorker(request, attachmentTab{}, nil, SessionEnvironment{Provenance: SessionEnvironmentLocalCLI, Cwd: tt.env.Cwd, Env: append([]string(nil), request.Env...)})
513 require.NoError(t, err)513 require.NoError(t, err)
514 events := make(chan AttachmentEvent, 1)514 events := make(chan AttachmentEvent, 1)
515 host := newWorkerTestHost(terminal, nil, attached.record)515 host := newWorkerTestHost(terminal, nil, attached.record)
M internal/usecase/client/attachment_worker.go +49 -5
@@ -609,6 +609,7 @@ func (h *attachmentHost) newForeground(token AttachmentToken, stream ports.Broke
609609 decided: true,
610610 onAttached: h.onAttach,
611611 repaint: make(chan struct{}, 1),
612+ tabSelect: make(chan domain.TabStableID, 1),
612613 }
613614 }
614615
@@ -664,16 +665,23 @@ func (h *attachmentHost) endNavigationOverlay() {
664665 // output for, so the supervisor can tell a commit to the attached session from
665666 // a commit elsewhere.
666667 func (h *attachmentHost) committedTarget() (protocol.ExactSessionTarget, bool) {
668+ target, _, ok := h.committedView()
669+ return target, ok
670+}
671+
672+// committedView reports the session and the tab the current foreground last
673+// committed output for.
674+func (h *attachmentHost) committedView() (protocol.ExactSessionTarget, domain.TabStableID, bool) {
667675 if h == nil {
668- return protocol.ExactSessionTarget{}, false
676+ return protocol.ExactSessionTarget{}, "", false
669677 }
670678 fg := h.authority.foreground()
671679 if fg == nil {
672- return protocol.ExactSessionTarget{}, false
680+ return protocol.ExactSessionTarget{}, "", false
673681 }
674682 fg.overlayMu.Lock()
675683 defer fg.overlayMu.Unlock()
676- return fg.committed, fg.committedKnown
684+ return fg.committed, fg.committedTab, fg.committedKnown
677685 }
678686
679687 // committedTargetOrZero is committedTarget with an unknown target as zero.
@@ -685,6 +693,29 @@ func (h *attachmentHost) committedTargetOrZero() protocol.ExactSessionTarget {
685693 return target
686694 }
687695
696+// requestTabSelection asks the current foreground's worker to show one tab of
697+// its attached session in place. The newest request replaces an unsent one.
698+func (h *attachmentHost) requestTabSelection(token AttachmentToken, tab domain.TabStableID) bool {
699+ if h == nil || tab == "" {
700+ return false
701+ }
702+ fg := h.authority.foreground()
703+ if fg == nil || fg.token != token {
704+ return false
705+ }
706+ for {
707+ select {
708+ case fg.tabSelect <- tab:
709+ return true
710+ default:
711+ }
712+ select {
713+ case <-fg.tabSelect:
714+ default:
715+ }
716+ }
717+}
718+
688719 // requestDetach asks the current foreground's worker to detach explicitly, so
689720 // the daemon observes a clean Detach before the supervisor swaps attachments.
690721 func (h *attachmentHost) requestDetach(token AttachmentToken) {
@@ -721,7 +752,8 @@ type attachmentOverlayForeground interface {
721752 divertInput(data []byte) bool
722753 overlayRepaint() <-chan struct{}
723754 overlayOutput(uiContext ports.UIContext, data []byte) error
724- noteCommitted(target protocol.ExactSessionTarget)
755+ noteCommitted(target protocol.ExactSessionTarget, tab domain.TabStableID)
756+ tabSelections() <-chan domain.TabStableID
725757 }
726758
727759 var _ attachmentOverlayForeground = (*attachmentForeground)(nil)
@@ -833,16 +865,25 @@ func (f *attachmentForeground) overlayRepaint() <-chan struct{} {
833865 return f.repaint
834866 }
835867
836-func (f *attachmentForeground) noteCommitted(target protocol.ExactSessionTarget) {
868+func (f *attachmentForeground) noteCommitted(target protocol.ExactSessionTarget, tab domain.TabStableID) {
837869 if f == nil {
838870 return
839871 }
840872 f.overlayMu.Lock()
841873 f.committed = target
874+ f.committedTab = tab
842875 f.committedKnown = true
843876 f.overlayMu.Unlock()
844877 }
845878
879+// tabSelections carries the supervisor's in-place tab switches to the worker.
880+func (f *attachmentForeground) tabSelections() <-chan domain.TabStableID {
881+ if f == nil {
882+ return nil
883+ }
884+ return f.tabSelect
885+}
886+
846887 // overlayOutput writes one overlay frame through the same lease and UI
847888 // transaction as attachment output, bypassing only the overlay suppression.
848889 func (f *attachmentForeground) overlayOutput(uiContext ports.UIContext, data []byte) error {
@@ -1026,10 +1067,13 @@ type attachmentForeground struct {
10261067 overlay attachmentOverlayKind
10271068 overlaySink pickerInputConsumer
10281069 committed protocol.ExactSessionTarget
1070+ committedTab domain.TabStableID
10291071 committedKnown bool
10301072 // repaint wakes the worker to request an authoritative repaint once an
10311073 // overlay released the terminal.
10321074 repaint chan struct{}
1075+ // tabSelect carries one pending in-place tab switch to the worker.
1076+ tabSelect chan domain.TabStableID
10331077 }
10341078
10351079 // Token is the generation/attempt identity of this grant.
@@ -609,6 +609,7 @@ func (h *attachmentHost) newForeground(token AttachmentToken, stream ports.Broke
609 decided: true,609 decided: true,
610 onAttached: h.onAttach,610 onAttached: h.onAttach,
611 repaint: make(chan struct{}, 1),611 repaint: make(chan struct{}, 1),
612+ tabSelect: make(chan domain.TabStableID, 1),
612 }613 }
613 }614 }
614 615
@@ -664,16 +665,23 @@ func (h *attachmentHost) endNavigationOverlay() {
664 // output for, so the supervisor can tell a commit to the attached session from665 // output for, so the supervisor can tell a commit to the attached session from
665 // a commit elsewhere.666 // a commit elsewhere.
666 func (h *attachmentHost) committedTarget() (protocol.ExactSessionTarget, bool) {667 func (h *attachmentHost) committedTarget() (protocol.ExactSessionTarget, bool) {
668+ target, _, ok := h.committedView()
669+ return target, ok
670+}
671+
672+// committedView reports the session and the tab the current foreground last
673+// committed output for.
674+func (h *attachmentHost) committedView() (protocol.ExactSessionTarget, domain.TabStableID, bool) {
667 if h == nil {675 if h == nil {
668- return protocol.ExactSessionTarget{}, false676+ return protocol.ExactSessionTarget{}, "", false
669 }677 }
670 fg := h.authority.foreground()678 fg := h.authority.foreground()
671 if fg == nil {679 if fg == nil {
672- return protocol.ExactSessionTarget{}, false680+ return protocol.ExactSessionTarget{}, "", false
673 }681 }
674 fg.overlayMu.Lock()682 fg.overlayMu.Lock()
675 defer fg.overlayMu.Unlock()683 defer fg.overlayMu.Unlock()
676- return fg.committed, fg.committedKnown684+ return fg.committed, fg.committedTab, fg.committedKnown
677 }685 }
678 686
679 // committedTargetOrZero is committedTarget with an unknown target as zero.687 // committedTargetOrZero is committedTarget with an unknown target as zero.
@@ -685,6 +693,29 @@ func (h *attachmentHost) committedTargetOrZero() protocol.ExactSessionTarget {
685 return target693 return target
686 }694 }
687 695
696+// requestTabSelection asks the current foreground's worker to show one tab of
697+// its attached session in place. The newest request replaces an unsent one.
698+func (h *attachmentHost) requestTabSelection(token AttachmentToken, tab domain.TabStableID) bool {
699+ if h == nil || tab == "" {
700+ return false
701+ }
702+ fg := h.authority.foreground()
703+ if fg == nil || fg.token != token {
704+ return false
705+ }
706+ for {
707+ select {
708+ case fg.tabSelect <- tab:
709+ return true
710+ default:
711+ }
712+ select {
713+ case <-fg.tabSelect:
714+ default:
715+ }
716+ }
717+}
718+
688 // requestDetach asks the current foreground's worker to detach explicitly, so719 // requestDetach asks the current foreground's worker to detach explicitly, so
689 // the daemon observes a clean Detach before the supervisor swaps attachments.720 // the daemon observes a clean Detach before the supervisor swaps attachments.
690 func (h *attachmentHost) requestDetach(token AttachmentToken) {721 func (h *attachmentHost) requestDetach(token AttachmentToken) {
@@ -721,7 +752,8 @@ type attachmentOverlayForeground interface {
721 divertInput(data []byte) bool752 divertInput(data []byte) bool
722 overlayRepaint() <-chan struct{}753 overlayRepaint() <-chan struct{}
723 overlayOutput(uiContext ports.UIContext, data []byte) error754 overlayOutput(uiContext ports.UIContext, data []byte) error
724- noteCommitted(target protocol.ExactSessionTarget)755+ noteCommitted(target protocol.ExactSessionTarget, tab domain.TabStableID)
756+ tabSelections() <-chan domain.TabStableID
725 }757 }
726 758
727 var _ attachmentOverlayForeground = (*attachmentForeground)(nil)759 var _ attachmentOverlayForeground = (*attachmentForeground)(nil)
@@ -833,16 +865,25 @@ func (f *attachmentForeground) overlayRepaint() <-chan struct{} {
833 return f.repaint865 return f.repaint
834 }866 }
835 867
836-func (f *attachmentForeground) noteCommitted(target protocol.ExactSessionTarget) {868+func (f *attachmentForeground) noteCommitted(target protocol.ExactSessionTarget, tab domain.TabStableID) {
837 if f == nil {869 if f == nil {
838 return870 return
839 }871 }
840 f.overlayMu.Lock()872 f.overlayMu.Lock()
841 f.committed = target873 f.committed = target
874+ f.committedTab = tab
842 f.committedKnown = true875 f.committedKnown = true
843 f.overlayMu.Unlock()876 f.overlayMu.Unlock()
844 }877 }
845 878
879+// tabSelections carries the supervisor's in-place tab switches to the worker.
880+func (f *attachmentForeground) tabSelections() <-chan domain.TabStableID {
881+ if f == nil {
882+ return nil
883+ }
884+ return f.tabSelect
885+}
886+
846 // overlayOutput writes one overlay frame through the same lease and UI887 // overlayOutput writes one overlay frame through the same lease and UI
847 // transaction as attachment output, bypassing only the overlay suppression.888 // transaction as attachment output, bypassing only the overlay suppression.
848 func (f *attachmentForeground) overlayOutput(uiContext ports.UIContext, data []byte) error {889 func (f *attachmentForeground) overlayOutput(uiContext ports.UIContext, data []byte) error {
@@ -1026,10 +1067,13 @@ type attachmentForeground struct {
1026 overlay attachmentOverlayKind1067 overlay attachmentOverlayKind
1027 overlaySink pickerInputConsumer1068 overlaySink pickerInputConsumer
1028 committed protocol.ExactSessionTarget1069 committed protocol.ExactSessionTarget
1070+ committedTab domain.TabStableID
1029 committedKnown bool1071 committedKnown bool
1030 // repaint wakes the worker to request an authoritative repaint once an1072 // repaint wakes the worker to request an authoritative repaint once an
1031 // overlay released the terminal.1073 // overlay released the terminal.
1032 repaint chan struct{}1074 repaint chan struct{}
1075+ // tabSelect carries one pending in-place tab switch to the worker.
1076+ tabSelect chan domain.TabStableID
1033 }1077 }
1034 1078
1035 // Token is the generation/attempt identity of this grant.1079 // Token is the generation/attempt identity of this grant.
M internal/usecase/client/picker_catalogue.go +129 -149
@@ -105,6 +105,18 @@ func (c pickerCatalogueErrorCode) String() string {
105105 type pickerCatalogueError struct {
106106 Code pickerCatalogueErrorCode
107107 Text string
108+ // Notice is the bounded user-facing sentence shown for this refusal when
109+ // the generic "picker selection <code>" wording would name the wrong
110+ // thing, as for a remote host known to be failing.
111+ Notice string
112+}
113+
114+// noticeText is the sentence a picker toast shows for this refusal.
115+func (e pickerCatalogueError) noticeText() string {
116+ if e.Notice != "" {
117+ return e.Notice
118+ }
119+ return e.Error()
108120 }
109121
110122 func (e pickerCatalogueError) Error() string {
@@ -146,6 +158,20 @@ type pickerSelectionRef struct {
146158 lifecycle domain.SessionLifecycleID
147159 name string
148160 createName string
161+ // tab names one tab row inside the session; its zero value is a
162+ // session-level selection.
163+ tab pickerTabRef
164+}
165+
166+// pickerTabRef is the exact tab a tab row names, captured when it was
167+// projected: the stable ID when the catalogue carries one, and the ordinal,
168+// raw name, and tab count a stopped remote selector needs otherwise.
169+type pickerTabRef struct {
170+ present bool
171+ id domain.TabStableID
172+ index int
173+ name string
174+ count int
149175 }
150176
151177 // pickerResolveBase carries the live broker connection identity a resolved
@@ -185,8 +211,15 @@ type pickerCatalogue struct {
185211 refs map[string]pickerSelectionRef
186212 retired map[string]pickerSelectionRef
187213 retiredOrder []string
214+ // lines/cursor are the grouped projection; recent/recentCursor the flat
215+ // recency projection. Both carry the same keys and actions.
188216 lines []protocol.PickerLine
189217 cursor protocol.PickerCursor
218+ recent []protocol.PickerLine
219+ recentCursor protocol.PickerCursor
220+ // current is the attachment the picker is presented over; it only
221+ // decides where a fresh cursor starts.
222+ current pickerCurrent
190223 }
191224
192225 // newPickerCatalogue builds an empty projection.
@@ -291,14 +324,44 @@ func (c *pickerCatalogue) Cursor() protocol.PickerCursor {
291324 return c.cursor
292325 }
293326
294-// projection returns the current rows and cursor hint together.
295-func (c *pickerCatalogue) projection() ([]protocol.PickerLine, protocol.PickerCursor) {
327+// projections returns the recent and grouped projections together, so both
328+// always describe the same publication.
329+func (c *pickerCatalogue) projections() (recent, grouped protocol.PickerProjection) {
330+ if c == nil {
331+ return protocol.PickerProjection{}, protocol.PickerProjection{}
332+ }
333+ c.mu.Lock()
334+ defer c.mu.Unlock()
335+ recent = protocol.PickerProjection{Lines: append([]protocol.PickerLine(nil), c.recent...), Cursor: c.recentCursor}
336+ grouped = protocol.PickerProjection{Lines: append([]protocol.PickerLine(nil), c.lines...), Cursor: c.cursor}
337+ return recent, grouped
338+}
339+
340+// RecentLines returns a copy of the flat recency projection.
341+func (c *pickerCatalogue) RecentLines() []protocol.PickerLine {
296342 if c == nil {
297- return nil, protocol.PickerCursor{}
343+ return nil
298344 }
299345 c.mu.Lock()
300346 defer c.mu.Unlock()
301- return append([]protocol.PickerLine(nil), c.lines...), c.cursor
347+ return append([]protocol.PickerLine(nil), c.recent...)
348+}
349+
350+// SetCurrent records the attachment the picker is presented over (or none)
351+// and recomputes the cursor hints.
352+func (c *pickerCatalogue) SetCurrent(current pickerCurrent) {
353+ if c == nil {
354+ return
355+ }
356+ c.mu.Lock()
357+ defer c.mu.Unlock()
358+ if c.current == current {
359+ return
360+ }
361+ c.current = current
362+ if c.snapshot.Epoch != 0 {
363+ c.projectLocked()
364+ }
302365 }
303366
304367 // Ref returns the exact selection identity recorded for one row key, including
@@ -336,6 +399,24 @@ func (c *pickerCatalogue) Resolve(key string, base pickerResolveBase) (ports.Bro
336399 return c.resolveRefLocked(ref, base)
337400 }
338401
402+// ResolveTarget is Resolve plus the exact tab the row names, both revalidated
403+// under one lock against the same publication.
404+func (c *pickerCatalogue) ResolveTarget(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) {
405+ if c == nil {
406+ return ports.BrokerOpenStreamRequest{}, attachmentTab{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "no catalogue"}
407+ }
408+ c.mu.Lock()
409+ defer c.mu.Unlock()
410+ ref, ok := c.refs[key]
411+ if !ok {
412+ ref, ok = c.retired[key]
413+ }
414+ if !ok {
415+ return ports.BrokerOpenStreamRequest{}, attachmentTab{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker row is not in the catalogue"}
416+ }
417+ return resolvePickerTarget(c.snapshot.Epoch, c.authorityForRefLocked(ref), ref, base, true)
418+}
419+
339420 // ResolveRef revalidates an exact selection identity a driver captured when it
340421 // displayed the row. It is the entry point for a selection held across a
341422 // broker epoch or an equivalent output boundary, where the key alone would be
@@ -357,6 +438,8 @@ func (c *pickerCatalogue) resetLocked() {
357438 c.retiredOrder = nil
358439 c.lines = nil
359440 c.cursor = protocol.PickerCursor{}
441+ c.recent = nil
442+ c.recentCursor = protocol.PickerCursor{}
360443 }
361444
362445 // mergeHostsLocked folds the newest publication into the per-daemon state. A
@@ -391,96 +474,6 @@ func (c *pickerCatalogue) mergeHostsLocked() {
391474 }
392475 }
393476
394-// projectLocked rebuilds the ordered rows and their opaque keys from the
395-// current host state. It is deterministic: the same inputs always produce the
396-// same keys, order, and actions.
397-func (c *pickerCatalogue) projectLocked() {
398- now := c.clock.Now()
399- refs := make(map[string]pickerSelectionRef)
400- lines := make([]protocol.PickerLine, 0, len(c.hosts)*2)
401- for _, key := range c.order {
402- host, ok := c.hosts[key]
403- if !ok {
404- continue
405- }
406- observation := host.observation
407- fresh := c.freshLocked(observation, now)
408- compatible := pickerObservationCompatible(observation)
409- available := observation.Availability == domain.RemoteAvailabilityReachable
410- eligible := compatible && available && fresh
411- origin := pickerOriginLabel(observation)
412- // Admission is deliberately more permissive than eligibility: a stale,
413- // unavailable, or not-yet-observed daemon is still selectable, because
414- // only the destination revalidates the exact identity and only it may
415- // start a stopped target under the resolved policy. Only a known
416- // incompatibility refuses immediately. Eligibility still drives the
417- // informational dimming and badges.
418- admissible := !pickerObservationKnownIncompatible(observation)
419-
420- lines = append(lines, protocol.PickerLine{Kind: protocol.PickerLineSection, Label: origin, Dim: true})
421- lines = append(lines, protocol.PickerLine{
422- Key: pickerHostRowKey(observation),
423- Kind: protocol.PickerLineHost,
424- Label: "status",
425- Detail: pickerHostDetail(observation),
426- Status: pickerObservationStatus(observation, fresh),
427- StatusDetail: pickerObservationReason(observation, fresh),
428- Focusable: false,
429- })
430-
431- for i := range observation.Sessions {
432- session := observation.Sessions[i]
433- if session.LifecycleID == (domain.SessionLifecycleID{}) {
434- // A session without a lifecycle cannot be addressed exactly and
435- // is never invented into one.
436- continue
437- }
438- ref := pickerSelectionRef{
439- kind: pickerSelectionExact,
440- epoch: c.snapshot.Epoch,
441- local: observation.Local,
442- endpoint: observation.Endpoint,
443- registration: observation.Registration,
444- lifecycle: session.LifecycleID,
445- name: session.Name,
446- }
447- rowKey := pickerSessionRowKey(ref)
448- refs[rowKey] = ref
449- selectable := admissible && session.State != catalogue.RemoteCatalogSessionBroken
450- actions := protocol.PickerLineActions(0)
451- if selectable {
452- actions = protocol.PickerCanNavigate
453- }
454- status := pickerSessionStatus(session)
455- reason := pickerObservationReason(observation, fresh)
456- if reason == "" {
457- reason = session.Reason
458- }
459- lines = append(lines, protocol.PickerLine{
460- Key: rowKey,
461- Kind: protocol.PickerLineSession,
462- Label: session.Name,
463- Detail: pickerSessionDetail(session),
464- Status: status,
465- StatusDetail: reason,
466- Stopped: session.State == catalogue.RemoteCatalogSessionDown,
467- // Dim keeps the informational eligibility signal (fresh,
468- // compatible, reachable) and the hard non-committable state
469- // (known incompatibility, broken session). It never removes the
470- // ability to try an explicit bounded attach or creation.
471- Dim: !eligible || !selectable,
472- Focusable: true,
473- Actions: actions,
474- Ephemeral: session.Ephemeral,
475- })
476- }
477- }
478- c.moveRetiredLocked(refs)
479- c.refs = refs
480- c.lines = lines
481- c.cursor = pickerCatalogueCursor(lines)
482-}
483-
484477 // moveRetiredLocked keeps the last known ref of a key that left the current
485478 // projection so a late selection can still be classified as gone or replaced.
486479 func (c *pickerCatalogue) moveRetiredLocked(next map[string]pickerSelectionRef) {
@@ -503,7 +496,8 @@ func (c *pickerCatalogue) moveRetiredLocked(next map[string]pickerSelectionRef)
503496 // resolveRefLocked revalidates exactly one captured selection against the
504497 // latest applied publication.
505498 func (c *pickerCatalogue) resolveRefLocked(ref pickerSelectionRef, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {
506- return resolvePickerRequest(c.snapshot.Epoch, c.authorityForRefLocked(ref), ref, base)
499+ request, _, err := resolvePickerTarget(c.snapshot.Epoch, c.authorityForRefLocked(ref), ref, base, true)
500+ return request, err
507501 }
508502
509503 // authorityForRefLocked selects the merged observation one selection names. The
@@ -581,21 +575,42 @@ func pickerAuthorityInSnapshot(snapshot ports.BrokerSnapshot, ref pickerSelectio
581575 // start-if-needed authorization; a policy that forbids launching still refuses
582576 // visibly at the destination.
583577 func resolvePickerRequest(epoch ports.BrokerEpoch, authority pickerResolveAuthority, ref pickerSelectionRef, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {
578+ request, _, err := resolvePickerTarget(epoch, authority, ref, base, false)
579+ return request, err
580+}
581+
582+// resolvePickerTarget is resolvePickerRequest plus the exact tab a tab row
583+// names. refuseFailing is the picker's instant refusal (Plan 003 B4): a
584+// remote host the broker observed failing (unreachable, authentication, or an
585+// invalid response) refuses with a notice instead of a slow attempt that
586+// would fail the same way. An explicit CLI target keeps the attempt.
587+func resolvePickerTarget(epoch ports.BrokerEpoch, authority pickerResolveAuthority, ref pickerSelectionRef, base pickerResolveBase, refuseFailing bool) (ports.BrokerOpenStreamRequest, attachmentTab, error) {
588+ fail := func(err error) (ports.BrokerOpenStreamRequest, attachmentTab, error) {
589+ return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err
590+ }
584591 if epoch == 0 {
585- return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "broker catalogue is unavailable"}
592+ return fail(pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "broker catalogue is unavailable"})
586593 }
587594 if ref.epoch != 0 && ref.epoch != epoch {
588- return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueEpochStale, Text: "broker epoch changed"}
595+ return fail(pickerCatalogueError{Code: pickerCatalogueEpochStale, Text: "broker epoch changed"})
589596 }
590597 if !authority.found {
591- return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueGone, Text: "host is no longer in the catalogue"}
598+ return fail(pickerCatalogueError{Code: pickerCatalogueGone, Text: "host is no longer in the catalogue"})
592599 }
593600 observation := authority.observation
594601 if !ref.local && !observation.Registration.Equal(ref.registration) {
595- return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "host identity was replaced"}
602+ return fail(pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "host identity was replaced"})
596603 }
597604 if observation.Availability == domain.RemoteAvailabilityIncompatible || pickerObservationVersionMismatch(observation) {
598- return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueIncompatible, Text: "host protocol is incompatible"}
605+ return fail(pickerCatalogueError{Code: pickerCatalogueIncompatible, Text: "host protocol is incompatible"})
606+ }
607+ if refuseFailing && !ref.local && pickerObservationFailing(observation) {
608+ reason := pickerObservationReason(observation, false)
609+ return fail(pickerCatalogueError{
610+ Code: pickerCatalogueUnavailable,
611+ Text: "host " + reason,
612+ Notice: pickerUnavailableNotice(ref, observation, reason),
613+ })
599614 }
600615 request := ports.BrokerOpenStreamRequest{
601616 Epoch: epoch,
@@ -614,40 +629,46 @@ func resolvePickerRequest(epoch ports.BrokerEpoch, authority pickerResolveAuthor
614629 if !ref.local {
615630 request.Endpoint = observation.Endpoint
616631 }
632+ var tab attachmentTab
617633 switch ref.kind {
618634 case pickerSelectionExact:
619635 session, ok := pickerFindSession(observation.Sessions, ref.lifecycle)
620636 if !ok {
621637 if pickerSessionReplaced(observation.Sessions, ref) {
622- return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "session identity was replaced"}
638+ return fail(pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "session identity was replaced"})
623639 }
624- return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueGone, Text: "session is no longer in the catalogue"}
640+ return fail(pickerCatalogueError{Code: pickerCatalogueGone, Text: "session is no longer in the catalogue"})
625641 }
626642 if session.Name != ref.name {
627643 // The exact identity is the lifecycle/name pair the daemon
628644 // revalidates: a renamed lifecycle is a different selection.
629- return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "session identity was replaced"}
645+ return fail(pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "session identity was replaced"})
630646 }
631647 if session.State == catalogue.RemoteCatalogSessionBroken {
632- return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "session is broken"}
648+ return fail(pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "session is broken"})
633649 }
650+ resolved, err := pickerResolveTab(ref, session)
651+ if err != nil {
652+ return fail(err)
653+ }
654+ tab = resolved
634655 request.Admission = ports.BrokerAdmissionExact
635656 request.Target = protocol.ExactSessionTarget{LifecycleID: session.LifecycleID, SessionName: session.Name}
636657 case pickerSelectionCreateNamed:
637658 if err := domain.ValidateSessionName(ref.createName); err != nil {
638- return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueInvalidName, Text: "session name is not valid"}
659+ return fail(pickerCatalogueError{Code: pickerCatalogueInvalidName, Text: "session name is not valid"})
639660 }
640661 request.Admission = ports.BrokerAdmissionCreateNamed
641662 request.Name = ref.createName
642663 case pickerSelectionCreateEphemeral:
643664 request.Admission = ports.BrokerAdmissionCreateEphemeral
644665 default:
645- return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker selection kind is unknown"}
666+ return fail(pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker selection kind is unknown"})
646667 }
647668 if err := request.Validate(); err != nil {
648- return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker stream request is not valid"}
669+ return fail(pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker stream request is not valid"})
649670 }
650- return request, nil
671+ return request, tab, nil
651672 }
652673
653674 // freshLocked reports whether LastSuccess is inside the freshness window. An
@@ -659,47 +680,6 @@ func (c *pickerCatalogue) freshLocked(observation ports.BrokerDaemonObservation,
659680 return now.Sub(observation.LastSuccess) <= c.freshness
660681 }
661682
662-// pickerHostDiagnostic is one bounded, presentation-safe catalogue observation
663-// the controller surfaces as a toast.
664-type pickerHostDiagnostic struct {
665- Key string
666- Origin string
667- Status protocol.PickerLineStatus
668- Message string
669-}
670-
671-// diagnostics reports actionable host failures. Ordinary observation progress
672-// and catalogue aging remain visible on the host status row and never become
673-// intrusive toasts.
674-func (c *pickerCatalogue) diagnostics() []pickerHostDiagnostic {
675- if c == nil {
676- return nil
677- }
678- c.mu.Lock()
679- defer c.mu.Unlock()
680- now := c.clock.Now()
681- diagnostics := make([]pickerHostDiagnostic, 0, len(c.hosts))
682- for _, key := range c.order {
683- host, ok := c.hosts[key]
684- if !ok {
685- continue
686- }
687- observation := host.observation
688- fresh := c.freshLocked(observation, now)
689- status := pickerObservationStatus(observation, fresh)
690- if status != protocol.PickerLineStatusDown && status != protocol.PickerLineStatusError && status != protocol.PickerLineStatusVersion {
691- continue
692- }
693- diagnostics = append(diagnostics, pickerHostDiagnostic{
694- Key: pickerHostRowKey(observation),
695- Origin: pickerOriginLabel(observation),
696- Status: status,
697- Message: pickerObservationReason(observation, fresh),
698- })
699- }
700- return diagnostics
701-}
702-
703683 func pickerDaemonKey(local bool, endpoint string) string {
704684 if local {
705685 return "local"
@@ -105,6 +105,18 @@ func (c pickerCatalogueErrorCode) String() string {
105 type pickerCatalogueError struct {105 type pickerCatalogueError struct {
106 Code pickerCatalogueErrorCode106 Code pickerCatalogueErrorCode
107 Text string107 Text string
108+ // Notice is the bounded user-facing sentence shown for this refusal when
109+ // the generic "picker selection <code>" wording would name the wrong
110+ // thing, as for a remote host known to be failing.
111+ Notice string
112+}
113+
114+// noticeText is the sentence a picker toast shows for this refusal.
115+func (e pickerCatalogueError) noticeText() string {
116+ if e.Notice != "" {
117+ return e.Notice
118+ }
119+ return e.Error()
108 }120 }
109 121
110 func (e pickerCatalogueError) Error() string {122 func (e pickerCatalogueError) Error() string {
@@ -146,6 +158,20 @@ type pickerSelectionRef struct {
146 lifecycle domain.SessionLifecycleID158 lifecycle domain.SessionLifecycleID
147 name string159 name string
148 createName string160 createName string
161+ // tab names one tab row inside the session; its zero value is a
162+ // session-level selection.
163+ tab pickerTabRef
164+}
165+
166+// pickerTabRef is the exact tab a tab row names, captured when it was
167+// projected: the stable ID when the catalogue carries one, and the ordinal,
168+// raw name, and tab count a stopped remote selector needs otherwise.
169+type pickerTabRef struct {
170+ present bool
171+ id domain.TabStableID
172+ index int
173+ name string
174+ count int
149 }175 }
150 176
151 // pickerResolveBase carries the live broker connection identity a resolved177 // pickerResolveBase carries the live broker connection identity a resolved
@@ -185,8 +211,15 @@ type pickerCatalogue struct {
185 refs map[string]pickerSelectionRef211 refs map[string]pickerSelectionRef
186 retired map[string]pickerSelectionRef212 retired map[string]pickerSelectionRef
187 retiredOrder []string213 retiredOrder []string
214+ // lines/cursor are the grouped projection; recent/recentCursor the flat
215+ // recency projection. Both carry the same keys and actions.
188 lines []protocol.PickerLine216 lines []protocol.PickerLine
189 cursor protocol.PickerCursor217 cursor protocol.PickerCursor
218+ recent []protocol.PickerLine
219+ recentCursor protocol.PickerCursor
220+ // current is the attachment the picker is presented over; it only
221+ // decides where a fresh cursor starts.
222+ current pickerCurrent
190 }223 }
191 224
192 // newPickerCatalogue builds an empty projection.225 // newPickerCatalogue builds an empty projection.
@@ -291,14 +324,44 @@ func (c *pickerCatalogue) Cursor() protocol.PickerCursor {
291 return c.cursor324 return c.cursor
292 }325 }
293 326
294-// projection returns the current rows and cursor hint together.327+// projections returns the recent and grouped projections together, so both
295-func (c *pickerCatalogue) projection() ([]protocol.PickerLine, protocol.PickerCursor) {328+// always describe the same publication.
329+func (c *pickerCatalogue) projections() (recent, grouped protocol.PickerProjection) {
330+ if c == nil {
331+ return protocol.PickerProjection{}, protocol.PickerProjection{}
332+ }
333+ c.mu.Lock()
334+ defer c.mu.Unlock()
335+ recent = protocol.PickerProjection{Lines: append([]protocol.PickerLine(nil), c.recent...), Cursor: c.recentCursor}
336+ grouped = protocol.PickerProjection{Lines: append([]protocol.PickerLine(nil), c.lines...), Cursor: c.cursor}
337+ return recent, grouped
338+}
339+
340+// RecentLines returns a copy of the flat recency projection.
341+func (c *pickerCatalogue) RecentLines() []protocol.PickerLine {
296 if c == nil {342 if c == nil {
297- return nil, protocol.PickerCursor{}343+ return nil
298 }344 }
299 c.mu.Lock()345 c.mu.Lock()
300 defer c.mu.Unlock()346 defer c.mu.Unlock()
301- return append([]protocol.PickerLine(nil), c.lines...), c.cursor347+ return append([]protocol.PickerLine(nil), c.recent...)
348+}
349+
350+// SetCurrent records the attachment the picker is presented over (or none)
351+// and recomputes the cursor hints.
352+func (c *pickerCatalogue) SetCurrent(current pickerCurrent) {
353+ if c == nil {
354+ return
355+ }
356+ c.mu.Lock()
357+ defer c.mu.Unlock()
358+ if c.current == current {
359+ return
360+ }
361+ c.current = current
362+ if c.snapshot.Epoch != 0 {
363+ c.projectLocked()
364+ }
302 }365 }
303 366
304 // Ref returns the exact selection identity recorded for one row key, including367 // Ref returns the exact selection identity recorded for one row key, including
@@ -336,6 +399,24 @@ func (c *pickerCatalogue) Resolve(key string, base pickerResolveBase) (ports.Bro
336 return c.resolveRefLocked(ref, base)399 return c.resolveRefLocked(ref, base)
337 }400 }
338 401
402+// ResolveTarget is Resolve plus the exact tab the row names, both revalidated
403+// under one lock against the same publication.
404+func (c *pickerCatalogue) ResolveTarget(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) {
405+ if c == nil {
406+ return ports.BrokerOpenStreamRequest{}, attachmentTab{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "no catalogue"}
407+ }
408+ c.mu.Lock()
409+ defer c.mu.Unlock()
410+ ref, ok := c.refs[key]
411+ if !ok {
412+ ref, ok = c.retired[key]
413+ }
414+ if !ok {
415+ return ports.BrokerOpenStreamRequest{}, attachmentTab{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker row is not in the catalogue"}
416+ }
417+ return resolvePickerTarget(c.snapshot.Epoch, c.authorityForRefLocked(ref), ref, base, true)
418+}
419+
339 // ResolveRef revalidates an exact selection identity a driver captured when it420 // ResolveRef revalidates an exact selection identity a driver captured when it
340 // displayed the row. It is the entry point for a selection held across a421 // displayed the row. It is the entry point for a selection held across a
341 // broker epoch or an equivalent output boundary, where the key alone would be422 // broker epoch or an equivalent output boundary, where the key alone would be
@@ -357,6 +438,8 @@ func (c *pickerCatalogue) resetLocked() {
357 c.retiredOrder = nil438 c.retiredOrder = nil
358 c.lines = nil439 c.lines = nil
359 c.cursor = protocol.PickerCursor{}440 c.cursor = protocol.PickerCursor{}
441+ c.recent = nil
442+ c.recentCursor = protocol.PickerCursor{}
360 }443 }
361 444
362 // mergeHostsLocked folds the newest publication into the per-daemon state. A445 // mergeHostsLocked folds the newest publication into the per-daemon state. A
@@ -391,96 +474,6 @@ func (c *pickerCatalogue) mergeHostsLocked() {
391 }474 }
392 }475 }
393 476
394-// projectLocked rebuilds the ordered rows and their opaque keys from the
395-// current host state. It is deterministic: the same inputs always produce the
396-// same keys, order, and actions.
397-func (c *pickerCatalogue) projectLocked() {
398- now := c.clock.Now()
399- refs := make(map[string]pickerSelectionRef)
400- lines := make([]protocol.PickerLine, 0, len(c.hosts)*2)
401- for _, key := range c.order {
402- host, ok := c.hosts[key]
403- if !ok {
404- continue
405- }
406- observation := host.observation
407- fresh := c.freshLocked(observation, now)
408- compatible := pickerObservationCompatible(observation)
409- available := observation.Availability == domain.RemoteAvailabilityReachable
410- eligible := compatible && available && fresh
411- origin := pickerOriginLabel(observation)
412- // Admission is deliberately more permissive than eligibility: a stale,
413- // unavailable, or not-yet-observed daemon is still selectable, because
414- // only the destination revalidates the exact identity and only it may
415- // start a stopped target under the resolved policy. Only a known
416- // incompatibility refuses immediately. Eligibility still drives the
417- // informational dimming and badges.
418- admissible := !pickerObservationKnownIncompatible(observation)
419-
420- lines = append(lines, protocol.PickerLine{Kind: protocol.PickerLineSection, Label: origin, Dim: true})
421- lines = append(lines, protocol.PickerLine{
422- Key: pickerHostRowKey(observation),
423- Kind: protocol.PickerLineHost,
424- Label: "status",
425- Detail: pickerHostDetail(observation),
426- Status: pickerObservationStatus(observation, fresh),
427- StatusDetail: pickerObservationReason(observation, fresh),
428- Focusable: false,
429- })
430-
431- for i := range observation.Sessions {
432- session := observation.Sessions[i]
433- if session.LifecycleID == (domain.SessionLifecycleID{}) {
434- // A session without a lifecycle cannot be addressed exactly and
435- // is never invented into one.
436- continue
437- }
438- ref := pickerSelectionRef{
439- kind: pickerSelectionExact,
440- epoch: c.snapshot.Epoch,
441- local: observation.Local,
442- endpoint: observation.Endpoint,
443- registration: observation.Registration,
444- lifecycle: session.LifecycleID,
445- name: session.Name,
446- }
447- rowKey := pickerSessionRowKey(ref)
448- refs[rowKey] = ref
449- selectable := admissible && session.State != catalogue.RemoteCatalogSessionBroken
450- actions := protocol.PickerLineActions(0)
451- if selectable {
452- actions = protocol.PickerCanNavigate
453- }
454- status := pickerSessionStatus(session)
455- reason := pickerObservationReason(observation, fresh)
456- if reason == "" {
457- reason = session.Reason
458- }
459- lines = append(lines, protocol.PickerLine{
460- Key: rowKey,
461- Kind: protocol.PickerLineSession,
462- Label: session.Name,
463- Detail: pickerSessionDetail(session),
464- Status: status,
465- StatusDetail: reason,
466- Stopped: session.State == catalogue.RemoteCatalogSessionDown,
467- // Dim keeps the informational eligibility signal (fresh,
468- // compatible, reachable) and the hard non-committable state
469- // (known incompatibility, broken session). It never removes the
470- // ability to try an explicit bounded attach or creation.
471- Dim: !eligible || !selectable,
472- Focusable: true,
473- Actions: actions,
474- Ephemeral: session.Ephemeral,
475- })
476- }
477- }
478- c.moveRetiredLocked(refs)
479- c.refs = refs
480- c.lines = lines
481- c.cursor = pickerCatalogueCursor(lines)
482-}
483-
484 // moveRetiredLocked keeps the last known ref of a key that left the current477 // moveRetiredLocked keeps the last known ref of a key that left the current
485 // projection so a late selection can still be classified as gone or replaced.478 // projection so a late selection can still be classified as gone or replaced.
486 func (c *pickerCatalogue) moveRetiredLocked(next map[string]pickerSelectionRef) {479 func (c *pickerCatalogue) moveRetiredLocked(next map[string]pickerSelectionRef) {
@@ -503,7 +496,8 @@ func (c *pickerCatalogue) moveRetiredLocked(next map[string]pickerSelectionRef)
503 // resolveRefLocked revalidates exactly one captured selection against the496 // resolveRefLocked revalidates exactly one captured selection against the
504 // latest applied publication.497 // latest applied publication.
505 func (c *pickerCatalogue) resolveRefLocked(ref pickerSelectionRef, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {498 func (c *pickerCatalogue) resolveRefLocked(ref pickerSelectionRef, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {
506- return resolvePickerRequest(c.snapshot.Epoch, c.authorityForRefLocked(ref), ref, base)499+ request, _, err := resolvePickerTarget(c.snapshot.Epoch, c.authorityForRefLocked(ref), ref, base, true)
500+ return request, err
507 }501 }
508 502
509 // authorityForRefLocked selects the merged observation one selection names. The503 // authorityForRefLocked selects the merged observation one selection names. The
@@ -581,21 +575,42 @@ func pickerAuthorityInSnapshot(snapshot ports.BrokerSnapshot, ref pickerSelectio
581 // start-if-needed authorization; a policy that forbids launching still refuses575 // start-if-needed authorization; a policy that forbids launching still refuses
582 // visibly at the destination.576 // visibly at the destination.
583 func resolvePickerRequest(epoch ports.BrokerEpoch, authority pickerResolveAuthority, ref pickerSelectionRef, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {577 func resolvePickerRequest(epoch ports.BrokerEpoch, authority pickerResolveAuthority, ref pickerSelectionRef, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {
578+ request, _, err := resolvePickerTarget(epoch, authority, ref, base, false)
579+ return request, err
580+}
581+
582+// resolvePickerTarget is resolvePickerRequest plus the exact tab a tab row
583+// names. refuseFailing is the picker's instant refusal (Plan 003 B4): a
584+// remote host the broker observed failing (unreachable, authentication, or an
585+// invalid response) refuses with a notice instead of a slow attempt that
586+// would fail the same way. An explicit CLI target keeps the attempt.
587+func resolvePickerTarget(epoch ports.BrokerEpoch, authority pickerResolveAuthority, ref pickerSelectionRef, base pickerResolveBase, refuseFailing bool) (ports.BrokerOpenStreamRequest, attachmentTab, error) {
588+ fail := func(err error) (ports.BrokerOpenStreamRequest, attachmentTab, error) {
589+ return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err
590+ }
584 if epoch == 0 {591 if epoch == 0 {
585- return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "broker catalogue is unavailable"}592+ return fail(pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "broker catalogue is unavailable"})
586 }593 }
587 if ref.epoch != 0 && ref.epoch != epoch {594 if ref.epoch != 0 && ref.epoch != epoch {
588- return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueEpochStale, Text: "broker epoch changed"}595+ return fail(pickerCatalogueError{Code: pickerCatalogueEpochStale, Text: "broker epoch changed"})
589 }596 }
590 if !authority.found {597 if !authority.found {
591- return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueGone, Text: "host is no longer in the catalogue"}598+ return fail(pickerCatalogueError{Code: pickerCatalogueGone, Text: "host is no longer in the catalogue"})
592 }599 }
593 observation := authority.observation600 observation := authority.observation
594 if !ref.local && !observation.Registration.Equal(ref.registration) {601 if !ref.local && !observation.Registration.Equal(ref.registration) {
595- return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "host identity was replaced"}602+ return fail(pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "host identity was replaced"})
596 }603 }
597 if observation.Availability == domain.RemoteAvailabilityIncompatible || pickerObservationVersionMismatch(observation) {604 if observation.Availability == domain.RemoteAvailabilityIncompatible || pickerObservationVersionMismatch(observation) {
598- return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueIncompatible, Text: "host protocol is incompatible"}605+ return fail(pickerCatalogueError{Code: pickerCatalogueIncompatible, Text: "host protocol is incompatible"})
606+ }
607+ if refuseFailing && !ref.local && pickerObservationFailing(observation) {
608+ reason := pickerObservationReason(observation, false)
609+ return fail(pickerCatalogueError{
610+ Code: pickerCatalogueUnavailable,
611+ Text: "host " + reason,
612+ Notice: pickerUnavailableNotice(ref, observation, reason),
613+ })
599 }614 }
600 request := ports.BrokerOpenStreamRequest{615 request := ports.BrokerOpenStreamRequest{
601 Epoch: epoch,616 Epoch: epoch,
@@ -614,40 +629,46 @@ func resolvePickerRequest(epoch ports.BrokerEpoch, authority pickerResolveAuthor
614 if !ref.local {629 if !ref.local {
615 request.Endpoint = observation.Endpoint630 request.Endpoint = observation.Endpoint
616 }631 }
632+ var tab attachmentTab
617 switch ref.kind {633 switch ref.kind {
618 case pickerSelectionExact:634 case pickerSelectionExact:
619 session, ok := pickerFindSession(observation.Sessions, ref.lifecycle)635 session, ok := pickerFindSession(observation.Sessions, ref.lifecycle)
620 if !ok {636 if !ok {
621 if pickerSessionReplaced(observation.Sessions, ref) {637 if pickerSessionReplaced(observation.Sessions, ref) {
622- return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "session identity was replaced"}638+ return fail(pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "session identity was replaced"})
623 }639 }
624- return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueGone, Text: "session is no longer in the catalogue"}640+ return fail(pickerCatalogueError{Code: pickerCatalogueGone, Text: "session is no longer in the catalogue"})
625 }641 }
626 if session.Name != ref.name {642 if session.Name != ref.name {
627 // The exact identity is the lifecycle/name pair the daemon643 // The exact identity is the lifecycle/name pair the daemon
628 // revalidates: a renamed lifecycle is a different selection.644 // revalidates: a renamed lifecycle is a different selection.
629- return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "session identity was replaced"}645+ return fail(pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "session identity was replaced"})
630 }646 }
631 if session.State == catalogue.RemoteCatalogSessionBroken {647 if session.State == catalogue.RemoteCatalogSessionBroken {
632- return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "session is broken"}648+ return fail(pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "session is broken"})
633 }649 }
650+ resolved, err := pickerResolveTab(ref, session)
651+ if err != nil {
652+ return fail(err)
653+ }
654+ tab = resolved
634 request.Admission = ports.BrokerAdmissionExact655 request.Admission = ports.BrokerAdmissionExact
635 request.Target = protocol.ExactSessionTarget{LifecycleID: session.LifecycleID, SessionName: session.Name}656 request.Target = protocol.ExactSessionTarget{LifecycleID: session.LifecycleID, SessionName: session.Name}
636 case pickerSelectionCreateNamed:657 case pickerSelectionCreateNamed:
637 if err := domain.ValidateSessionName(ref.createName); err != nil {658 if err := domain.ValidateSessionName(ref.createName); err != nil {
638- return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueInvalidName, Text: "session name is not valid"}659+ return fail(pickerCatalogueError{Code: pickerCatalogueInvalidName, Text: "session name is not valid"})
639 }660 }
640 request.Admission = ports.BrokerAdmissionCreateNamed661 request.Admission = ports.BrokerAdmissionCreateNamed
641 request.Name = ref.createName662 request.Name = ref.createName
642 case pickerSelectionCreateEphemeral:663 case pickerSelectionCreateEphemeral:
643 request.Admission = ports.BrokerAdmissionCreateEphemeral664 request.Admission = ports.BrokerAdmissionCreateEphemeral
644 default:665 default:
645- return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker selection kind is unknown"}666+ return fail(pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker selection kind is unknown"})
646 }667 }
647 if err := request.Validate(); err != nil {668 if err := request.Validate(); err != nil {
648- return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker stream request is not valid"}669+ return fail(pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker stream request is not valid"})
649 }670 }
650- return request, nil671+ return request, tab, nil
651 }672 }
652 673
653 // freshLocked reports whether LastSuccess is inside the freshness window. An674 // freshLocked reports whether LastSuccess is inside the freshness window. An
@@ -659,47 +680,6 @@ func (c *pickerCatalogue) freshLocked(observation ports.BrokerDaemonObservation,
659 return now.Sub(observation.LastSuccess) <= c.freshness680 return now.Sub(observation.LastSuccess) <= c.freshness
660 }681 }
661 682
662-// pickerHostDiagnostic is one bounded, presentation-safe catalogue observation
663-// the controller surfaces as a toast.
664-type pickerHostDiagnostic struct {
665- Key string
666- Origin string
667- Status protocol.PickerLineStatus
668- Message string
669-}
670-
671-// diagnostics reports actionable host failures. Ordinary observation progress
672-// and catalogue aging remain visible on the host status row and never become
673-// intrusive toasts.
674-func (c *pickerCatalogue) diagnostics() []pickerHostDiagnostic {
675- if c == nil {
676- return nil
677- }
678- c.mu.Lock()
679- defer c.mu.Unlock()
680- now := c.clock.Now()
681- diagnostics := make([]pickerHostDiagnostic, 0, len(c.hosts))
682- for _, key := range c.order {
683- host, ok := c.hosts[key]
684- if !ok {
685- continue
686- }
687- observation := host.observation
688- fresh := c.freshLocked(observation, now)
689- status := pickerObservationStatus(observation, fresh)
690- if status != protocol.PickerLineStatusDown && status != protocol.PickerLineStatusError && status != protocol.PickerLineStatusVersion {
691- continue
692- }
693- diagnostics = append(diagnostics, pickerHostDiagnostic{
694- Key: pickerHostRowKey(observation),
695- Origin: pickerOriginLabel(observation),
696- Status: status,
697- Message: pickerObservationReason(observation, fresh),
698- })
699- }
700- return diagnostics
701-}
702-
703 func pickerDaemonKey(local bool, endpoint string) string {683 func pickerDaemonKey(local bool, endpoint string) string {
704 if local {684 if local {
705 return "local"685 return "local"
A internal/usecase/client/picker_catalogue_projection.go +601 -0
@@ -0,0 +1,601 @@
1+package client
2+
3+import (
4+ "fmt"
5+ "sort"
6+ "strconv"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+ "github.com/bnema/vev/internal/protocol"
11+ "github.com/bnema/vev/internal/protocol/catalogue"
12+)
13+
14+// Picker projections (Plan 003 B2/B3/C1), ported from the daemon-built picker
15+// on main (picker.go, picker_lines.go).
16+//
17+// The catalogue publishes two projections of the same rows, keys, and
18+// actions:
19+//
20+// - Recent is flat: live local sessions by recency (LastUsedSeq), then each
21+// remote host's sessions in catalogue order, then stopped local sessions
22+// by recency. Remote rows carry their origin in the detail because no
23+// section names it.
24+// - Grouped is sectioned by daemon: the local section (live by recency,
25+// then stopped), then one section per remote host.
26+//
27+// A session with tabs is a non-focusable header; its tab rows are the
28+// destinations, each resolving to its exact tab. A session without tab
29+// metadata is its own destination. A host status row is published only when
30+// it adds information: the host lists no session and is not reachable.
31+//
32+// The cursor starts on the attachment the picker is presented over (its tab,
33+// else its session), otherwise on the most recent session's active tab.
34+
35+// pickerCurrent names the attachment the picker is presented over. Only its
36+// presence changes the cursor hint; it is never a selection authority.
37+type pickerCurrent struct {
38+ known bool
39+ local bool
40+ endpoint string
41+ lifecycle domain.SessionLifecycleID
42+ tab domain.TabStableID
43+}
44+
45+// pickerRow is one projected line plus the facts its cursor hint needs.
46+type pickerRow struct {
47+ line protocol.PickerLine
48+ ref pickerSelectionRef
49+ hasRef bool
50+ defaultCursor bool
51+}
52+
53+// pickerSessionBlock is one session header and its tab rows.
54+type pickerSessionBlock struct {
55+ rows []pickerRow
56+ lastUsedSeq uint64
57+ name string
58+ lifecycle domain.SessionLifecycleID
59+}
60+
61+// projectLocked rebuilds both projections and their opaque keys from the
62+// current host state. It is deterministic: the same inputs always produce the
63+// same keys, order, and actions.
64+func (c *pickerCatalogue) projectLocked() {
65+ now := c.clock.Now()
66+ refs := make(map[string]pickerSelectionRef)
67+ var localLive, localStopped []pickerSessionBlock
68+ localOrigin := ""
69+ hasLocal := false
70+ type remoteGroup struct {
71+ origin string
72+ rows []pickerRow
73+ }
74+ var remotes []remoteGroup
75+ var localHost []pickerRow
76+ for _, key := range c.order {
77+ host, ok := c.hosts[key]
78+ if !ok {
79+ continue
80+ }
81+ observation := host.observation
82+ fresh := c.freshLocked(observation, now)
83+ origin := pickerOriginLabel(observation)
84+ var hostRows []pickerRow
85+ for i := range observation.Sessions {
86+ session := observation.Sessions[i]
87+ if session.LifecycleID == (domain.SessionLifecycleID{}) {
88+ // A session without a lifecycle cannot be addressed exactly and
89+ // is never invented into one.
90+ continue
91+ }
92+ block := c.sessionBlockLocked(observation, session, fresh, refs)
93+ switch {
94+ case !observation.Local:
95+ hostRows = append(hostRows, block.rows...)
96+ case session.State == catalogue.RemoteCatalogSessionUp:
97+ localLive = append(localLive, block)
98+ default:
99+ localStopped = append(localStopped, block)
100+ }
101+ }
102+ if len(observation.Sessions) == 0 && observation.Availability != domain.RemoteAvailabilityReachable {
103+ hostRows = append(hostRows, pickerRow{line: protocol.PickerLine{
104+ Key: pickerHostRowKey(observation),
105+ Kind: protocol.PickerLineHost,
106+ Label: origin,
107+ Detail: pickerHostDetail(observation),
108+ Status: pickerObservationStatus(observation, fresh),
109+ StatusDetail: pickerObservationReason(observation, fresh),
110+ Dim: true,
111+ // A host row stays reachable for inspection and never commits.
112+ Focusable: true,
113+ }})
114+ }
115+ if observation.Local {
116+ hasLocal = true
117+ localOrigin = origin
118+ localHost = hostRows
119+ continue
120+ }
121+ if len(hostRows) != 0 {
122+ remotes = append(remotes, remoteGroup{origin: origin, rows: hostRows})
123+ }
124+ }
125+ sortPickerBlocks(localLive)
126+ sortPickerBlocks(localStopped)
127+
128+ var recent, grouped []pickerRow
129+ localRows := make([]pickerRow, 0)
130+ for _, block := range localLive {
131+ localRows = append(localRows, block.rows...)
132+ }
133+ localRows = append(localRows, localHost...)
134+ recent = append(recent, localRows...)
135+ for _, remote := range remotes {
136+ for _, row := range remote.rows {
137+ if row.line.Kind == protocol.PickerLineSession && row.line.Detail == "" {
138+ row.line.Detail = "@" + remote.origin
139+ } else if row.line.Kind == protocol.PickerLineSession {
140+ row.line.Detail = "@" + remote.origin + " · " + row.line.Detail
141+ }
142+ recent = append(recent, row)
143+ }
144+ }
145+ stoppedRows := make([]pickerRow, 0)
146+ for _, block := range localStopped {
147+ stoppedRows = append(stoppedRows, block.rows...)
148+ }
149+ recent = append(recent, stoppedRows...)
150+
151+ localRows = append(localRows, stoppedRows...)
152+ if hasLocal && len(localRows) != 0 {
153+ grouped = append(grouped, pickerRow{line: protocol.PickerLine{Kind: protocol.PickerLineSection, Label: localOrigin, Dim: true}})
154+ grouped = append(grouped, localRows...)
155+ }
156+ for _, remote := range remotes {
157+ grouped = append(grouped, pickerRow{line: protocol.PickerLine{Kind: protocol.PickerLineSection, Label: remote.origin, Dim: true}})
158+ grouped = append(grouped, remote.rows...)
159+ }
160+
161+ c.moveRetiredLocked(refs)
162+ c.refs = refs
163+ c.lines, c.cursor = c.pickerProjectionLocked(grouped)
164+ c.recent, c.recentCursor = c.pickerProjectionLocked(recent)
165+}
166+
167+// sessionBlockLocked projects one session into its header and tab rows.
168+func (c *pickerCatalogue) sessionBlockLocked(observation ports.BrokerDaemonObservation, session catalogue.RemoteCatalogSession, fresh bool, refs map[string]pickerSelectionRef) pickerSessionBlock {
169+ compatible := pickerObservationCompatible(observation)
170+ available := observation.Availability == domain.RemoteAvailabilityReachable
171+ eligible := compatible && available && fresh
172+ // Admission is deliberately more permissive than eligibility: a stale or
173+ // not-yet-observed daemon is still selectable, because only the
174+ // destination revalidates the exact identity and only it may start a
175+ // stopped target under the resolved policy. A known incompatibility and a
176+ // broken session refuse immediately; a remote host known to be failing
177+ // refuses at resolution with a notice instead of a slow attempt.
178+ admissible := !pickerObservationKnownIncompatible(observation)
179+ selectable := admissible && session.State != catalogue.RemoteCatalogSessionBroken
180+ stopped := session.State == catalogue.RemoteCatalogSessionDown
181+ reason := pickerObservationReason(observation, fresh)
182+ if reason == "" {
183+ reason = session.Reason
184+ }
185+ ref := pickerSelectionRef{
186+ kind: pickerSelectionExact,
187+ epoch: c.snapshot.Epoch,
188+ local: observation.Local,
189+ endpoint: observation.Endpoint,
190+ registration: observation.Registration,
191+ lifecycle: session.LifecycleID,
192+ name: session.Name,
193+ }
194+ actions := pickerRowActions(selectable, observation.Local)
195+ attention := false
196+ for _, tab := range session.Tabs {
197+ attention = attention || tab.Attention
198+ }
199+ headerKey := pickerSessionRowKey(ref)
200+ refs[headerKey] = ref
201+ header := pickerRow{
202+ line: protocol.PickerLine{
203+ Key: headerKey,
204+ Kind: protocol.PickerLineSession,
205+ Label: session.Name,
206+ Detail: pickerSessionDetail(session),
207+ Status: pickerSessionStatus(session),
208+ StatusDetail: reason,
209+ Attention: attention,
210+ Stopped: stopped,
211+ // Dim keeps the informational eligibility signal (fresh,
212+ // compatible, reachable) and the hard non-committable state.
213+ Dim: !eligible || !selectable,
214+ Ephemeral: session.Ephemeral,
215+ },
216+ ref: ref,
217+ hasRef: true,
218+ }
219+ block := pickerSessionBlock{lastUsedSeq: session.LastUsedSeq, name: session.Name, lifecycle: session.LifecycleID}
220+ if len(session.Tabs) == 0 {
221+ // Without tab metadata the session is its own destination.
222+ header.line.Focusable = true
223+ header.line.Actions = actions
224+ header.defaultCursor = true
225+ block.rows = append(block.rows, header)
226+ return block
227+ }
228+ // A session with tabs names a group: its tab rows are the destinations.
229+ block.rows = append(block.rows, header)
230+ active := pickerActiveTabIndex(session)
231+ for i, tab := range session.Tabs {
232+ tabRef := ref
233+ tabRef.tab = pickerTabRef{present: true, id: domain.TabStableID(tab.ID), index: i, name: tab.Name, count: len(session.Tabs)}
234+ key := pickerTabRowKey(tabRef)
235+ refs[key] = tabRef
236+ tabActions := actions
237+ if !stopped && tab.ID == "" {
238+ // A live tab without a stable identity cannot be targeted exactly.
239+ tabActions &^= protocol.PickerCanNavigate
240+ }
241+ label := tab.Name
242+ if label == "" {
243+ label = strconv.Itoa(int(tab.Index) + 1)
244+ }
245+ block.rows = append(block.rows, pickerRow{
246+ line: protocol.PickerLine{
247+ Key: key,
248+ Kind: protocol.PickerLineTab,
249+ Label: label,
250+ Detail: tab.Detail,
251+ StatusDetail: reason,
252+ Attention: tab.Attention,
253+ Stopped: stopped,
254+ Dim: header.line.Dim,
255+ Focusable: true,
256+ Actions: tabActions,
257+ Ephemeral: session.Ephemeral,
258+ },
259+ ref: tabRef,
260+ hasRef: true,
261+ defaultCursor: i == active,
262+ })
263+ }
264+ return block
265+}
266+
267+// pickerRowActions maps eligibility to the actions a row authorises. Kill is
268+// offered on local rows only, exactly as on main; the daemon revalidates the
269+// name, and a stopped row deletes its history record.
270+func pickerRowActions(selectable, local bool) protocol.PickerLineActions {
271+ var actions protocol.PickerLineActions
272+ if selectable {
273+ actions |= protocol.PickerCanNavigate
274+ }
275+ if local {
276+ actions |= protocol.PickerCanKill
277+ }
278+ return actions
279+}
280+
281+// pickerActiveTabIndex is the session's active tab, or its first tab.
282+func pickerActiveTabIndex(session catalogue.RemoteCatalogSession) int {
283+ for i, tab := range session.Tabs {
284+ if session.ActiveTabID != "" && tab.ID == session.ActiveTabID {
285+ return i
286+ }
287+ }
288+ return 0
289+}
290+
291+// sortPickerBlocks orders sessions by recency, then name, then lifecycle.
292+func sortPickerBlocks(blocks []pickerSessionBlock) {
293+ sort.SliceStable(blocks, func(i, j int) bool {
294+ if blocks[i].lastUsedSeq != blocks[j].lastUsedSeq {
295+ return blocks[i].lastUsedSeq > blocks[j].lastUsedSeq
296+ }
297+ if blocks[i].name != blocks[j].name {
298+ return blocks[i].name < blocks[j].name
299+ }
300+ return blocks[i].lifecycle.String() < blocks[j].lifecycle.String()
301+ })
302+}
303+
304+// pickerProjectionLocked flattens one projection and computes its cursor:
305+// the current attachment's tab, else its session, else the first default row
306+// (the most recent session's active tab), else the first actionable row.
307+func (c *pickerCatalogue) pickerProjectionLocked(rows []pickerRow) ([]protocol.PickerLine, protocol.PickerCursor) {
308+ lines := make([]protocol.PickerLine, len(rows))
309+ for i := range rows {
310+ lines[i] = rows[i].line
311+ }
312+ if cursor, ok := c.currentCursorLocked(rows); ok {
313+ return lines, cursor
314+ }
315+ for i, row := range rows {
316+ if row.defaultCursor && row.line.Focusable && row.line.Actions&protocol.PickerCanNavigate != 0 {
317+ return lines, protocol.PickerCursor{Key: row.line.Key, Index: i}
318+ }
319+ }
320+ return lines, pickerCatalogueCursor(lines)
321+}
322+
323+func (c *pickerCatalogue) currentCursorLocked(rows []pickerRow) (protocol.PickerCursor, bool) {
324+ current := c.current
325+ if !current.known {
326+ return protocol.PickerCursor{}, false
327+ }
328+ matches := func(ref pickerSelectionRef) bool {
329+ if ref.lifecycle != current.lifecycle || ref.local != current.local {
330+ return false
331+ }
332+ return ref.local || ref.endpoint == current.endpoint
333+ }
334+ // Exact tab first, then the session's own row (no tab metadata), then its
335+ // active tab.
336+ session, active := -1, -1
337+ for i, row := range rows {
338+ if !row.hasRef || !row.line.Focusable || !matches(row.ref) {
339+ continue
340+ }
341+ if !row.ref.tab.present {
342+ session = i
343+ continue
344+ }
345+ if current.tab != "" && row.ref.tab.id == current.tab {
346+ return protocol.PickerCursor{Key: row.line.Key, Index: i}, true
347+ }
348+ if row.defaultCursor && active < 0 {
349+ active = i
350+ }
351+ }
352+ for _, index := range []int{session, active} {
353+ if index >= 0 {
354+ return protocol.PickerCursor{Key: rows[index].line.Key, Index: index}, true
355+ }
356+ }
357+ return protocol.PickerCursor{}, false
358+}
359+
360+// pickerTabRowKey is the stable, opaque key of one tab row: the session
361+// identity plus the tab's stable ID, or its ordinal and raw name when the
362+// catalogue carries no ID.
363+func pickerTabRowKey(ref pickerSelectionRef) string {
364+ tab := string(ref.tab.id)
365+ if tab == "" {
366+ tab = fmt.Sprintf("#%d:%s", ref.tab.index, ref.tab.name)
367+ }
368+ lifecycle := append(append([]byte(nil), ref.lifecycle[:]...), 0)
369+ lifecycle = append(lifecycle, tab...)
370+ return pickerRowKey("t", ref.local, ref.endpoint, ref.registration.Incarnation[:], ref.registration.Generation, lifecycle)
371+}
372+
373+// attachmentTab is the client-owned tab selection an attachment Hello
374+// carries: a stable tab to prefer on a live or stopped-local session, or an
375+// exact stopped-remote selector. Its zero value attaches at session level.
376+type attachmentTab struct {
377+ preferred domain.TabStableID
378+ stopped *protocol.SessionAttachTarget
379+}
380+
381+// pickerResolveTab turns a tab ref into the attachment tab it names, checked
382+// against the latest session metadata. A tab that left the session is gone.
383+func pickerResolveTab(ref pickerSelectionRef, session catalogue.RemoteCatalogSession) (attachmentTab, error) {
384+ if !ref.tab.present {
385+ return attachmentTab{}, nil
386+ }
387+ gone := pickerCatalogueError{Code: pickerCatalogueGone, Text: "tab is no longer in the catalogue"}
388+ stopped := session.State == catalogue.RemoteCatalogSessionDown
389+ if ref.tab.id != "" {
390+ found := false
391+ for _, tab := range session.Tabs {
392+ if domain.TabStableID(tab.ID) == ref.tab.id {
393+ found = true
394+ break
395+ }
396+ }
397+ if !found {
398+ return attachmentTab{}, gone
399+ }
400+ if stopped && !ref.local {
401+ target := protocol.SessionAttachTarget{LifecycleID: session.LifecycleID, SessionName: session.Name, TabID: ref.tab.id, TabIndex: protocol.NoTabIndex, Stopped: true}
402+ if target.Validate() != nil {
403+ return attachmentTab{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "stopped tab cannot be addressed"}
404+ }
405+ return attachmentTab{stopped: &target}, nil
406+ }
407+ return attachmentTab{preferred: ref.tab.id}, nil
408+ }
409+ if ref.tab.index >= len(session.Tabs) || session.Tabs[ref.tab.index].Name != ref.tab.name || len(session.Tabs) != ref.tab.count {
410+ return attachmentTab{}, gone
411+ }
412+ if !stopped {
413+ return attachmentTab{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "tab has no stable identity"}
414+ }
415+ if ref.local || ref.tab.count > int(^uint16(0)) {
416+ // A local restore without a stable tab falls back to the session.
417+ return attachmentTab{}, nil
418+ }
419+ target := protocol.SessionAttachTarget{
420+ LifecycleID: session.LifecycleID, SessionName: session.Name, Stopped: true,
421+ TabIndex: int32(ref.tab.index), TabRawName: ref.tab.name, TabExpectedCount: uint16(ref.tab.count),
422+ }
423+ if target.Validate() != nil {
424+ return attachmentTab{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "stopped tab cannot be addressed"}
425+ }
426+ return attachmentTab{stopped: &target}, nil
427+}
428+
429+// pickerObservationFailing reports a host the broker observed failing: its
430+// last probe could not reach it, authenticate, or read its catalogue.
431+func pickerObservationFailing(observation ports.BrokerDaemonObservation) bool {
432+ switch observation.Availability {
433+ case domain.RemoteAvailabilityUnreachable, domain.RemoteAvailabilityAuthFailed, domain.RemoteAvailabilityInvalidResponse:
434+ return true
435+ default:
436+ return false
437+ }
438+}
439+
440+// pickerUnavailableNotice is main's remote-unavailable notice: the session
441+// and its origin, then the reason in words.
442+func pickerUnavailableNotice(ref pickerSelectionRef, observation ports.BrokerDaemonObservation, reason string) string {
443+ message := "Remote session unavailable"
444+ origin := pickerOriginLabel(observation)
445+ if ref.name != "" {
446+ message += ": " + ref.name + "@" + origin
447+ } else {
448+ message += ": " + origin
449+ }
450+ return message + " — " + pickerReasonText(reason)
451+}
452+
453+// pickerReasonText renders one domain.RemoteReason in words (main's
454+// remotePickerReasonText).
455+func pickerReasonText(reason string) string {
456+ switch reason {
457+ case domain.RemoteReasonCatalogStale:
458+ return "catalog stale"
459+ case domain.RemoteReasonHostUnreachable:
460+ return "host unreachable"
461+ case domain.RemoteReasonVersionMismatch:
462+ return "version mismatch"
463+ case domain.RemoteReasonSessionStopped:
464+ return "session stopped"
465+ case domain.RemoteReasonSessionBroken:
466+ return "session broken"
467+ case domain.RemoteReasonMalformed:
468+ return "catalog malformed"
469+ case domain.RemoteReasonAuthFailure:
470+ return "authentication failed"
471+ case domain.RemoteReasonRefreshing:
472+ return "refreshing"
473+ case domain.RemoteReasonIdentityChanged:
474+ return "session identity changed"
475+ default:
476+ return "unavailable"
477+ }
478+}
479+
480+// pickerKillTarget is one resolved kill: the exact local control route and
481+// the session name the daemon kills (live) or deletes from history
482+// (stopped).
483+type pickerKillTarget struct {
484+ route BrokerOperationRoute
485+ name string
486+ stopped bool
487+}
488+
489+// ResolveKill revalidates one row key for destruction against the latest
490+// publication. Only local sessions are killable, exactly as on main; the row
491+// must still name the same lifecycle and name.
492+func (c *pickerCatalogue) ResolveKill(key string) (pickerKillTarget, error) {
493+ if c == nil {
494+ return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "no catalogue"}
495+ }
496+ c.mu.Lock()
497+ defer c.mu.Unlock()
498+ ref, ok := c.refs[key]
499+ if !ok {
500+ ref, ok = c.retired[key]
501+ }
502+ if !ok || ref.kind != pickerSelectionExact {
503+ return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker row is not in the catalogue"}
504+ }
505+ if !ref.local {
506+ return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "remote sessions cannot be killed from the picker"}
507+ }
508+ epoch := c.snapshot.Epoch
509+ if epoch == 0 {
510+ return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "broker catalogue is unavailable"}
511+ }
512+ if ref.epoch != 0 && ref.epoch != epoch {
513+ return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueEpochStale, Text: "broker epoch changed"}
514+ }
515+ authority := c.authorityForRefLocked(ref)
516+ if !authority.found {
517+ return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueGone, Text: "host is no longer in the catalogue"}
518+ }
519+ session, found := pickerFindSession(authority.observation.Sessions, ref.lifecycle)
520+ if !found {
521+ if pickerSessionReplaced(authority.observation.Sessions, ref) {
522+ return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "session identity was replaced"}
523+ }
524+ return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueGone, Text: "session is no longer in the catalogue"}
525+ }
526+ if session.Name != ref.name {
527+ return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "session identity was replaced"}
528+ }
529+ if domain.ValidateSessionName(session.Name) != nil {
530+ return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueInvalidName, Text: "session name is not valid"}
531+ }
532+ return pickerKillTarget{
533+ route: BrokerOperationRoute{Epoch: epoch, Destination: ports.BrokerEndpointFence{Local: true}},
534+ name: session.Name,
535+ stopped: session.State != catalogue.RemoteCatalogSessionUp,
536+ }, nil
537+}
538+
539+// pickerHostFailure is one remote host failure episode worth one toast.
540+type pickerHostFailure struct {
541+ key string
542+ episode uint64
543+ message string
544+}
545+
546+// failures reports every remote host currently in a failure episode. The
547+// controller toasts each (host, episode) once, as main's
548+// notifyNewRemoteFailures did.
549+func (c *pickerCatalogue) failures() []pickerHostFailure {
550+ if c == nil {
551+ return nil
552+ }
553+ c.mu.Lock()
554+ defer c.mu.Unlock()
555+ failures := make([]pickerHostFailure, 0)
556+ for _, key := range c.order {
557+ host, ok := c.hosts[key]
558+ if !ok || host.observation.Local {
559+ continue
560+ }
561+ observation := host.observation
562+ if !pickerObservationFailing(observation) && observation.Availability != domain.RemoteAvailabilityIncompatible && !pickerObservationVersionMismatch(observation) {
563+ continue
564+ }
565+ failures = append(failures, pickerHostFailure{
566+ key: key,
567+ episode: observation.FailureEpisode,
568+ message: pickerHostFailureMessage(observation),
569+ })
570+ }
571+ return failures
572+}
573+
574+// pickerHostFailureMessage is main's remoteFailureNoticeMessage, named by the
575+// host's display origin rather than a raw endpoint.
576+func pickerHostFailureMessage(observation ports.BrokerDaemonObservation) string {
577+ prefix := "Remote check failed: " + pickerOriginLabel(observation) + " — "
578+ if observation.Availability == domain.RemoteAvailabilityIncompatible || pickerObservationVersionMismatch(observation) {
579+ return prefix + "remote vev version is incompatible"
580+ }
581+ switch observation.LastFailure.Kind {
582+ case domain.RemoteFailureAuthentication:
583+ return prefix + "SSH authentication failed; verify non-interactive SSH access"
584+ case domain.RemoteFailureTrust:
585+ return prefix + "SSH host verification failed; verify the host key policy"
586+ case domain.RemoteFailureIncompatible:
587+ return prefix + "remote vev version is incompatible"
588+ case domain.RemoteFailureInvalidResponse:
589+ return prefix + "remote catalog response is invalid"
590+ case domain.RemoteFailureTimeout:
591+ return prefix + "SSH timed out"
592+ }
593+ switch observation.Availability {
594+ case domain.RemoteAvailabilityAuthFailed:
595+ return prefix + "SSH authentication failed; verify non-interactive SSH access"
596+ case domain.RemoteAvailabilityInvalidResponse:
597+ return prefix + "remote catalog response is invalid"
598+ default:
599+ return prefix + "SSH connection failed; verify SSH access"
600+ }
601+}
@@ -0,0 +1,601 @@
1+package client
2+
3+import (
4+ "fmt"
5+ "sort"
6+ "strconv"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+ "github.com/bnema/vev/internal/protocol"
11+ "github.com/bnema/vev/internal/protocol/catalogue"
12+)
13+
14+// Picker projections (Plan 003 B2/B3/C1), ported from the daemon-built picker
15+// on main (picker.go, picker_lines.go).
16+//
17+// The catalogue publishes two projections of the same rows, keys, and
18+// actions:
19+//
20+// - Recent is flat: live local sessions by recency (LastUsedSeq), then each
21+// remote host's sessions in catalogue order, then stopped local sessions
22+// by recency. Remote rows carry their origin in the detail because no
23+// section names it.
24+// - Grouped is sectioned by daemon: the local section (live by recency,
25+// then stopped), then one section per remote host.
26+//
27+// A session with tabs is a non-focusable header; its tab rows are the
28+// destinations, each resolving to its exact tab. A session without tab
29+// metadata is its own destination. A host status row is published only when
30+// it adds information: the host lists no session and is not reachable.
31+//
32+// The cursor starts on the attachment the picker is presented over (its tab,
33+// else its session), otherwise on the most recent session's active tab.
34+
35+// pickerCurrent names the attachment the picker is presented over. Only its
36+// presence changes the cursor hint; it is never a selection authority.
37+type pickerCurrent struct {
38+ known bool
39+ local bool
40+ endpoint string
41+ lifecycle domain.SessionLifecycleID
42+ tab domain.TabStableID
43+}
44+
45+// pickerRow is one projected line plus the facts its cursor hint needs.
46+type pickerRow struct {
47+ line protocol.PickerLine
48+ ref pickerSelectionRef
49+ hasRef bool
50+ defaultCursor bool
51+}
52+
53+// pickerSessionBlock is one session header and its tab rows.
54+type pickerSessionBlock struct {
55+ rows []pickerRow
56+ lastUsedSeq uint64
57+ name string
58+ lifecycle domain.SessionLifecycleID
59+}
60+
61+// projectLocked rebuilds both projections and their opaque keys from the
62+// current host state. It is deterministic: the same inputs always produce the
63+// same keys, order, and actions.
64+func (c *pickerCatalogue) projectLocked() {
65+ now := c.clock.Now()
66+ refs := make(map[string]pickerSelectionRef)
67+ var localLive, localStopped []pickerSessionBlock
68+ localOrigin := ""
69+ hasLocal := false
70+ type remoteGroup struct {
71+ origin string
72+ rows []pickerRow
73+ }
74+ var remotes []remoteGroup
75+ var localHost []pickerRow
76+ for _, key := range c.order {
77+ host, ok := c.hosts[key]
78+ if !ok {
79+ continue
80+ }
81+ observation := host.observation
82+ fresh := c.freshLocked(observation, now)
83+ origin := pickerOriginLabel(observation)
84+ var hostRows []pickerRow
85+ for i := range observation.Sessions {
86+ session := observation.Sessions[i]
87+ if session.LifecycleID == (domain.SessionLifecycleID{}) {
88+ // A session without a lifecycle cannot be addressed exactly and
89+ // is never invented into one.
90+ continue
91+ }
92+ block := c.sessionBlockLocked(observation, session, fresh, refs)
93+ switch {
94+ case !observation.Local:
95+ hostRows = append(hostRows, block.rows...)
96+ case session.State == catalogue.RemoteCatalogSessionUp:
97+ localLive = append(localLive, block)
98+ default:
99+ localStopped = append(localStopped, block)
100+ }
101+ }
102+ if len(observation.Sessions) == 0 && observation.Availability != domain.RemoteAvailabilityReachable {
103+ hostRows = append(hostRows, pickerRow{line: protocol.PickerLine{
104+ Key: pickerHostRowKey(observation),
105+ Kind: protocol.PickerLineHost,
106+ Label: origin,
107+ Detail: pickerHostDetail(observation),
108+ Status: pickerObservationStatus(observation, fresh),
109+ StatusDetail: pickerObservationReason(observation, fresh),
110+ Dim: true,
111+ // A host row stays reachable for inspection and never commits.
112+ Focusable: true,
113+ }})
114+ }
115+ if observation.Local {
116+ hasLocal = true
117+ localOrigin = origin
118+ localHost = hostRows
119+ continue
120+ }
121+ if len(hostRows) != 0 {
122+ remotes = append(remotes, remoteGroup{origin: origin, rows: hostRows})
123+ }
124+ }
125+ sortPickerBlocks(localLive)
126+ sortPickerBlocks(localStopped)
127+
128+ var recent, grouped []pickerRow
129+ localRows := make([]pickerRow, 0)
130+ for _, block := range localLive {
131+ localRows = append(localRows, block.rows...)
132+ }
133+ localRows = append(localRows, localHost...)
134+ recent = append(recent, localRows...)
135+ for _, remote := range remotes {
136+ for _, row := range remote.rows {
137+ if row.line.Kind == protocol.PickerLineSession && row.line.Detail == "" {
138+ row.line.Detail = "@" + remote.origin
139+ } else if row.line.Kind == protocol.PickerLineSession {
140+ row.line.Detail = "@" + remote.origin + " · " + row.line.Detail
141+ }
142+ recent = append(recent, row)
143+ }
144+ }
145+ stoppedRows := make([]pickerRow, 0)
146+ for _, block := range localStopped {
147+ stoppedRows = append(stoppedRows, block.rows...)
148+ }
149+ recent = append(recent, stoppedRows...)
150+
151+ localRows = append(localRows, stoppedRows...)
152+ if hasLocal && len(localRows) != 0 {
153+ grouped = append(grouped, pickerRow{line: protocol.PickerLine{Kind: protocol.PickerLineSection, Label: localOrigin, Dim: true}})
154+ grouped = append(grouped, localRows...)
155+ }
156+ for _, remote := range remotes {
157+ grouped = append(grouped, pickerRow{line: protocol.PickerLine{Kind: protocol.PickerLineSection, Label: remote.origin, Dim: true}})
158+ grouped = append(grouped, remote.rows...)
159+ }
160+
161+ c.moveRetiredLocked(refs)
162+ c.refs = refs
163+ c.lines, c.cursor = c.pickerProjectionLocked(grouped)
164+ c.recent, c.recentCursor = c.pickerProjectionLocked(recent)
165+}
166+
167+// sessionBlockLocked projects one session into its header and tab rows.
168+func (c *pickerCatalogue) sessionBlockLocked(observation ports.BrokerDaemonObservation, session catalogue.RemoteCatalogSession, fresh bool, refs map[string]pickerSelectionRef) pickerSessionBlock {
169+ compatible := pickerObservationCompatible(observation)
170+ available := observation.Availability == domain.RemoteAvailabilityReachable
171+ eligible := compatible && available && fresh
172+ // Admission is deliberately more permissive than eligibility: a stale or
173+ // not-yet-observed daemon is still selectable, because only the
174+ // destination revalidates the exact identity and only it may start a
175+ // stopped target under the resolved policy. A known incompatibility and a
176+ // broken session refuse immediately; a remote host known to be failing
177+ // refuses at resolution with a notice instead of a slow attempt.
178+ admissible := !pickerObservationKnownIncompatible(observation)
179+ selectable := admissible && session.State != catalogue.RemoteCatalogSessionBroken
180+ stopped := session.State == catalogue.RemoteCatalogSessionDown
181+ reason := pickerObservationReason(observation, fresh)
182+ if reason == "" {
183+ reason = session.Reason
184+ }
185+ ref := pickerSelectionRef{
186+ kind: pickerSelectionExact,
187+ epoch: c.snapshot.Epoch,
188+ local: observation.Local,
189+ endpoint: observation.Endpoint,
190+ registration: observation.Registration,
191+ lifecycle: session.LifecycleID,
192+ name: session.Name,
193+ }
194+ actions := pickerRowActions(selectable, observation.Local)
195+ attention := false
196+ for _, tab := range session.Tabs {
197+ attention = attention || tab.Attention
198+ }
199+ headerKey := pickerSessionRowKey(ref)
200+ refs[headerKey] = ref
201+ header := pickerRow{
202+ line: protocol.PickerLine{
203+ Key: headerKey,
204+ Kind: protocol.PickerLineSession,
205+ Label: session.Name,
206+ Detail: pickerSessionDetail(session),
207+ Status: pickerSessionStatus(session),
208+ StatusDetail: reason,
209+ Attention: attention,
210+ Stopped: stopped,
211+ // Dim keeps the informational eligibility signal (fresh,
212+ // compatible, reachable) and the hard non-committable state.
213+ Dim: !eligible || !selectable,
214+ Ephemeral: session.Ephemeral,
215+ },
216+ ref: ref,
217+ hasRef: true,
218+ }
219+ block := pickerSessionBlock{lastUsedSeq: session.LastUsedSeq, name: session.Name, lifecycle: session.LifecycleID}
220+ if len(session.Tabs) == 0 {
221+ // Without tab metadata the session is its own destination.
222+ header.line.Focusable = true
223+ header.line.Actions = actions
224+ header.defaultCursor = true
225+ block.rows = append(block.rows, header)
226+ return block
227+ }
228+ // A session with tabs names a group: its tab rows are the destinations.
229+ block.rows = append(block.rows, header)
230+ active := pickerActiveTabIndex(session)
231+ for i, tab := range session.Tabs {
232+ tabRef := ref
233+ tabRef.tab = pickerTabRef{present: true, id: domain.TabStableID(tab.ID), index: i, name: tab.Name, count: len(session.Tabs)}
234+ key := pickerTabRowKey(tabRef)
235+ refs[key] = tabRef
236+ tabActions := actions
237+ if !stopped && tab.ID == "" {
238+ // A live tab without a stable identity cannot be targeted exactly.
239+ tabActions &^= protocol.PickerCanNavigate
240+ }
241+ label := tab.Name
242+ if label == "" {
243+ label = strconv.Itoa(int(tab.Index) + 1)
244+ }
245+ block.rows = append(block.rows, pickerRow{
246+ line: protocol.PickerLine{
247+ Key: key,
248+ Kind: protocol.PickerLineTab,
249+ Label: label,
250+ Detail: tab.Detail,
251+ StatusDetail: reason,
252+ Attention: tab.Attention,
253+ Stopped: stopped,
254+ Dim: header.line.Dim,
255+ Focusable: true,
256+ Actions: tabActions,
257+ Ephemeral: session.Ephemeral,
258+ },
259+ ref: tabRef,
260+ hasRef: true,
261+ defaultCursor: i == active,
262+ })
263+ }
264+ return block
265+}
266+
267+// pickerRowActions maps eligibility to the actions a row authorises. Kill is
268+// offered on local rows only, exactly as on main; the daemon revalidates the
269+// name, and a stopped row deletes its history record.
270+func pickerRowActions(selectable, local bool) protocol.PickerLineActions {
271+ var actions protocol.PickerLineActions
272+ if selectable {
273+ actions |= protocol.PickerCanNavigate
274+ }
275+ if local {
276+ actions |= protocol.PickerCanKill
277+ }
278+ return actions
279+}
280+
281+// pickerActiveTabIndex is the session's active tab, or its first tab.
282+func pickerActiveTabIndex(session catalogue.RemoteCatalogSession) int {
283+ for i, tab := range session.Tabs {
284+ if session.ActiveTabID != "" && tab.ID == session.ActiveTabID {
285+ return i
286+ }
287+ }
288+ return 0
289+}
290+
291+// sortPickerBlocks orders sessions by recency, then name, then lifecycle.
292+func sortPickerBlocks(blocks []pickerSessionBlock) {
293+ sort.SliceStable(blocks, func(i, j int) bool {
294+ if blocks[i].lastUsedSeq != blocks[j].lastUsedSeq {
295+ return blocks[i].lastUsedSeq > blocks[j].lastUsedSeq
296+ }
297+ if blocks[i].name != blocks[j].name {
298+ return blocks[i].name < blocks[j].name
299+ }
300+ return blocks[i].lifecycle.String() < blocks[j].lifecycle.String()
301+ })
302+}
303+
304+// pickerProjectionLocked flattens one projection and computes its cursor:
305+// the current attachment's tab, else its session, else the first default row
306+// (the most recent session's active tab), else the first actionable row.
307+func (c *pickerCatalogue) pickerProjectionLocked(rows []pickerRow) ([]protocol.PickerLine, protocol.PickerCursor) {
308+ lines := make([]protocol.PickerLine, len(rows))
309+ for i := range rows {
310+ lines[i] = rows[i].line
311+ }
312+ if cursor, ok := c.currentCursorLocked(rows); ok {
313+ return lines, cursor
314+ }
315+ for i, row := range rows {
316+ if row.defaultCursor && row.line.Focusable && row.line.Actions&protocol.PickerCanNavigate != 0 {
317+ return lines, protocol.PickerCursor{Key: row.line.Key, Index: i}
318+ }
319+ }
320+ return lines, pickerCatalogueCursor(lines)
321+}
322+
323+func (c *pickerCatalogue) currentCursorLocked(rows []pickerRow) (protocol.PickerCursor, bool) {
324+ current := c.current
325+ if !current.known {
326+ return protocol.PickerCursor{}, false
327+ }
328+ matches := func(ref pickerSelectionRef) bool {
329+ if ref.lifecycle != current.lifecycle || ref.local != current.local {
330+ return false
331+ }
332+ return ref.local || ref.endpoint == current.endpoint
333+ }
334+ // Exact tab first, then the session's own row (no tab metadata), then its
335+ // active tab.
336+ session, active := -1, -1
337+ for i, row := range rows {
338+ if !row.hasRef || !row.line.Focusable || !matches(row.ref) {
339+ continue
340+ }
341+ if !row.ref.tab.present {
342+ session = i
343+ continue
344+ }
345+ if current.tab != "" && row.ref.tab.id == current.tab {
346+ return protocol.PickerCursor{Key: row.line.Key, Index: i}, true
347+ }
348+ if row.defaultCursor && active < 0 {
349+ active = i
350+ }
351+ }
352+ for _, index := range []int{session, active} {
353+ if index >= 0 {
354+ return protocol.PickerCursor{Key: rows[index].line.Key, Index: index}, true
355+ }
356+ }
357+ return protocol.PickerCursor{}, false
358+}
359+
360+// pickerTabRowKey is the stable, opaque key of one tab row: the session
361+// identity plus the tab's stable ID, or its ordinal and raw name when the
362+// catalogue carries no ID.
363+func pickerTabRowKey(ref pickerSelectionRef) string {
364+ tab := string(ref.tab.id)
365+ if tab == "" {
366+ tab = fmt.Sprintf("#%d:%s", ref.tab.index, ref.tab.name)
367+ }
368+ lifecycle := append(append([]byte(nil), ref.lifecycle[:]...), 0)
369+ lifecycle = append(lifecycle, tab...)
370+ return pickerRowKey("t", ref.local, ref.endpoint, ref.registration.Incarnation[:], ref.registration.Generation, lifecycle)
371+}
372+
373+// attachmentTab is the client-owned tab selection an attachment Hello
374+// carries: a stable tab to prefer on a live or stopped-local session, or an
375+// exact stopped-remote selector. Its zero value attaches at session level.
376+type attachmentTab struct {
377+ preferred domain.TabStableID
378+ stopped *protocol.SessionAttachTarget
379+}
380+
381+// pickerResolveTab turns a tab ref into the attachment tab it names, checked
382+// against the latest session metadata. A tab that left the session is gone.
383+func pickerResolveTab(ref pickerSelectionRef, session catalogue.RemoteCatalogSession) (attachmentTab, error) {
384+ if !ref.tab.present {
385+ return attachmentTab{}, nil
386+ }
387+ gone := pickerCatalogueError{Code: pickerCatalogueGone, Text: "tab is no longer in the catalogue"}
388+ stopped := session.State == catalogue.RemoteCatalogSessionDown
389+ if ref.tab.id != "" {
390+ found := false
391+ for _, tab := range session.Tabs {
392+ if domain.TabStableID(tab.ID) == ref.tab.id {
393+ found = true
394+ break
395+ }
396+ }
397+ if !found {
398+ return attachmentTab{}, gone
399+ }
400+ if stopped && !ref.local {
401+ target := protocol.SessionAttachTarget{LifecycleID: session.LifecycleID, SessionName: session.Name, TabID: ref.tab.id, TabIndex: protocol.NoTabIndex, Stopped: true}
402+ if target.Validate() != nil {
403+ return attachmentTab{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "stopped tab cannot be addressed"}
404+ }
405+ return attachmentTab{stopped: &target}, nil
406+ }
407+ return attachmentTab{preferred: ref.tab.id}, nil
408+ }
409+ if ref.tab.index >= len(session.Tabs) || session.Tabs[ref.tab.index].Name != ref.tab.name || len(session.Tabs) != ref.tab.count {
410+ return attachmentTab{}, gone
411+ }
412+ if !stopped {
413+ return attachmentTab{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "tab has no stable identity"}
414+ }
415+ if ref.local || ref.tab.count > int(^uint16(0)) {
416+ // A local restore without a stable tab falls back to the session.
417+ return attachmentTab{}, nil
418+ }
419+ target := protocol.SessionAttachTarget{
420+ LifecycleID: session.LifecycleID, SessionName: session.Name, Stopped: true,
421+ TabIndex: int32(ref.tab.index), TabRawName: ref.tab.name, TabExpectedCount: uint16(ref.tab.count),
422+ }
423+ if target.Validate() != nil {
424+ return attachmentTab{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "stopped tab cannot be addressed"}
425+ }
426+ return attachmentTab{stopped: &target}, nil
427+}
428+
429+// pickerObservationFailing reports a host the broker observed failing: its
430+// last probe could not reach it, authenticate, or read its catalogue.
431+func pickerObservationFailing(observation ports.BrokerDaemonObservation) bool {
432+ switch observation.Availability {
433+ case domain.RemoteAvailabilityUnreachable, domain.RemoteAvailabilityAuthFailed, domain.RemoteAvailabilityInvalidResponse:
434+ return true
435+ default:
436+ return false
437+ }
438+}
439+
440+// pickerUnavailableNotice is main's remote-unavailable notice: the session
441+// and its origin, then the reason in words.
442+func pickerUnavailableNotice(ref pickerSelectionRef, observation ports.BrokerDaemonObservation, reason string) string {
443+ message := "Remote session unavailable"
444+ origin := pickerOriginLabel(observation)
445+ if ref.name != "" {
446+ message += ": " + ref.name + "@" + origin
447+ } else {
448+ message += ": " + origin
449+ }
450+ return message + " — " + pickerReasonText(reason)
451+}
452+
453+// pickerReasonText renders one domain.RemoteReason in words (main's
454+// remotePickerReasonText).
455+func pickerReasonText(reason string) string {
456+ switch reason {
457+ case domain.RemoteReasonCatalogStale:
458+ return "catalog stale"
459+ case domain.RemoteReasonHostUnreachable:
460+ return "host unreachable"
461+ case domain.RemoteReasonVersionMismatch:
462+ return "version mismatch"
463+ case domain.RemoteReasonSessionStopped:
464+ return "session stopped"
465+ case domain.RemoteReasonSessionBroken:
466+ return "session broken"
467+ case domain.RemoteReasonMalformed:
468+ return "catalog malformed"
469+ case domain.RemoteReasonAuthFailure:
470+ return "authentication failed"
471+ case domain.RemoteReasonRefreshing:
472+ return "refreshing"
473+ case domain.RemoteReasonIdentityChanged:
474+ return "session identity changed"
475+ default:
476+ return "unavailable"
477+ }
478+}
479+
480+// pickerKillTarget is one resolved kill: the exact local control route and
481+// the session name the daemon kills (live) or deletes from history
482+// (stopped).
483+type pickerKillTarget struct {
484+ route BrokerOperationRoute
485+ name string
486+ stopped bool
487+}
488+
489+// ResolveKill revalidates one row key for destruction against the latest
490+// publication. Only local sessions are killable, exactly as on main; the row
491+// must still name the same lifecycle and name.
492+func (c *pickerCatalogue) ResolveKill(key string) (pickerKillTarget, error) {
493+ if c == nil {
494+ return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "no catalogue"}
495+ }
496+ c.mu.Lock()
497+ defer c.mu.Unlock()
498+ ref, ok := c.refs[key]
499+ if !ok {
500+ ref, ok = c.retired[key]
501+ }
502+ if !ok || ref.kind != pickerSelectionExact {
503+ return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker row is not in the catalogue"}
504+ }
505+ if !ref.local {
506+ return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "remote sessions cannot be killed from the picker"}
507+ }
508+ epoch := c.snapshot.Epoch
509+ if epoch == 0 {
510+ return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "broker catalogue is unavailable"}
511+ }
512+ if ref.epoch != 0 && ref.epoch != epoch {
513+ return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueEpochStale, Text: "broker epoch changed"}
514+ }
515+ authority := c.authorityForRefLocked(ref)
516+ if !authority.found {
517+ return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueGone, Text: "host is no longer in the catalogue"}
518+ }
519+ session, found := pickerFindSession(authority.observation.Sessions, ref.lifecycle)
520+ if !found {
521+ if pickerSessionReplaced(authority.observation.Sessions, ref) {
522+ return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "session identity was replaced"}
523+ }
524+ return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueGone, Text: "session is no longer in the catalogue"}
525+ }
526+ if session.Name != ref.name {
527+ return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "session identity was replaced"}
528+ }
529+ if domain.ValidateSessionName(session.Name) != nil {
530+ return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueInvalidName, Text: "session name is not valid"}
531+ }
532+ return pickerKillTarget{
533+ route: BrokerOperationRoute{Epoch: epoch, Destination: ports.BrokerEndpointFence{Local: true}},
534+ name: session.Name,
535+ stopped: session.State != catalogue.RemoteCatalogSessionUp,
536+ }, nil
537+}
538+
539+// pickerHostFailure is one remote host failure episode worth one toast.
540+type pickerHostFailure struct {
541+ key string
542+ episode uint64
543+ message string
544+}
545+
546+// failures reports every remote host currently in a failure episode. The
547+// controller toasts each (host, episode) once, as main's
548+// notifyNewRemoteFailures did.
549+func (c *pickerCatalogue) failures() []pickerHostFailure {
550+ if c == nil {
551+ return nil
552+ }
553+ c.mu.Lock()
554+ defer c.mu.Unlock()
555+ failures := make([]pickerHostFailure, 0)
556+ for _, key := range c.order {
557+ host, ok := c.hosts[key]
558+ if !ok || host.observation.Local {
559+ continue
560+ }
561+ observation := host.observation
562+ if !pickerObservationFailing(observation) && observation.Availability != domain.RemoteAvailabilityIncompatible && !pickerObservationVersionMismatch(observation) {
563+ continue
564+ }
565+ failures = append(failures, pickerHostFailure{
566+ key: key,
567+ episode: observation.FailureEpisode,
568+ message: pickerHostFailureMessage(observation),
569+ })
570+ }
571+ return failures
572+}
573+
574+// pickerHostFailureMessage is main's remoteFailureNoticeMessage, named by the
575+// host's display origin rather than a raw endpoint.
576+func pickerHostFailureMessage(observation ports.BrokerDaemonObservation) string {
577+ prefix := "Remote check failed: " + pickerOriginLabel(observation) + " — "
578+ if observation.Availability == domain.RemoteAvailabilityIncompatible || pickerObservationVersionMismatch(observation) {
579+ return prefix + "remote vev version is incompatible"
580+ }
581+ switch observation.LastFailure.Kind {
582+ case domain.RemoteFailureAuthentication:
583+ return prefix + "SSH authentication failed; verify non-interactive SSH access"
584+ case domain.RemoteFailureTrust:
585+ return prefix + "SSH host verification failed; verify the host key policy"
586+ case domain.RemoteFailureIncompatible:
587+ return prefix + "remote vev version is incompatible"
588+ case domain.RemoteFailureInvalidResponse:
589+ return prefix + "remote catalog response is invalid"
590+ case domain.RemoteFailureTimeout:
591+ return prefix + "SSH timed out"
592+ }
593+ switch observation.Availability {
594+ case domain.RemoteAvailabilityAuthFailed:
595+ return prefix + "SSH authentication failed; verify non-interactive SSH access"
596+ case domain.RemoteAvailabilityInvalidResponse:
597+ return prefix + "remote catalog response is invalid"
598+ default:
599+ return prefix + "SSH connection failed; verify SSH access"
600+ }
601+}
M internal/usecase/client/picker_catalogue_test.go +81 -59
@@ -2,7 +2,6 @@ package client
22
33 import (
44 "fmt"
5- "strings"
65 "sync"
76 "testing"
87 "time"
@@ -170,36 +169,39 @@ func TestPickerCatalogueEmptySnapshot(t *testing.T) {
170169 func TestPickerCatalogueProjectionCases(t *testing.T) {
171170 clock := newSupervisorTestClock()
172171 now := clock.Now()
172+ navigateKill := protocol.PickerCanNavigate | protocol.PickerCanKill
173173
174174 tests := []struct {
175- name string
176- daemons []ports.BrokerDaemonObservation
177- wantSections []string
178- wantSessions []string
179- wantSessionKeys map[string]bool // label -> action available
180- wantStatus map[string]protocol.PickerLineStatus
181- wantDim map[string]bool
175+ name string
176+ daemons []ports.BrokerDaemonObservation
177+ wantSections []string
178+ wantSessions []string
179+ wantActions map[string]protocol.PickerLineActions
180+ wantStatus map[string]protocol.PickerLineStatus
181+ wantDim map[string]bool
182+ wantHostRow bool
182183 }{
183184 {
184- name: "local only",
185- daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up), pickerTestSession("beta", 2, catalogue_Up))},
186- wantSections: []string{"local"},
187- wantSessions: []string{"alpha", "beta"},
188- wantSessionKeys: map[string]bool{"alpha": true, "beta": true},
185+ name: "local only",
186+ daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up), pickerTestSession("beta", 2, catalogue_Up))},
187+ wantSections: []string{"local"},
188+ wantSessions: []string{"alpha", "beta"},
189+ wantActions: map[string]protocol.PickerLineActions{"alpha": navigateKill, "beta": navigateKill},
189190 },
190191 {
191- name: "unobserved local daemon still projects",
192+ name: "unobserved local daemon with no session shows its status",
192193 daemons: []ports.BrokerDaemonObservation{pickerTestUnobservedObservation(now)},
193194 wantSections: []string{"local"},
194195 wantSessions: []string{},
195196 wantStatus: map[string]protocol.PickerLineStatus{"local": protocol.PickerLineStatusStale},
197+ wantHostRow: true,
196198 },
197199 {
198- name: "no local daemon entry",
199- daemons: []ports.BrokerDaemonObservation{pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 3, catalogue_Up))},
200- wantSections: []string{"user@arch"},
201- wantSessions: []string{"remote-a"},
202- wantSessionKeys: map[string]bool{"remote-a": true},
200+ name: "no local daemon entry",
201+ daemons: []ports.BrokerDaemonObservation{pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 3, catalogue_Up))},
202+ wantSections: []string{"user@arch"},
203+ wantSessions: []string{"remote-a"},
204+ wantActions: map[string]protocol.PickerLineActions{"remote-a": protocol.PickerCanNavigate},
203205 },
204206 {
205207 name: "stale observation stays explicitly selectable",
@@ -210,14 +212,14 @@ func TestPickerCatalogueProjectionCases(t *testing.T) {
210212 return observation
211213 }(),
212214 },
213- wantSections: []string{"local"},
214- wantSessions: []string{"alpha"},
215- wantSessionKeys: map[string]bool{"alpha": true},
216- wantStatus: map[string]protocol.PickerLineStatus{"alpha": protocol.PickerLineStatusUp, "local": protocol.PickerLineStatusStale},
217- wantDim: map[string]bool{"alpha": true},
215+ wantSections: []string{"local"},
216+ wantSessions: []string{"alpha"},
217+ wantActions: map[string]protocol.PickerLineActions{"alpha": navigateKill},
218+ wantStatus: map[string]protocol.PickerLineStatus{"alpha": protocol.PickerLineStatusUp},
219+ wantDim: map[string]bool{"alpha": true},
218220 },
219221 {
220- name: "unavailable host stays explicitly selectable",
222+ name: "unavailable host keeps its rows and hides the redundant status row",
221223 daemons: []ports.BrokerDaemonObservation{
222224 func() ports.BrokerDaemonObservation {
223225 observation := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 3, catalogue_Up))
@@ -225,11 +227,25 @@ func TestPickerCatalogueProjectionCases(t *testing.T) {
225227 return observation
226228 }(),
227229 },
228- wantSections: []string{"user@arch"},
229- wantSessions: []string{"remote-a"},
230- wantSessionKeys: map[string]bool{"remote-a": true},
231- wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusUp, "user@arch": protocol.PickerLineStatusDown},
232- wantDim: map[string]bool{"remote-a": true},
230+ wantSections: []string{"user@arch"},
231+ wantSessions: []string{"remote-a"},
232+ wantActions: map[string]protocol.PickerLineActions{"remote-a": protocol.PickerCanNavigate},
233+ wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusUp},
234+ wantDim: map[string]bool{"remote-a": true},
235+ },
236+ {
237+ name: "unavailable host without sessions shows its status row",
238+ daemons: []ports.BrokerDaemonObservation{
239+ func() ports.BrokerDaemonObservation {
240+ observation := pickerTestRemoteObservation("user@arch", 1, 1, now)
241+ observation.Availability = domain.RemoteAvailabilityUnreachable
242+ return observation
243+ }(),
244+ },
245+ wantSections: []string{"user@arch"},
246+ wantSessions: []string{},
247+ wantStatus: map[string]protocol.PickerLineStatus{"user@arch": protocol.PickerLineStatusDown},
248+ wantHostRow: true,
233249 },
234250 {
235251 name: "incompatible host",
@@ -240,20 +256,20 @@ func TestPickerCatalogueProjectionCases(t *testing.T) {
240256 return observation
241257 }(),
242258 },
243- wantSections: []string{"user@arch"},
244- wantSessions: []string{"remote-a"},
245- wantSessionKeys: map[string]bool{"remote-a": false},
246- wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusUp, "user@arch": protocol.PickerLineStatusVersion},
247- wantDim: map[string]bool{"remote-a": true},
259+ wantSections: []string{"user@arch"},
260+ wantSessions: []string{"remote-a"},
261+ wantActions: map[string]protocol.PickerLineActions{"remote-a": 0},
262+ wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusUp},
263+ wantDim: map[string]bool{"remote-a": true},
248264 },
249265 {
250- name: "broken session",
251- daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Broken))},
252- wantSections: []string{"local"},
253- wantSessions: []string{"alpha"},
254- wantSessionKeys: map[string]bool{"alpha": false},
255- wantStatus: map[string]protocol.PickerLineStatus{"alpha": protocol.PickerLineStatusError},
256- wantDim: map[string]bool{"alpha": true},
266+ name: "broken session is only killable",
267+ daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Broken))},
268+ wantSections: []string{"local"},
269+ wantSessions: []string{"alpha"},
270+ wantActions: map[string]protocol.PickerLineActions{"alpha": protocol.PickerCanKill},
271+ wantStatus: map[string]protocol.PickerLineStatus{"alpha": protocol.PickerLineStatusError},
272+ wantDim: map[string]bool{"alpha": true},
257273 },
258274 }
259275
@@ -264,21 +280,16 @@ func TestPickerCatalogueProjectionCases(t *testing.T) {
264280 lines := catalogue.Lines()
265281 require.Equal(t, tt.wantSections, pickerSectionLabels(lines))
266282 require.Equal(t, tt.wantSessions, pickerSessionLabels(lines))
267- for label, actionable := range tt.wantSessionKeys {
283+ _, hasHost := pickerHostLine(lines)
284+ require.Equal(t, tt.wantHostRow, hasHost, "a host status row is published only when it adds information")
285+ for label, actions := range tt.wantActions {
268286 line, ok := pickerLineByLabel(lines, label)
269287 require.True(t, ok, "session %q must be projected", label)
270288 require.True(t, line.Focusable, "a projected row is always a cursor destination")
271- if actionable {
272- require.Equal(t, protocol.PickerCanNavigate, line.Actions, "an eligible row admits navigation")
273- } else {
274- require.Zero(t, line.Actions, "a refused row is inspectable but never committable")
275- }
289+ require.Equal(t, actions, line.Actions, "row %q actions", label)
276290 }
277291 for label, status := range tt.wantStatus {
278292 line, ok := pickerLineByLabel(lines, label)
279- if !ok && (label == "local" || strings.Contains(label, "@")) {
280- line, ok = pickerHostLine(lines)
281- }
282293 require.True(t, ok, "row %q must be projected", label)
283294 require.Equal(t, status, line.Status, "row %q badge", label)
284295 }
@@ -322,6 +333,7 @@ func TestPickerCatalogueMonotonicUpdates(t *testing.T) {
322333 // catalogue state into every literal.
323334 const (
324335 catalogue_Up = catalogue.RemoteCatalogSessionState("up")
336+ catalogue_Down = catalogue.RemoteCatalogSessionState("down")
325337 catalogue_Broken = catalogue.RemoteCatalogSessionState("broken")
326338 )
327339
@@ -510,7 +522,9 @@ func TestPickerCatalogueSelectionResolution(t *testing.T) {
510522 wantErr: pickerCatalogueIncompatible,
511523 },
512524 {
513- name: "unavailable host is still explicitly selectable",
525+ // Plan 003 B4: a remote host the broker observed failing refuses
526+ // instantly instead of a slow attempt that fails the same way.
527+ name: "unreachable remote host refuses instantly",
514528 build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string {
515529 unavailable := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 2, catalogue_Up))
516530 unavailable.Availability = domain.RemoteAvailabilityUnreachable
@@ -519,6 +533,17 @@ func TestPickerCatalogueSelectionResolution(t *testing.T) {
519533 require.True(t, ok)
520534 return line.Key
521535 },
536+ wantErr: pickerCatalogueUnavailable,
537+ },
538+ {
539+ name: "stale reachable remote host stays attemptable",
540+ build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string {
541+ stale := pickerTestRemoteObservation("user@arch", 1, 1, now.Add(-24*time.Hour), pickerTestSession("remote-a", 2, catalogue_Up))
542+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{stale}}))
543+ line, ok := pickerLineByLabel(catalogue.Lines(), "remote-a")
544+ require.True(t, ok)
545+ return line.Key
546+ },
522547 wantAdmission: ports.BrokerAdmissionExact,
523548 },
524549 {
@@ -891,17 +916,14 @@ func TestPickerCatalogueUnobservedDaemonProjects(t *testing.T) {
891916 lines := catalogue.Lines()
892917 require.Equal(t, []string{"local"}, pickerSectionLabels(lines))
893918 require.Equal(t, []string{"alpha"}, pickerSessionLabels(lines))
894- host, ok := pickerHostLine(lines)
895- require.True(t, ok)
896- require.Equal(t, "status", host.Label)
897- require.False(t, host.Focusable, "host status is informational, not a duplicate destination")
898- require.Equal(t, protocol.PickerLineStatusStale, host.Status)
899- require.Equal(t, domain.RemoteReasonRefreshing, host.StatusDetail, "an unobserved daemon refreshes, it is not a version mismatch")
919+ _, hasHost := pickerHostLine(lines)
920+ require.False(t, hasHost, "the session rows already carry the host state")
900921 session, ok := pickerLineByLabel(lines, "alpha")
901922 require.True(t, ok)
902923 require.True(t, session.Focusable)
903- require.Equal(t, protocol.PickerCanNavigate, session.Actions, "an unobserved daemon may still be attempted explicitly")
924+ require.Equal(t, protocol.PickerCanNavigate|protocol.PickerCanKill, session.Actions, "an unobserved daemon may still be attempted explicitly")
904925 require.True(t, session.Dim)
926+ require.Equal(t, domain.RemoteReasonRefreshing, session.StatusDetail, "an unobserved daemon refreshes, it is not a version mismatch")
905927 }
906928
907929 // TestPickerCatalogueOriginLabelNeverShowsRawEndpoint proves display labels
@@ -2,7 +2,6 @@ package client
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5- "strings"
6 "sync"5 "sync"
7 "testing"6 "testing"
8 "time"7 "time"
@@ -170,36 +169,39 @@ func TestPickerCatalogueEmptySnapshot(t *testing.T) {
170 func TestPickerCatalogueProjectionCases(t *testing.T) {169 func TestPickerCatalogueProjectionCases(t *testing.T) {
171 clock := newSupervisorTestClock()170 clock := newSupervisorTestClock()
172 now := clock.Now()171 now := clock.Now()
172+ navigateKill := protocol.PickerCanNavigate | protocol.PickerCanKill
173 173
174 tests := []struct {174 tests := []struct {
175- name string175+ name string
176- daemons []ports.BrokerDaemonObservation176+ daemons []ports.BrokerDaemonObservation
177- wantSections []string177+ wantSections []string
178- wantSessions []string178+ wantSessions []string
179- wantSessionKeys map[string]bool // label -> action available179+ wantActions map[string]protocol.PickerLineActions
180- wantStatus map[string]protocol.PickerLineStatus180+ wantStatus map[string]protocol.PickerLineStatus
181- wantDim map[string]bool181+ wantDim map[string]bool
182+ wantHostRow bool
182 }{183 }{
183 {184 {
184- name: "local only",185+ name: "local only",
185- daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up), pickerTestSession("beta", 2, catalogue_Up))},186+ daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up), pickerTestSession("beta", 2, catalogue_Up))},
186- wantSections: []string{"local"},187+ wantSections: []string{"local"},
187- wantSessions: []string{"alpha", "beta"},188+ wantSessions: []string{"alpha", "beta"},
188- wantSessionKeys: map[string]bool{"alpha": true, "beta": true},189+ wantActions: map[string]protocol.PickerLineActions{"alpha": navigateKill, "beta": navigateKill},
189 },190 },
190 {191 {
191- name: "unobserved local daemon still projects",192+ name: "unobserved local daemon with no session shows its status",
192 daemons: []ports.BrokerDaemonObservation{pickerTestUnobservedObservation(now)},193 daemons: []ports.BrokerDaemonObservation{pickerTestUnobservedObservation(now)},
193 wantSections: []string{"local"},194 wantSections: []string{"local"},
194 wantSessions: []string{},195 wantSessions: []string{},
195 wantStatus: map[string]protocol.PickerLineStatus{"local": protocol.PickerLineStatusStale},196 wantStatus: map[string]protocol.PickerLineStatus{"local": protocol.PickerLineStatusStale},
197+ wantHostRow: true,
196 },198 },
197 {199 {
198- name: "no local daemon entry",200+ name: "no local daemon entry",
199- daemons: []ports.BrokerDaemonObservation{pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 3, catalogue_Up))},201+ daemons: []ports.BrokerDaemonObservation{pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 3, catalogue_Up))},
200- wantSections: []string{"user@arch"},202+ wantSections: []string{"user@arch"},
201- wantSessions: []string{"remote-a"},203+ wantSessions: []string{"remote-a"},
202- wantSessionKeys: map[string]bool{"remote-a": true},204+ wantActions: map[string]protocol.PickerLineActions{"remote-a": protocol.PickerCanNavigate},
203 },205 },
204 {206 {
205 name: "stale observation stays explicitly selectable",207 name: "stale observation stays explicitly selectable",
@@ -210,14 +212,14 @@ func TestPickerCatalogueProjectionCases(t *testing.T) {
210 return observation212 return observation
211 }(),213 }(),
212 },214 },
213- wantSections: []string{"local"},215+ wantSections: []string{"local"},
214- wantSessions: []string{"alpha"},216+ wantSessions: []string{"alpha"},
215- wantSessionKeys: map[string]bool{"alpha": true},217+ wantActions: map[string]protocol.PickerLineActions{"alpha": navigateKill},
216- wantStatus: map[string]protocol.PickerLineStatus{"alpha": protocol.PickerLineStatusUp, "local": protocol.PickerLineStatusStale},218+ wantStatus: map[string]protocol.PickerLineStatus{"alpha": protocol.PickerLineStatusUp},
217- wantDim: map[string]bool{"alpha": true},219+ wantDim: map[string]bool{"alpha": true},
218 },220 },
219 {221 {
220- name: "unavailable host stays explicitly selectable",222+ name: "unavailable host keeps its rows and hides the redundant status row",
221 daemons: []ports.BrokerDaemonObservation{223 daemons: []ports.BrokerDaemonObservation{
222 func() ports.BrokerDaemonObservation {224 func() ports.BrokerDaemonObservation {
223 observation := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 3, catalogue_Up))225 observation := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 3, catalogue_Up))
@@ -225,11 +227,25 @@ func TestPickerCatalogueProjectionCases(t *testing.T) {
225 return observation227 return observation
226 }(),228 }(),
227 },229 },
228- wantSections: []string{"user@arch"},230+ wantSections: []string{"user@arch"},
229- wantSessions: []string{"remote-a"},231+ wantSessions: []string{"remote-a"},
230- wantSessionKeys: map[string]bool{"remote-a": true},232+ wantActions: map[string]protocol.PickerLineActions{"remote-a": protocol.PickerCanNavigate},
231- wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusUp, "user@arch": protocol.PickerLineStatusDown},233+ wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusUp},
232- wantDim: map[string]bool{"remote-a": true},234+ wantDim: map[string]bool{"remote-a": true},
235+ },
236+ {
237+ name: "unavailable host without sessions shows its status row",
238+ daemons: []ports.BrokerDaemonObservation{
239+ func() ports.BrokerDaemonObservation {
240+ observation := pickerTestRemoteObservation("user@arch", 1, 1, now)
241+ observation.Availability = domain.RemoteAvailabilityUnreachable
242+ return observation
243+ }(),
244+ },
245+ wantSections: []string{"user@arch"},
246+ wantSessions: []string{},
247+ wantStatus: map[string]protocol.PickerLineStatus{"user@arch": protocol.PickerLineStatusDown},
248+ wantHostRow: true,
233 },249 },
234 {250 {
235 name: "incompatible host",251 name: "incompatible host",
@@ -240,20 +256,20 @@ func TestPickerCatalogueProjectionCases(t *testing.T) {
240 return observation256 return observation
241 }(),257 }(),
242 },258 },
243- wantSections: []string{"user@arch"},259+ wantSections: []string{"user@arch"},
244- wantSessions: []string{"remote-a"},260+ wantSessions: []string{"remote-a"},
245- wantSessionKeys: map[string]bool{"remote-a": false},261+ wantActions: map[string]protocol.PickerLineActions{"remote-a": 0},
246- wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusUp, "user@arch": protocol.PickerLineStatusVersion},262+ wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusUp},
247- wantDim: map[string]bool{"remote-a": true},263+ wantDim: map[string]bool{"remote-a": true},
248 },264 },
249 {265 {
250- name: "broken session",266+ name: "broken session is only killable",
251- daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Broken))},267+ daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Broken))},
252- wantSections: []string{"local"},268+ wantSections: []string{"local"},
253- wantSessions: []string{"alpha"},269+ wantSessions: []string{"alpha"},
254- wantSessionKeys: map[string]bool{"alpha": false},270+ wantActions: map[string]protocol.PickerLineActions{"alpha": protocol.PickerCanKill},
255- wantStatus: map[string]protocol.PickerLineStatus{"alpha": protocol.PickerLineStatusError},271+ wantStatus: map[string]protocol.PickerLineStatus{"alpha": protocol.PickerLineStatusError},
256- wantDim: map[string]bool{"alpha": true},272+ wantDim: map[string]bool{"alpha": true},
257 },273 },
258 }274 }
259 275
@@ -264,21 +280,16 @@ func TestPickerCatalogueProjectionCases(t *testing.T) {
264 lines := catalogue.Lines()280 lines := catalogue.Lines()
265 require.Equal(t, tt.wantSections, pickerSectionLabels(lines))281 require.Equal(t, tt.wantSections, pickerSectionLabels(lines))
266 require.Equal(t, tt.wantSessions, pickerSessionLabels(lines))282 require.Equal(t, tt.wantSessions, pickerSessionLabels(lines))
267- for label, actionable := range tt.wantSessionKeys {283+ _, hasHost := pickerHostLine(lines)
284+ require.Equal(t, tt.wantHostRow, hasHost, "a host status row is published only when it adds information")
285+ for label, actions := range tt.wantActions {
268 line, ok := pickerLineByLabel(lines, label)286 line, ok := pickerLineByLabel(lines, label)
269 require.True(t, ok, "session %q must be projected", label)287 require.True(t, ok, "session %q must be projected", label)
270 require.True(t, line.Focusable, "a projected row is always a cursor destination")288 require.True(t, line.Focusable, "a projected row is always a cursor destination")
271- if actionable {289+ require.Equal(t, actions, line.Actions, "row %q actions", label)
272- require.Equal(t, protocol.PickerCanNavigate, line.Actions, "an eligible row admits navigation")
273- } else {
274- require.Zero(t, line.Actions, "a refused row is inspectable but never committable")
275- }
276 }290 }
277 for label, status := range tt.wantStatus {291 for label, status := range tt.wantStatus {
278 line, ok := pickerLineByLabel(lines, label)292 line, ok := pickerLineByLabel(lines, label)
279- if !ok && (label == "local" || strings.Contains(label, "@")) {
280- line, ok = pickerHostLine(lines)
281- }
282 require.True(t, ok, "row %q must be projected", label)293 require.True(t, ok, "row %q must be projected", label)
283 require.Equal(t, status, line.Status, "row %q badge", label)294 require.Equal(t, status, line.Status, "row %q badge", label)
284 }295 }
@@ -322,6 +333,7 @@ func TestPickerCatalogueMonotonicUpdates(t *testing.T) {
322 // catalogue state into every literal.333 // catalogue state into every literal.
323 const (334 const (
324 catalogue_Up = catalogue.RemoteCatalogSessionState("up")335 catalogue_Up = catalogue.RemoteCatalogSessionState("up")
336+ catalogue_Down = catalogue.RemoteCatalogSessionState("down")
325 catalogue_Broken = catalogue.RemoteCatalogSessionState("broken")337 catalogue_Broken = catalogue.RemoteCatalogSessionState("broken")
326 )338 )
327 339
@@ -510,7 +522,9 @@ func TestPickerCatalogueSelectionResolution(t *testing.T) {
510 wantErr: pickerCatalogueIncompatible,522 wantErr: pickerCatalogueIncompatible,
511 },523 },
512 {524 {
513- name: "unavailable host is still explicitly selectable",525+ // Plan 003 B4: a remote host the broker observed failing refuses
526+ // instantly instead of a slow attempt that fails the same way.
527+ name: "unreachable remote host refuses instantly",
514 build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string {528 build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string {
515 unavailable := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 2, catalogue_Up))529 unavailable := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 2, catalogue_Up))
516 unavailable.Availability = domain.RemoteAvailabilityUnreachable530 unavailable.Availability = domain.RemoteAvailabilityUnreachable
@@ -519,6 +533,17 @@ func TestPickerCatalogueSelectionResolution(t *testing.T) {
519 require.True(t, ok)533 require.True(t, ok)
520 return line.Key534 return line.Key
521 },535 },
536+ wantErr: pickerCatalogueUnavailable,
537+ },
538+ {
539+ name: "stale reachable remote host stays attemptable",
540+ build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string {
541+ stale := pickerTestRemoteObservation("user@arch", 1, 1, now.Add(-24*time.Hour), pickerTestSession("remote-a", 2, catalogue_Up))
542+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{stale}}))
543+ line, ok := pickerLineByLabel(catalogue.Lines(), "remote-a")
544+ require.True(t, ok)
545+ return line.Key
546+ },
522 wantAdmission: ports.BrokerAdmissionExact,547 wantAdmission: ports.BrokerAdmissionExact,
523 },548 },
524 {549 {
@@ -891,17 +916,14 @@ func TestPickerCatalogueUnobservedDaemonProjects(t *testing.T) {
891 lines := catalogue.Lines()916 lines := catalogue.Lines()
892 require.Equal(t, []string{"local"}, pickerSectionLabels(lines))917 require.Equal(t, []string{"local"}, pickerSectionLabels(lines))
893 require.Equal(t, []string{"alpha"}, pickerSessionLabels(lines))918 require.Equal(t, []string{"alpha"}, pickerSessionLabels(lines))
894- host, ok := pickerHostLine(lines)919+ _, hasHost := pickerHostLine(lines)
895- require.True(t, ok)920+ require.False(t, hasHost, "the session rows already carry the host state")
896- require.Equal(t, "status", host.Label)
897- require.False(t, host.Focusable, "host status is informational, not a duplicate destination")
898- require.Equal(t, protocol.PickerLineStatusStale, host.Status)
899- require.Equal(t, domain.RemoteReasonRefreshing, host.StatusDetail, "an unobserved daemon refreshes, it is not a version mismatch")
900 session, ok := pickerLineByLabel(lines, "alpha")921 session, ok := pickerLineByLabel(lines, "alpha")
901 require.True(t, ok)922 require.True(t, ok)
902 require.True(t, session.Focusable)923 require.True(t, session.Focusable)
903- require.Equal(t, protocol.PickerCanNavigate, session.Actions, "an unobserved daemon may still be attempted explicitly")924+ require.Equal(t, protocol.PickerCanNavigate|protocol.PickerCanKill, session.Actions, "an unobserved daemon may still be attempted explicitly")
904 require.True(t, session.Dim)925 require.True(t, session.Dim)
926+ require.Equal(t, domain.RemoteReasonRefreshing, session.StatusDetail, "an unobserved daemon refreshes, it is not a version mismatch")
905 }927 }
906 928
907 // TestPickerCatalogueOriginLabelNeverShowsRawEndpoint proves display labels929 // TestPickerCatalogueOriginLabelNeverShowsRawEndpoint proves display labels
M internal/usecase/client/picker_composition.go +23 -0
@@ -79,6 +79,29 @@ func (p *Picker) ResolveKey(key string, base pickerResolveBase) (ports.BrokerOpe
7979 return p.controller.ResolveKey(key, base)
8080 }
8181
82+// ResolveKeyTarget resolves a committed key plus the exact tab it names.
83+func (p *Picker) ResolveKeyTarget(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) {
84+ if p == nil || p.controller == nil {
85+ return (*pickerController)(nil).ResolveKeyTarget(key, base)
86+ }
87+ return p.controller.ResolveKeyTarget(key, base)
88+}
89+
90+// ResolveKill revalidates one kill key for the supervisor.
91+func (p *Picker) ResolveKill(key string) (pickerKillTarget, error) {
92+ if p == nil || p.controller == nil {
93+ return (*pickerController)(nil).ResolveKill(key)
94+ }
95+ return p.controller.ResolveKill(key)
96+}
97+
98+// SetCurrent names the attachment the picker is presented over.
99+func (p *Picker) SetCurrent(current pickerCurrent) {
100+ if p != nil && p.controller != nil {
101+ p.controller.SetCurrent(current)
102+ }
103+}
104+
82105 // SetOwnsInput updates picker input ownership at an attachment boundary.
83106 func (p *Picker) PreviewRequest(connection ports.BrokerConnectionID, stream ports.BrokerStreamID, size domain.Size) (ports.BrokerOpenStreamRequest, protocol.RemotePreviewRequest, bool) {
84107 if p == nil || p.controller == nil {
@@ -79,6 +79,29 @@ func (p *Picker) ResolveKey(key string, base pickerResolveBase) (ports.BrokerOpe
79 return p.controller.ResolveKey(key, base)79 return p.controller.ResolveKey(key, base)
80 }80 }
81 81
82+// ResolveKeyTarget resolves a committed key plus the exact tab it names.
83+func (p *Picker) ResolveKeyTarget(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) {
84+ if p == nil || p.controller == nil {
85+ return (*pickerController)(nil).ResolveKeyTarget(key, base)
86+ }
87+ return p.controller.ResolveKeyTarget(key, base)
88+}
89+
90+// ResolveKill revalidates one kill key for the supervisor.
91+func (p *Picker) ResolveKill(key string) (pickerKillTarget, error) {
92+ if p == nil || p.controller == nil {
93+ return (*pickerController)(nil).ResolveKill(key)
94+ }
95+ return p.controller.ResolveKill(key)
96+}
97+
98+// SetCurrent names the attachment the picker is presented over.
99+func (p *Picker) SetCurrent(current pickerCurrent) {
100+ if p != nil && p.controller != nil {
101+ p.controller.SetCurrent(current)
102+ }
103+}
104+
82 // SetOwnsInput updates picker input ownership at an attachment boundary.105 // SetOwnsInput updates picker input ownership at an attachment boundary.
83 func (p *Picker) PreviewRequest(connection ports.BrokerConnectionID, stream ports.BrokerStreamID, size domain.Size) (ports.BrokerOpenStreamRequest, protocol.RemotePreviewRequest, bool) {106 func (p *Picker) PreviewRequest(connection ports.BrokerConnectionID, stream ports.BrokerStreamID, size domain.Size) (ports.BrokerOpenStreamRequest, protocol.RemotePreviewRequest, bool) {
84 if p == nil || p.controller == nil {107 if p == nil || p.controller == nil {
M internal/usecase/client/picker_controller.go +131 -29
@@ -2,12 +2,14 @@ package client
22
33 import (
44 "bytes"
5+ "errors"
56 "sync"
67 "time"
78
89 "github.com/bnema/vev/internal/domain"
910 "github.com/bnema/vev/internal/ports"
1011 "github.com/bnema/vev/internal/protocol"
12+ "github.com/bnema/vev/internal/protocol/catalogue"
1113 pickerusecase "github.com/bnema/vev/internal/usecase/picker"
1214 "github.com/bnema/vev/internal/usecase/ui"
1315 )
@@ -102,7 +104,12 @@ type pickerController struct {
102104 // lastCommitKey is the catalogue key captured with the pending commit
103105 // decision, so TakeOp can hand a driver the exact committed row.
104106 lastCommitKey string
105- preview pickerusecase.Preview
107+ // lastKillKey is the catalogue key captured with a pending kill decision.
108+ lastKillKey string
109+ // noticedFailures records the failure episode already toasted per remote
110+ // host, so one outage toasts once (main's notifyNewRemoteFailures).
111+ noticedFailures map[string]uint64
112+ preview pickerusecase.Preview
106113 // flushStop cancels the armed lone-escape flush. It is non-nil exactly
107114 // while one flush is armed for the currently withheld prefix.
108115 flushStop chan struct{}
@@ -195,8 +202,11 @@ func (p *pickerController) recordOpLocked(op pickerOp) {
195202 if op.commit {
196203 p.lastCommitKey = p.commitKeyLocked()
197204 }
205+ if op.kill {
206+ p.lastKillKey = p.killKeyLocked()
207+ }
198208 p.lastOp = mergePickerOps(p.lastOp, op)
199- if p.lastOp.commit || p.lastOp.close {
209+ if p.lastOp.commit || p.lastOp.close || p.lastOp.kill {
200210 // Coalesce exactly one wakeup: a driver that has not yet run TakeOp
201211 // re-reads the accumulated decision, so a dropped signal is harmless.
202212 select {
@@ -229,6 +239,19 @@ func (p *pickerController) commitKeyLocked() string {
229239 return selection.Key
230240 }
231241
242+// killKeyLocked returns the opaque catalogue key of the row under the cursor
243+// when it authorises destruction, or the empty string.
244+func (p *pickerController) killKeyLocked() string {
245+ if p.loop == nil {
246+ return ""
247+ }
248+ selection, ok := killSelection(p.loop, 0)
249+ if !ok {
250+ return ""
251+ }
252+ return selection.Key
253+}
254+
232255 // TakeOp returns and clears the accumulated presentation decision since the
233256 // last call, together with the catalogue key of the row the commit decision
234257 // selected at the moment it was recorded. Resolving that key with ResolveKey
@@ -243,8 +266,14 @@ func (p *pickerController) TakeOp() (pickerOp, string) {
243266 defer p.mu.Unlock()
244267 op := p.lastOp
245268 key := p.lastCommitKey
269+ if !op.commit {
270+ // A kill carries its own captured row; a commit in the same read wins
271+ // because it leaves the picker anyway.
272+ key = p.lastKillKey
273+ }
246274 p.lastOp = pickerOp{}
247275 p.lastCommitKey = ""
276+ p.lastKillKey = ""
248277 return op, key
249278 }
250279
@@ -392,7 +421,15 @@ func (p *pickerController) PreviewRequest(connection ports.BrokerConnectionID, s
392421 return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false
393422 }
394423 session, ok := pickerFindSession(authority.observation.Sessions, ref.lifecycle)
395- if !ok || session.State == "down" || session.ActiveTabID == "" {
424+ if !ok || session.State != catalogue.RemoteCatalogSessionUp {
425+ return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false
426+ }
427+ // A tab row previews its own tab; a session row previews the active one.
428+ previewTab := domain.TabStableID(session.ActiveTabID)
429+ if ref.tab.present {
430+ previewTab = ref.tab.id
431+ }
432+ if previewTab == "" {
396433 return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false
397434 }
398435 viewport := pickerPreviewSize(size)
@@ -402,7 +439,7 @@ func (p *pickerController) PreviewRequest(connection ports.BrokerConnectionID, s
402439 // local daemon; "local" is the broker's canonical local authority.
403440 endpoint = "local"
404441 }
405- target := domain.RemoteSessionTarget{Endpoint: endpoint, DisplayOrigin: authority.observation.DisplayOrigin, LifecycleID: session.LifecycleID, SessionName: session.Name, LiveTabID: domain.TabStableID(session.ActiveTabID)}
442+ target := domain.RemoteSessionTarget{Endpoint: endpoint, DisplayOrigin: authority.observation.DisplayOrigin, LifecycleID: session.LifecycleID, SessionName: session.Name, LiveTabID: previewTab}
406443 preview := protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: clampPreviewDimension(viewport.Cols, protocol.RemotePreviewMaxWidth), Height: clampPreviewDimension(viewport.Rows, protocol.RemotePreviewMaxHeight)}
407444 route.Purpose = ports.BrokerStreamObservation
408445 route.Admission = 0
@@ -510,12 +547,66 @@ func (p *pickerController) ResolveKey(key string, base pickerResolveBase) (ports
510547 }
511548 return ports.BrokerOpenStreamRequest{}, err
512549 }
513- request, err := p.catalogue.Resolve(key, base)
550+ request, _, err := p.ResolveKeyTarget(key, base)
551+ return request, err
552+}
553+
554+// ResolveKeyTarget is ResolveKey plus the exact tab the committed row names.
555+func (p *pickerController) ResolveKeyTarget(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) {
556+ if p == nil || p.catalogue == nil {
557+ err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"}
558+ if p != nil {
559+ p.offerNotice("picker-refusal", err.Error())
560+ }
561+ return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err
562+ }
563+ request, tab, err := p.catalogue.ResolveTarget(key, base)
514564 if err != nil {
515- p.offerNotice("picker-refusal", err.Error())
516- return ports.BrokerOpenStreamRequest{}, err
565+ p.offerNotice("picker-refusal", pickerRefusalNotice(err))
566+ return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err
517567 }
518- return request, nil
568+ return request, tab, nil
569+}
570+
571+// ResolveKill revalidates one kill key; a refusal is surfaced as a notice.
572+func (p *pickerController) ResolveKill(key string) (pickerKillTarget, error) {
573+ if p == nil || p.catalogue == nil {
574+ return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"}
575+ }
576+ target, err := p.catalogue.ResolveKill(key)
577+ if err != nil {
578+ p.offerNotice("picker-refusal", pickerRefusalNotice(err))
579+ return pickerKillTarget{}, err
580+ }
581+ return target, nil
582+}
583+
584+// SetCurrent names the attachment the picker is about to be presented over
585+// (or none) and restarts the presentation, so a fresh picker opens with the
586+// cursor on that session's tab and no leftover search, exactly as each daemon
587+// interaction did on main.
588+func (p *pickerController) SetCurrent(current pickerCurrent) {
589+ if p == nil || p.catalogue == nil {
590+ return
591+ }
592+ p.catalogue.SetCurrent(current)
593+ p.mu.Lock()
594+ defer p.mu.Unlock()
595+ if p.catalogue.Epoch() == 0 {
596+ return
597+ }
598+ p.loop = nil
599+ p.rebuildLocked()
600+ p.renderer.invalidate()
601+}
602+
603+// pickerRefusalNotice is the toast text for one resolution refusal.
604+func pickerRefusalNotice(err error) string {
605+ var typed pickerCatalogueError
606+ if errors.As(err, &typed) {
607+ return typed.noticeText()
608+ }
609+ return err.Error()
519610 }
520611
521612 // displayedKey names the row under the cursor, whether or not it admits an
@@ -577,43 +668,54 @@ func (p *pickerController) handleRead(data []byte) bool {
577668 }
578669
579670 func (p *pickerController) rebuildLocked() {
580- lines, cursor := p.catalogue.projection()
671+ recent, grouped := p.catalogue.projections()
581672 revision := uint64(p.catalogue.Revision())
582- if p.loop == nil {
583- p.interaction++
584- p.loop = pickerLoopFromSnapshot(protocol.PickerSnapshot{
673+ snapshot := func() protocol.PickerSnapshot {
674+ return protocol.PickerSnapshot{
585675 InteractionID: p.interaction,
586676 SourceID: pickerCatalogueSourceID,
587677 SourceRevision: revision,
588- Lines: lines,
589- Cursor: cursor,
590- }, protocol.PickerIntentNavigation, defaultPickerSort())
678+ Lines: grouped.Lines,
679+ Cursor: grouped.Cursor,
680+ Recent: recent,
681+ Grouped: grouped,
682+ }
683+ }
684+ if p.loop == nil {
685+ p.interaction++
686+ p.loop = pickerLoopFromSnapshot(snapshot(), protocol.PickerIntentNavigation, defaultPickerSort())
591687 return
592688 }
593- p.loop.replaceLines(protocol.PickerSnapshot{
594- InteractionID: p.interaction,
595- SourceID: pickerCatalogueSourceID,
596- SourceRevision: revision,
597- Lines: lines,
598- Cursor: cursor,
599- })
689+ p.loop.replaceLines(snapshot())
600690 }
601691
602-// offerDiagnosticsLocked surfaces every host that is not a clean, fresh,
603-// compatible, reachable daemon as a bounded toast. The toast manager keeps one
604-// entry per host key, so the notices stay bounded regardless of update rate.
692+// offerDiagnosticsLocked toasts each remote host failure once per failure
693+// episode (main's notifyNewRemoteFailures). Ordinary observation progress and
694+// catalogue aging stay on the rows; a recovered host forgets its episode so a
695+// later outage toasts again.
605696 func (p *pickerController) offerDiagnosticsLocked() {
606- for _, diagnostic := range p.catalogue.diagnostics() {
607- if diagnostic.Message == "" {
697+ failing := make(map[string]struct{})
698+ for _, failure := range p.catalogue.failures() {
699+ failing[failure.key] = struct{}{}
700+ if noticed, ok := p.noticedFailures[failure.key]; ok && noticed == failure.episode {
608701 continue
609702 }
703+ if p.noticedFailures == nil {
704+ p.noticedFailures = make(map[string]uint64)
705+ }
706+ p.noticedFailures[failure.key] = failure.episode
610707 p.notices.Show(p.clock.Now(), ui.Toast{
611- ID: "picker-host:" + diagnostic.Key,
612- Message: diagnostic.Origin + ": " + diagnostic.Message,
708+ ID: "picker-host:" + failure.key,
709+ Message: failure.message,
613710 Anchor: domain.AnchorCenter,
614711 Duration: pickerNoticeLifetime,
615712 })
616713 }
714+ for key := range p.noticedFailures {
715+ if _, ok := failing[key]; !ok {
716+ delete(p.noticedFailures, key)
717+ }
718+ }
617719 }
618720
619721 func (p *pickerController) offerNotice(id, message string) {
@@ -2,12 +2,14 @@ package client
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5+ "errors"
5 "sync"6 "sync"
6 "time"7 "time"
7 8
8 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
10 "github.com/bnema/vev/internal/protocol"11 "github.com/bnema/vev/internal/protocol"
12+ "github.com/bnema/vev/internal/protocol/catalogue"
11 pickerusecase "github.com/bnema/vev/internal/usecase/picker"13 pickerusecase "github.com/bnema/vev/internal/usecase/picker"
12 "github.com/bnema/vev/internal/usecase/ui"14 "github.com/bnema/vev/internal/usecase/ui"
13 )15 )
@@ -102,7 +104,12 @@ type pickerController struct {
102 // lastCommitKey is the catalogue key captured with the pending commit104 // lastCommitKey is the catalogue key captured with the pending commit
103 // decision, so TakeOp can hand a driver the exact committed row.105 // decision, so TakeOp can hand a driver the exact committed row.
104 lastCommitKey string106 lastCommitKey string
105- preview pickerusecase.Preview107+ // lastKillKey is the catalogue key captured with a pending kill decision.
108+ lastKillKey string
109+ // noticedFailures records the failure episode already toasted per remote
110+ // host, so one outage toasts once (main's notifyNewRemoteFailures).
111+ noticedFailures map[string]uint64
112+ preview pickerusecase.Preview
106 // flushStop cancels the armed lone-escape flush. It is non-nil exactly113 // flushStop cancels the armed lone-escape flush. It is non-nil exactly
107 // while one flush is armed for the currently withheld prefix.114 // while one flush is armed for the currently withheld prefix.
108 flushStop chan struct{}115 flushStop chan struct{}
@@ -195,8 +202,11 @@ func (p *pickerController) recordOpLocked(op pickerOp) {
195 if op.commit {202 if op.commit {
196 p.lastCommitKey = p.commitKeyLocked()203 p.lastCommitKey = p.commitKeyLocked()
197 }204 }
205+ if op.kill {
206+ p.lastKillKey = p.killKeyLocked()
207+ }
198 p.lastOp = mergePickerOps(p.lastOp, op)208 p.lastOp = mergePickerOps(p.lastOp, op)
199- if p.lastOp.commit || p.lastOp.close {209+ if p.lastOp.commit || p.lastOp.close || p.lastOp.kill {
200 // Coalesce exactly one wakeup: a driver that has not yet run TakeOp210 // Coalesce exactly one wakeup: a driver that has not yet run TakeOp
201 // re-reads the accumulated decision, so a dropped signal is harmless.211 // re-reads the accumulated decision, so a dropped signal is harmless.
202 select {212 select {
@@ -229,6 +239,19 @@ func (p *pickerController) commitKeyLocked() string {
229 return selection.Key239 return selection.Key
230 }240 }
231 241
242+// killKeyLocked returns the opaque catalogue key of the row under the cursor
243+// when it authorises destruction, or the empty string.
244+func (p *pickerController) killKeyLocked() string {
245+ if p.loop == nil {
246+ return ""
247+ }
248+ selection, ok := killSelection(p.loop, 0)
249+ if !ok {
250+ return ""
251+ }
252+ return selection.Key
253+}
254+
232 // TakeOp returns and clears the accumulated presentation decision since the255 // TakeOp returns and clears the accumulated presentation decision since the
233 // last call, together with the catalogue key of the row the commit decision256 // last call, together with the catalogue key of the row the commit decision
234 // selected at the moment it was recorded. Resolving that key with ResolveKey257 // selected at the moment it was recorded. Resolving that key with ResolveKey
@@ -243,8 +266,14 @@ func (p *pickerController) TakeOp() (pickerOp, string) {
243 defer p.mu.Unlock()266 defer p.mu.Unlock()
244 op := p.lastOp267 op := p.lastOp
245 key := p.lastCommitKey268 key := p.lastCommitKey
269+ if !op.commit {
270+ // A kill carries its own captured row; a commit in the same read wins
271+ // because it leaves the picker anyway.
272+ key = p.lastKillKey
273+ }
246 p.lastOp = pickerOp{}274 p.lastOp = pickerOp{}
247 p.lastCommitKey = ""275 p.lastCommitKey = ""
276+ p.lastKillKey = ""
248 return op, key277 return op, key
249 }278 }
250 279
@@ -392,7 +421,15 @@ func (p *pickerController) PreviewRequest(connection ports.BrokerConnectionID, s
392 return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false421 return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false
393 }422 }
394 session, ok := pickerFindSession(authority.observation.Sessions, ref.lifecycle)423 session, ok := pickerFindSession(authority.observation.Sessions, ref.lifecycle)
395- if !ok || session.State == "down" || session.ActiveTabID == "" {424+ if !ok || session.State != catalogue.RemoteCatalogSessionUp {
425+ return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false
426+ }
427+ // A tab row previews its own tab; a session row previews the active one.
428+ previewTab := domain.TabStableID(session.ActiveTabID)
429+ if ref.tab.present {
430+ previewTab = ref.tab.id
431+ }
432+ if previewTab == "" {
396 return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false433 return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false
397 }434 }
398 viewport := pickerPreviewSize(size)435 viewport := pickerPreviewSize(size)
@@ -402,7 +439,7 @@ func (p *pickerController) PreviewRequest(connection ports.BrokerConnectionID, s
402 // local daemon; "local" is the broker's canonical local authority.439 // local daemon; "local" is the broker's canonical local authority.
403 endpoint = "local"440 endpoint = "local"
404 }441 }
405- target := domain.RemoteSessionTarget{Endpoint: endpoint, DisplayOrigin: authority.observation.DisplayOrigin, LifecycleID: session.LifecycleID, SessionName: session.Name, LiveTabID: domain.TabStableID(session.ActiveTabID)}442+ target := domain.RemoteSessionTarget{Endpoint: endpoint, DisplayOrigin: authority.observation.DisplayOrigin, LifecycleID: session.LifecycleID, SessionName: session.Name, LiveTabID: previewTab}
406 preview := protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: clampPreviewDimension(viewport.Cols, protocol.RemotePreviewMaxWidth), Height: clampPreviewDimension(viewport.Rows, protocol.RemotePreviewMaxHeight)}443 preview := protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: clampPreviewDimension(viewport.Cols, protocol.RemotePreviewMaxWidth), Height: clampPreviewDimension(viewport.Rows, protocol.RemotePreviewMaxHeight)}
407 route.Purpose = ports.BrokerStreamObservation444 route.Purpose = ports.BrokerStreamObservation
408 route.Admission = 0445 route.Admission = 0
@@ -510,12 +547,66 @@ func (p *pickerController) ResolveKey(key string, base pickerResolveBase) (ports
510 }547 }
511 return ports.BrokerOpenStreamRequest{}, err548 return ports.BrokerOpenStreamRequest{}, err
512 }549 }
513- request, err := p.catalogue.Resolve(key, base)550+ request, _, err := p.ResolveKeyTarget(key, base)
551+ return request, err
552+}
553+
554+// ResolveKeyTarget is ResolveKey plus the exact tab the committed row names.
555+func (p *pickerController) ResolveKeyTarget(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) {
556+ if p == nil || p.catalogue == nil {
557+ err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"}
558+ if p != nil {
559+ p.offerNotice("picker-refusal", err.Error())
560+ }
561+ return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err
562+ }
563+ request, tab, err := p.catalogue.ResolveTarget(key, base)
514 if err != nil {564 if err != nil {
515- p.offerNotice("picker-refusal", err.Error())565+ p.offerNotice("picker-refusal", pickerRefusalNotice(err))
516- return ports.BrokerOpenStreamRequest{}, err566+ return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err
517 }567 }
518- return request, nil568+ return request, tab, nil
569+}
570+
571+// ResolveKill revalidates one kill key; a refusal is surfaced as a notice.
572+func (p *pickerController) ResolveKill(key string) (pickerKillTarget, error) {
573+ if p == nil || p.catalogue == nil {
574+ return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"}
575+ }
576+ target, err := p.catalogue.ResolveKill(key)
577+ if err != nil {
578+ p.offerNotice("picker-refusal", pickerRefusalNotice(err))
579+ return pickerKillTarget{}, err
580+ }
581+ return target, nil
582+}
583+
584+// SetCurrent names the attachment the picker is about to be presented over
585+// (or none) and restarts the presentation, so a fresh picker opens with the
586+// cursor on that session's tab and no leftover search, exactly as each daemon
587+// interaction did on main.
588+func (p *pickerController) SetCurrent(current pickerCurrent) {
589+ if p == nil || p.catalogue == nil {
590+ return
591+ }
592+ p.catalogue.SetCurrent(current)
593+ p.mu.Lock()
594+ defer p.mu.Unlock()
595+ if p.catalogue.Epoch() == 0 {
596+ return
597+ }
598+ p.loop = nil
599+ p.rebuildLocked()
600+ p.renderer.invalidate()
601+}
602+
603+// pickerRefusalNotice is the toast text for one resolution refusal.
604+func pickerRefusalNotice(err error) string {
605+ var typed pickerCatalogueError
606+ if errors.As(err, &typed) {
607+ return typed.noticeText()
608+ }
609+ return err.Error()
519 }610 }
520 611
521 // displayedKey names the row under the cursor, whether or not it admits an612 // displayedKey names the row under the cursor, whether or not it admits an
@@ -577,43 +668,54 @@ func (p *pickerController) handleRead(data []byte) bool {
577 }668 }
578 669
579 func (p *pickerController) rebuildLocked() {670 func (p *pickerController) rebuildLocked() {
580- lines, cursor := p.catalogue.projection()671+ recent, grouped := p.catalogue.projections()
581 revision := uint64(p.catalogue.Revision())672 revision := uint64(p.catalogue.Revision())
582- if p.loop == nil {673+ snapshot := func() protocol.PickerSnapshot {
583- p.interaction++674+ return protocol.PickerSnapshot{
584- p.loop = pickerLoopFromSnapshot(protocol.PickerSnapshot{
585 InteractionID: p.interaction,675 InteractionID: p.interaction,
586 SourceID: pickerCatalogueSourceID,676 SourceID: pickerCatalogueSourceID,
587 SourceRevision: revision,677 SourceRevision: revision,
588- Lines: lines,678+ Lines: grouped.Lines,
589- Cursor: cursor,679+ Cursor: grouped.Cursor,
590- }, protocol.PickerIntentNavigation, defaultPickerSort())680+ Recent: recent,
681+ Grouped: grouped,
682+ }
683+ }
684+ if p.loop == nil {
685+ p.interaction++
686+ p.loop = pickerLoopFromSnapshot(snapshot(), protocol.PickerIntentNavigation, defaultPickerSort())
591 return687 return
592 }688 }
593- p.loop.replaceLines(protocol.PickerSnapshot{689+ p.loop.replaceLines(snapshot())
594- InteractionID: p.interaction,
595- SourceID: pickerCatalogueSourceID,
596- SourceRevision: revision,
597- Lines: lines,
598- Cursor: cursor,
599- })
600 }690 }
601 691
602-// offerDiagnosticsLocked surfaces every host that is not a clean, fresh,692+// offerDiagnosticsLocked toasts each remote host failure once per failure
603-// compatible, reachable daemon as a bounded toast. The toast manager keeps one693+// episode (main's notifyNewRemoteFailures). Ordinary observation progress and
604-// entry per host key, so the notices stay bounded regardless of update rate.694+// catalogue aging stay on the rows; a recovered host forgets its episode so a
695+// later outage toasts again.
605 func (p *pickerController) offerDiagnosticsLocked() {696 func (p *pickerController) offerDiagnosticsLocked() {
606- for _, diagnostic := range p.catalogue.diagnostics() {697+ failing := make(map[string]struct{})
607- if diagnostic.Message == "" {698+ for _, failure := range p.catalogue.failures() {
699+ failing[failure.key] = struct{}{}
700+ if noticed, ok := p.noticedFailures[failure.key]; ok && noticed == failure.episode {
608 continue701 continue
609 }702 }
703+ if p.noticedFailures == nil {
704+ p.noticedFailures = make(map[string]uint64)
705+ }
706+ p.noticedFailures[failure.key] = failure.episode
610 p.notices.Show(p.clock.Now(), ui.Toast{707 p.notices.Show(p.clock.Now(), ui.Toast{
611- ID: "picker-host:" + diagnostic.Key,708+ ID: "picker-host:" + failure.key,
612- Message: diagnostic.Origin + ": " + diagnostic.Message,709+ Message: failure.message,
613 Anchor: domain.AnchorCenter,710 Anchor: domain.AnchorCenter,
614 Duration: pickerNoticeLifetime,711 Duration: pickerNoticeLifetime,
615 })712 })
616 }713 }
714+ for key := range p.noticedFailures {
715+ if _, ok := failing[key]; !ok {
716+ delete(p.noticedFailures, key)
717+ }
718+ }
617 }719 }
618 720
619 func (p *pickerController) offerNotice(id, message string) {721 func (p *pickerController) offerNotice(id, message string) {
M internal/usecase/client/picker_controller_test.go +5 -4
@@ -301,13 +301,14 @@ func TestPickerControllerNotOwningInputLeavesReadUnconsumed(t *testing.T) {
301301
302302 func TestPickerControllerKeepsProgressInRowsAndSurfacesFailuresAsNotices(t *testing.T) {
303303 controller, clock := pickerTestController(t)
304- checking := pickerTestLocalObservation(clock.Now(), pickerTestSession("alpha", 1, catalogue_Up))
304+ checking := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now(), pickerTestSession("alpha", 1, catalogue_Up))
305305 checking.Checking = true
306306 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{checking}})
307- require.Empty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "ordinary refreshing progress stays in the host row")
307+ require.Empty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "ordinary refreshing progress stays in the rows")
308308
309- unreachable := pickerTestLocalObservation(clock.Now())
309+ unreachable := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now())
310310 unreachable.Availability = domain.RemoteAvailabilityUnreachable
311+ unreachable.FailureEpisode = 1
311312 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{unreachable}})
312313 require.NotEmpty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "an unavailable host is surfaced as a failure")
313314 }
@@ -589,7 +590,7 @@ func TestPickerControllerApplySnapshotRejectsInvalidSnapshot(t *testing.T) {
589590 // row and surfaces no failure toast.
590591 func TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch(t *testing.T) {
591592 controller, clock := pickerTestController(t)
592- unobserved := pickerTestUnobservedObservation(clock.Now(), pickerTestSession("alpha", 1, catalogue_Up))
593+ unobserved := pickerTestUnobservedObservation(clock.Now())
593594 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{unobserved}})
594595
595596 controller.mu.Lock()
@@ -301,13 +301,14 @@ func TestPickerControllerNotOwningInputLeavesReadUnconsumed(t *testing.T) {
301 301
302 func TestPickerControllerKeepsProgressInRowsAndSurfacesFailuresAsNotices(t *testing.T) {302 func TestPickerControllerKeepsProgressInRowsAndSurfacesFailuresAsNotices(t *testing.T) {
303 controller, clock := pickerTestController(t)303 controller, clock := pickerTestController(t)
304- checking := pickerTestLocalObservation(clock.Now(), pickerTestSession("alpha", 1, catalogue_Up))304+ checking := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now(), pickerTestSession("alpha", 1, catalogue_Up))
305 checking.Checking = true305 checking.Checking = true
306 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{checking}})306 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{checking}})
307- require.Empty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "ordinary refreshing progress stays in the host row")307+ require.Empty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "ordinary refreshing progress stays in the rows")
308 308
309- unreachable := pickerTestLocalObservation(clock.Now())309+ unreachable := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now())
310 unreachable.Availability = domain.RemoteAvailabilityUnreachable310 unreachable.Availability = domain.RemoteAvailabilityUnreachable
311+ unreachable.FailureEpisode = 1
311 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{unreachable}})312 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{unreachable}})
312 require.NotEmpty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "an unavailable host is surfaced as a failure")313 require.NotEmpty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "an unavailable host is surfaced as a failure")
313 }314 }
@@ -589,7 +590,7 @@ func TestPickerControllerApplySnapshotRejectsInvalidSnapshot(t *testing.T) {
589 // row and surfaces no failure toast.590 // row and surfaces no failure toast.
590 func TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch(t *testing.T) {591 func TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch(t *testing.T) {
591 controller, clock := pickerTestController(t)592 controller, clock := pickerTestController(t)
592- unobserved := pickerTestUnobservedObservation(clock.Now(), pickerTestSession("alpha", 1, catalogue_Up))593+ unobserved := pickerTestUnobservedObservation(clock.Now())
593 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{unobserved}})594 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{unobserved}})
594 595
595 controller.mu.Lock()596 controller.mu.Lock()
A internal/usecase/client/picker_parity_test.go +615 -0
@@ -0,0 +1,615 @@
1+package client
2+
3+import (
4+ "strings"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/internal/protocol"
13+ "github.com/bnema/vev/internal/protocol/catalogue"
14+ "github.com/bnema/vev/internal/usecase/ui"
15+)
16+
17+// Picker parity with main (Plan 003 B1-B4, C1). These port the behaviors the
18+// deleted daemon picker tests pinned on main (picker_interaction_test.go,
19+// remote_picker_test.go, picker_lines.go) onto the client-owned catalogue.
20+
21+func pickerTestTab(id, name, detail string, attention bool) catalogue.RemoteCatalogTab {
22+ return catalogue.RemoteCatalogTab{ID: id, Name: name, Detail: detail, Attention: attention}
23+}
24+
25+func pickerTestTabbed(name string, seed byte, state catalogue.RemoteCatalogSessionState, lastUsed uint64, active string, tabs ...catalogue.RemoteCatalogTab) catalogue.RemoteCatalogSession {
26+ session := pickerTestSession(name, seed, state)
27+ session.LastUsedSeq = lastUsed
28+ session.ActiveTabID = active
29+ for i := range tabs {
30+ tabs[i].Index = uint16(i)
31+ }
32+ session.Tabs = tabs
33+ return session
34+}
35+
36+func pickerLinesOfKind(lines []protocol.PickerLine, kind protocol.PickerLineKind) []protocol.PickerLine {
37+ out := make([]protocol.PickerLine, 0)
38+ for _, line := range lines {
39+ if line.Kind == kind {
40+ out = append(out, line)
41+ }
42+ }
43+ return out
44+}
45+
46+// pickerTabKey returns the key of the tab row labelled label that follows the
47+// session header labelled session.
48+func pickerTabKey(t *testing.T, lines []protocol.PickerLine, session, label string) string {
49+ t.Helper()
50+ inSession := false
51+ for _, line := range lines {
52+ switch line.Kind {
53+ case protocol.PickerLineSession, protocol.PickerLineHost, protocol.PickerLineSection:
54+ inSession = line.Kind == protocol.PickerLineSession && line.Label == session
55+ case protocol.PickerLineTab:
56+ if inSession && line.Label == label {
57+ return line.Key
58+ }
59+ }
60+ }
61+ t.Fatalf("no tab %q under session %q", label, session)
62+ return ""
63+}
64+
65+// TestPickerCatalogueTabRows ports main's picker_lines.go tab rows: a session
66+// with tabs is a non-focusable header naming its tabs; each tab row is a
67+// destination with its pane-title detail and its own bell, and the header
68+// rings when any tab does.
69+func TestPickerCatalogueTabRows(t *testing.T) {
70+ now := time.Unix(1000, 0)
71+ navigateKill := protocol.PickerCanNavigate | protocol.PickerCanKill
72+ tests := []struct {
73+ name string
74+ daemon ports.BrokerDaemonObservation
75+ session string
76+ wantHeaderBel bool
77+ wantTabs []protocol.PickerLine // Label, Detail, Attention, Actions, Stopped
78+ }{
79+ {
80+ name: "live local session",
81+ daemon: pickerTestLocalObservation(now, pickerTestTabbed("work", 1, catalogue_Up, 3, "t2",
82+ pickerTestTab("t1", "shell", "(zsh)", false),
83+ pickerTestTab("t2", "build", "(make)", true),
84+ pickerTestTab("t3", "", "", false),
85+ )),
86+ session: "work",
87+ wantHeaderBel: true,
88+ wantTabs: []protocol.PickerLine{
89+ {Label: "shell", Detail: "(zsh)", Actions: navigateKill},
90+ {Label: "build", Detail: "(make)", Attention: true, Actions: navigateKill},
91+ {Label: "3", Actions: navigateKill},
92+ },
93+ },
94+ {
95+ name: "stopped local session keeps its tabs",
96+ daemon: pickerTestLocalObservation(now, pickerTestTabbed("old", 2, catalogue_Down, 1, "",
97+ pickerTestTab("t1", "notes", "", false),
98+ )),
99+ session: "old",
100+ wantTabs: []protocol.PickerLine{
101+ {Label: "notes", Actions: navigateKill, Stopped: true},
102+ },
103+ },
104+ {
105+ name: "remote tabs navigate but never kill",
106+ daemon: pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 3, catalogue_Up, 0, "r1",
107+ pickerTestTab("r1", "logs", "(tail)", false),
108+ pickerTestTab("", "anon", "", false),
109+ )),
110+ session: "remote-a",
111+ wantTabs: []protocol.PickerLine{
112+ {Label: "logs", Detail: "(tail)", Actions: protocol.PickerCanNavigate},
113+ // A live tab without a stable ID cannot be targeted exactly.
114+ {Label: "anon"},
115+ },
116+ },
117+ }
118+ for _, tt := range tests {
119+ t.Run(tt.name, func(t *testing.T) {
120+ catalogue, _ := pickerTestCatalogue(t)
121+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{tt.daemon}}))
122+ for _, lines := range [][]protocol.PickerLine{catalogue.Lines(), catalogue.RecentLines()} {
123+ header, ok := pickerLineByLabel(lines, tt.session)
124+ require.True(t, ok)
125+ require.Equal(t, protocol.PickerLineSession, header.Kind)
126+ require.False(t, header.Focusable, "a tabbed session header is not a destination")
127+ require.Zero(t, header.Actions)
128+ require.Equal(t, tt.wantHeaderBel, header.Attention, "the header rings when any tab does")
129+
130+ tabs := pickerLinesOfKind(lines, protocol.PickerLineTab)
131+ require.Len(t, tabs, len(tt.wantTabs))
132+ for i, want := range tt.wantTabs {
133+ got := tabs[i]
134+ require.Equal(t, want.Label, got.Label)
135+ require.Equal(t, want.Detail, got.Detail)
136+ require.Equal(t, want.Attention, got.Attention)
137+ require.Equal(t, want.Actions, got.Actions, "tab %q actions", got.Label)
138+ require.Equal(t, want.Stopped, got.Stopped)
139+ require.True(t, got.Focusable, "every tab row is reachable")
140+ require.NotEqual(t, header.Key, got.Key)
141+ }
142+ }
143+ })
144+ }
145+}
146+
147+// TestPickerCatalogueResolvesTabTargets pins exact tab targets: live and
148+// stopped-local tabs prefer their stable tab, a stopped remote tab restores
149+// through its exact selector (stable ID, else ordinal), and a tab that left
150+// the session is gone.
151+func TestPickerCatalogueResolvesTabTargets(t *testing.T) {
152+ now := time.Unix(1000, 0)
153+ lifecycle := pickerTestLifecycle(3)
154+ tests := []struct {
155+ name string
156+ daemon ports.BrokerDaemonObservation
157+ session string
158+ tab string
159+ after *ports.BrokerDaemonObservation
160+ want attachmentTab
161+ wantErr pickerCatalogueErrorCode
162+ }{
163+ {
164+ name: "live local tab",
165+ daemon: pickerTestLocalObservation(now, pickerTestTabbed("work", 3, catalogue_Up, 1, "t1", pickerTestTab("t1", "shell", "", false), pickerTestTab("t2", "build", "", false))),
166+ session: "work", tab: "build",
167+ want: attachmentTab{preferred: "t2"},
168+ },
169+ {
170+ name: "stopped local tab",
171+ daemon: pickerTestLocalObservation(now, pickerTestTabbed("work", 3, catalogue_Down, 1, "", pickerTestTab("t1", "shell", "", false), pickerTestTab("t2", "build", "", false))),
172+ session: "work", tab: "build",
173+ want: attachmentTab{preferred: "t2"},
174+ },
175+ {
176+ name: "live remote tab",
177+ daemon: pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 3, catalogue_Up, 0, "r1", pickerTestTab("r1", "logs", "", false), pickerTestTab("r2", "edit", "", false))),
178+ session: "remote-a", tab: "edit",
179+ want: attachmentTab{preferred: "r2"},
180+ },
181+ {
182+ name: "stopped remote tab by stable id",
183+ daemon: pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 3, catalogue_Down, 0, "", pickerTestTab("r1", "logs", "", false), pickerTestTab("r2", "edit", "", false))),
184+ session: "remote-a", tab: "edit",
185+ want: attachmentTab{stopped: &protocol.SessionAttachTarget{LifecycleID: lifecycle, SessionName: "remote-a", TabID: "r2", TabIndex: protocol.NoTabIndex, Stopped: true}},
186+ },
187+ {
188+ name: "stopped remote tab by ordinal",
189+ daemon: pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 3, catalogue_Down, 0, "", pickerTestTab("", "logs", "", false), pickerTestTab("", "edit", "", false))),
190+ session: "remote-a", tab: "edit",
191+ want: attachmentTab{stopped: &protocol.SessionAttachTarget{LifecycleID: lifecycle, SessionName: "remote-a", TabIndex: 1, TabRawName: "edit", TabExpectedCount: 2, Stopped: true}},
192+ },
193+ {
194+ name: "tab closed after projection",
195+ daemon: pickerTestLocalObservation(now, pickerTestTabbed("work", 3, catalogue_Up, 1, "t1", pickerTestTab("t1", "shell", "", false), pickerTestTab("t2", "build", "", false))),
196+ session: "work", tab: "build",
197+ after: func() *ports.BrokerDaemonObservation {
198+ o := pickerTestLocalObservation(now, pickerTestTabbed("work", 3, catalogue_Up, 1, "t1", pickerTestTab("t1", "shell", "", false)))
199+ return &o
200+ }(),
201+ wantErr: pickerCatalogueGone,
202+ },
203+ }
204+ for _, tt := range tests {
205+ t.Run(tt.name, func(t *testing.T) {
206+ catalogue, _ := pickerTestCatalogue(t)
207+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{tt.daemon}}))
208+ key := pickerTabKey(t, catalogue.Lines(), tt.session, tt.tab)
209+ if tt.after != nil {
210+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{*tt.after}}))
211+ }
212+ request, tab, err := catalogue.ResolveTarget(key, pickerTestBase())
213+ if tt.wantErr != 0 {
214+ require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err)
215+ return
216+ }
217+ require.NoError(t, err)
218+ require.Equal(t, ports.BrokerAdmissionExact, request.Admission)
219+ require.Equal(t, lifecycle, request.Target.LifecycleID)
220+ require.Equal(t, tt.want, tab)
221+ })
222+ }
223+}
224+
225+// TestPickerCatalogueRecentAndGroupedProjections ports main's
226+// TestPickerSnapshotPublishesDistinctRecentAndGroupedProjections plus the MRU
227+// order of picker.go: Recent is flat (live by recency, then remote, then
228+// stopped by recency); Grouped is sectioned per daemon; both carry the same
229+// destinations and actions.
230+func TestPickerCatalogueRecentAndGroupedProjections(t *testing.T) {
231+ now := time.Unix(1000, 0)
232+ local := pickerTestLocalObservation(now,
233+ pickerTestTabbed("alpha", 1, catalogue_Up, 5, "", pickerTestTab("a1", "sh", "", false)),
234+ pickerTestTabbed("beta", 2, catalogue_Up, 9, "", pickerTestTab("b1", "sh", "", false)),
235+ pickerTestTabbed("gamma", 3, catalogue_Down, 7, ""),
236+ pickerTestTabbed("delta", 4, catalogue_Down, 8, ""),
237+ )
238+ remote := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 5, catalogue_Up, 99, "", pickerTestTab("r1", "sh", "", false)))
239+ catalogue, _ := pickerTestCatalogue(t)
240+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{local, remote}}))
241+ recent, grouped := catalogue.projections()
242+
243+ tests := []struct {
244+ name string
245+ lines []protocol.PickerLine
246+ wantSessions []string
247+ wantSections []string
248+ }{
249+ {name: "recent", lines: recent.Lines, wantSessions: []string{"beta", "alpha", "remote-a", "delta", "gamma"}, wantSections: []string{}},
250+ {name: "grouped", lines: grouped.Lines, wantSessions: []string{"beta", "alpha", "delta", "gamma", "remote-a"}, wantSections: []string{"local", "user@arch"}},
251+ }
252+ for _, tt := range tests {
253+ t.Run(tt.name, func(t *testing.T) {
254+ require.Equal(t, tt.wantSessions, pickerSessionLabels(tt.lines))
255+ require.Equal(t, tt.wantSections, pickerSectionLabels(tt.lines))
256+ })
257+ }
258+ actions := func(lines []protocol.PickerLine) map[string]protocol.PickerLineActions {
259+ out := make(map[string]protocol.PickerLineActions)
260+ for _, line := range lines {
261+ if line.Key != "" {
262+ out[line.Key] = line.Actions
263+ }
264+ }
265+ return out
266+ }
267+ require.Equal(t, actions(recent.Lines), actions(grouped.Lines), "projections disagree on destination actions")
268+ require.NotEqual(t, recent.Lines, grouped.Lines, "both projections carry the same sort input")
269+ remoteRecent, ok := pickerLineByLabel(recent.Lines, "remote-a")
270+ require.True(t, ok)
271+ require.Contains(t, remoteRecent.Detail, "@user@arch", "a flat remote row names its origin")
272+ require.NoError(t, protocol.ValidatePickerSnapshot(protocol.PickerSnapshot{InteractionID: 1, SourceID: pickerCatalogueSourceID, SourceRevision: 1, Status: protocol.PickerSourceOK, Recent: recent, Grouped: grouped}))
273+}
274+
275+// TestPickerCatalogueCursorStartsOnCurrent ports main's pickerSelectionMatches
276+// and default cursor: the attached tab, else the attached session's active
277+// tab, else the most recent session's active tab.
278+func TestPickerCatalogueCursorStartsOnCurrent(t *testing.T) {
279+ now := time.Unix(1000, 0)
280+ local := pickerTestLocalObservation(now,
281+ pickerTestTabbed("alpha", 1, catalogue_Up, 5, "a2", pickerTestTab("a1", "one", "", false), pickerTestTab("a2", "two", "", false)),
282+ pickerTestTabbed("beta", 2, catalogue_Up, 9, "b2", pickerTestTab("b1", "one", "", false), pickerTestTab("b2", "two", "", false)),
283+ )
284+ remote := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 5, catalogue_Up, 0, "r1", pickerTestTab("r1", "one", "", false), pickerTestTab("r2", "two", "", false)))
285+ tests := []struct {
286+ name string
287+ current pickerCurrent
288+ wantSession string
289+ wantTab string
290+ }{
291+ {name: "no attachment: most recent active tab", wantSession: "beta", wantTab: "two"},
292+ {name: "attached tab", current: pickerCurrent{known: true, local: true, lifecycle: pickerTestLifecycle(1), tab: "a1"}, wantSession: "alpha", wantTab: "one"},
293+ {name: "attached session, unknown tab", current: pickerCurrent{known: true, local: true, lifecycle: pickerTestLifecycle(1)}, wantSession: "alpha", wantTab: "two"},
294+ {name: "attached remote tab", current: pickerCurrent{known: true, endpoint: "user@arch", lifecycle: pickerTestLifecycle(5), tab: "r2"}, wantSession: "remote-a", wantTab: "two"},
295+ {name: "other endpoint never matches", current: pickerCurrent{known: true, endpoint: "user@other", lifecycle: pickerTestLifecycle(5), tab: "r2"}, wantSession: "beta", wantTab: "two"},
296+ }
297+ for _, tt := range tests {
298+ t.Run(tt.name, func(t *testing.T) {
299+ catalogue, _ := pickerTestCatalogue(t)
300+ catalogue.SetCurrent(tt.current)
301+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{local, remote}}))
302+ recent, grouped := catalogue.projections()
303+ for _, projection := range []protocol.PickerProjection{recent, grouped} {
304+ want := pickerTabKey(t, projection.Lines, tt.wantSession, tt.wantTab)
305+ require.Equal(t, want, projection.Cursor.Key)
306+ require.Equal(t, want, projection.Lines[projection.Cursor.Index].Key)
307+ }
308+ })
309+ }
310+}
311+
312+// TestPickerControllerOverlayOpensOnCurrentTab pins that SetCurrent restarts
313+// the presentation on the attached tab and drops a leftover search.
314+func TestPickerControllerOverlayOpensOnCurrentTab(t *testing.T) {
315+ controller, clock := pickerTestController(t)
316+ controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(clock.Now(),
317+ pickerTestTabbed("alpha", 1, catalogue_Up, 1, "a1", pickerTestTab("a1", "one", "", false), pickerTestTab("a2", "two", "", false)),
318+ pickerTestTabbed("beta", 2, catalogue_Up, 9, "b1", pickerTestTab("b1", "one", "", false)),
319+ )}})
320+ require.True(t, controller.ConsumeTerminalRead([]byte("/on")))
321+ require.True(t, controller.modelSearchActive(t))
322+
323+ controller.SetCurrent(pickerCurrent{known: true, local: true, lifecycle: pickerTestLifecycle(1), tab: "a2"})
324+ require.False(t, controller.modelSearchActive(t), "a fresh overlay starts without the previous search")
325+ require.Equal(t, pickerTabKey(t, controller.Catalogue().RecentLines(), "alpha", "two"), mustCursorKey(t, controller))
326+}
327+
328+// TestPickerControllerSearchesTabNamesAndPaneTitles pins that tab rows are
329+// searchable by their tab name and their pane-title detail.
330+func TestPickerControllerSearchesTabNamesAndPaneTitles(t *testing.T) {
331+ tests := []struct {
332+ name string
333+ query string
334+ want string
335+ }{
336+ {name: "tab name", query: "deploy", want: "deploy"},
337+ {name: "pane title", query: "htop", want: "monitor"},
338+ }
339+ for _, tt := range tests {
340+ t.Run(tt.name, func(t *testing.T) {
341+ controller, clock := pickerTestController(t)
342+ controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(clock.Now(),
343+ pickerTestTabbed("work", 1, catalogue_Up, 1, "w1",
344+ pickerTestTab("w1", "shell", "(zsh)", false),
345+ pickerTestTab("w2", "deploy", "(ansible)", false),
346+ pickerTestTab("w3", "monitor", "(htop)", false),
347+ ),
348+ )}})
349+ require.True(t, controller.ConsumeTerminalRead([]byte("/"+tt.query)))
350+ require.Equal(t, pickerTabKey(t, controller.Catalogue().RecentLines(), "work", tt.want), mustCursorKey(t, controller))
351+ })
352+ }
353+}
354+
355+// TestPickerControllerSortToggleSwitchesProjection pins that `s` switches
356+// between the distinct Recent and Grouped projections.
357+func TestPickerControllerSortToggleSwitchesProjection(t *testing.T) {
358+ controller, clock := pickerTestController(t)
359+ controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
360+ pickerTestLocalObservation(clock.Now(), pickerTestTabbed("alpha", 1, catalogue_Up, 5, ""), pickerTestTabbed("gamma", 3, catalogue_Down, 1, "")),
361+ pickerTestRemoteObservation("user@arch", 1, 1, clock.Now(), pickerTestTabbed("remote-a", 5, catalogue_Up, 0, "")),
362+ }})
363+ order := func() []string {
364+ frame := string(controller.Render(domain.Size{Cols: 100, Rows: 30}))
365+ positions := map[string]int{}
366+ for _, label := range []string{"alpha", "gamma", "remote-a"} {
367+ positions[label] = strings.Index(frame, label)
368+ require.GreaterOrEqual(t, positions[label], 0, "%q is rendered", label)
369+ }
370+ labels := []string{"alpha", "gamma", "remote-a"}
371+ for i := range labels {
372+ for j := i + 1; j < len(labels); j++ {
373+ if positions[labels[j]] < positions[labels[i]] {
374+ labels[i], labels[j] = labels[j], labels[i]
375+ }
376+ }
377+ }
378+ return labels
379+ }
380+ require.Equal(t, []string{"alpha", "remote-a", "gamma"}, order(), "recent: live, remote, stopped")
381+ require.True(t, controller.ConsumeTerminalRead([]byte("s")))
382+ require.Equal(t, []string{"alpha", "gamma", "remote-a"}, order(), "grouped: local section, then remote")
383+ require.True(t, controller.ConsumeTerminalRead([]byte("s")))
384+ require.Equal(t, []string{"alpha", "remote-a", "gamma"}, order())
385+}
386+
387+// TestPickerControllerRendersBellsAndRepaintsOnPublication ports main's
388+// TestPickerSnapshotCarriesAttentionBellsToTheClient and
389+// TestAttentionPulseRepublishesTheOpenPickerSource: a bell raised by a later
390+// publication reaches the rendered picker.
391+func TestPickerControllerRendersBellsAndRepaintsOnPublication(t *testing.T) {
392+ controller, clock := pickerTestController(t)
393+ quiet := pickerTestLocalObservation(clock.Now(), pickerTestTabbed("work", 1, catalogue_Up, 1, "t1", pickerTestTab("t1", "shell", "", false)))
394+ controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{quiet}})
395+ bell := string(ui.AttentionGlyph)
396+ require.NotContains(t, string(controller.Render(domain.Size{Cols: 100, Rows: 30})), bell)
397+
398+ ringing := pickerTestLocalObservation(clock.Now(), pickerTestTabbed("work", 1, catalogue_Up, 1, "t1", pickerTestTab("t1", "shell", "", true)))
399+ controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{ringing}})
400+ controller.invalidatePresentation()
401+ frame := string(controller.Render(domain.Size{Cols: 100, Rows: 30}))
402+ require.Equal(t, 2, strings.Count(frame, bell), "the header and its tab row both ring")
403+}
404+
405+// TestPickerCatalogueResolveKill pins which rows `x` may destroy: local live
406+// and stopped sessions (by exact lifecycle and name), never a remote row, and
407+// never a lifecycle that left or was replaced.
408+func TestPickerCatalogueResolveKill(t *testing.T) {
409+ now := time.Unix(1000, 0)
410+ tests := []struct {
411+ name string
412+ daemon ports.BrokerDaemonObservation
413+ label string
414+ after *ports.BrokerDaemonObservation
415+ wantStopped bool
416+ wantErr pickerCatalogueErrorCode
417+ }{
418+ {name: "live local", daemon: pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), label: "alpha"},
419+ {name: "stopped local deletes history", daemon: pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Down)), label: "alpha", wantStopped: true},
420+ {name: "broken local deletes history", daemon: pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Broken)), label: "alpha", wantStopped: true},
421+ {name: "remote is refused", daemon: pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("alpha", 1, catalogue_Up)), label: "alpha", wantErr: pickerCatalogueUnavailable},
422+ {
423+ name: "gone", daemon: pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), label: "alpha",
424+ after: func() *ports.BrokerDaemonObservation { o := pickerTestLocalObservation(now); return &o }(),
425+ wantErr: pickerCatalogueGone,
426+ },
427+ {
428+ name: "replaced by the same name", daemon: pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), label: "alpha",
429+ after: func() *ports.BrokerDaemonObservation {
430+ o := pickerTestLocalObservation(now, pickerTestSession("alpha", 2, catalogue_Up))
431+ return &o
432+ }(),
433+ wantErr: pickerCatalogueReplaced,
434+ },
435+ }
436+ for _, tt := range tests {
437+ t.Run(tt.name, func(t *testing.T) {
438+ catalogue, _ := pickerTestCatalogue(t)
439+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{tt.daemon}}))
440+ line, ok := pickerLineByLabel(catalogue.Lines(), tt.label)
441+ require.True(t, ok)
442+ if tt.after != nil {
443+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{*tt.after}}))
444+ }
445+ target, err := catalogue.ResolveKill(line.Key)
446+ if tt.wantErr != 0 {
447+ require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err)
448+ return
449+ }
450+ require.NoError(t, err)
451+ require.Equal(t, BrokerOperationRoute{Epoch: 3, Destination: ports.BrokerEndpointFence{Local: true}}, target.route)
452+ require.NoError(t, target.route.Validate())
453+ require.Equal(t, tt.label, target.name)
454+ require.Equal(t, tt.wantStopped, target.stopped)
455+ })
456+ }
457+}
458+
459+// TestPickerControllerKillKeyWakesSupervisor pins that `x` wakes the
460+// supervisor with the killed row's key, only on a row that authorises it, and
461+// types into an active search instead.
462+func TestPickerControllerKillKeyWakesSupervisor(t *testing.T) {
463+ tests := []struct {
464+ name string
465+ daemon func(time.Time) ports.BrokerDaemonObservation
466+ input string
467+ wantKill bool
468+ wantKey bool
469+ }{
470+ {name: "local row", daemon: func(now time.Time) ports.BrokerDaemonObservation {
471+ return pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up))
472+ }, input: "x", wantKill: true, wantKey: true},
473+ {name: "remote row authorises no kill", daemon: func(now time.Time) ports.BrokerDaemonObservation {
474+ return pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("alpha", 1, catalogue_Up))
475+ }, input: "x", wantKill: true},
476+ {name: "search types x", daemon: func(now time.Time) ports.BrokerDaemonObservation {
477+ return pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up))
478+ }, input: "/x"},
479+ }
480+ for _, tt := range tests {
481+ t.Run(tt.name, func(t *testing.T) {
482+ controller, clock := pickerTestController(t)
483+ controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{tt.daemon(clock.Now())}})
484+ require.True(t, controller.ConsumeTerminalRead([]byte(tt.input)))
485+ if tt.wantKill {
486+ select {
487+ case <-controller.OpsReady():
488+ default:
489+ t.Fatal("a kill decision must wake the supervisor")
490+ }
491+ }
492+ op, key := controller.TakeOp()
493+ require.Equal(t, tt.wantKill, op.kill)
494+ if tt.wantKey {
495+ require.Equal(t, pickerRowKeyByLabel(t, controller, "alpha"), key)
496+ } else {
497+ require.Empty(t, key)
498+ }
499+ })
500+ }
501+}
502+
503+// TestPickerControllerHostFailureToastOncePerEpisode ports main's
504+// TestRemoteFailureNoticeEmittedOncePerFailureEpisode and
505+// TestRemoteFailureNoticesKeepEndpointsDistinct.
506+func TestPickerControllerHostFailureToastOncePerEpisode(t *testing.T) {
507+ controller, clock := pickerTestController(t)
508+ failing := func(endpoint string, seed byte, episode uint64, kind domain.RemoteFailureKind) ports.BrokerDaemonObservation {
509+ o := pickerTestRemoteObservation(endpoint, seed, seed, clock.Now())
510+ o.Availability = domain.RemoteAvailabilityUnreachable
511+ o.ConsecutiveFailures = 1
512+ o.FailureEpisode = episode
513+ o.LastFailure = domain.RemoteFailure{Kind: kind}
514+ return o
515+ }
516+ revision := ports.BrokerRevision(0)
517+ apply := func(daemons ...ports.BrokerDaemonObservation) []ui.ActiveToast {
518+ revision++
519+ controller.mu.Lock()
520+ controller.notices.Clear()
521+ controller.mu.Unlock()
522+ controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: revision, Daemons: daemons})
523+ controller.mu.Lock()
524+ defer controller.mu.Unlock()
525+ return controller.notices.Active(clock.Now())
526+ }
527+
528+ toasts := apply(failing("user@arch", 1, 1, domain.RemoteFailureTrust))
529+ require.Len(t, toasts, 1)
530+ require.Contains(t, toasts[0].Message, "SSH host verification failed")
531+ require.Contains(t, toasts[0].Message, "user@arch")
532+ require.Empty(t, apply(failing("user@arch", 1, 1, domain.RemoteFailureTrust)), "the same episode toasts once")
533+ require.Len(t, apply(failing("user@arch", 1, 2, domain.RemoteFailureTrust)), 1, "a new episode toasts again")
534+
535+ recovered := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now().Add(time.Second))
536+ recovered.FailureEpisode = 2
537+ require.Empty(t, apply(recovered))
538+ require.Len(t, apply(failing("user@arch", 1, 3, domain.RemoteFailureTimeout)), 1, "an outage after recovery toasts again")
539+
540+ toasts = apply(failing("user@arch", 1, 3, domain.RemoteFailureTimeout), failing("user@mule", 2, 1, domain.RemoteFailureAuthentication))
541+ require.Len(t, toasts, 1, "only the newly failing endpoint toasts")
542+ require.Contains(t, toasts[0].Message, "user@mule")
543+}
544+
545+// TestPickerControllerRefusesFailingRemoteInstantly pins B4: a commit on a
546+// remote host the broker observed failing refuses at once with main's notice
547+// instead of dialing, while a stale-but-reachable host stays attemptable.
548+func TestPickerControllerRefusesFailingRemoteInstantly(t *testing.T) {
549+ tests := []struct {
550+ name string
551+ availability domain.RemoteAvailability
552+ wantNotice string
553+ }{
554+ {name: "unreachable", availability: domain.RemoteAvailabilityUnreachable, wantNotice: "Remote session unavailable: remote-a@user@arch — host unreachable"},
555+ {name: "authentication", availability: domain.RemoteAvailabilityAuthFailed, wantNotice: "Remote session unavailable: remote-a@user@arch — authentication failed"},
556+ {name: "reachable", availability: domain.RemoteAvailabilityReachable},
557+ }
558+ for _, tt := range tests {
559+ t.Run(tt.name, func(t *testing.T) {
560+ controller, clock := pickerTestController(t)
561+ o := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now().Add(-time.Hour), pickerTestSession("remote-a", 3, catalogue_Up))
562+ o.Availability = tt.availability
563+ controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{o}})
564+ controller.mu.Lock()
565+ controller.notices.Clear()
566+ controller.mu.Unlock()
567+
568+ _, _, err := controller.ResolveKeyTarget(pickerRowKeyByLabel(t, controller, "remote-a"), pickerTestBase())
569+ if tt.wantNotice == "" {
570+ require.NoError(t, err)
571+ return
572+ }
573+ require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueUnavailable))
574+ controller.mu.Lock()
575+ active := controller.notices.Active(clock.Now())
576+ controller.mu.Unlock()
577+ require.Len(t, active, 1)
578+ require.Equal(t, tt.wantNotice, active[0].Message)
579+ })
580+ }
581+}
582+
583+// TestSessionAttachmentHelloCarriesTab pins that a committed tab reaches the
584+// daemon: a stable tab is preferred, a stopped remote tab is restored through
585+// its exact selector, and a session-level commit carries neither.
586+func TestSessionAttachmentHelloCarriesTab(t *testing.T) {
587+ stopped := protocol.SessionAttachTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "alpha", TabIndex: 1, TabRawName: "edit", TabExpectedCount: 2, Stopped: true}
588+ tests := []struct {
589+ name string
590+ local bool
591+ tab attachmentTab
592+ wantPreferred domain.TabStableID
593+ wantTarget *protocol.SessionAttachTarget
594+ }{
595+ {name: "session level", local: true},
596+ {name: "local tab", local: true, tab: attachmentTab{preferred: "t_two"}, wantPreferred: "t_two"},
597+ {name: "remote live tab", tab: attachmentTab{preferred: "t_two"}, wantPreferred: "t_two"},
598+ {name: "remote stopped tab", tab: attachmentTab{stopped: &stopped}, wantTarget: &stopped},
599+ }
600+ for _, tt := range tests {
601+ t.Run(tt.name, func(t *testing.T) {
602+ request := sessionTestRequest(tt.local)
603+ provenance := SessionEnvironmentRemote
604+ if tt.local {
605+ provenance = SessionEnvironmentLocalPicker
606+ }
607+ worker, err := newSessionAttachmentWorker(sessionAttachmentConfig{Request: request, Geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, SessionEnvironment: SessionEnvironment{Provenance: provenance}, Tab: tt.tab})
608+ require.NoError(t, err)
609+ hello := worker.hello(newSessionTestStream())
610+ require.Equal(t, tt.wantPreferred, hello.PreferredTabID)
611+ require.Equal(t, tt.wantTarget, hello.SessionTarget)
612+ require.NoError(t, protocol.ValidateHello(hello))
613+ })
614+ }
615+}
@@ -0,0 +1,615 @@
1+package client
2+
3+import (
4+ "strings"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/internal/protocol"
13+ "github.com/bnema/vev/internal/protocol/catalogue"
14+ "github.com/bnema/vev/internal/usecase/ui"
15+)
16+
17+// Picker parity with main (Plan 003 B1-B4, C1). These port the behaviors the
18+// deleted daemon picker tests pinned on main (picker_interaction_test.go,
19+// remote_picker_test.go, picker_lines.go) onto the client-owned catalogue.
20+
21+func pickerTestTab(id, name, detail string, attention bool) catalogue.RemoteCatalogTab {
22+ return catalogue.RemoteCatalogTab{ID: id, Name: name, Detail: detail, Attention: attention}
23+}
24+
25+func pickerTestTabbed(name string, seed byte, state catalogue.RemoteCatalogSessionState, lastUsed uint64, active string, tabs ...catalogue.RemoteCatalogTab) catalogue.RemoteCatalogSession {
26+ session := pickerTestSession(name, seed, state)
27+ session.LastUsedSeq = lastUsed
28+ session.ActiveTabID = active
29+ for i := range tabs {
30+ tabs[i].Index = uint16(i)
31+ }
32+ session.Tabs = tabs
33+ return session
34+}
35+
36+func pickerLinesOfKind(lines []protocol.PickerLine, kind protocol.PickerLineKind) []protocol.PickerLine {
37+ out := make([]protocol.PickerLine, 0)
38+ for _, line := range lines {
39+ if line.Kind == kind {
40+ out = append(out, line)
41+ }
42+ }
43+ return out
44+}
45+
46+// pickerTabKey returns the key of the tab row labelled label that follows the
47+// session header labelled session.
48+func pickerTabKey(t *testing.T, lines []protocol.PickerLine, session, label string) string {
49+ t.Helper()
50+ inSession := false
51+ for _, line := range lines {
52+ switch line.Kind {
53+ case protocol.PickerLineSession, protocol.PickerLineHost, protocol.PickerLineSection:
54+ inSession = line.Kind == protocol.PickerLineSession && line.Label == session
55+ case protocol.PickerLineTab:
56+ if inSession && line.Label == label {
57+ return line.Key
58+ }
59+ }
60+ }
61+ t.Fatalf("no tab %q under session %q", label, session)
62+ return ""
63+}
64+
65+// TestPickerCatalogueTabRows ports main's picker_lines.go tab rows: a session
66+// with tabs is a non-focusable header naming its tabs; each tab row is a
67+// destination with its pane-title detail and its own bell, and the header
68+// rings when any tab does.
69+func TestPickerCatalogueTabRows(t *testing.T) {
70+ now := time.Unix(1000, 0)
71+ navigateKill := protocol.PickerCanNavigate | protocol.PickerCanKill
72+ tests := []struct {
73+ name string
74+ daemon ports.BrokerDaemonObservation
75+ session string
76+ wantHeaderBel bool
77+ wantTabs []protocol.PickerLine // Label, Detail, Attention, Actions, Stopped
78+ }{
79+ {
80+ name: "live local session",
81+ daemon: pickerTestLocalObservation(now, pickerTestTabbed("work", 1, catalogue_Up, 3, "t2",
82+ pickerTestTab("t1", "shell", "(zsh)", false),
83+ pickerTestTab("t2", "build", "(make)", true),
84+ pickerTestTab("t3", "", "", false),
85+ )),
86+ session: "work",
87+ wantHeaderBel: true,
88+ wantTabs: []protocol.PickerLine{
89+ {Label: "shell", Detail: "(zsh)", Actions: navigateKill},
90+ {Label: "build", Detail: "(make)", Attention: true, Actions: navigateKill},
91+ {Label: "3", Actions: navigateKill},
92+ },
93+ },
94+ {
95+ name: "stopped local session keeps its tabs",
96+ daemon: pickerTestLocalObservation(now, pickerTestTabbed("old", 2, catalogue_Down, 1, "",
97+ pickerTestTab("t1", "notes", "", false),
98+ )),
99+ session: "old",
100+ wantTabs: []protocol.PickerLine{
101+ {Label: "notes", Actions: navigateKill, Stopped: true},
102+ },
103+ },
104+ {
105+ name: "remote tabs navigate but never kill",
106+ daemon: pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 3, catalogue_Up, 0, "r1",
107+ pickerTestTab("r1", "logs", "(tail)", false),
108+ pickerTestTab("", "anon", "", false),
109+ )),
110+ session: "remote-a",
111+ wantTabs: []protocol.PickerLine{
112+ {Label: "logs", Detail: "(tail)", Actions: protocol.PickerCanNavigate},
113+ // A live tab without a stable ID cannot be targeted exactly.
114+ {Label: "anon"},
115+ },
116+ },
117+ }
118+ for _, tt := range tests {
119+ t.Run(tt.name, func(t *testing.T) {
120+ catalogue, _ := pickerTestCatalogue(t)
121+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{tt.daemon}}))
122+ for _, lines := range [][]protocol.PickerLine{catalogue.Lines(), catalogue.RecentLines()} {
123+ header, ok := pickerLineByLabel(lines, tt.session)
124+ require.True(t, ok)
125+ require.Equal(t, protocol.PickerLineSession, header.Kind)
126+ require.False(t, header.Focusable, "a tabbed session header is not a destination")
127+ require.Zero(t, header.Actions)
128+ require.Equal(t, tt.wantHeaderBel, header.Attention, "the header rings when any tab does")
129+
130+ tabs := pickerLinesOfKind(lines, protocol.PickerLineTab)
131+ require.Len(t, tabs, len(tt.wantTabs))
132+ for i, want := range tt.wantTabs {
133+ got := tabs[i]
134+ require.Equal(t, want.Label, got.Label)
135+ require.Equal(t, want.Detail, got.Detail)
136+ require.Equal(t, want.Attention, got.Attention)
137+ require.Equal(t, want.Actions, got.Actions, "tab %q actions", got.Label)
138+ require.Equal(t, want.Stopped, got.Stopped)
139+ require.True(t, got.Focusable, "every tab row is reachable")
140+ require.NotEqual(t, header.Key, got.Key)
141+ }
142+ }
143+ })
144+ }
145+}
146+
147+// TestPickerCatalogueResolvesTabTargets pins exact tab targets: live and
148+// stopped-local tabs prefer their stable tab, a stopped remote tab restores
149+// through its exact selector (stable ID, else ordinal), and a tab that left
150+// the session is gone.
151+func TestPickerCatalogueResolvesTabTargets(t *testing.T) {
152+ now := time.Unix(1000, 0)
153+ lifecycle := pickerTestLifecycle(3)
154+ tests := []struct {
155+ name string
156+ daemon ports.BrokerDaemonObservation
157+ session string
158+ tab string
159+ after *ports.BrokerDaemonObservation
160+ want attachmentTab
161+ wantErr pickerCatalogueErrorCode
162+ }{
163+ {
164+ name: "live local tab",
165+ daemon: pickerTestLocalObservation(now, pickerTestTabbed("work", 3, catalogue_Up, 1, "t1", pickerTestTab("t1", "shell", "", false), pickerTestTab("t2", "build", "", false))),
166+ session: "work", tab: "build",
167+ want: attachmentTab{preferred: "t2"},
168+ },
169+ {
170+ name: "stopped local tab",
171+ daemon: pickerTestLocalObservation(now, pickerTestTabbed("work", 3, catalogue_Down, 1, "", pickerTestTab("t1", "shell", "", false), pickerTestTab("t2", "build", "", false))),
172+ session: "work", tab: "build",
173+ want: attachmentTab{preferred: "t2"},
174+ },
175+ {
176+ name: "live remote tab",
177+ daemon: pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 3, catalogue_Up, 0, "r1", pickerTestTab("r1", "logs", "", false), pickerTestTab("r2", "edit", "", false))),
178+ session: "remote-a", tab: "edit",
179+ want: attachmentTab{preferred: "r2"},
180+ },
181+ {
182+ name: "stopped remote tab by stable id",
183+ daemon: pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 3, catalogue_Down, 0, "", pickerTestTab("r1", "logs", "", false), pickerTestTab("r2", "edit", "", false))),
184+ session: "remote-a", tab: "edit",
185+ want: attachmentTab{stopped: &protocol.SessionAttachTarget{LifecycleID: lifecycle, SessionName: "remote-a", TabID: "r2", TabIndex: protocol.NoTabIndex, Stopped: true}},
186+ },
187+ {
188+ name: "stopped remote tab by ordinal",
189+ daemon: pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 3, catalogue_Down, 0, "", pickerTestTab("", "logs", "", false), pickerTestTab("", "edit", "", false))),
190+ session: "remote-a", tab: "edit",
191+ want: attachmentTab{stopped: &protocol.SessionAttachTarget{LifecycleID: lifecycle, SessionName: "remote-a", TabIndex: 1, TabRawName: "edit", TabExpectedCount: 2, Stopped: true}},
192+ },
193+ {
194+ name: "tab closed after projection",
195+ daemon: pickerTestLocalObservation(now, pickerTestTabbed("work", 3, catalogue_Up, 1, "t1", pickerTestTab("t1", "shell", "", false), pickerTestTab("t2", "build", "", false))),
196+ session: "work", tab: "build",
197+ after: func() *ports.BrokerDaemonObservation {
198+ o := pickerTestLocalObservation(now, pickerTestTabbed("work", 3, catalogue_Up, 1, "t1", pickerTestTab("t1", "shell", "", false)))
199+ return &o
200+ }(),
201+ wantErr: pickerCatalogueGone,
202+ },
203+ }
204+ for _, tt := range tests {
205+ t.Run(tt.name, func(t *testing.T) {
206+ catalogue, _ := pickerTestCatalogue(t)
207+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{tt.daemon}}))
208+ key := pickerTabKey(t, catalogue.Lines(), tt.session, tt.tab)
209+ if tt.after != nil {
210+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{*tt.after}}))
211+ }
212+ request, tab, err := catalogue.ResolveTarget(key, pickerTestBase())
213+ if tt.wantErr != 0 {
214+ require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err)
215+ return
216+ }
217+ require.NoError(t, err)
218+ require.Equal(t, ports.BrokerAdmissionExact, request.Admission)
219+ require.Equal(t, lifecycle, request.Target.LifecycleID)
220+ require.Equal(t, tt.want, tab)
221+ })
222+ }
223+}
224+
225+// TestPickerCatalogueRecentAndGroupedProjections ports main's
226+// TestPickerSnapshotPublishesDistinctRecentAndGroupedProjections plus the MRU
227+// order of picker.go: Recent is flat (live by recency, then remote, then
228+// stopped by recency); Grouped is sectioned per daemon; both carry the same
229+// destinations and actions.
230+func TestPickerCatalogueRecentAndGroupedProjections(t *testing.T) {
231+ now := time.Unix(1000, 0)
232+ local := pickerTestLocalObservation(now,
233+ pickerTestTabbed("alpha", 1, catalogue_Up, 5, "", pickerTestTab("a1", "sh", "", false)),
234+ pickerTestTabbed("beta", 2, catalogue_Up, 9, "", pickerTestTab("b1", "sh", "", false)),
235+ pickerTestTabbed("gamma", 3, catalogue_Down, 7, ""),
236+ pickerTestTabbed("delta", 4, catalogue_Down, 8, ""),
237+ )
238+ remote := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 5, catalogue_Up, 99, "", pickerTestTab("r1", "sh", "", false)))
239+ catalogue, _ := pickerTestCatalogue(t)
240+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{local, remote}}))
241+ recent, grouped := catalogue.projections()
242+
243+ tests := []struct {
244+ name string
245+ lines []protocol.PickerLine
246+ wantSessions []string
247+ wantSections []string
248+ }{
249+ {name: "recent", lines: recent.Lines, wantSessions: []string{"beta", "alpha", "remote-a", "delta", "gamma"}, wantSections: []string{}},
250+ {name: "grouped", lines: grouped.Lines, wantSessions: []string{"beta", "alpha", "delta", "gamma", "remote-a"}, wantSections: []string{"local", "user@arch"}},
251+ }
252+ for _, tt := range tests {
253+ t.Run(tt.name, func(t *testing.T) {
254+ require.Equal(t, tt.wantSessions, pickerSessionLabels(tt.lines))
255+ require.Equal(t, tt.wantSections, pickerSectionLabels(tt.lines))
256+ })
257+ }
258+ actions := func(lines []protocol.PickerLine) map[string]protocol.PickerLineActions {
259+ out := make(map[string]protocol.PickerLineActions)
260+ for _, line := range lines {
261+ if line.Key != "" {
262+ out[line.Key] = line.Actions
263+ }
264+ }
265+ return out
266+ }
267+ require.Equal(t, actions(recent.Lines), actions(grouped.Lines), "projections disagree on destination actions")
268+ require.NotEqual(t, recent.Lines, grouped.Lines, "both projections carry the same sort input")
269+ remoteRecent, ok := pickerLineByLabel(recent.Lines, "remote-a")
270+ require.True(t, ok)
271+ require.Contains(t, remoteRecent.Detail, "@user@arch", "a flat remote row names its origin")
272+ require.NoError(t, protocol.ValidatePickerSnapshot(protocol.PickerSnapshot{InteractionID: 1, SourceID: pickerCatalogueSourceID, SourceRevision: 1, Status: protocol.PickerSourceOK, Recent: recent, Grouped: grouped}))
273+}
274+
275+// TestPickerCatalogueCursorStartsOnCurrent ports main's pickerSelectionMatches
276+// and default cursor: the attached tab, else the attached session's active
277+// tab, else the most recent session's active tab.
278+func TestPickerCatalogueCursorStartsOnCurrent(t *testing.T) {
279+ now := time.Unix(1000, 0)
280+ local := pickerTestLocalObservation(now,
281+ pickerTestTabbed("alpha", 1, catalogue_Up, 5, "a2", pickerTestTab("a1", "one", "", false), pickerTestTab("a2", "two", "", false)),
282+ pickerTestTabbed("beta", 2, catalogue_Up, 9, "b2", pickerTestTab("b1", "one", "", false), pickerTestTab("b2", "two", "", false)),
283+ )
284+ remote := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 5, catalogue_Up, 0, "r1", pickerTestTab("r1", "one", "", false), pickerTestTab("r2", "two", "", false)))
285+ tests := []struct {
286+ name string
287+ current pickerCurrent
288+ wantSession string
289+ wantTab string
290+ }{
291+ {name: "no attachment: most recent active tab", wantSession: "beta", wantTab: "two"},
292+ {name: "attached tab", current: pickerCurrent{known: true, local: true, lifecycle: pickerTestLifecycle(1), tab: "a1"}, wantSession: "alpha", wantTab: "one"},
293+ {name: "attached session, unknown tab", current: pickerCurrent{known: true, local: true, lifecycle: pickerTestLifecycle(1)}, wantSession: "alpha", wantTab: "two"},
294+ {name: "attached remote tab", current: pickerCurrent{known: true, endpoint: "user@arch", lifecycle: pickerTestLifecycle(5), tab: "r2"}, wantSession: "remote-a", wantTab: "two"},
295+ {name: "other endpoint never matches", current: pickerCurrent{known: true, endpoint: "user@other", lifecycle: pickerTestLifecycle(5), tab: "r2"}, wantSession: "beta", wantTab: "two"},
296+ }
297+ for _, tt := range tests {
298+ t.Run(tt.name, func(t *testing.T) {
299+ catalogue, _ := pickerTestCatalogue(t)
300+ catalogue.SetCurrent(tt.current)
301+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{local, remote}}))
302+ recent, grouped := catalogue.projections()
303+ for _, projection := range []protocol.PickerProjection{recent, grouped} {
304+ want := pickerTabKey(t, projection.Lines, tt.wantSession, tt.wantTab)
305+ require.Equal(t, want, projection.Cursor.Key)
306+ require.Equal(t, want, projection.Lines[projection.Cursor.Index].Key)
307+ }
308+ })
309+ }
310+}
311+
312+// TestPickerControllerOverlayOpensOnCurrentTab pins that SetCurrent restarts
313+// the presentation on the attached tab and drops a leftover search.
314+func TestPickerControllerOverlayOpensOnCurrentTab(t *testing.T) {
315+ controller, clock := pickerTestController(t)
316+ controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(clock.Now(),
317+ pickerTestTabbed("alpha", 1, catalogue_Up, 1, "a1", pickerTestTab("a1", "one", "", false), pickerTestTab("a2", "two", "", false)),
318+ pickerTestTabbed("beta", 2, catalogue_Up, 9, "b1", pickerTestTab("b1", "one", "", false)),
319+ )}})
320+ require.True(t, controller.ConsumeTerminalRead([]byte("/on")))
321+ require.True(t, controller.modelSearchActive(t))
322+
323+ controller.SetCurrent(pickerCurrent{known: true, local: true, lifecycle: pickerTestLifecycle(1), tab: "a2"})
324+ require.False(t, controller.modelSearchActive(t), "a fresh overlay starts without the previous search")
325+ require.Equal(t, pickerTabKey(t, controller.Catalogue().RecentLines(), "alpha", "two"), mustCursorKey(t, controller))
326+}
327+
328+// TestPickerControllerSearchesTabNamesAndPaneTitles pins that tab rows are
329+// searchable by their tab name and their pane-title detail.
330+func TestPickerControllerSearchesTabNamesAndPaneTitles(t *testing.T) {
331+ tests := []struct {
332+ name string
333+ query string
334+ want string
335+ }{
336+ {name: "tab name", query: "deploy", want: "deploy"},
337+ {name: "pane title", query: "htop", want: "monitor"},
338+ }
339+ for _, tt := range tests {
340+ t.Run(tt.name, func(t *testing.T) {
341+ controller, clock := pickerTestController(t)
342+ controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(clock.Now(),
343+ pickerTestTabbed("work", 1, catalogue_Up, 1, "w1",
344+ pickerTestTab("w1", "shell", "(zsh)", false),
345+ pickerTestTab("w2", "deploy", "(ansible)", false),
346+ pickerTestTab("w3", "monitor", "(htop)", false),
347+ ),
348+ )}})
349+ require.True(t, controller.ConsumeTerminalRead([]byte("/"+tt.query)))
350+ require.Equal(t, pickerTabKey(t, controller.Catalogue().RecentLines(), "work", tt.want), mustCursorKey(t, controller))
351+ })
352+ }
353+}
354+
355+// TestPickerControllerSortToggleSwitchesProjection pins that `s` switches
356+// between the distinct Recent and Grouped projections.
357+func TestPickerControllerSortToggleSwitchesProjection(t *testing.T) {
358+ controller, clock := pickerTestController(t)
359+ controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
360+ pickerTestLocalObservation(clock.Now(), pickerTestTabbed("alpha", 1, catalogue_Up, 5, ""), pickerTestTabbed("gamma", 3, catalogue_Down, 1, "")),
361+ pickerTestRemoteObservation("user@arch", 1, 1, clock.Now(), pickerTestTabbed("remote-a", 5, catalogue_Up, 0, "")),
362+ }})
363+ order := func() []string {
364+ frame := string(controller.Render(domain.Size{Cols: 100, Rows: 30}))
365+ positions := map[string]int{}
366+ for _, label := range []string{"alpha", "gamma", "remote-a"} {
367+ positions[label] = strings.Index(frame, label)
368+ require.GreaterOrEqual(t, positions[label], 0, "%q is rendered", label)
369+ }
370+ labels := []string{"alpha", "gamma", "remote-a"}
371+ for i := range labels {
372+ for j := i + 1; j < len(labels); j++ {
373+ if positions[labels[j]] < positions[labels[i]] {
374+ labels[i], labels[j] = labels[j], labels[i]
375+ }
376+ }
377+ }
378+ return labels
379+ }
380+ require.Equal(t, []string{"alpha", "remote-a", "gamma"}, order(), "recent: live, remote, stopped")
381+ require.True(t, controller.ConsumeTerminalRead([]byte("s")))
382+ require.Equal(t, []string{"alpha", "gamma", "remote-a"}, order(), "grouped: local section, then remote")
383+ require.True(t, controller.ConsumeTerminalRead([]byte("s")))
384+ require.Equal(t, []string{"alpha", "remote-a", "gamma"}, order())
385+}
386+
387+// TestPickerControllerRendersBellsAndRepaintsOnPublication ports main's
388+// TestPickerSnapshotCarriesAttentionBellsToTheClient and
389+// TestAttentionPulseRepublishesTheOpenPickerSource: a bell raised by a later
390+// publication reaches the rendered picker.
391+func TestPickerControllerRendersBellsAndRepaintsOnPublication(t *testing.T) {
392+ controller, clock := pickerTestController(t)
393+ quiet := pickerTestLocalObservation(clock.Now(), pickerTestTabbed("work", 1, catalogue_Up, 1, "t1", pickerTestTab("t1", "shell", "", false)))
394+ controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{quiet}})
395+ bell := string(ui.AttentionGlyph)
396+ require.NotContains(t, string(controller.Render(domain.Size{Cols: 100, Rows: 30})), bell)
397+
398+ ringing := pickerTestLocalObservation(clock.Now(), pickerTestTabbed("work", 1, catalogue_Up, 1, "t1", pickerTestTab("t1", "shell", "", true)))
399+ controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{ringing}})
400+ controller.invalidatePresentation()
401+ frame := string(controller.Render(domain.Size{Cols: 100, Rows: 30}))
402+ require.Equal(t, 2, strings.Count(frame, bell), "the header and its tab row both ring")
403+}
404+
405+// TestPickerCatalogueResolveKill pins which rows `x` may destroy: local live
406+// and stopped sessions (by exact lifecycle and name), never a remote row, and
407+// never a lifecycle that left or was replaced.
408+func TestPickerCatalogueResolveKill(t *testing.T) {
409+ now := time.Unix(1000, 0)
410+ tests := []struct {
411+ name string
412+ daemon ports.BrokerDaemonObservation
413+ label string
414+ after *ports.BrokerDaemonObservation
415+ wantStopped bool
416+ wantErr pickerCatalogueErrorCode
417+ }{
418+ {name: "live local", daemon: pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), label: "alpha"},
419+ {name: "stopped local deletes history", daemon: pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Down)), label: "alpha", wantStopped: true},
420+ {name: "broken local deletes history", daemon: pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Broken)), label: "alpha", wantStopped: true},
421+ {name: "remote is refused", daemon: pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("alpha", 1, catalogue_Up)), label: "alpha", wantErr: pickerCatalogueUnavailable},
422+ {
423+ name: "gone", daemon: pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), label: "alpha",
424+ after: func() *ports.BrokerDaemonObservation { o := pickerTestLocalObservation(now); return &o }(),
425+ wantErr: pickerCatalogueGone,
426+ },
427+ {
428+ name: "replaced by the same name", daemon: pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), label: "alpha",
429+ after: func() *ports.BrokerDaemonObservation {
430+ o := pickerTestLocalObservation(now, pickerTestSession("alpha", 2, catalogue_Up))
431+ return &o
432+ }(),
433+ wantErr: pickerCatalogueReplaced,
434+ },
435+ }
436+ for _, tt := range tests {
437+ t.Run(tt.name, func(t *testing.T) {
438+ catalogue, _ := pickerTestCatalogue(t)
439+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{tt.daemon}}))
440+ line, ok := pickerLineByLabel(catalogue.Lines(), tt.label)
441+ require.True(t, ok)
442+ if tt.after != nil {
443+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{*tt.after}}))
444+ }
445+ target, err := catalogue.ResolveKill(line.Key)
446+ if tt.wantErr != 0 {
447+ require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err)
448+ return
449+ }
450+ require.NoError(t, err)
451+ require.Equal(t, BrokerOperationRoute{Epoch: 3, Destination: ports.BrokerEndpointFence{Local: true}}, target.route)
452+ require.NoError(t, target.route.Validate())
453+ require.Equal(t, tt.label, target.name)
454+ require.Equal(t, tt.wantStopped, target.stopped)
455+ })
456+ }
457+}
458+
459+// TestPickerControllerKillKeyWakesSupervisor pins that `x` wakes the
460+// supervisor with the killed row's key, only on a row that authorises it, and
461+// types into an active search instead.
462+func TestPickerControllerKillKeyWakesSupervisor(t *testing.T) {
463+ tests := []struct {
464+ name string
465+ daemon func(time.Time) ports.BrokerDaemonObservation
466+ input string
467+ wantKill bool
468+ wantKey bool
469+ }{
470+ {name: "local row", daemon: func(now time.Time) ports.BrokerDaemonObservation {
471+ return pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up))
472+ }, input: "x", wantKill: true, wantKey: true},
473+ {name: "remote row authorises no kill", daemon: func(now time.Time) ports.BrokerDaemonObservation {
474+ return pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("alpha", 1, catalogue_Up))
475+ }, input: "x", wantKill: true},
476+ {name: "search types x", daemon: func(now time.Time) ports.BrokerDaemonObservation {
477+ return pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up))
478+ }, input: "/x"},
479+ }
480+ for _, tt := range tests {
481+ t.Run(tt.name, func(t *testing.T) {
482+ controller, clock := pickerTestController(t)
483+ controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{tt.daemon(clock.Now())}})
484+ require.True(t, controller.ConsumeTerminalRead([]byte(tt.input)))
485+ if tt.wantKill {
486+ select {
487+ case <-controller.OpsReady():
488+ default:
489+ t.Fatal("a kill decision must wake the supervisor")
490+ }
491+ }
492+ op, key := controller.TakeOp()
493+ require.Equal(t, tt.wantKill, op.kill)
494+ if tt.wantKey {
495+ require.Equal(t, pickerRowKeyByLabel(t, controller, "alpha"), key)
496+ } else {
497+ require.Empty(t, key)
498+ }
499+ })
500+ }
501+}
502+
503+// TestPickerControllerHostFailureToastOncePerEpisode ports main's
504+// TestRemoteFailureNoticeEmittedOncePerFailureEpisode and
505+// TestRemoteFailureNoticesKeepEndpointsDistinct.
506+func TestPickerControllerHostFailureToastOncePerEpisode(t *testing.T) {
507+ controller, clock := pickerTestController(t)
508+ failing := func(endpoint string, seed byte, episode uint64, kind domain.RemoteFailureKind) ports.BrokerDaemonObservation {
509+ o := pickerTestRemoteObservation(endpoint, seed, seed, clock.Now())
510+ o.Availability = domain.RemoteAvailabilityUnreachable
511+ o.ConsecutiveFailures = 1
512+ o.FailureEpisode = episode
513+ o.LastFailure = domain.RemoteFailure{Kind: kind}
514+ return o
515+ }
516+ revision := ports.BrokerRevision(0)
517+ apply := func(daemons ...ports.BrokerDaemonObservation) []ui.ActiveToast {
518+ revision++
519+ controller.mu.Lock()
520+ controller.notices.Clear()
521+ controller.mu.Unlock()
522+ controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: revision, Daemons: daemons})
523+ controller.mu.Lock()
524+ defer controller.mu.Unlock()
525+ return controller.notices.Active(clock.Now())
526+ }
527+
528+ toasts := apply(failing("user@arch", 1, 1, domain.RemoteFailureTrust))
529+ require.Len(t, toasts, 1)
530+ require.Contains(t, toasts[0].Message, "SSH host verification failed")
531+ require.Contains(t, toasts[0].Message, "user@arch")
532+ require.Empty(t, apply(failing("user@arch", 1, 1, domain.RemoteFailureTrust)), "the same episode toasts once")
533+ require.Len(t, apply(failing("user@arch", 1, 2, domain.RemoteFailureTrust)), 1, "a new episode toasts again")
534+
535+ recovered := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now().Add(time.Second))
536+ recovered.FailureEpisode = 2
537+ require.Empty(t, apply(recovered))
538+ require.Len(t, apply(failing("user@arch", 1, 3, domain.RemoteFailureTimeout)), 1, "an outage after recovery toasts again")
539+
540+ toasts = apply(failing("user@arch", 1, 3, domain.RemoteFailureTimeout), failing("user@mule", 2, 1, domain.RemoteFailureAuthentication))
541+ require.Len(t, toasts, 1, "only the newly failing endpoint toasts")
542+ require.Contains(t, toasts[0].Message, "user@mule")
543+}
544+
545+// TestPickerControllerRefusesFailingRemoteInstantly pins B4: a commit on a
546+// remote host the broker observed failing refuses at once with main's notice
547+// instead of dialing, while a stale-but-reachable host stays attemptable.
548+func TestPickerControllerRefusesFailingRemoteInstantly(t *testing.T) {
549+ tests := []struct {
550+ name string
551+ availability domain.RemoteAvailability
552+ wantNotice string
553+ }{
554+ {name: "unreachable", availability: domain.RemoteAvailabilityUnreachable, wantNotice: "Remote session unavailable: remote-a@user@arch — host unreachable"},
555+ {name: "authentication", availability: domain.RemoteAvailabilityAuthFailed, wantNotice: "Remote session unavailable: remote-a@user@arch — authentication failed"},
556+ {name: "reachable", availability: domain.RemoteAvailabilityReachable},
557+ }
558+ for _, tt := range tests {
559+ t.Run(tt.name, func(t *testing.T) {
560+ controller, clock := pickerTestController(t)
561+ o := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now().Add(-time.Hour), pickerTestSession("remote-a", 3, catalogue_Up))
562+ o.Availability = tt.availability
563+ controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{o}})
564+ controller.mu.Lock()
565+ controller.notices.Clear()
566+ controller.mu.Unlock()
567+
568+ _, _, err := controller.ResolveKeyTarget(pickerRowKeyByLabel(t, controller, "remote-a"), pickerTestBase())
569+ if tt.wantNotice == "" {
570+ require.NoError(t, err)
571+ return
572+ }
573+ require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueUnavailable))
574+ controller.mu.Lock()
575+ active := controller.notices.Active(clock.Now())
576+ controller.mu.Unlock()
577+ require.Len(t, active, 1)
578+ require.Equal(t, tt.wantNotice, active[0].Message)
579+ })
580+ }
581+}
582+
583+// TestSessionAttachmentHelloCarriesTab pins that a committed tab reaches the
584+// daemon: a stable tab is preferred, a stopped remote tab is restored through
585+// its exact selector, and a session-level commit carries neither.
586+func TestSessionAttachmentHelloCarriesTab(t *testing.T) {
587+ stopped := protocol.SessionAttachTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "alpha", TabIndex: 1, TabRawName: "edit", TabExpectedCount: 2, Stopped: true}
588+ tests := []struct {
589+ name string
590+ local bool
591+ tab attachmentTab
592+ wantPreferred domain.TabStableID
593+ wantTarget *protocol.SessionAttachTarget
594+ }{
595+ {name: "session level", local: true},
596+ {name: "local tab", local: true, tab: attachmentTab{preferred: "t_two"}, wantPreferred: "t_two"},
597+ {name: "remote live tab", tab: attachmentTab{preferred: "t_two"}, wantPreferred: "t_two"},
598+ {name: "remote stopped tab", tab: attachmentTab{stopped: &stopped}, wantTarget: &stopped},
599+ }
600+ for _, tt := range tests {
601+ t.Run(tt.name, func(t *testing.T) {
602+ request := sessionTestRequest(tt.local)
603+ provenance := SessionEnvironmentRemote
604+ if tt.local {
605+ provenance = SessionEnvironmentLocalPicker
606+ }
607+ worker, err := newSessionAttachmentWorker(sessionAttachmentConfig{Request: request, Geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, SessionEnvironment: SessionEnvironment{Provenance: provenance}, Tab: tt.tab})
608+ require.NoError(t, err)
609+ hello := worker.hello(newSessionTestStream())
610+ require.Equal(t, tt.wantPreferred, hello.PreferredTabID)
611+ require.Equal(t, tt.wantTarget, hello.SessionTarget)
612+ require.NoError(t, protocol.ValidateHello(hello))
613+ })
614+ }
615+}
M internal/usecase/client/supervisor.go +16 -1
@@ -409,7 +409,9 @@ type Supervisor struct {
409409 // pendingSwap is the request the picker overlay committed to another
410410 // target while an attachment was live. It is only touched from the run
411411 // goroutine and consumed by runResolvedAttachment.
412- pendingSwap *ports.BrokerOpenStreamRequest
412+ pendingSwap *pickerAttachmentTarget
413+ // kills runs the picker's `x` operations off the run goroutine.
414+ kills pickerKills
413415 }
414416
415417 // NewSupervisor validates the required dependencies and returns a supervisor
@@ -508,6 +510,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) {
508510 sub ports.BrokerSubscription
509511 )
510512 retire := func() {
513+ s.retirePickerKill()
511514 s.preview.close(s.cfg.Picker)
512515 s.readySub = nil
513516 if sub != nil {
@@ -825,8 +828,15 @@ func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetim
825828 if s.preview.publish(s.cfg.Picker) {
826829 s.renderCurrent()
827830 }
831+ case outcome := <-s.kills.results():
832+ s.finishPickerKill(service, outcome)
828833 case <-ops:
829834 op, key := s.cfg.Picker.TakeOp()
835+ if op.kill && !op.commit && !op.close {
836+ s.startPickerKill(service, key)
837+ s.renderCurrent()
838+ continue
839+ }
830840 if !op.close && !op.commit {
831841 s.refreshPreview(service)
832842 s.renderCurrent()
@@ -892,6 +902,11 @@ func (s *Supervisor) takePickerClose() bool {
892902 s.offerPickerNotice("picker-exit-hint", "no session attached: press Ctrl+C to quit")
893903 s.renderCurrent()
894904 }
905+ if op.kill && !op.commit && !op.close && key != "" {
906+ // A kill needs the broker's control stream; it is refused, not queued.
907+ s.offerPickerNotice("picker-kill", "couldn't kill: broker unavailable")
908+ s.renderCurrent()
909+ }
895910 return op.close && op.exit
896911 }
897912
@@ -409,7 +409,9 @@ type Supervisor struct {
409 // pendingSwap is the request the picker overlay committed to another409 // pendingSwap is the request the picker overlay committed to another
410 // target while an attachment was live. It is only touched from the run410 // target while an attachment was live. It is only touched from the run
411 // goroutine and consumed by runResolvedAttachment.411 // goroutine and consumed by runResolvedAttachment.
412- pendingSwap *ports.BrokerOpenStreamRequest412+ pendingSwap *pickerAttachmentTarget
413+ // kills runs the picker's `x` operations off the run goroutine.
414+ kills pickerKills
413 }415 }
414 416
415 // NewSupervisor validates the required dependencies and returns a supervisor417 // NewSupervisor validates the required dependencies and returns a supervisor
@@ -508,6 +510,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) {
508 sub ports.BrokerSubscription510 sub ports.BrokerSubscription
509 )511 )
510 retire := func() {512 retire := func() {
513+ s.retirePickerKill()
511 s.preview.close(s.cfg.Picker)514 s.preview.close(s.cfg.Picker)
512 s.readySub = nil515 s.readySub = nil
513 if sub != nil {516 if sub != nil {
@@ -825,8 +828,15 @@ func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetim
825 if s.preview.publish(s.cfg.Picker) {828 if s.preview.publish(s.cfg.Picker) {
826 s.renderCurrent()829 s.renderCurrent()
827 }830 }
831+ case outcome := <-s.kills.results():
832+ s.finishPickerKill(service, outcome)
828 case <-ops:833 case <-ops:
829 op, key := s.cfg.Picker.TakeOp()834 op, key := s.cfg.Picker.TakeOp()
835+ if op.kill && !op.commit && !op.close {
836+ s.startPickerKill(service, key)
837+ s.renderCurrent()
838+ continue
839+ }
830 if !op.close && !op.commit {840 if !op.close && !op.commit {
831 s.refreshPreview(service)841 s.refreshPreview(service)
832 s.renderCurrent()842 s.renderCurrent()
@@ -892,6 +902,11 @@ func (s *Supervisor) takePickerClose() bool {
892 s.offerPickerNotice("picker-exit-hint", "no session attached: press Ctrl+C to quit")902 s.offerPickerNotice("picker-exit-hint", "no session attached: press Ctrl+C to quit")
893 s.renderCurrent()903 s.renderCurrent()
894 }904 }
905+ if op.kill && !op.commit && !op.close && key != "" {
906+ // A kill needs the broker's control stream; it is refused, not queued.
907+ s.offerPickerNotice("picker-kill", "couldn't kill: broker unavailable")
908+ s.renderCurrent()
909+ }
895 return op.close && op.exit910 return op.close && op.exit
896 }911 }
897 912
M internal/usecase/client/supervisor_attach.go +38 -15
@@ -272,22 +272,39 @@ func classifyInitialNavigationFailure(err error) error {
272272 return pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "initial navigation could not be resolved"}
273273 }
274274
275-func (s *Supervisor) resolveCommittedStreamRequest(service ports.BrokerService, key string) (ports.BrokerOpenStreamRequest, error) {
275+func (s *Supervisor) resolveCommittedStreamRequest(service ports.BrokerService, key string) (ports.BrokerOpenStreamRequest, attachmentTab, error) {
276276 stream, err := service.NextStreamID()
277277 if err != nil {
278- return ports.BrokerOpenStreamRequest{}, err
278+ return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err
279279 }
280280 base := pickerResolveBase{
281281 Connection: service.ConnectionID(),
282282 Stream: stream,
283283 }
284- return s.cfg.Picker.ResolveKey(key, base)
284+ if resolver, ok := s.cfg.Picker.(pickerTargetResolver); ok {
285+ return resolver.ResolveKeyTarget(key, base)
286+ }
287+ request, err := s.cfg.Picker.ResolveKey(key, base)
288+ return request, attachmentTab{}, err
289+}
290+
291+// pickerTargetResolver is the optional tab-aware resolution of the real
292+// picker; a scripted picker may resolve sessions only.
293+type pickerTargetResolver interface {
294+ ResolveKeyTarget(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error)
295+}
296+
297+// pickerAttachmentTarget is one resolved attachment: the exact broker stream
298+// request and the tab its Hello selects.
299+type pickerAttachmentTarget struct {
300+ request ports.BrokerOpenStreamRequest
301+ tab attachmentTab
285302 }
286303
287304 // newAttachmentWorker builds the real typed-session worker for one admitted
288305 // request. The worker receives no endpoint, route, or raw-mode authority: it
289306 // only drives the typed session protocol on the stream the supervisor opened.
290-func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest, beforeAttached func() error, sessionEnv SessionEnvironment) (AttachmentWorker, error) {
307+func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest, tab attachmentTab, beforeAttached func() error, sessionEnv SessionEnvironment) (AttachmentWorker, error) {
291308 geometry, ok := s.attachments.latestGeometry()
292309 if !ok {
293310 var err error
@@ -308,6 +325,7 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest,
308325 BeforeAttached: beforeAttached,
309326 Clock: s.cfg.Clock,
310327 Theme: &s.theme,
328+ Tab: tab,
311329 })
312330 if err != nil {
313331 return nil, err
@@ -388,24 +406,24 @@ func (s *Supervisor) runInitialNavigation(ctx context.Context, input *terminalIn
388406 s.reportAttachmentFailure(err)
389407 return false, nil
390408 }
391- return s.runResolvedAttachment(ctx, input, service, request, SessionEnvironmentLocalCLI)
409+ return s.runResolvedAttachment(ctx, input, service, pickerAttachmentTarget{request: request}, SessionEnvironmentLocalCLI)
392410 }
393411
394412 func (s *Supervisor) runCommittedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, key string) (bool, error) {
395413 if s.cfg.Picker == nil {
396414 return false, nil
397415 }
398- request, err := s.resolveCommittedStreamRequest(service, key)
416+ request, tab, err := s.resolveCommittedStreamRequest(service, key)
399417 if err != nil {
400418 // A refused selection is a typed presentation notice; it never opens a
401419 // stream or becomes a session write.
402420 s.reportAttachmentFailure(err)
403421 return false, nil
404422 }
405- return s.runResolvedAttachment(ctx, input, service, request, SessionEnvironmentLocalPicker)
423+ return s.runResolvedAttachment(ctx, input, service, pickerAttachmentTarget{request: request, tab: tab}, SessionEnvironmentLocalPicker)
406424 }
407425
408-func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, request ports.BrokerOpenStreamRequest, localProvenance SessionEnvironmentProvenance) (bool, error) {
426+func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, target pickerAttachmentTarget, localProvenance SessionEnvironmentProvenance) (bool, error) {
409427 picker := s.cfg.Picker
410428 if picker == nil {
411429 return false, nil
@@ -416,25 +434,27 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI
416434 // Whoever owned the terminal since the last picker frame, the next
417435 // frame must draw the whole box again.
418436 s.invalidatePickerPresentation()
437+ // Back on the plain picker there is no attachment to start on.
438+ s.setPickerCurrent(pickerCurrent{})
419439 picker.SetOwnsInput(true)
420440 input.acquirePicker()
421441 }()
422442 for {
423- terminated, termErr, swap := s.attachOnce(ctx, input, service, request, localProvenance)
443+ terminated, termErr, swap := s.attachOnce(ctx, input, service, target, localProvenance)
424444 if terminated || swap == nil {
425445 return terminated, termErr
426446 }
427447 // A commit from the picker overlay to another target: the previous
428448 // attachment detached cleanly, and the swap goes through Connecting
429449 // exactly like any committed selection.
430- request, localProvenance = *swap, SessionEnvironmentLocalPicker
450+ target, localProvenance = *swap, SessionEnvironmentLocalPicker
431451 }
432452 }
433453
434454 // attachOnce runs one resolved attachment end to end. A non-nil swap names the
435-// next request the picker overlay committed while this attachment was live.
436-func (s *Supervisor) attachOnce(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, request ports.BrokerOpenStreamRequest, localProvenance SessionEnvironmentProvenance) (bool, error, *ports.BrokerOpenStreamRequest) {
437- terminated, termErr := s.attachResolved(ctx, input, service, request, localProvenance)
455+// next target the picker overlay committed while this attachment was live.
456+func (s *Supervisor) attachOnce(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, target pickerAttachmentTarget, localProvenance SessionEnvironmentProvenance) (bool, error, *pickerAttachmentTarget) {
457+ terminated, termErr := s.attachResolved(ctx, input, service, target, localProvenance)
438458 swap := s.pendingSwap
439459 s.pendingSwap = nil
440460 if terminated {
@@ -443,7 +463,8 @@ func (s *Supervisor) attachOnce(ctx context.Context, input *terminalInputLifetim
443463 return false, nil, swap
444464 }
445465
446-func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, request ports.BrokerOpenStreamRequest, localProvenance SessionEnvironmentProvenance) (bool, error) {
466+func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, target pickerAttachmentTarget, localProvenance SessionEnvironmentProvenance) (bool, error) {
467+ request := target.request
447468 s.transition(supervisorEvent{kind: supervisorAttachBegin})
448469 deadline := startAttachmentDeadline(ctx, s.cfg.Clock)
449470 defer deadline.finish()
@@ -479,7 +500,7 @@ func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLif
479500 return false, nil
480501 }
481502
482- worker, err := s.newAttachmentWorker(request, func() error {
503+ worker, err := s.newAttachmentWorker(request, target.tab, func() error {
483504 deadline.disarm()
484505 if deadline.expired.Load() {
485506 return errAttachmentDeadline
@@ -542,6 +563,8 @@ settlement:
542563 overlay.takeOp()
543564 case <-overlay.invalidation():
544565 overlay.resize()
566+ case outcome := <-s.kills.results():
567+ s.finishPickerKill(service, outcome)
545568 case <-service.Done():
546569 brokerLost = true
547570 // The broker connection is gone; the supervisor cancels the run and
@@ -272,22 +272,39 @@ func classifyInitialNavigationFailure(err error) error {
272 return pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "initial navigation could not be resolved"}272 return pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "initial navigation could not be resolved"}
273 }273 }
274 274
275-func (s *Supervisor) resolveCommittedStreamRequest(service ports.BrokerService, key string) (ports.BrokerOpenStreamRequest, error) {275+func (s *Supervisor) resolveCommittedStreamRequest(service ports.BrokerService, key string) (ports.BrokerOpenStreamRequest, attachmentTab, error) {
276 stream, err := service.NextStreamID()276 stream, err := service.NextStreamID()
277 if err != nil {277 if err != nil {
278- return ports.BrokerOpenStreamRequest{}, err278+ return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err
279 }279 }
280 base := pickerResolveBase{280 base := pickerResolveBase{
281 Connection: service.ConnectionID(),281 Connection: service.ConnectionID(),
282 Stream: stream,282 Stream: stream,
283 }283 }
284- return s.cfg.Picker.ResolveKey(key, base)284+ if resolver, ok := s.cfg.Picker.(pickerTargetResolver); ok {
285+ return resolver.ResolveKeyTarget(key, base)
286+ }
287+ request, err := s.cfg.Picker.ResolveKey(key, base)
288+ return request, attachmentTab{}, err
289+}
290+
291+// pickerTargetResolver is the optional tab-aware resolution of the real
292+// picker; a scripted picker may resolve sessions only.
293+type pickerTargetResolver interface {
294+ ResolveKeyTarget(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error)
295+}
296+
297+// pickerAttachmentTarget is one resolved attachment: the exact broker stream
298+// request and the tab its Hello selects.
299+type pickerAttachmentTarget struct {
300+ request ports.BrokerOpenStreamRequest
301+ tab attachmentTab
285 }302 }
286 303
287 // newAttachmentWorker builds the real typed-session worker for one admitted304 // newAttachmentWorker builds the real typed-session worker for one admitted
288 // request. The worker receives no endpoint, route, or raw-mode authority: it305 // request. The worker receives no endpoint, route, or raw-mode authority: it
289 // only drives the typed session protocol on the stream the supervisor opened.306 // only drives the typed session protocol on the stream the supervisor opened.
290-func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest, beforeAttached func() error, sessionEnv SessionEnvironment) (AttachmentWorker, error) {307+func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest, tab attachmentTab, beforeAttached func() error, sessionEnv SessionEnvironment) (AttachmentWorker, error) {
291 geometry, ok := s.attachments.latestGeometry()308 geometry, ok := s.attachments.latestGeometry()
292 if !ok {309 if !ok {
293 var err error310 var err error
@@ -308,6 +325,7 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest,
308 BeforeAttached: beforeAttached,325 BeforeAttached: beforeAttached,
309 Clock: s.cfg.Clock,326 Clock: s.cfg.Clock,
310 Theme: &s.theme,327 Theme: &s.theme,
328+ Tab: tab,
311 })329 })
312 if err != nil {330 if err != nil {
313 return nil, err331 return nil, err
@@ -388,24 +406,24 @@ func (s *Supervisor) runInitialNavigation(ctx context.Context, input *terminalIn
388 s.reportAttachmentFailure(err)406 s.reportAttachmentFailure(err)
389 return false, nil407 return false, nil
390 }408 }
391- return s.runResolvedAttachment(ctx, input, service, request, SessionEnvironmentLocalCLI)409+ return s.runResolvedAttachment(ctx, input, service, pickerAttachmentTarget{request: request}, SessionEnvironmentLocalCLI)
392 }410 }
393 411
394 func (s *Supervisor) runCommittedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, key string) (bool, error) {412 func (s *Supervisor) runCommittedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, key string) (bool, error) {
395 if s.cfg.Picker == nil {413 if s.cfg.Picker == nil {
396 return false, nil414 return false, nil
397 }415 }
398- request, err := s.resolveCommittedStreamRequest(service, key)416+ request, tab, err := s.resolveCommittedStreamRequest(service, key)
399 if err != nil {417 if err != nil {
400 // A refused selection is a typed presentation notice; it never opens a418 // A refused selection is a typed presentation notice; it never opens a
401 // stream or becomes a session write.419 // stream or becomes a session write.
402 s.reportAttachmentFailure(err)420 s.reportAttachmentFailure(err)
403 return false, nil421 return false, nil
404 }422 }
405- return s.runResolvedAttachment(ctx, input, service, request, SessionEnvironmentLocalPicker)423+ return s.runResolvedAttachment(ctx, input, service, pickerAttachmentTarget{request: request, tab: tab}, SessionEnvironmentLocalPicker)
406 }424 }
407 425
408-func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, request ports.BrokerOpenStreamRequest, localProvenance SessionEnvironmentProvenance) (bool, error) {426+func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, target pickerAttachmentTarget, localProvenance SessionEnvironmentProvenance) (bool, error) {
409 picker := s.cfg.Picker427 picker := s.cfg.Picker
410 if picker == nil {428 if picker == nil {
411 return false, nil429 return false, nil
@@ -416,25 +434,27 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI
416 // Whoever owned the terminal since the last picker frame, the next434 // Whoever owned the terminal since the last picker frame, the next
417 // frame must draw the whole box again.435 // frame must draw the whole box again.
418 s.invalidatePickerPresentation()436 s.invalidatePickerPresentation()
437+ // Back on the plain picker there is no attachment to start on.
438+ s.setPickerCurrent(pickerCurrent{})
419 picker.SetOwnsInput(true)439 picker.SetOwnsInput(true)
420 input.acquirePicker()440 input.acquirePicker()
421 }()441 }()
422 for {442 for {
423- terminated, termErr, swap := s.attachOnce(ctx, input, service, request, localProvenance)443+ terminated, termErr, swap := s.attachOnce(ctx, input, service, target, localProvenance)
424 if terminated || swap == nil {444 if terminated || swap == nil {
425 return terminated, termErr445 return terminated, termErr
426 }446 }
427 // A commit from the picker overlay to another target: the previous447 // A commit from the picker overlay to another target: the previous
428 // attachment detached cleanly, and the swap goes through Connecting448 // attachment detached cleanly, and the swap goes through Connecting
429 // exactly like any committed selection.449 // exactly like any committed selection.
430- request, localProvenance = *swap, SessionEnvironmentLocalPicker450+ target, localProvenance = *swap, SessionEnvironmentLocalPicker
431 }451 }
432 }452 }
433 453
434 // attachOnce runs one resolved attachment end to end. A non-nil swap names the454 // attachOnce runs one resolved attachment end to end. A non-nil swap names the
435-// next request the picker overlay committed while this attachment was live.455+// next target the picker overlay committed while this attachment was live.
436-func (s *Supervisor) attachOnce(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, request ports.BrokerOpenStreamRequest, localProvenance SessionEnvironmentProvenance) (bool, error, *ports.BrokerOpenStreamRequest) {456+func (s *Supervisor) attachOnce(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, target pickerAttachmentTarget, localProvenance SessionEnvironmentProvenance) (bool, error, *pickerAttachmentTarget) {
437- terminated, termErr := s.attachResolved(ctx, input, service, request, localProvenance)457+ terminated, termErr := s.attachResolved(ctx, input, service, target, localProvenance)
438 swap := s.pendingSwap458 swap := s.pendingSwap
439 s.pendingSwap = nil459 s.pendingSwap = nil
440 if terminated {460 if terminated {
@@ -443,7 +463,8 @@ func (s *Supervisor) attachOnce(ctx context.Context, input *terminalInputLifetim
443 return false, nil, swap463 return false, nil, swap
444 }464 }
445 465
446-func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, request ports.BrokerOpenStreamRequest, localProvenance SessionEnvironmentProvenance) (bool, error) {466+func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, target pickerAttachmentTarget, localProvenance SessionEnvironmentProvenance) (bool, error) {
467+ request := target.request
447 s.transition(supervisorEvent{kind: supervisorAttachBegin})468 s.transition(supervisorEvent{kind: supervisorAttachBegin})
448 deadline := startAttachmentDeadline(ctx, s.cfg.Clock)469 deadline := startAttachmentDeadline(ctx, s.cfg.Clock)
449 defer deadline.finish()470 defer deadline.finish()
@@ -479,7 +500,7 @@ func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLif
479 return false, nil500 return false, nil
480 }501 }
481 502
482- worker, err := s.newAttachmentWorker(request, func() error {503+ worker, err := s.newAttachmentWorker(request, target.tab, func() error {
483 deadline.disarm()504 deadline.disarm()
484 if deadline.expired.Load() {505 if deadline.expired.Load() {
485 return errAttachmentDeadline506 return errAttachmentDeadline
@@ -542,6 +563,8 @@ settlement:
542 overlay.takeOp()563 overlay.takeOp()
543 case <-overlay.invalidation():564 case <-overlay.invalidation():
544 overlay.resize()565 overlay.resize()
566+ case outcome := <-s.kills.results():
567+ s.finishPickerKill(service, outcome)
545 case <-service.Done():568 case <-service.Done():
546 brokerLost = true569 brokerLost = true
547 // The broker connection is gone; the supervisor cancels the run and570 // The broker connection is gone; the supervisor cancels the run and
M internal/usecase/client/supervisor_overlay.go +31 -3
@@ -1,6 +1,7 @@
11 package client
22
33 import (
4+ "github.com/bnema/vev/internal/domain"
45 "github.com/bnema/vev/internal/ports"
56 "github.com/bnema/vev/internal/protocol"
67 )
@@ -78,6 +79,8 @@ func (o *attachmentPickerOverlay) enter() {
7879 return
7980 }
8081 picker.ApplySnapshot(o.service.Snapshot())
82+ // A fresh overlay starts on the attached session's tab.
83+ s.setPickerCurrent(o.current())
8184 // A decision recorded before the overlay existed never applies to it.
8285 picker.TakeOp()
8386 picker.SetOwnsInput(true)
@@ -143,27 +146,52 @@ func (o *attachmentPickerOverlay) takeOp() {
143146 // attachment to return to, so nothing here ends the process.
144147 o.exit()
145148 case op.commit && key != "":
146- request, err := s.resolveCommittedStreamRequest(o.service, key)
149+ request, tab, err := s.resolveCommittedStreamRequest(o.service, key)
147150 if err != nil {
148151 // The picker already shows the bounded refusal; the attachment
149152 // stays live under the overlay.
150153 s.renderCurrent()
151154 return
152155 }
153- if o.sameTarget(request) {
156+ if o.sameTarget(request) && tab.stopped == nil {
157+ // Another tab of the attached session switches in place; the
158+ // attachment is never reconnected for it.
159+ _, current, _ := s.attachments.committedView()
160+ if tab.preferred != "" && tab.preferred != current {
161+ s.attachments.requestTabSelection(o.run.token, tab.preferred)
162+ }
154163 o.exit()
155164 return
156165 }
157- s.pendingSwap = &request
166+ s.pendingSwap = &pickerAttachmentTarget{request: request, tab: tab}
158167 o.swapping = true
159168 s.preview.close(o.picker())
160169 s.attachments.requestDetach(o.run.token)
170+ case op.kill && key != "":
171+ s.startPickerKill(o.service, key)
172+ s.renderCurrent()
161173 default:
162174 s.refreshPreview(o.service)
163175 s.renderCurrent()
164176 }
165177 }
166178
179+// current names the attachment this overlay is presented over, so the picker
180+// opens on its session and tab.
181+func (o *attachmentPickerOverlay) current() pickerCurrent {
182+ target, tab, known := o.sup.attachments.committedView()
183+ if !known {
184+ if o.request.Admission != ports.BrokerAdmissionExact {
185+ return pickerCurrent{}
186+ }
187+ target = o.request.Target
188+ }
189+ if target.LifecycleID == (domain.SessionLifecycleID{}) {
190+ return pickerCurrent{}
191+ }
192+ return pickerCurrent{known: true, local: o.request.Local, endpoint: o.request.Endpoint, lifecycle: target.LifecycleID, tab: tab}
193+}
194+
167195 // sameTarget reports whether request names the session this attachment is
168196 // already showing, in which case committing it is a close.
169197 func (o *attachmentPickerOverlay) sameTarget(request ports.BrokerOpenStreamRequest) bool {
@@ -1,6 +1,7 @@
1 package client1 package client
2 2
3 import (3 import (
4+ "github.com/bnema/vev/internal/domain"
4 "github.com/bnema/vev/internal/ports"5 "github.com/bnema/vev/internal/ports"
5 "github.com/bnema/vev/internal/protocol"6 "github.com/bnema/vev/internal/protocol"
6 )7 )
@@ -78,6 +79,8 @@ func (o *attachmentPickerOverlay) enter() {
78 return79 return
79 }80 }
80 picker.ApplySnapshot(o.service.Snapshot())81 picker.ApplySnapshot(o.service.Snapshot())
82+ // A fresh overlay starts on the attached session's tab.
83+ s.setPickerCurrent(o.current())
81 // A decision recorded before the overlay existed never applies to it.84 // A decision recorded before the overlay existed never applies to it.
82 picker.TakeOp()85 picker.TakeOp()
83 picker.SetOwnsInput(true)86 picker.SetOwnsInput(true)
@@ -143,27 +146,52 @@ func (o *attachmentPickerOverlay) takeOp() {
143 // attachment to return to, so nothing here ends the process.146 // attachment to return to, so nothing here ends the process.
144 o.exit()147 o.exit()
145 case op.commit && key != "":148 case op.commit && key != "":
146- request, err := s.resolveCommittedStreamRequest(o.service, key)149+ request, tab, err := s.resolveCommittedStreamRequest(o.service, key)
147 if err != nil {150 if err != nil {
148 // The picker already shows the bounded refusal; the attachment151 // The picker already shows the bounded refusal; the attachment
149 // stays live under the overlay.152 // stays live under the overlay.
150 s.renderCurrent()153 s.renderCurrent()
151 return154 return
152 }155 }
153- if o.sameTarget(request) {156+ if o.sameTarget(request) && tab.stopped == nil {
157+ // Another tab of the attached session switches in place; the
158+ // attachment is never reconnected for it.
159+ _, current, _ := s.attachments.committedView()
160+ if tab.preferred != "" && tab.preferred != current {
161+ s.attachments.requestTabSelection(o.run.token, tab.preferred)
162+ }
154 o.exit()163 o.exit()
155 return164 return
156 }165 }
157- s.pendingSwap = &request166+ s.pendingSwap = &pickerAttachmentTarget{request: request, tab: tab}
158 o.swapping = true167 o.swapping = true
159 s.preview.close(o.picker())168 s.preview.close(o.picker())
160 s.attachments.requestDetach(o.run.token)169 s.attachments.requestDetach(o.run.token)
170+ case op.kill && key != "":
171+ s.startPickerKill(o.service, key)
172+ s.renderCurrent()
161 default:173 default:
162 s.refreshPreview(o.service)174 s.refreshPreview(o.service)
163 s.renderCurrent()175 s.renderCurrent()
164 }176 }
165 }177 }
166 178
179+// current names the attachment this overlay is presented over, so the picker
180+// opens on its session and tab.
181+func (o *attachmentPickerOverlay) current() pickerCurrent {
182+ target, tab, known := o.sup.attachments.committedView()
183+ if !known {
184+ if o.request.Admission != ports.BrokerAdmissionExact {
185+ return pickerCurrent{}
186+ }
187+ target = o.request.Target
188+ }
189+ if target.LifecycleID == (domain.SessionLifecycleID{}) {
190+ return pickerCurrent{}
191+ }
192+ return pickerCurrent{known: true, local: o.request.Local, endpoint: o.request.Endpoint, lifecycle: target.LifecycleID, tab: tab}
193+}
194+
167 // sameTarget reports whether request names the session this attachment is195 // sameTarget reports whether request names the session this attachment is
168 // already showing, in which case committing it is a close.196 // already showing, in which case committing it is a close.
169 func (o *attachmentPickerOverlay) sameTarget(request ports.BrokerOpenStreamRequest) bool {197 func (o *attachmentPickerOverlay) sameTarget(request ports.BrokerOpenStreamRequest) bool {
A internal/usecase/client/supervisor_picker_kill.go +155 -0
@@ -0,0 +1,155 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/internal/protocol"
10+)
11+
12+// Picker kill (Plan 003 B1).
13+//
14+// `x` on a local session row kills it through the broker operations seam: one
15+// independent, bounded control stream to the owning daemon, never the
16+// attachment stream. A live session is torn down; a stopped session's history
17+// record is deleted by the same typed Kill, which the daemon routes to its
18+// stopped-session purge. The operation runs off the supervisor's run goroutine
19+// so the picker keeps rendering; its typed outcome comes back as one notice and
20+// a catalogue reconcile. An unknown outcome is reported and never replayed.
21+
22+// pickerKillResolver is the optional kill resolution of the real picker.
23+type pickerKillResolver interface {
24+ ResolveKill(key string) (pickerKillTarget, error)
25+}
26+
27+// pickerCurrentHost is the optional cursor seam of the real picker.
28+type pickerCurrentHost interface {
29+ SetCurrent(pickerCurrent)
30+}
31+
32+// pickerKillOutcome is one settled kill.
33+type pickerKillOutcome struct {
34+ target pickerKillTarget
35+ result protocol.KillResult
36+ err error
37+}
38+
39+// pickerKills owns at most one in-flight picker kill. It is only touched from
40+// the supervisor's run goroutine; the worker goroutine only sends on done.
41+type pickerKills struct {
42+ running bool
43+ done chan pickerKillOutcome
44+ cancel context.CancelFunc
45+ wg sync.WaitGroup
46+}
47+
48+// results is the settled-kill wake, nil while no kill runs.
49+func (k *pickerKills) results() <-chan pickerKillOutcome {
50+ if k == nil || !k.running {
51+ return nil
52+ }
53+ return k.done
54+}
55+
56+// setPickerCurrent names the attachment the picker is presented over.
57+func (s *Supervisor) setPickerCurrent(current pickerCurrent) {
58+ if host, ok := s.cfg.Picker.(pickerCurrentHost); ok {
59+ host.SetCurrent(current)
60+ }
61+}
62+
63+// startPickerKill resolves one kill key against the latest catalogue and runs
64+// the kill. A refusal is already a picker notice; a second `x` while one kill
65+// runs is refused rather than queued.
66+func (s *Supervisor) startPickerKill(service ports.BrokerService, key string) {
67+ resolver, ok := s.cfg.Picker.(pickerKillResolver)
68+ if !ok || key == "" || supervisorNil(service) {
69+ return
70+ }
71+ if s.kills.running {
72+ s.offerPickerNotice("picker-kill", "a kill is already in progress")
73+ return
74+ }
75+ target, err := resolver.ResolveKill(key)
76+ if err != nil {
77+ return
78+ }
79+ operations, err := NewBrokerOperations(service, s.cfg.Clock)
80+ if err != nil {
81+ s.offerPickerNotice("picker-kill", "couldn't kill "+target.name+": broker unavailable")
82+ return
83+ }
84+ if s.kills.done == nil {
85+ s.kills.done = make(chan pickerKillOutcome, 1)
86+ }
87+ killCtx, cancel := context.WithCancel(context.Background())
88+ s.kills.running = true
89+ s.kills.cancel = cancel
90+ s.kills.wg.Add(1)
91+ go func() {
92+ defer s.kills.wg.Done()
93+ result, err := operations.Kill(killCtx, target.route, target.name)
94+ s.kills.done <- pickerKillOutcome{target: target, result: result, err: err}
95+ }()
96+}
97+
98+// finishPickerKill reports one settled kill and asks the broker to re-observe
99+// the local daemon, so the killed row leaves the catalogue promptly.
100+func (s *Supervisor) finishPickerKill(service ports.BrokerService, outcome pickerKillOutcome) {
101+ s.settlePickerKill()
102+ s.offerPickerNotice("picker-kill", pickerKillNotice(outcome))
103+ if !supervisorNil(service) {
104+ service.RequestReconcile("")
105+ }
106+ s.renderCurrent()
107+}
108+
109+// retirePickerKill cancels and joins an in-flight kill before its borrowed
110+// service closes. Its outcome is still reported, never replayed.
111+func (s *Supervisor) retirePickerKill() {
112+ if !s.kills.running {
113+ return
114+ }
115+ s.kills.cancel()
116+ s.kills.wg.Wait()
117+ outcome := <-s.kills.done
118+ s.settlePickerKill()
119+ s.offerPickerNotice("picker-kill", pickerKillNotice(outcome))
120+}
121+
122+func (s *Supervisor) settlePickerKill() {
123+ if s.kills.cancel != nil {
124+ s.kills.cancel()
125+ }
126+ s.kills.running = false
127+ s.kills.cancel = nil
128+}
129+
130+// pickerKillNotice renders one typed kill outcome.
131+func pickerKillNotice(outcome pickerKillOutcome) string {
132+ name := outcome.target.name
133+ verb, done := "kill", "killed session "
134+ if outcome.target.stopped {
135+ verb, done = "delete stopped session", "deleted stopped session "
136+ }
137+ if outcome.err != nil {
138+ if errors.Is(outcome.err, ErrBrokerOperationNotSent) {
139+ return "couldn't " + verb + " " + name + ": not sent"
140+ }
141+ return "couldn't " + verb + " " + name
142+ }
143+ switch outcome.result.Outcome {
144+ case protocol.KillSucceeded:
145+ return done + name
146+ case protocol.KillFailed:
147+ if outcome.result.Text != "" {
148+ return "couldn't " + verb + " " + name + ": " + outcome.result.Text
149+ }
150+ return "couldn't " + verb + " " + name
151+ default:
152+ // The request may have reached the daemon: never retried.
153+ return verb + " " + name + ": outcome unknown"
154+ }
155+}
@@ -0,0 +1,155 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/internal/protocol"
10+)
11+
12+// Picker kill (Plan 003 B1).
13+//
14+// `x` on a local session row kills it through the broker operations seam: one
15+// independent, bounded control stream to the owning daemon, never the
16+// attachment stream. A live session is torn down; a stopped session's history
17+// record is deleted by the same typed Kill, which the daemon routes to its
18+// stopped-session purge. The operation runs off the supervisor's run goroutine
19+// so the picker keeps rendering; its typed outcome comes back as one notice and
20+// a catalogue reconcile. An unknown outcome is reported and never replayed.
21+
22+// pickerKillResolver is the optional kill resolution of the real picker.
23+type pickerKillResolver interface {
24+ ResolveKill(key string) (pickerKillTarget, error)
25+}
26+
27+// pickerCurrentHost is the optional cursor seam of the real picker.
28+type pickerCurrentHost interface {
29+ SetCurrent(pickerCurrent)
30+}
31+
32+// pickerKillOutcome is one settled kill.
33+type pickerKillOutcome struct {
34+ target pickerKillTarget
35+ result protocol.KillResult
36+ err error
37+}
38+
39+// pickerKills owns at most one in-flight picker kill. It is only touched from
40+// the supervisor's run goroutine; the worker goroutine only sends on done.
41+type pickerKills struct {
42+ running bool
43+ done chan pickerKillOutcome
44+ cancel context.CancelFunc
45+ wg sync.WaitGroup
46+}
47+
48+// results is the settled-kill wake, nil while no kill runs.
49+func (k *pickerKills) results() <-chan pickerKillOutcome {
50+ if k == nil || !k.running {
51+ return nil
52+ }
53+ return k.done
54+}
55+
56+// setPickerCurrent names the attachment the picker is presented over.
57+func (s *Supervisor) setPickerCurrent(current pickerCurrent) {
58+ if host, ok := s.cfg.Picker.(pickerCurrentHost); ok {
59+ host.SetCurrent(current)
60+ }
61+}
62+
63+// startPickerKill resolves one kill key against the latest catalogue and runs
64+// the kill. A refusal is already a picker notice; a second `x` while one kill
65+// runs is refused rather than queued.
66+func (s *Supervisor) startPickerKill(service ports.BrokerService, key string) {
67+ resolver, ok := s.cfg.Picker.(pickerKillResolver)
68+ if !ok || key == "" || supervisorNil(service) {
69+ return
70+ }
71+ if s.kills.running {
72+ s.offerPickerNotice("picker-kill", "a kill is already in progress")
73+ return
74+ }
75+ target, err := resolver.ResolveKill(key)
76+ if err != nil {
77+ return
78+ }
79+ operations, err := NewBrokerOperations(service, s.cfg.Clock)
80+ if err != nil {
81+ s.offerPickerNotice("picker-kill", "couldn't kill "+target.name+": broker unavailable")
82+ return
83+ }
84+ if s.kills.done == nil {
85+ s.kills.done = make(chan pickerKillOutcome, 1)
86+ }
87+ killCtx, cancel := context.WithCancel(context.Background())
88+ s.kills.running = true
89+ s.kills.cancel = cancel
90+ s.kills.wg.Add(1)
91+ go func() {
92+ defer s.kills.wg.Done()
93+ result, err := operations.Kill(killCtx, target.route, target.name)
94+ s.kills.done <- pickerKillOutcome{target: target, result: result, err: err}
95+ }()
96+}
97+
98+// finishPickerKill reports one settled kill and asks the broker to re-observe
99+// the local daemon, so the killed row leaves the catalogue promptly.
100+func (s *Supervisor) finishPickerKill(service ports.BrokerService, outcome pickerKillOutcome) {
101+ s.settlePickerKill()
102+ s.offerPickerNotice("picker-kill", pickerKillNotice(outcome))
103+ if !supervisorNil(service) {
104+ service.RequestReconcile("")
105+ }
106+ s.renderCurrent()
107+}
108+
109+// retirePickerKill cancels and joins an in-flight kill before its borrowed
110+// service closes. Its outcome is still reported, never replayed.
111+func (s *Supervisor) retirePickerKill() {
112+ if !s.kills.running {
113+ return
114+ }
115+ s.kills.cancel()
116+ s.kills.wg.Wait()
117+ outcome := <-s.kills.done
118+ s.settlePickerKill()
119+ s.offerPickerNotice("picker-kill", pickerKillNotice(outcome))
120+}
121+
122+func (s *Supervisor) settlePickerKill() {
123+ if s.kills.cancel != nil {
124+ s.kills.cancel()
125+ }
126+ s.kills.running = false
127+ s.kills.cancel = nil
128+}
129+
130+// pickerKillNotice renders one typed kill outcome.
131+func pickerKillNotice(outcome pickerKillOutcome) string {
132+ name := outcome.target.name
133+ verb, done := "kill", "killed session "
134+ if outcome.target.stopped {
135+ verb, done = "delete stopped session", "deleted stopped session "
136+ }
137+ if outcome.err != nil {
138+ if errors.Is(outcome.err, ErrBrokerOperationNotSent) {
139+ return "couldn't " + verb + " " + name + ": not sent"
140+ }
141+ return "couldn't " + verb + " " + name
142+ }
143+ switch outcome.result.Outcome {
144+ case protocol.KillSucceeded:
145+ return done + name
146+ case protocol.KillFailed:
147+ if outcome.result.Text != "" {
148+ return "couldn't " + verb + " " + name + ": " + outcome.result.Text
149+ }
150+ return "couldn't " + verb + " " + name
151+ default:
152+ // The request may have reached the daemon: never retried.
153+ return verb + " " + name + ": outcome unknown"
154+ }
155+}
A internal/usecase/client/supervisor_picker_parity_test.go +218 -0
@@ -0,0 +1,218 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "strings"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/protocol"
15+)
16+
17+// Supervisor halves of picker parity (Plan 003 B1/B2): the in-place tab switch
18+// over a live attachment and the `x` kill through the broker operations seam.
19+// They drive the real supervisor, host, foreground, and worker with bounded
20+// waits and no sleeps.
21+
22+// parityTestPicker adds the optional tab, kill, and notice seams of the real
23+// picker to the scripted picker. The fields are guarded by attachTestPicker.mu.
24+type parityTestPicker struct {
25+ *attachTestPicker
26+ tab attachmentTab
27+ kill pickerKillTarget
28+ notices []string
29+}
30+
31+func newParityTestPicker() *parityTestPicker {
32+ return &parityTestPicker{attachTestPicker: newAttachTestPicker()}
33+}
34+
35+func (p *parityTestPicker) ResolveKeyTarget(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) {
36+ request, err := p.ResolveKey(key, base)
37+ p.mu.Lock()
38+ defer p.mu.Unlock()
39+ return request, p.tab, err
40+}
41+
42+func (p *parityTestPicker) ResolveKill(string) (pickerKillTarget, error) {
43+ p.mu.Lock()
44+ defer p.mu.Unlock()
45+ return p.kill, nil
46+}
47+
48+func (p *parityTestPicker) offerNotice(_ string, message string) {
49+ p.mu.Lock()
50+ p.notices = append(p.notices, message)
51+ p.mu.Unlock()
52+}
53+
54+func (p *parityTestPicker) invalidatePresentation() {}
55+
56+func (p *parityTestPicker) noticeList() []string {
57+ p.mu.Lock()
58+ defer p.mu.Unlock()
59+ return append([]string(nil), p.notices...)
60+}
61+
62+// TestPickerOverlayCommitAnotherTabSwitchesInPlace pins the tab-aware same
63+// target: committing another tab of the attached session switches the live
64+// attachment's view with SelectTab and never reconnects; the attached tab or
65+// the session itself just closes the overlay.
66+func TestPickerOverlayCommitAnotherTabSwitchesInPlace(t *testing.T) {
67+ tests := []struct {
68+ name string
69+ tab attachmentTab
70+ inPlace bool
71+ }{
72+ {name: "another tab", tab: attachmentTab{preferred: "t_other"}, inPlace: true},
73+ {name: "the attached tab", tab: attachmentTab{preferred: "t_abc123"}},
74+ {name: "the session row", tab: attachmentTab{}},
75+ }
76+ for _, tt := range tests {
77+ t.Run(tt.name, func(t *testing.T) {
78+ picker := newParityTestPicker()
79+ harness := startAttachHarness(t, picker)
80+ stream := attachLiveSession(t, harness, picker.attachTestPicker)
81+ foreground := harness.sup.attachments.authority.foreground()
82+ stream.deliver(navigationOffer(1))
83+ awaitPresentation(t, harness.sup, PresentAttachedPicker)
84+
85+ picker.mu.Lock()
86+ picker.tab = tt.tab
87+ picker.mu.Unlock()
88+ picker.recordOp(pickerOp{commit: true}, "row", sessionTestRequest(true))
89+
90+ awaitPresentation(t, harness.sup, PresentAttached)
91+ awaitSent(t, stream, "OutputResetRequest", isSent[protocol.OutputResetRequest])
92+ if tt.inPlace {
93+ selected := awaitSent(t, stream, "SelectTab", isSent[protocol.SelectTab]).(protocol.SelectTab)
94+ require.Equal(t, tt.tab.preferred, selected.TabID)
95+ } else {
96+ require.Zero(t, countSent[protocol.SelectTab](stream))
97+ }
98+ require.Same(t, foreground, harness.sup.attachments.authority.foreground(), "the attachment is never replaced")
99+ require.Len(t, harness.service.openedRequests(), 1, "a tab switch never reconnects")
100+ require.Zero(t, countSent[protocol.Detach](stream))
101+ require.False(t, stream.closedNow())
102+ })
103+ }
104+}
105+
106+// TestSupervisorPickerKill pins B1 end to end: `x` resolves the row, sends one
107+// typed Kill on its own local control stream, reports the typed outcome as a
108+// notice, and asks the broker to re-observe the local daemon. An unknown
109+// outcome is reported once and never replayed.
110+func TestSupervisorPickerKill(t *testing.T) {
111+ tests := []struct {
112+ name string
113+ stopped bool
114+ overlay bool
115+ reply func(*brokerOpsTestStream)
116+ wantNotice string
117+ }{
118+ {name: "live session", reply: func(s *brokerOpsTestStream) {
119+ s.deliver(protocol.KillResult{RequestID: brokerOperationRequestID, Outcome: protocol.KillSucceeded})
120+ }, wantNotice: "killed session alpha"},
121+ {name: "stopped session history", stopped: true, reply: func(s *brokerOpsTestStream) {
122+ s.deliver(protocol.KillResult{RequestID: brokerOperationRequestID, Outcome: protocol.KillSucceeded})
123+ }, wantNotice: "deleted stopped session alpha"},
124+ {name: "daemon refusal", reply: func(s *brokerOpsTestStream) {
125+ s.deliver(protocol.KillResult{RequestID: brokerOperationRequestID, Outcome: protocol.KillFailed, Code: protocol.ErrNoSuchSession, Text: "no such session: alpha"})
126+ }, wantNotice: "couldn't kill alpha: no such session: alpha"},
127+ {name: "outcome unknown is never replayed", reply: func(s *brokerOpsTestStream) {
128+ s.failReceive(errors.New("carriage lost"))
129+ _ = s.Close()
130+ }, wantNotice: "kill alpha: outcome unknown"},
131+ {name: "from the overlay", overlay: true, reply: func(s *brokerOpsTestStream) {
132+ s.deliver(protocol.KillResult{RequestID: brokerOperationRequestID, Outcome: protocol.KillSucceeded})
133+ }, wantNotice: "killed session alpha"},
134+ }
135+ for _, tt := range tests {
136+ t.Run(tt.name, func(t *testing.T) {
137+ picker := newParityTestPicker()
138+ harness := startAttachHarness(t, picker)
139+ harness.service.publishSnapshot(localDaemonSnapshot(3, 2))
140+
141+ var mu sync.Mutex
142+ controls := make([]*brokerOpsTestStream, 0, 1)
143+ session := newSessionTestStream()
144+ harness.service.setOpenStream(func(_ context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
145+ if request.Purpose != ports.BrokerStreamControl {
146+ return session, nil
147+ }
148+ control := newBrokerOpsTestStream()
149+ mu.Lock()
150+ controls = append(controls, control)
151+ mu.Unlock()
152+ return control, nil
153+ })
154+ if tt.overlay {
155+ picker.commit(sessionTestRequest(true))
156+ awaitStreamHello(t, &sync.Mutex{}, &session)
157+ deliverReadyStream(t, session)
158+ awaitAttachedState(t, harness.sup)
159+ session.deliver(navigationOffer(1))
160+ awaitPresentation(t, harness.sup, PresentAttachedPicker)
161+ }
162+
163+ picker.mu.Lock()
164+ picker.kill = pickerKillTarget{route: BrokerOperationRoute{Epoch: 3, Destination: ports.BrokerEndpointFence{Local: true}}, name: "alpha", stopped: tt.stopped}
165+ picker.mu.Unlock()
166+ picker.recordOp(pickerOp{kill: true}, "row", ports.BrokerOpenStreamRequest{})
167+
168+ var control *brokerOpsTestStream
169+ require.Eventually(t, func() bool {
170+ mu.Lock()
171+ defer mu.Unlock()
172+ if len(controls) == 0 {
173+ return false
174+ }
175+ control = controls[0]
176+ return true
177+ }, 5*time.Second, time.Millisecond, "the kill never opened a control stream")
178+ control.awaitDispatch(t)
179+ tt.reply(control)
180+
181+ require.Eventually(t, func() bool {
182+ for _, notice := range picker.noticeList() {
183+ if notice == tt.wantNotice {
184+ return true
185+ }
186+ }
187+ return false
188+ }, 5*time.Second, time.Millisecond, "notice %q never shown (got %v)", tt.wantNotice, picker.noticeList())
189+ require.Eventually(t, func() bool { return len(harness.service.reconcileHints()) > 0 }, 5*time.Second, time.Millisecond)
190+ for _, hint := range harness.service.reconcileHints() {
191+ require.Empty(t, hint, "the local daemon is re-observed")
192+ }
193+
194+ mu.Lock()
195+ require.Len(t, controls, 1, "a kill is never replayed")
196+ mu.Unlock()
197+ sent := control.messages()
198+ require.Len(t, sent, 1)
199+ kill, ok := sent[0].(protocol.Kill)
200+ require.True(t, ok)
201+ require.Equal(t, protocol.Kill{RequestID: brokerOperationRequestID, Name: "alpha", Scope: protocol.KillSession}, kill)
202+ var controlOpens int
203+ for _, request := range harness.service.openedRequests() {
204+ if request.Purpose == ports.BrokerStreamControl {
205+ controlOpens++
206+ require.True(t, request.Local)
207+ require.Equal(t, ports.BrokerEpoch(3), request.Epoch)
208+ }
209+ }
210+ require.Equal(t, 1, controlOpens)
211+ if tt.overlay {
212+ require.Equal(t, PresentAttachedPicker, harness.sup.State().Presentation, "a kill keeps the overlay open")
213+ require.False(t, session.closedNow())
214+ }
215+ require.False(t, strings.Contains(strings.Join(picker.noticeList(), "\n"), "already in progress"))
216+ })
217+ }
218+}
@@ -0,0 +1,218 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "strings"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/protocol"
15+)
16+
17+// Supervisor halves of picker parity (Plan 003 B1/B2): the in-place tab switch
18+// over a live attachment and the `x` kill through the broker operations seam.
19+// They drive the real supervisor, host, foreground, and worker with bounded
20+// waits and no sleeps.
21+
22+// parityTestPicker adds the optional tab, kill, and notice seams of the real
23+// picker to the scripted picker. The fields are guarded by attachTestPicker.mu.
24+type parityTestPicker struct {
25+ *attachTestPicker
26+ tab attachmentTab
27+ kill pickerKillTarget
28+ notices []string
29+}
30+
31+func newParityTestPicker() *parityTestPicker {
32+ return &parityTestPicker{attachTestPicker: newAttachTestPicker()}
33+}
34+
35+func (p *parityTestPicker) ResolveKeyTarget(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) {
36+ request, err := p.ResolveKey(key, base)
37+ p.mu.Lock()
38+ defer p.mu.Unlock()
39+ return request, p.tab, err
40+}
41+
42+func (p *parityTestPicker) ResolveKill(string) (pickerKillTarget, error) {
43+ p.mu.Lock()
44+ defer p.mu.Unlock()
45+ return p.kill, nil
46+}
47+
48+func (p *parityTestPicker) offerNotice(_ string, message string) {
49+ p.mu.Lock()
50+ p.notices = append(p.notices, message)
51+ p.mu.Unlock()
52+}
53+
54+func (p *parityTestPicker) invalidatePresentation() {}
55+
56+func (p *parityTestPicker) noticeList() []string {
57+ p.mu.Lock()
58+ defer p.mu.Unlock()
59+ return append([]string(nil), p.notices...)
60+}
61+
62+// TestPickerOverlayCommitAnotherTabSwitchesInPlace pins the tab-aware same
63+// target: committing another tab of the attached session switches the live
64+// attachment's view with SelectTab and never reconnects; the attached tab or
65+// the session itself just closes the overlay.
66+func TestPickerOverlayCommitAnotherTabSwitchesInPlace(t *testing.T) {
67+ tests := []struct {
68+ name string
69+ tab attachmentTab
70+ inPlace bool
71+ }{
72+ {name: "another tab", tab: attachmentTab{preferred: "t_other"}, inPlace: true},
73+ {name: "the attached tab", tab: attachmentTab{preferred: "t_abc123"}},
74+ {name: "the session row", tab: attachmentTab{}},
75+ }
76+ for _, tt := range tests {
77+ t.Run(tt.name, func(t *testing.T) {
78+ picker := newParityTestPicker()
79+ harness := startAttachHarness(t, picker)
80+ stream := attachLiveSession(t, harness, picker.attachTestPicker)
81+ foreground := harness.sup.attachments.authority.foreground()
82+ stream.deliver(navigationOffer(1))
83+ awaitPresentation(t, harness.sup, PresentAttachedPicker)
84+
85+ picker.mu.Lock()
86+ picker.tab = tt.tab
87+ picker.mu.Unlock()
88+ picker.recordOp(pickerOp{commit: true}, "row", sessionTestRequest(true))
89+
90+ awaitPresentation(t, harness.sup, PresentAttached)
91+ awaitSent(t, stream, "OutputResetRequest", isSent[protocol.OutputResetRequest])
92+ if tt.inPlace {
93+ selected := awaitSent(t, stream, "SelectTab", isSent[protocol.SelectTab]).(protocol.SelectTab)
94+ require.Equal(t, tt.tab.preferred, selected.TabID)
95+ } else {
96+ require.Zero(t, countSent[protocol.SelectTab](stream))
97+ }
98+ require.Same(t, foreground, harness.sup.attachments.authority.foreground(), "the attachment is never replaced")
99+ require.Len(t, harness.service.openedRequests(), 1, "a tab switch never reconnects")
100+ require.Zero(t, countSent[protocol.Detach](stream))
101+ require.False(t, stream.closedNow())
102+ })
103+ }
104+}
105+
106+// TestSupervisorPickerKill pins B1 end to end: `x` resolves the row, sends one
107+// typed Kill on its own local control stream, reports the typed outcome as a
108+// notice, and asks the broker to re-observe the local daemon. An unknown
109+// outcome is reported once and never replayed.
110+func TestSupervisorPickerKill(t *testing.T) {
111+ tests := []struct {
112+ name string
113+ stopped bool
114+ overlay bool
115+ reply func(*brokerOpsTestStream)
116+ wantNotice string
117+ }{
118+ {name: "live session", reply: func(s *brokerOpsTestStream) {
119+ s.deliver(protocol.KillResult{RequestID: brokerOperationRequestID, Outcome: protocol.KillSucceeded})
120+ }, wantNotice: "killed session alpha"},
121+ {name: "stopped session history", stopped: true, reply: func(s *brokerOpsTestStream) {
122+ s.deliver(protocol.KillResult{RequestID: brokerOperationRequestID, Outcome: protocol.KillSucceeded})
123+ }, wantNotice: "deleted stopped session alpha"},
124+ {name: "daemon refusal", reply: func(s *brokerOpsTestStream) {
125+ s.deliver(protocol.KillResult{RequestID: brokerOperationRequestID, Outcome: protocol.KillFailed, Code: protocol.ErrNoSuchSession, Text: "no such session: alpha"})
126+ }, wantNotice: "couldn't kill alpha: no such session: alpha"},
127+ {name: "outcome unknown is never replayed", reply: func(s *brokerOpsTestStream) {
128+ s.failReceive(errors.New("carriage lost"))
129+ _ = s.Close()
130+ }, wantNotice: "kill alpha: outcome unknown"},
131+ {name: "from the overlay", overlay: true, reply: func(s *brokerOpsTestStream) {
132+ s.deliver(protocol.KillResult{RequestID: brokerOperationRequestID, Outcome: protocol.KillSucceeded})
133+ }, wantNotice: "killed session alpha"},
134+ }
135+ for _, tt := range tests {
136+ t.Run(tt.name, func(t *testing.T) {
137+ picker := newParityTestPicker()
138+ harness := startAttachHarness(t, picker)
139+ harness.service.publishSnapshot(localDaemonSnapshot(3, 2))
140+
141+ var mu sync.Mutex
142+ controls := make([]*brokerOpsTestStream, 0, 1)
143+ session := newSessionTestStream()
144+ harness.service.setOpenStream(func(_ context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
145+ if request.Purpose != ports.BrokerStreamControl {
146+ return session, nil
147+ }
148+ control := newBrokerOpsTestStream()
149+ mu.Lock()
150+ controls = append(controls, control)
151+ mu.Unlock()
152+ return control, nil
153+ })
154+ if tt.overlay {
155+ picker.commit(sessionTestRequest(true))
156+ awaitStreamHello(t, &sync.Mutex{}, &session)
157+ deliverReadyStream(t, session)
158+ awaitAttachedState(t, harness.sup)
159+ session.deliver(navigationOffer(1))
160+ awaitPresentation(t, harness.sup, PresentAttachedPicker)
161+ }
162+
163+ picker.mu.Lock()
164+ picker.kill = pickerKillTarget{route: BrokerOperationRoute{Epoch: 3, Destination: ports.BrokerEndpointFence{Local: true}}, name: "alpha", stopped: tt.stopped}
165+ picker.mu.Unlock()
166+ picker.recordOp(pickerOp{kill: true}, "row", ports.BrokerOpenStreamRequest{})
167+
168+ var control *brokerOpsTestStream
169+ require.Eventually(t, func() bool {
170+ mu.Lock()
171+ defer mu.Unlock()
172+ if len(controls) == 0 {
173+ return false
174+ }
175+ control = controls[0]
176+ return true
177+ }, 5*time.Second, time.Millisecond, "the kill never opened a control stream")
178+ control.awaitDispatch(t)
179+ tt.reply(control)
180+
181+ require.Eventually(t, func() bool {
182+ for _, notice := range picker.noticeList() {
183+ if notice == tt.wantNotice {
184+ return true
185+ }
186+ }
187+ return false
188+ }, 5*time.Second, time.Millisecond, "notice %q never shown (got %v)", tt.wantNotice, picker.noticeList())
189+ require.Eventually(t, func() bool { return len(harness.service.reconcileHints()) > 0 }, 5*time.Second, time.Millisecond)
190+ for _, hint := range harness.service.reconcileHints() {
191+ require.Empty(t, hint, "the local daemon is re-observed")
192+ }
193+
194+ mu.Lock()
195+ require.Len(t, controls, 1, "a kill is never replayed")
196+ mu.Unlock()
197+ sent := control.messages()
198+ require.Len(t, sent, 1)
199+ kill, ok := sent[0].(protocol.Kill)
200+ require.True(t, ok)
201+ require.Equal(t, protocol.Kill{RequestID: brokerOperationRequestID, Name: "alpha", Scope: protocol.KillSession}, kill)
202+ var controlOpens int
203+ for _, request := range harness.service.openedRequests() {
204+ if request.Purpose == ports.BrokerStreamControl {
205+ controlOpens++
206+ require.True(t, request.Local)
207+ require.Equal(t, ports.BrokerEpoch(3), request.Epoch)
208+ }
209+ }
210+ require.Equal(t, 1, controlOpens)
211+ if tt.overlay {
212+ require.Equal(t, PresentAttachedPicker, harness.sup.State().Presentation, "a kill keeps the overlay open")
213+ require.False(t, session.closedNow())
214+ }
215+ require.False(t, strings.Contains(strings.Join(picker.noticeList(), "\n"), "already in progress"))
216+ })
217+ }
218+}
M internal/usecase/client/supervisor_test.go +13 -1
@@ -329,6 +329,7 @@ type supervisorTestService struct {
329329 openStream func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error)
330330 openCalls []ports.BrokerOpenStreamRequest
331331 nextStream ports.BrokerStreamID
332+ reconciles []string
332333 }
333334
334335 func newSupervisorTestService(id ports.BrokerConnectionID) *supervisorTestService {
@@ -416,7 +417,18 @@ func (s *supervisorTestService) UpdateHostPolicy(context.Context, domain.RemoteR
416417 return domain.RemoteRegistration{}, nil
417418 }
418419
419-func (s *supervisorTestService) RequestReconcile(string) {}
420+func (s *supervisorTestService) RequestReconcile(endpoint string) {
421+ s.mu.Lock()
422+ s.reconciles = append(s.reconciles, endpoint)
423+ s.mu.Unlock()
424+}
425+
426+// reconcileHints returns every re-observation hint the supervisor asked for.
427+func (s *supervisorTestService) reconcileHints() []string {
428+ s.mu.Lock()
429+ defer s.mu.Unlock()
430+ return append([]string(nil), s.reconciles...)
431+}
420432
421433 func (s *supervisorTestService) Close() error {
422434 s.closeOnce.Do(func() {
@@ -329,6 +329,7 @@ type supervisorTestService struct {
329 openStream func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error)329 openStream func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error)
330 openCalls []ports.BrokerOpenStreamRequest330 openCalls []ports.BrokerOpenStreamRequest
331 nextStream ports.BrokerStreamID331 nextStream ports.BrokerStreamID
332+ reconciles []string
332 }333 }
333 334
334 func newSupervisorTestService(id ports.BrokerConnectionID) *supervisorTestService {335 func newSupervisorTestService(id ports.BrokerConnectionID) *supervisorTestService {
@@ -416,7 +417,18 @@ func (s *supervisorTestService) UpdateHostPolicy(context.Context, domain.RemoteR
416 return domain.RemoteRegistration{}, nil417 return domain.RemoteRegistration{}, nil
417 }418 }
418 419
419-func (s *supervisorTestService) RequestReconcile(string) {}420+func (s *supervisorTestService) RequestReconcile(endpoint string) {
421+ s.mu.Lock()
422+ s.reconciles = append(s.reconciles, endpoint)
423+ s.mu.Unlock()
424+}
425+
426+// reconcileHints returns every re-observation hint the supervisor asked for.
427+func (s *supervisorTestService) reconcileHints() []string {
428+ s.mu.Lock()
429+ defer s.mu.Unlock()
430+ return append([]string(nil), s.reconciles...)
431+}
420 432
421 func (s *supervisorTestService) Close() error {433 func (s *supervisorTestService) Close() error {
422 s.closeOnce.Do(func() {434 s.closeOnce.Do(func() {
M internal/usecase/picker/model.go +4 -0
@@ -535,6 +535,10 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
535535 continue
536536 }
537537 if r.rendersAsHeader() {
538+ if r.line.Attention && x < contentClipX {
539+ // A session header rings when any of its tabs does.
540+ x = ui.DrawText(frame, x, rect.Y+y, contentClipX, " "+string(ui.AttentionGlyph), base)
541+ }
538542 if r.line.Detail != "" && x < contentClipX {
539543 x = ui.DrawText(frame, x, rect.Y+y, contentClipX, " ", detailStyle)
540544 }
@@ -535,6 +535,10 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
535 continue535 continue
536 }536 }
537 if r.rendersAsHeader() {537 if r.rendersAsHeader() {
538+ if r.line.Attention && x < contentClipX {
539+ // A session header rings when any of its tabs does.
540+ x = ui.DrawText(frame, x, rect.Y+y, contentClipX, " "+string(ui.AttentionGlyph), base)
541+ }
538 if r.line.Detail != "" && x < contentClipX {542 if r.line.Detail != "" && x < contentClipX {
539 x = ui.DrawText(frame, x, rect.Y+y, contentClipX, " ", detailStyle)543 x = ui.DrawText(frame, x, rect.Y+y, contentClipX, " ", detailStyle)
540 }544 }
M internal/usecase/picker/model_test.go +26 -0
@@ -1,11 +1,13 @@
11 package picker
22
33 import (
4+ "strings"
45 "testing"
56
67 renderer "github.com/bnema/vev-vt"
78 "github.com/bnema/vev/internal/domain"
89 "github.com/bnema/vev/internal/protocol"
10+ "github.com/bnema/vev/internal/usecase/ui"
911 "github.com/stretchr/testify/require"
1012 )
1113
@@ -321,6 +323,30 @@ func TestRenderAttentionMarkerFollowsTheTabName(t *testing.T) {
321323 require.NotEqual(t, rowText(frame.Row(0)), rowText(frame.Row(1)))
322324 }
323325
326+// TestRenderAttentionBellOnHeadersAndTabs pins Plan 003 C1: a session header
327+// and a tab row both draw the bell right after their name when they ring, and
328+// neither draws it otherwise.
329+func TestRenderAttentionBellOnHeadersAndTabs(t *testing.T) {
330+ bell := string(ui.AttentionGlyph)
331+ tests := []struct {
332+ name string
333+ line protocol.PickerLine
334+ want bool
335+ }{
336+ {name: "ringing header", line: protocol.PickerLine{Key: "s1", Kind: protocol.PickerLineSession, Label: "work", Detail: "attached", Attention: true}, want: true},
337+ {name: "quiet header", line: protocol.PickerLine{Key: "s1", Kind: protocol.PickerLineSession, Label: "work", Detail: "attached"}},
338+ {name: "ringing tab", line: protocol.PickerLine{Key: "t1", Kind: protocol.PickerLineTab, Label: "build", Attention: true, Focusable: true, Actions: protocol.PickerCanNavigate}, want: true},
339+ {name: "quiet tab", line: protocol.PickerLine{Key: "t1", Kind: protocol.PickerLineTab, Label: "build", Focusable: true, Actions: protocol.PickerCanNavigate}},
340+ }
341+ for _, tt := range tests {
342+ t.Run(tt.name, func(t *testing.T) {
343+ m := New([]protocol.PickerLine{tt.line}, Config{Intent: protocol.PickerIntentNavigation})
344+ text := rowText(m.Render(domain.Size{Cols: 60, Rows: 6}, Preview{}).Row(0))
345+ require.Equal(t, tt.want, strings.Contains(text, tt.line.Label+" "+bell), "row %q", text)
346+ })
347+ }
348+}
349+
324350 func TestSearchMatchesLabelsAndDetails(t *testing.T) {
325351 m := New([]protocol.PickerLine{
326352 navLine("a/one", "one"),
@@ -1,11 +1,13 @@
1 package picker1 package picker
2 2
3 import (3 import (
4+ "strings"
4 "testing"5 "testing"
5 6
6 renderer "github.com/bnema/vev-vt"7 renderer "github.com/bnema/vev-vt"
7 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/protocol"9 "github.com/bnema/vev/internal/protocol"
10+ "github.com/bnema/vev/internal/usecase/ui"
9 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
10 )12 )
11 13
@@ -321,6 +323,30 @@ func TestRenderAttentionMarkerFollowsTheTabName(t *testing.T) {
321 require.NotEqual(t, rowText(frame.Row(0)), rowText(frame.Row(1)))323 require.NotEqual(t, rowText(frame.Row(0)), rowText(frame.Row(1)))
322 }324 }
323 325
326+// TestRenderAttentionBellOnHeadersAndTabs pins Plan 003 C1: a session header
327+// and a tab row both draw the bell right after their name when they ring, and
328+// neither draws it otherwise.
329+func TestRenderAttentionBellOnHeadersAndTabs(t *testing.T) {
330+ bell := string(ui.AttentionGlyph)
331+ tests := []struct {
332+ name string
333+ line protocol.PickerLine
334+ want bool
335+ }{
336+ {name: "ringing header", line: protocol.PickerLine{Key: "s1", Kind: protocol.PickerLineSession, Label: "work", Detail: "attached", Attention: true}, want: true},
337+ {name: "quiet header", line: protocol.PickerLine{Key: "s1", Kind: protocol.PickerLineSession, Label: "work", Detail: "attached"}},
338+ {name: "ringing tab", line: protocol.PickerLine{Key: "t1", Kind: protocol.PickerLineTab, Label: "build", Attention: true, Focusable: true, Actions: protocol.PickerCanNavigate}, want: true},
339+ {name: "quiet tab", line: protocol.PickerLine{Key: "t1", Kind: protocol.PickerLineTab, Label: "build", Focusable: true, Actions: protocol.PickerCanNavigate}},
340+ }
341+ for _, tt := range tests {
342+ t.Run(tt.name, func(t *testing.T) {
343+ m := New([]protocol.PickerLine{tt.line}, Config{Intent: protocol.PickerIntentNavigation})
344+ text := rowText(m.Render(domain.Size{Cols: 60, Rows: 6}, Preview{}).Row(0))
345+ require.Equal(t, tt.want, strings.Contains(text, tt.line.Label+" "+bell), "row %q", text)
346+ })
347+ }
348+}
349+
324 func TestSearchMatchesLabelsAndDetails(t *testing.T) {350 func TestSearchMatchesLabelsAndDetails(t *testing.T) {
325 m := New([]protocol.PickerLine{351 m := New([]protocol.PickerLine{
326 navLine("a/one", "one"),352 navLine("a/one", "one"),

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