v0.2.0 80c61e3

picker_test.go RawHistoryFolder

1package daemon
2
3import (
4	"context"
5	"errors"
6	"io"
7	"strings"
8	"testing"
9	"time"
10
11	"github.com/stretchr/testify/mock"
12	"github.com/stretchr/testify/require"
13
14	renderer "github.com/bnema/vev-vt/ansi"
15	"github.com/bnema/vev/internal/domain"
16	"github.com/bnema/vev/internal/persist"
17	"github.com/bnema/vev/internal/ports"
18	portsmocks "github.com/bnema/vev/internal/ports/mocks"
19	"github.com/bnema/vev/internal/usecase/layout"
20	"github.com/bnema/vev/internal/usecase/picker"
21	recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
22	themeui "github.com/bnema/vev/internal/usecase/theme"
23)
24
25var (
26	_ domain.TabStableID  = moveSourceLocator{}.TabID
27	_ domain.PaneStableID = moveSourceLocator{}.PaneID
28)
29
30// --- test doubles -----------------------------------------------------------
31
32type remotePickerSendErrorTransport struct {
33	err error
34}
35
36func (t *remotePickerSendErrorTransport) Send(ports.Frame) error   { return t.err }
37func (*remotePickerSendErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
38func (*remotePickerSendErrorTransport) Close() error               { return nil }
39
40type refusingSnapshotDeleteRepository struct {
41	noOpSnapshotRepository
42	err error
43}
44
45func (refusingSnapshotDeleteRepository) Publish(context.Context, ports.SnapshotPublication) error {
46	return nil
47}
48func (s refusingSnapshotDeleteRepository) DeleteIncarnation(context.Context, domain.IncarnationID) error {
49	return s.err
50}
51
52func newTestTabWithContext(p ports.PTY, ctx context.Context, cancel context.CancelFunc) *tab {
53	tb := newTab(p, domain.Size{Cols: 80, Rows: 23})
54	tb.ctx, tb.cancel = ctx, cancel
55	for _, pane := range tb.panes {
56		pane.ctx, pane.cancel = ctx, cancel
57	}
58	return tb
59}
60
61// stubClock returns timers whose channel never fires, so a scheduler under it
62// blocks in its debounce loop until the session context is cancelled. Used by
63
64func TestAltTForwardsToPTY(t *testing.T) {
65	writes := make(chan []byte, 2)
66	p, releasePTY := newBlockingPTYWithWrites(t, writes)
67	d, sess, ac, _ := newManualSessionWithPTYs(t, p)
68	defer releasePTY()
69
70	d.handleInput(sess, ac, []byte("\x1bt"))
71
72	require.False(t, ac.overlays.pickerActive())
73	require.Equal(t, []byte("\x1bt"), <-writes)
74}
75
76func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) {
77	d := newTestDaemon(t, nil, stubClock{})
78	ctx, cancel := context.WithCancel(d.serveCtx)
79	defer cancel()
80	current := &session{sessionCore: sessionCore{id: "s1", name: "alpha"}, ctx: ctx, cancel: cancel, tabs: []*tab{{}, {}}}
81	ringing := &session{sessionCore: sessionCore{id: "s2", name: "beta"}, ctx: ctx, cancel: cancel, tabs: []*tab{{name: "shell"}, {name: "logs"}}}
82	ringing.mu.Lock()
83	ringing.tabs[1].attention = true
84	ringing.tabs[1].attentionAt = time.Unix(10, 0)
85	ringing.mu.Unlock()
86	d.sessions[current.id] = current
87	d.sessions[ringing.id] = ringing
88
89	views, currentSelection := d.pickerViews(current, nil)
90
91	require.Equal(t, picker.SourceFilter{Session: current.id}, currentSelection)
92	require.Len(t, views, 2)
93	require.Equal(t, "alpha", views[0].Name)
94	require.Equal(t, []picker.TabEntry{{Name: "1"}, {Name: "2"}}, views[0].Tabs)
95	require.Equal(t, "beta ", views[1].Name)
96	require.Equal(t, []picker.TabEntry{{Name: "shell"}, {Name: "logs", Attention: true}}, views[1].Tabs)
97
98	model := picker.New(views, picker.SelectionConfig{Mode: picker.SelectNavigationTab, Current: picker.SourceFilter{Session: current.id, TabID: views[0].Tabs[0].TabID}})
99	model.Down()
100	model.Down()
101	target, ok := model.Selected()
102	require.True(t, ok)
103	require.Equal(t, "beta", target.Name)
104	require.NotNil(t, target.ExpectedCreatedAt)
105}
106
107func TestPickerViewsIncludesEphemeralSessions(t *testing.T) {
108	d := newTestDaemon(t, nil, stubClock{})
109	current := &session{sessionCore: sessionCore{id: "current", name: "current"}, tabs: []*tab{{}}}
110	ephemeral := &session{sessionCore: sessionCore{id: "ephemeral", name: "1", ephemeral: true}, tabs: []*tab{{}}}
111	d.sessions[current.id] = current
112	d.sessions[ephemeral.id] = ephemeral
113
114	views, _ := d.pickerViews(current, nil)
115
116	require.Len(t, views, 2)
117	var ephemeralView picker.SessionView
118	for _, view := range views {
119		if view.ID == ephemeral.id {
120			ephemeralView = view
121			break
122		}
123	}
124	require.Equal(t, ephemeral.id, ephemeralView.ID)
125	require.Equal(t, "1", ephemeralView.Name)
126}
127
128func TestPickerViewsOrdersByMRUWithEphemeralInterleaved(t *testing.T) {
129	d := newTestDaemon(t, nil, stubClock{})
130	old := &session{sessionCore: sessionCore{id: "old", name: "alpha"}, tabs: []*tab{{}}}
131	old.mruAt.Store(1)
132	eph := &session{sessionCore: sessionCore{id: "eph", name: "1", ephemeral: true}, tabs: []*tab{{}}}
133	eph.mruAt.Store(3)
134	recent := &session{sessionCore: sessionCore{id: "recent", name: "zeta"}, tabs: []*tab{{}}}
135	recent.mruAt.Store(2)
136	d.sessions[old.id] = old
137	d.sessions[eph.id] = eph
138	d.sessions[recent.id] = recent
139	d.stopped["halted-old"] = stoppedSession{name: "halted-old", createdAt: 10, lastUsedSeq: 1}
140	d.stopped["halted-new"] = stoppedSession{name: "halted-new", createdAt: 11, lastUsedSeq: 2}
141
142	views, _ := d.pickerViews(recent, nil)
143
144	names := make([]string, 0, len(views))
145	for _, v := range views {
146		names = append(names, v.Name)
147	}
148	// Live MRU-desc (ephemeral "1" is most recent), then stopped block MRU-desc.
149	require.Equal(t, []string{"1", "zeta", "alpha", "halted-new", "halted-old"}, names)
150	require.True(t, views[3].Stopped)
151	require.True(t, views[4].Stopped)
152}
153
154func TestPickerViewsMRUTieBreaksAlphabetically(t *testing.T) {
155	d := newTestDaemon(t, nil, stubClock{})
156	b := &session{sessionCore: sessionCore{id: "b", name: "bravo"}, tabs: []*tab{{}}}
157	a := &session{sessionCore: sessionCore{id: "a", name: "alpha"}, tabs: []*tab{{}}}
158	// Equal mruAt (both zero) must yield a stable alphabetical order.
159	d.sessions[b.id] = b
160	d.sessions[a.id] = a
161
162	views, _ := d.pickerViews(a, nil)
163
164	require.Equal(t, "alpha", views[0].Name)
165	require.Equal(t, "bravo", views[1].Name)
166}
167
168func TestPickerViewsGroupedModePutsNamedBeforeEphemeral(t *testing.T) {
169	d := newTestDaemon(t, nil, stubClock{})
170	d.pickerSort.Store(uint32(pickerSortGrouped))
171	eph := &session{sessionCore: sessionCore{id: "eph", name: "1", ephemeral: true}, tabs: []*tab{{}}}
172	eph.mruAt.Store(5)
173	named := &session{sessionCore: sessionCore{id: "named", name: "work"}, tabs: []*tab{{}}}
174	named.mruAt.Store(2)
175	named2 := &session{sessionCore: sessionCore{id: "named2", name: "notes"}, tabs: []*tab{{}}}
176	named2.mruAt.Store(3)
177	d.sessions[eph.id] = eph
178	d.sessions[named.id] = named
179	d.sessions[named2.id] = named2
180	d.stopped["halted"] = stoppedSession{name: "halted", createdAt: 9, lastUsedSeq: 9}
181
182	views, _ := d.pickerViews(named, nil)
183
184	names := make([]string, 0, len(views))
185	for _, v := range views {
186		names = append(names, v.Name)
187	}
188	// Named (MRU-desc) → ephemeral (MRU-desc) → stopped, even though the
189	// ephemeral session has the highest mruAt.
190	require.Equal(t, []string{"notes", "work", "1", "halted"}, names)
191}
192
193func TestPickerSortToggleFlipsModeAndKeepsSelection(t *testing.T) {
194	// Rows in recent mode are: ephemeral "1" (header + tab), "work" (header +
195	// two tabs), then the stopped block. Navigation starts on "work"'s active
196	// tab, so "j" walks down from there.
197	cases := []struct {
198		name        string
199		navigate    []byte
200		wantStopped bool
201	}{
202		{name: "live tab selection", navigate: []byte("j")},
203		{name: "stopped session selection", navigate: []byte("jj"), wantStopped: true},
204	}
205	for _, tc := range cases {
206		t.Run(tc.name, func(t *testing.T) {
207			d, sess, ac, sends, releases := newManualTabSession(t, 2)
208			defer releaseAll(releases)
209			sess.mu.Lock()
210			sess.registerAttachmentLocked(ac)
211			secondTabID := domain.TabStableID(sess.tabs[1].stableID)
212			sess.mu.Unlock()
213			sess.mruAt.Store(1)
214			// A second live session, ephemeral and more recently used: recent mode
215			// lists it first, grouped mode moves it below the named session.
216			eph := &session{sessionCore: sessionCore{id: "eph", name: "1", ephemeral: true}, tabs: []*tab{{}}}
217			eph.mruAt.Store(5)
218			d.mu.Lock()
219			d.sessions[eph.id] = eph
220			d.stopped["halted"] = stoppedSession{name: "halted", createdAt: 9, lastUsedSeq: 9}
221			d.mu.Unlock()
222
223			d.enterPicker(sess, ac)
224			awaitFrame(t, sends, ports.MsgOutput)
225			// Move off the attached session's active tab: a navigate rebuild snaps
226			// back to it unless the toggle preserves the selection.
227			d.handleInput(sess, ac, tc.navigate)
228			awaitFrame(t, sends, ports.MsgOutput)
229			ac.overlays.pickerMu.Lock()
230			before, ok := ac.overlays.picker.Selected()
231			ac.overlays.pickerMu.Unlock()
232			require.True(t, ok)
233			require.Equal(t, tc.wantStopped, before.Stopped)
234			if tc.wantStopped {
235				require.Equal(t, "halted", before.Name)
236			} else {
237				require.Equal(t, secondTabID, before.TabID)
238			}
239
240			d.handleInput(sess, ac, []byte("s"))
241			awaitFrame(t, sends, ports.MsgOutput)
242
243			require.Equal(t, uint32(pickerSortGrouped), d.pickerSort.Load())
244			require.True(t, ac.overlays.pickerActive())
245			ac.overlays.pickerMu.Lock()
246			selected, ok := ac.overlays.picker.Selected()
247			title := ac.overlays.pickerTitle
248			ac.overlays.pickerMu.Unlock()
249			require.True(t, ok)
250			require.Equal(t, before, selected)
251			require.Equal(t, " Sessions · grouped ", title)
252
253			d.handleInput(sess, ac, []byte("s"))
254			awaitFrame(t, sends, ports.MsgOutput)
255
256			require.Equal(t, uint32(pickerSortRecent), d.pickerSort.Load())
257			require.True(t, ac.overlays.pickerActive())
258			ac.overlays.pickerMu.Lock()
259			selected, ok = ac.overlays.picker.Selected()
260			title = ac.overlays.pickerTitle
261			ac.overlays.pickerMu.Unlock()
262			require.True(t, ok)
263			require.Equal(t, before, selected)
264			require.Equal(t, " Sessions · recent ", title)
265		})
266	}
267}
268
269func TestPickerTitleReflectsSortMode(t *testing.T) {
270	tests := []struct {
271		name string
272		mode pickerSortMode
273		want string
274	}{
275		{name: "recent", mode: pickerSortRecent, want: " Sessions · recent "},
276		{name: "grouped", mode: pickerSortGrouped, want: " Sessions · grouped "},
277	}
278	for _, tc := range tests {
279		t.Run(tc.name, func(t *testing.T) {
280			require.Equal(t, tc.want, pickerTitle(tc.mode))
281		})
282	}
283}
284
285func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) {
286	d := newTestDaemon(t, nil, stubClock{})
287	ctx, cancel := context.WithCancel(d.serveCtx)
288	defer cancel()
289	active := &session{sessionCore: sessionCore{id: "active", name: "active", createdAt: 23}, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}
290	d.sessions[active.id] = active
291	d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 24}
292
293	views, _ := d.pickerViews(active, nil)
294
295	require.Len(t, views, 2)
296	require.NotNil(t, views[0].ExpectedCreatedAt)
297	require.Equal(t, int64(23), *views[0].ExpectedCreatedAt)
298	require.NotNil(t, views[1].ExpectedCreatedAt)
299	require.Equal(t, int64(24), *views[1].ExpectedCreatedAt)
300}
301
302func TestPickerCanonicalViewsLeaveMoveFilteringToModel(t *testing.T) {
303	d := newTestDaemon(t, nil, stubClock{})
304	source := &session{sessionCore: sessionCore{id: "source", name: "source", incarnation: domain.IncarnationID{1}},
305		tabs: []*tab{
306			{stableID: "source-tab", panes: map[layout.PaneID]*pane{}},
307			{stableID: "sibling-tab", panes: map[layout.PaneID]*pane{}},
308		},
309	}
310	destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, tabs: []*tab{{stableID: "destination-tab", panes: map[layout.PaneID]*pane{}}}}
311	d.sessions[source.id] = source
312	d.sessions[destination.id] = destination
313	d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 9, incarnation: domain.IncarnationID{9}}
314	sourceLocator := moveSourceLocator{
315		Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
316		TabID:   "source-tab",
317		PaneID:  "source-pane",
318	}
319
320	views, current := d.pickerViews(source, nil)
321	require.Len(t, views, 3, "canonical capture includes active and stopped lifecycles")
322	require.Equal(t, picker.SourceFilter{Session: source.id, Incarnation: source.incarnation, TabID: "source-tab"}, current)
323	var sourceView picker.SessionView
324	for _, view := range views {
325		if view.ID == source.id {
326			sourceView = view
327		}
328	}
329	require.Equal(t, []picker.TabEntry{{TabID: "source-tab", Name: "1"}, {TabID: "sibling-tab", Name: "2"}}, sourceView.Tabs,
330		"daemon capture does not remove the move source")
331
332	paneModel := d.newPickerModel(source, nil, pickerMovePane, sourceLocator, picker.SourceFilter{
333		Session: source.id, Incarnation: source.incarnation, TabID: "sibling-tab",
334	})
335	paneTarget, ok := paneModel.Selected()
336	require.True(t, ok)
337	require.Equal(t, domain.TabStableID("sibling-tab"), paneTarget.TabID)
338
339	tabModel := d.newPickerModel(source, nil, pickerMoveTab, sourceLocator, picker.SourceFilter{})
340	tabTarget, ok := tabModel.Selected()
341	require.True(t, ok)
342	require.Equal(t, destination.id, tabTarget.Session)
343	require.Equal(t, destination.incarnation, tabTarget.Incarnation)
344	require.Equal(t, destination.name, tabTarget.Name)
345	require.Equal(t, -1, tabTarget.TabIndex)
346}
347
348func TestPickerMoveWithoutDestinationDoesNotPublishOverlay(t *testing.T) {
349	d, sess, ac, _, releases := newManualTabSession(t, 1)
350	defer releases[0]()
351	sess.mu.Lock()
352	sess.incarnation = domain.IncarnationID{1}
353	tb := sess.tabs[0]
354	sess.mu.Unlock()
355	tb.mu.Lock()
356	tabID := tb.stableID
357	paneID := tb.focusedPane().stableID
358	tb.mu.Unlock()
359	source := moveSourceLocator{
360		Session: moveSessionLocator{ID: sess.id, Incarnation: sess.incarnation, Name: sess.name},
361		TabID:   domain.TabStableID(tabID), PaneID: domain.PaneStableID(paneID),
362	}
363
364	for _, intent := range []pickerIntent{pickerMovePane, pickerMoveTab} {
365		err := d.enterPickerForIntent(sess, ac, intent, source)
366
367		require.ErrorIs(t, err, errNoMoveDestination)
368		require.False(t, ac.overlays.pickerActive())
369		ac.overlays.pickerMu.Lock()
370		require.Nil(t, ac.overlays.pickerPreview)
371		require.Nil(t, ac.overlays.pickerPreviewSession)
372		require.Zero(t, ac.overlays.pickerPreviewGeneration, "failed entry must not publish or retire a picker generation")
373		ac.overlays.pickerMu.Unlock()
374	}
375}
376
377func TestPickerMovePaneStoresSourceAndCleansPreviewSubscription(t *testing.T) {
378	p1, release1 := newBlockingPTY(t)
379	p2, release2 := newBlockingPTY(t)
380	defer release1()
381	defer release2()
382	d, sourceSession, ac, _ := newManualSessionWithPTYs(t, p1)
383	sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "z-source", domain.IncarnationID{1}
384	delete(d.sessions, domain.SessionID("manual"))
385	d.sessions[sourceSession.id] = sourceSession
386	targetTab := newTestTabWithContext(p2, sourceSession.ctx, sourceSession.cancel)
387	targetTab.stableID = "destination-tab"
388	targetSession := &session{sessionCore: sessionCore{id: "destination", name: "a-destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{targetTab}}
389	d.sessions[targetSession.id] = targetSession
390	sourceTab := sourceSession.tabs[0]
391	sourceTab.mu.Lock()
392	sourceTab.stableID = "source-tab"
393	sourcePaneID := sourceTab.focusedPane().stableID
394	sourceTab.mu.Unlock()
395	source := moveSourceLocator{
396		Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name},
397		TabID:   "source-tab", PaneID: domain.PaneStableID(sourcePaneID),
398	}
399
400	require.NoError(t, d.enterPickerForIntent(sourceSession, ac, pickerMovePane, source))
401	ac.overlays.pickerMu.Lock()
402	storedIntent, storedSource := ac.overlays.pickerIntent, ac.overlays.pickerSource
403	selected, ok := ac.overlays.picker.Selected()
404	generation := ac.overlays.pickerPreviewGeneration
405	require.Same(t, targetTab, ac.overlays.pickerPreview)
406	require.Same(t, targetSession, ac.overlays.pickerPreviewSession)
407	ac.overlays.pickerMu.Unlock()
408	require.Equal(t, pickerMovePane, storedIntent)
409	require.Equal(t, source, storedSource)
410	require.True(t, ok)
411	require.Equal(t, targetSession.id, selected.Session)
412	require.Equal(t, targetSession.incarnation, selected.Incarnation)
413	require.Equal(t, targetSession.name, selected.Name)
414	require.Equal(t, domain.TabStableID(targetTab.stableID), selected.TabID)
415	require.Equal(t, 0, selected.TabIndex)
416	require.Nil(t, selected.ExpectedCreatedAt)
417	rc := targetSession.renderCoordinator()
418	require.NotNil(t, rc)
419	rc.mu.Lock()
420	subscription, subscribed := rc.previewWakes[ac]
421	rc.mu.Unlock()
422	require.True(t, subscribed)
423	require.Equal(t, generation, subscription.generation)
424
425	d.enterPicker(sourceSession, ac)
426
427	rc.mu.Lock()
428	_, subscribed = rc.previewWakes[ac]
429	rc.mu.Unlock()
430	require.False(t, subscribed, "picker replacement must retire the prior cross-session preview subscription")
431	ac.overlays.pickerMu.Lock()
432	require.NotNil(t, ac.overlays.picker)
433	require.Equal(t, pickerNavigate, ac.overlays.pickerIntent)
434	require.Equal(t, moveSourceLocator{}, ac.overlays.pickerSource)
435	ac.overlays.pickerMu.Unlock()
436
437	d.closePicker(ac)
438	ac.overlays.pickerMu.Lock()
439	require.Nil(t, ac.overlays.picker)
440	require.Nil(t, ac.overlays.pickerPreview)
441	ac.overlays.pickerMu.Unlock()
442}
443
444func TestPickerMoveTabPreviewsDestinationActiveTabWithoutActivatingIt(t *testing.T) {
445	p1, release1 := newBlockingPTY(t)
446	p2, release2 := newBlockingPTY(t)
447	p3, release3 := newBlockingPTY(t)
448	defer release1()
449	defer release2()
450	defer release3()
451	d, sourceSession, ac, _ := newManualSessionWithPTYs(t, p1)
452	sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "source", domain.IncarnationID{1}
453	delete(d.sessions, domain.SessionID("manual"))
454	d.sessions[sourceSession.id] = sourceSession
455	target := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true},
456		ctx: sourceSession.ctx, cancel: func() {},
457		tabs: []*tab{
458			newTestTabWithContext(p2, sourceSession.ctx, sourceSession.cancel),
459			newTestTabWithContext(p3, sourceSession.ctx, sourceSession.cancel),
460		},
461	}
462	target.tabs[0].stableID, target.tabs[1].stableID = "first", "active"
463	d.sessions[target.id] = target
464	source := moveSourceLocator{
465		Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name},
466		TabID:   domain.TabStableID(sourceSession.tabs[0].stableID),
467	}
468
469	require.NoError(t, d.enterPickerForIntent(sourceSession, ac, pickerMoveTab, source))
470
471	ac.overlays.pickerMu.Lock()
472	selected, ok := ac.overlays.picker.Selected()
473	require.Same(t, target.tabs[0], ac.overlays.pickerPreview)
474	ac.overlays.pickerMu.Unlock()
475	require.True(t, ok)
476	require.Equal(t, target.id, selected.Session)
477	require.Equal(t, target.incarnation, selected.Incarnation)
478	require.Equal(t, target.name, selected.Name)
479	require.Equal(t, -1, selected.TabIndex)
480	require.Nil(t, selected.ExpectedCreatedAt)
481	require.Equal(t, 0, testAttachmentTabIndex(target), "preview must not mutate destination view")
482	d.closePicker(ac)
483}
484
485func TestPickerMoveRefreshPreservesSelectedDestination(t *testing.T) {
486	t.Run("pane destination stable tab", func(t *testing.T) {
487		d, sourceSession, ac, _, sourceReleases := newManualTabSession(t, 1)
488		defer releaseAll(sourceReleases)
489		sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "source", domain.IncarnationID{1}
490		sourceSession.tabs[0].stableID = "source-tab"
491		delete(d.sessions, domain.SessionID("manual"))
492		d.sessions[sourceSession.id] = sourceSession
493
494		firstPTY, releaseFirst := newBlockingPTY(t)
495		defer releaseFirst()
496		secondPTY, releaseSecond := newBlockingPTY(t)
497		defer releaseSecond()
498		firstTab := newTestTabWithContext(firstPTY, sourceSession.ctx, sourceSession.cancel)
499		secondTab := newTestTabWithContext(secondPTY, sourceSession.ctx, sourceSession.cancel)
500		firstTab.stableID, secondTab.stableID = "first", "selected"
501		destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{firstTab, secondTab}}
502		d.sessions[destination.id] = destination
503		source := moveSourceLocator{
504			Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name},
505			TabID:   domain.TabStableID(sourceSession.tabs[0].stableID),
506		}
507
508		require.NoError(t, d.enterPickerForIntent(sourceSession, ac, pickerMovePane, source))
509		ac.overlays.pickerMu.Lock()
510		ac.overlays.picker.Down()
511		before, ok := ac.overlays.picker.Selected()
512		ac.overlays.pickerMu.Unlock()
513		require.True(t, ok)
514		require.Equal(t, domain.TabStableID("selected"), before.TabID)
515
516		destination.mu.Lock()
517		destination.tabs[0], destination.tabs[1] = destination.tabs[1], destination.tabs[0]
518		destination.mu.Unlock()
519		d.refreshPickerOpts(ac, pickerRefreshOptions{nearestRow: -1})
520
521		ac.overlays.pickerMu.Lock()
522		after, ok := ac.overlays.picker.Selected()
523		ac.overlays.pickerMu.Unlock()
524		require.True(t, ok)
525		require.Equal(t, before.Session, after.Session)
526		require.Equal(t, before.Incarnation, after.Incarnation)
527		require.Equal(t, before.TabID, after.TabID)
528		require.Equal(t, 0, after.TabIndex, "mutable index follows the preserved stable tab")
529		d.closePicker(ac)
530	})
531
532	t.Run("tab destination session", func(t *testing.T) {
533		d, sourceSession, ac, _, sourceReleases := newManualTabSession(t, 1)
534		defer releaseAll(sourceReleases)
535		sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "source", domain.IncarnationID{1}
536		sourceSession.tabs[0].stableID = "source-tab"
537		delete(d.sessions, domain.SessionID("manual"))
538		d.sessions[sourceSession.id] = sourceSession
539
540		releases := make([]func(), 0, 2)
541		for i, destination := range []struct {
542			id   domain.SessionID
543			name string
544		}{{id: "first", name: "0-first"}, {id: "selected", name: "1-selected"}} {
545			pty, release := newBlockingPTY(t)
546			releases = append(releases, release)
547			tb := newTestTabWithContext(pty, sourceSession.ctx, sourceSession.cancel)
548			tb.stableID = string(destination.id) + "-tab"
549			d.sessions[destination.id] = &session{sessionCore: sessionCore{id: destination.id, name: destination.name, incarnation: domain.IncarnationID{byte(i + 2)}, ephemeral: true}, ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{tb}}
550		}
551		defer releaseAll(releases)
552		source := moveSourceLocator{
553			Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name},
554			TabID:   domain.TabStableID(sourceSession.tabs[0].stableID),
555		}
556
557		require.NoError(t, d.enterPickerForIntent(sourceSession, ac, pickerMoveTab, source))
558		ac.overlays.pickerMu.Lock()
559		ac.overlays.picker.Down()
560		before, ok := ac.overlays.picker.Selected()
561		ac.overlays.pickerMu.Unlock()
562		require.True(t, ok)
563		require.Equal(t, domain.SessionID("selected"), before.Session)
564
565		d.refreshPickerOpts(ac, pickerRefreshOptions{nearestRow: -1})
566
567		ac.overlays.pickerMu.Lock()
568		after, ok := ac.overlays.picker.Selected()
569		ac.overlays.pickerMu.Unlock()
570		require.True(t, ok)
571		require.Equal(t, before, after)
572		d.closePicker(ac)
573	})
574}
575
576func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) {
577	p, release := newBlockingPTY(t)
578	defer release()
579	d, current, ac, _ := newManualSessionWithPTYs(t, p)
580	ctx, cancel := context.WithCancel(d.serveCtx)
581	defer cancel()
582	original := &session{sessionCore: sessionCore{id: "ephemeral-1", name: "2", ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}
583	d.sessions[original.id] = original
584
585	views, _ := d.pickerViews(current, nil)
586	var originalView picker.SessionView
587	for _, view := range views {
588		if view.ID == original.id {
589			originalView = view
590			break
591		}
592	}
593	model := picker.New([]picker.SessionView{originalView}, picker.SelectionConfig{Mode: picker.SelectNavigationTab, Current: picker.SourceFilter{Session: original.id, TabID: originalView.Tabs[0].TabID}})
594	target, ok := model.Selected()
595	require.True(t, ok)
596
597	delete(d.sessions, original.id)
598	replacement := &session{sessionCore: sessionCore{id: "ephemeral-2", name: original.name, ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}
599	d.sessions[replacement.id] = replacement
600
601	require.Error(t, d.switchToTarget(current, ac, target))
602	require.Same(t, current, ac.currentSession())
603}
604
605func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) {
606	p1, releasePTY1 := newBlockingPTY(t)
607	p2, releasePTY2 := newBlockingPTY(t)
608	defer releasePTY1()
609	defer releasePTY2()
610	d, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
611	d.shell = "/bin/sh"
612	sess.tabs[1].name = "logs"
613
614	pane0 := sess.tabs[0].focusedPane()
615	pane0.mu.Lock()
616	pane0.title.processName = "vim"
617	pane0.title.processNameValid = true
618	pane0.mu.Unlock()
619
620	sess.mu.Lock()
621	sess.tabs[1].attention = true
622	sess.mu.Unlock()
623
624	views, _ := d.pickerViews(sess, nil)
625
626	require.Len(t, views, 1)
627	require.Equal(t, []picker.TabEntry{
628		{TabID: domain.TabStableID(sess.tabs[0].stableID), Name: "1", Detail: " (vim)"},
629		{TabID: domain.TabStableID(sess.tabs[1].stableID), Name: "logs", Detail: " (sh)", Attention: true},
630	}, views[0].Tabs)
631}
632
633func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) {
634	p1, releasePTY1 := newBlockingPTY(t)
635	p2, releasePTY2 := newBlockingPTY(t)
636	defer releasePTY1()
637	defer releasePTY2()
638	d, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
639	d.shell = "/bin/sh"
640	sess.tabs[1].name = "logs"
641
642	pane0 := sess.tabs[0].focusedPane()
643	pane0.mu.Lock()
644	pane0.title.processName = "vim"
645	pane0.title.processNameValid = true
646	pane0.title.terminalTitle = "server.go — vev"
647	pane0.mu.Unlock()
648
649	d.ApplyConfig(domain.Config{Tabs: domain.TabsConfig{TerminalTitle: false}})
650
651	views, _ := d.pickerViews(sess, nil)
652
653	require.Len(t, views, 1)
654	require.Equal(t, []picker.TabEntry{
655		{TabID: domain.TabStableID(sess.tabs[0].stableID), Name: "1", Detail: " (vim)"},
656		{TabID: domain.TabStableID(sess.tabs[1].stableID), Name: "logs", Detail: " (sh)"},
657	}, views[0].Tabs)
658}
659
660func TestPickerWaitsForRestoringTargetBeforeSwitching(t *testing.T) {
661	p, release := newBlockingPTY(t)
662	defer release()
663	d, from, ac, _ := newManualSessionWithPTYs(t, p)
664	record := durableRecoveryRecord(0)
665	record.Name = "restoring"
666	state, done := initialSessionState(record)
667	d.stopped[record.Name] = stoppedSession{
668		name:        record.Name,
669		createdAt:   record.CreatedAt,
670		incarnation: record.IncarnationID,
671		record:      record,
672		state:       state,
673		restoreDone: done,
674	}
675	factory := &recoveryCountingPTYFactory{}
676	d.ptys = factory
677
678	result := make(chan error, 1)
679	go func() {
680		result <- d.switchToTarget(from, ac, picker.Target{Name: record.Name, Stopped: true})
681	}()
682
683	select {
684	case err := <-result:
685		t.Fatalf("picker switch returned before target restoration completed: %v", err)
686	case <-time.After(25 * time.Millisecond):
687	}
688	require.Zero(t, factory.calls.Load())
689	require.Same(t, from, ac.currentSession())
690
691	ctx, cancel := context.WithCancel(d.serveCtx)
692	defer cancel()
693	target := &session{sessionCore: sessionCore{id: "restored", name: record.Name, createdAt: record.CreatedAt}, ctx: ctx, cancel: cancel, tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})}}
694	d.mu.Lock()
695	delete(d.stopped, record.Name)
696	d.sessions[target.id] = target
697	closeRuntimeRestoreDoneLocked(done)
698	d.mu.Unlock()
699
700	require.NoError(t, <-result)
701	require.Same(t, target, ac.currentSession())
702}
703
704func TestRestoreCancellationTransitionsBeforePickerCompletion(t *testing.T) {
705	p, release := newBlockingPTY(t)
706	defer release()
707	d, from, ac, _ := newManualSessionWithPTYs(t, p)
708	record := durableRecoveryRecord(0)
709	record.Name = "restoring"
710	repository := &cancellationRecoveryRepository{started: make(chan struct{})}
711	catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})
712	coordinator := recoveryusecase.NewCoordinator(catalogue, repository, nil)
713	WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d)
714	WithSnapshotRepository(repository)(d)
715	WithRecoveryCoordinator(coordinator)(d)
716	restoreCtx, cancelRestore := context.WithCancel(context.Background())
717	restored := make(chan struct{})
718	go func() {
719		d.restoreCatalogue(restoreCtx, mustDurableRecords(t, catalogue))
720		close(restored)
721	}()
722	<-repository.started
723
724	result := make(chan error, 1)
725	go func() {
726		result <- d.switchToTarget(from, ac, picker.Target{Name: record.Name, Stopped: true})
727	}()
728	cancelRestore()
729
730	require.Error(t, <-result)
731	<-restored
732	require.Same(t, from, ac.currentSession())
733	d.mu.Lock()
734	entry := d.stopped[record.Name]
735	d.mu.Unlock()
736	require.Equal(t, ports.SessionBroken, entry.state)
737	require.Equal(t, "restore interrupted", entry.record.DegradedReason)
738	select {
739	case <-entry.restoreDone:
740	default:
741		t.Fatal("restore completion was not signaled")
742	}
743}
744
745func TestPickerRejectsCatalogueTargetsWithoutFreshRuntime(t *testing.T) {
746	tests := []struct {
747		name         string
748		broken       bool
749		runtimeState ports.SessionState
750	}{
751		{name: "broken", broken: true, runtimeState: ports.SessionBroken},
752		{name: "healthy without restored runtime", runtimeState: ports.SessionDown},
753	}
754
755	for _, tt := range tests {
756		t.Run(tt.name, func(t *testing.T) {
757			p, release := newBlockingPTY(t)
758			defer release()
759			d, from, ac, _ := newManualSessionWithPTYs(t, p)
760			store, storeState := newMockStore(t)
761			record := durableRecoveryRecord(0)
762			record.Name = "unsafe"
763			if tt.broken {
764				record.DegradedReason = "checkpoint validation failed"
765			}
766			catalogue := persist.New(store)
767			WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d)
768			d.stopped[record.Name] = stoppedSession{
769				name:        record.Name,
770				createdAt:   record.CreatedAt,
771				incarnation: record.IncarnationID,
772				record:      record,
773				state:       tt.runtimeState,
774			}
775			factory := &recoveryCountingPTYFactory{}
776			d.ptys = factory
777
778			err := d.switchToTarget(from, ac, picker.Target{Name: record.Name, Stopped: true})
779
780			var userError *domain.UserError
781			require.ErrorAs(t, err, &userError)
782			require.Equal(t, domain.NoticeSessionUnavailable, userError.Code)
783			var protocolError *protoErr
784			require.ErrorAs(t, err, &protocolError)
785			require.Equal(t, ports.ErrInternal, protocolError.code)
786			require.Zero(t, factory.calls.Load(), "unsafe target must not open a PTY")
787			require.Same(t, from, ac.currentSession())
788
789			storeState.mu.Lock()
790			sets := storeState.sets
791			storeState.mu.Unlock()
792			require.Zero(t, sets, "unsafe target must not mutate the catalogue")
793			d.mu.Lock()
794			entry := d.stopped[record.Name]
795			d.mu.Unlock()
796			require.Equal(t, record, entry.record)
797			require.Equal(t, tt.runtimeState, entry.state)
798		})
799	}
800}
801
802func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) {
803	p1, release1 := newBlockingPTY(t)
804	p2, release2 := newBlockingPTY(t)
805	p3, release3 := newBlockingPTY(t)
806	defer release1()
807	defer release2()
808	defer release3()
809	d, from, ac, sends := newManualSessionWithPTYs(t, p1)
810	d.ptys = newFactorySeq(t, p2, p3)
811	d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}, record: domain.CatalogueRecord{Name: "work"}, state: ports.SessionDown}
812
813	d.resumeStoppedAndSwitch(from, ac, picker.Target{Name: "work", Stopped: true})
814	awaitFrame(t, sends, ports.MsgOutput)
815
816	target := ac.currentSession()
817	require.NotNil(t, target)
818	require.Equal(t, "work", target.name)
819	require.Len(t, target.tabs, 2)
820	require.Equal(t, "shell", target.tabs[0].name)
821	require.Equal(t, "logs", target.tabs[1].name)
822}
823
824func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
825	d, sess, ac, sends, releases := newManualTabSession(t, 2)
826	sess.mu.Lock()
827	sess.registerAttachmentLocked(ac)
828	sess.mu.Unlock()
829	d.ptys = newBlockingOpenFactory(t, d)
830	defer func() {
831		for _, release := range releases {
832			release()
833		}
834	}()
835
836	d.enterPicker(sess, ac)
837	awaitFrame(t, sends, ports.MsgOutput)
838	d.handleInput(sess, ac, []byte("j"))
839	awaitFrame(t, sends, ports.MsgOutput)
840	d.handleInput(sess, ac, []byte("\r"))
841
842	require.Equal(t, 1, testAttachmentTabIndex(sess))
843	requireFloatingInitialized(t, testAttachmentTab(sess))
844	awaitFrame(t, sends, ports.MsgOutput)
845	d.enterPicker(sess, ac)
846	awaitFrame(t, sends, ports.MsgOutput)
847	clk := &signalClock{timers: make(chan *signalTimer, 1)}
848	d.clock = clk
849	d.handleInput(sess, ac, []byte("\x1b"))
850	timer := <-clk.timers
851	require.True(t, ac.overlays.pickerActive())
852	timer.ch <- time.Now()
853	require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond)
854	awaitFrame(t, sends, ports.MsgOutput)
855}
856
857func TestPickerSplitArrowNavigatesWithoutExiting(t *testing.T) {
858	cases := []struct {
859		name       string
860		input      [][]byte
861		wantActive int
862	}{
863		{name: "escape then down arrow", input: [][]byte{[]byte("\x1b"), []byte("[B")}, wantActive: 1},
864		{name: "escape then up arrow", input: [][]byte{[]byte("j"), []byte("\x1b"), []byte("[A")}, wantActive: 0},
865		{name: "split down arrow", input: [][]byte{[]byte("\x1b["), []byte("B")}, wantActive: 1},
866		{name: "split SS3 down arrow", input: [][]byte{[]byte("\x1bO"), []byte("B")}, wantActive: 1},
867	}
868	for _, tc := range cases {
869		t.Run(tc.name, func(t *testing.T) {
870			d, sess, ac, sends, releases := newManualTabSession(t, 2)
871			defer func() {
872				for _, release := range releases {
873					release()
874				}
875			}()
876
877			d.enterPicker(sess, ac)
878			awaitFrame(t, sends, ports.MsgOutput)
879			for _, input := range tc.input {
880				d.handleInput(sess, ac, input)
881			}
882			require.True(t, ac.overlays.pickerActive())
883			d.handleInput(sess, ac, []byte("\r"))
884			require.Equal(t, tc.wantActive, testAttachmentTabIndex(sess))
885		})
886	}
887}
888
889func TestPickerNavigationBackSendFailureKeepsPickerOpen(t *testing.T) {
890	d, sess, ac, _, releases := newManualTabSession(t, 1)
891	defer releases[0]()
892	const sendErr = "navigation back send failed"
893	tr := &remotePickerSendErrorTransport{err: errors.New(sendErr)}
894	ac.replaceTransport(tr)
895	ac.startupOverlay = ports.StartupOverlaySessionPicker
896	ac.navigationCapabilities = ports.NavigationCapabilityBack
897	ac.overlays.pickerMu.Lock()
898	ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})
899	ac.overlays.pickerGeneration++
900	ac.overlays.pickerMu.Unlock()
901
902	rc := d.attachCoordinator(sess, nil, ac, true)
903	token := sess.attachmentToken(ac, tr)
904	token.lease = rc.attachmentLease(ac)
905	ac.publishAttachmentCapability(token)
906	effect, admitted := ac.beginAttachmentEffect(token)
907	require.True(t, admitted)
908	defer effect.End()
909	token.effect = effect
910
911	d.handlePickerInput(ac, []byte("\r"), effect)
912	require.True(t, ac.overlays.pickerActive(), "failed navigation send must leave the picker open")
913	require.Same(t, sess, ac.currentAttachmentSession())
914	history := d.notices.history()
915	require.NotEmpty(t, history)
916	require.Contains(t, history[len(history)-1].Details, sendErr)
917}
918
919func TestPickerNavigationBackWithoutCallerTicketKeepsPickerOpen(t *testing.T) {
920	for _, tc := range []struct {
921		name    string
922		passNil bool
923	}{
924		{name: "empty effects"},
925		{name: "nil first effect", passNil: true},
926	} {
927		t.Run(tc.name, func(t *testing.T) {
928			d, sess, ac, _, releases := newManualTabSession(t, 1)
929			defer releases[0]()
930			ac.startupOverlay = ports.StartupOverlaySessionPicker
931			ac.navigationCapabilities = ports.NavigationCapabilityBack
932			ac.overlays.pickerMu.Lock()
933			ac.overlays.picker = picker.New([]picker.SessionView{{ID: sess.id, Name: sess.name, Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, picker.SelectionConfig{})
934			ac.overlays.pickerGeneration++
935			ac.overlays.pickerMu.Unlock()
936
937			if tc.passNil {
938				d.handlePickerInput(ac, []byte{3}, nil)
939			} else {
940				d.handlePickerInput(ac, []byte{3})
941			}
942
943			require.True(t, ac.overlays.pickerActive(), "back with missing effect must keep picker open when fresh admission is unavailable")
944		})
945	}
946}
947
948func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) {
949	d, sess, ac, sends, releases := newManualTabSession(t, 1)
950	defer releases[0]()
951
952	d.enterPicker(sess, ac)
953	awaitFrame(t, sends, ports.MsgOutput)
954	clk := &signalClock{timers: make(chan *signalTimer, 1)}
955	d.clock = clk
956	d.handleInput(sess, ac, []byte("\x1b"))
957	timer := <-clk.timers
958	require.True(t, ac.overlays.pickerActive())
959	timer.ch <- time.Now()
960	require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond)
961}
962
963func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {
964	p1, releasePTY1 := newBlockingPTY(t)
965	p2, releasePTY2 := newBlockingPTY(t)
966	defer releasePTY1()
967	defer releasePTY2()
968	d := newTestDaemon(t, nil, stubClock{})
969	tr1, sends1 := newCapturingTransport(t)
970	tr2, _ := newCapturingTransport(t)
971	ac1 := &attachedClient{tr: tr1, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
972	ac1.initOverlays()
973	ac2 := &attachedClient{tr: tr2, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
974	ac2.initOverlays()
975	sctx1, cancel1 := context.WithCancel(d.serveCtx)
976	sctx2, cancel2 := context.WithCancel(d.serveCtx)
977	defer cancel1()
978	defer cancel2()
979	sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", ephemeral: true, attachments: map[*attachedClient]struct{}{ac1: {}}}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}}
980	sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", attachments: map[*attachedClient]struct{}{ac2: {}}}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}}
981	ac1.setSession(sess1)
982	ac2.setSession(sess2)
983	d.sessions[sess1.id] = sess1
984	d.sessions[sess2.id] = sess2
985
986	d.firstPaint(sess1, ac1)
987	awaitFrame(t, sends1, ports.MsgOutput)
988	d.stealClientForTarget(sess1, ac1, sess2, picker.Target{Session: sess2.id})
989	d.firstPaint(sess2, ac1)
990	awaitFrame(t, sends1, ports.MsgOutput)
991	require.Same(t, sess2, ac1.currentSession())
992	for len(sends1) > 0 {
993		<-sends1
994	}
995	oldPane := sess1.tabs[0].focusedPane()
996	oldPane.screen.Write([]byte("stale-damage"))
997	require.NotEmpty(t, oldPane.screen.Damage())
998
999	d.paint(sess1, ac1, false, nil)
1000
1001	require.Zero(t, len(sends1), "stale paint from old session sent a frame")
1002	require.NotEmpty(t, oldPane.screen.Damage(), "stale paint from old session consumed damage")
1003}
1004
1005func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing.T) {
1006	p1, releasePTY1 := newBlockingPTY(t)
1007	p2, releasePTY2 := newBlockingPTY(t)
1008	defer releasePTY1()
1009	defer releasePTY2()
1010	d := newTestDaemon(t, nil, stubClock{})
1011	enteredSend := make(chan struct{})
1012	releaseSend := make(chan struct{})
1013	tr := portsmocks.NewMockTransport(t)
1014	tr.EXPECT().Send(mock.Anything).RunAndReturn(func(ports.Frame) error {
1015		close(enteredSend)
1016		<-releaseSend
1017		return nil
1018	}).Once()
1019	tr.EXPECT().Close().Return(nil).Maybe()
1020	ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
1021	ac.initOverlays()
1022	handoffAtSendMu := make(chan struct{})
1023	ac.renderStages = renderStageHooks{
1024		handoffRebase: func() { close(handoffAtSendMu) },
1025	}
1026	sctx1, cancel1 := context.WithCancel(d.serveCtx)
1027	sctx2, cancel2 := context.WithCancel(d.serveCtx)
1028	defer cancel1()
1029	defer cancel2()
1030	sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", attachments: map[*attachedClient]struct{}{ac: {}}}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}}
1031	sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta"}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}}
1032	ac.setSession(sess1)
1033	d.sessions[sess1.id] = sess1
1034	d.sessions[sess2.id] = sess2
1035
1036	sess1.tabs[0].focusedPane().screen.Write([]byte("paint while switching"))
1037	paintDone := make(chan struct{})
1038	go func() {
1039		d.paint(sess1, ac, false, nil)
1040		close(paintDone)
1041	}()
1042	<-enteredSend
1043
1044	switchDone := make(chan attachmentTransitionResult, 1)
1045	switchErr := make(chan error, 1)
1046	go func() {
1047		result, err := d.transitionAttachment(attachmentTransitionRequest{
1048			source: sess1,
1049			target: sess2,
1050			next:   ac,
1051
1052			expectedTransport: ac.transportSnapshot(),
1053			ready:             true,
1054		})
1055		switchDone <- result
1056		switchErr <- err
1057	}()
1058
1059	var result attachmentTransitionResult
1060	select {
1061	case result = <-switchDone:
1062		require.NoError(t, <-switchErr)
1063	case <-time.After(2 * time.Second):
1064		t.Fatal("session switch waited for in-flight transport send")
1065	}
1066	require.True(t, result.published.attachmentCurrent())
1067	select {
1068	case <-handoffAtSendMu:
1069		t.Fatal("output rebase ran during architecture publication")
1070	default:
1071	}
1072
1073	close(releaseSend)
1074	awaitTestCompletion(t, paintDone, "paint did not finish after Send was released")
1075	for _, cleanup := range result.cleanups {
1076		cleanup.finish()
1077	}
1078}
1079
1080func TestPickerPreviewGenerationRejectsStaleSubscriptionReplacement(t *testing.T) {
1081	rc := newRenderCoordinator(renderCoordinatorOptions{})
1082	viewer := &attachedClient{}
1083	newer := func(renderWake) {}
1084
1085	require.True(t, rc.subscribePreviewFor(viewer, 2, newer))
1086	require.False(t, rc.subscribePreviewFor(viewer, 1, func(renderWake) {}))
1087	rc.teardownPreviewFor(viewer, 1)
1088
1089	rc.mu.Lock()
1090	subscription, ok := rc.previewWakes[viewer]
1091	rc.mu.Unlock()
1092	require.True(t, ok)
1093	require.Equal(t, uint64(2), subscription.generation)
1094	require.NotNil(t, subscription.fn)
1095}
1096
1097func TestPickerPreviewPostSubscribeRevalidationKeepsNewerSubscription(t *testing.T) {
1098	d := newTestDaemon(t, nil, stubClock{})
1099	viewer := &attachedClient{}
1100	viewer.initOverlays()
1101	target := &session{}
1102	tab := &tab{}
1103	rc := newRenderCoordinator(renderCoordinatorOptions{})
1104
1105	viewer.overlays.pickerMu.Lock()
1106	viewer.overlays.pickerPreviewGeneration = 2
1107	viewer.overlays.pickerPreviewSession = target
1108	viewer.overlays.pickerPreview = tab
1109	viewer.overlays.pickerMu.Unlock()
1110	require.True(t, rc.subscribePreviewFor(viewer, 2, func(renderWake) {}))
1111
1112	// This is the exact post-subscribe check from registerPreviewForSelection.
1113	// It represents a generation-1 subscriber resuming after generation 2 won.
1114	d.revalidatePreviewSubscription(viewer, rc, target, tab, 1)
1115
1116	rc.mu.Lock()
1117	subscription, ok := rc.previewWakes[viewer]
1118	rc.mu.Unlock()
1119	require.True(t, ok)
1120	require.Equal(t, uint64(2), subscription.generation)
1121}
1122
1123func TestPickerDestroyedTabCleanupDoesNotClearNewerGeneration(t *testing.T) {
1124	d := newTestDaemon(t, nil, stubClock{})
1125	viewer := &attachedClient{}
1126	viewer.initOverlays()
1127	newerSession := &session{}
1128	newerTab := &tab{}
1129	rc := newRenderCoordinator(renderCoordinatorOptions{})
1130
1131	viewer.overlays.pickerMu.Lock()
1132	viewer.overlays.pickerPreviewGeneration = 2
1133	viewer.overlays.pickerPreviewSession = newerSession
1134	viewer.overlays.pickerPreview = newerTab
1135	viewer.overlays.pickerMu.Unlock()
1136	require.True(t, rc.subscribePreviewFor(viewer, 2, func(renderWake) {}))
1137
1138	require.False(t, d.clearPreviewGeneration(viewer, 1))
1139	require.True(t, pickerPreviewCurrent(viewer, newerSession, newerTab, 2))
1140	rc.mu.Lock()
1141	_, subscribed := rc.previewWakes[viewer]
1142	rc.mu.Unlock()
1143	require.True(t, subscribed)
1144}
1145
1146func TestCaptureOverlayLayersPreservesPickerSemanticSurfacesAcrossFallbacks(t *testing.T) {
1147	palette := [16]renderer.RGB{}
1148	palette[2] = renderer.RGB{R: 10, G: 230, B: 120}
1149	palette[10] = palette[2]
1150	accentTheme := themeui.Theme{
1151		Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
1152		HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
1153		Palette: palette, PaletteKnown: 1<<2 | 1<<10,
1154	}
1155	indexedTheme := accentTheme
1156	indexedTheme.TrueColor = false
1157	neutralTheme := accentTheme
1158	neutralTheme.PaletteKnown = 0
1159	neutralTheme.SchemeKnown = false
1160
1161	defaults := domain.Defaults()
1162	paletteOff := defaults
1163	paletteOff.ThemePalette = false
1164	forcedDark := defaults
1165	forcedDark.Theme = domain.ThemeDark
1166	forcedLight := defaults
1167	forcedLight.Theme = domain.ThemeLight
1168	tests := []struct {
1169		name    string
1170		raw     themeui.Theme
1171		config  domain.Config
1172		indexed bool
1173	}{
1174		{name: "truecolor accent", raw: accentTheme, config: defaults},
1175		{name: "indexed only", raw: indexedTheme, config: defaults, indexed: true},
1176		{name: "palette off", raw: accentTheme, config: paletteOff},
1177		{name: "forced dark", raw: accentTheme, config: forcedDark},
1178		{name: "forced light", raw: accentTheme, config: forcedLight},
1179		{name: "neutral fallback", raw: neutralTheme, config: defaults},
1180	}
1181
1182	for _, tt := range tests {
1183		t.Run(tt.name, func(t *testing.T) {
1184			d := newTestDaemon(t, nil, stubClock{})
1185			d.ApplyConfig(tt.config)
1186			applied := d.resolveAppliedTheme(tt.raw)
1187			state := capturedRenderState{
1188				theme:  applied.Raw,
1189				styles: applied.Resolved.Styles,
1190				layout: capturedTabLayout{area: domain.Rect{Width: 100, Height: 38}},
1191			}
1192			snap := &overlayRenderSnapshot{
1193				pickerActive: true,
1194				pickerModel: picker.New([]picker.SessionView{
1195					{ID: "selected", Name: "selected", Tabs: []picker.TabEntry{{Name: "one"}}, Active: 0},
1196					{ID: "inactive", Name: "inactive", Tabs: []picker.TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0},
1197				}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}),
1198			}
1199
1200			captureOverlayLayers(&state, snap, domain.PaletteConfig{})
1201
1202			require.False(t, state.styles.PickerDescription.HasBackgroundRGB)
1203			require.False(t, state.styles.PickerSeparator.HasBackgroundRGB)
1204			if tt.indexed {
1205				require.Equal(t, 2, state.styles.PickerDescription.Foreground)
1206				require.Equal(t, 2, state.styles.PickerSeparator.Foreground)
1207			}
1208
1209			inner := state.overlays.picker.inner
1210			layout := picker.ChooseLayout(domain.Size{Cols: inner.Width, Rows: inner.Height})
1211			require.Equal(t, picker.LayoutHorizontal, layout.Mode)
1212			require.True(t, inner.At(5, 3).Style.Equal(state.styles.PickerDescription), "description text keeps a muted foreground on the terminal background")
1213			require.True(t, inner.At(layout.List.Width-1, 3).Style.Equal(state.styles.PickerBase), "inactive row filler keeps the terminal background")
1214			for y := layout.Separator.Y; y < layout.Separator.Y+layout.Separator.Height; y++ {
1215				for x := layout.Separator.X; x < layout.Separator.X+layout.Separator.Width; x++ {
1216					cell := inner.At(x, y)
1217					require.Equal(t, '│', cell.Rune)
1218					require.True(t, cell.Style.Equal(state.styles.PickerSeparator), "separator keeps a foreground-only style on the terminal background")
1219				}
1220			}
1221		})
1222	}
1223}
1224
1225func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testing.T) {
1226	model := picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
1227	state := capturedRenderState{theme: themeui.BuiltinDark, styles: themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles}
1228	cases := []struct {
1229		name       string
1230		size       domain.Size
1231		marker     rune
1232		wantMode   picker.LayoutMode
1233		wantSep    rune
1234		wantMarker bool
1235	}{
1236		{name: "horizontal", size: domain.Size{Cols: 100, Rows: 40}, marker: '◆', wantMode: picker.LayoutHorizontal, wantSep: '│', wantMarker: true},
1237		{name: "stacked", size: domain.Size{Cols: 40, Rows: 20}, marker: '◇', wantMode: picker.LayoutStacked, wantSep: '─', wantMarker: true},
1238		{name: "list only", size: domain.Size{Cols: 24, Rows: 11}, marker: '●', wantMode: picker.LayoutListOnly},
1239	}
1240	for _, tt := range cases {
1241		t.Run(tt.name, func(t *testing.T) {
1242			state.layout.area = domain.Rect{Width: tt.size.Cols, Height: tt.size.Rows - 2}
1243			snap := &overlayRenderSnapshot{pickerActive: true, pickerModel: model, previewTab: nil}
1244			state.preview = picker.Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{{Rune: tt.marker, Style: renderer.DefaultStyle()}}}}
1245
1246			captureOverlayLayers(&state, snap, domain.PaletteConfig{})
1247
1248			inner := state.overlays.picker.inner
1249			layout := picker.ChooseLayout(domain.Size{Cols: inner.Width, Rows: inner.Height})
1250			require.Equal(t, tt.wantMode, layout.Mode)
1251			got := strings.Join(frameRows(inner), "\n")
1252			if tt.wantMarker {
1253				require.Contains(t, got, string(tt.marker))
1254			} else {
1255				require.NotContains(t, got, "◆")
1256				require.NotContains(t, got, "◇")
1257				require.NotContains(t, got, "●")
1258			}
1259			if tt.wantSep == 0 {
1260				require.NotContains(t, got, "│")
1261				require.NotContains(t, got, "─")
1262				return
1263			}
1264			if tt.wantSep == '│' {
1265				require.NotContains(t, got, "─")
1266			} else {
1267				require.NotContains(t, got, "│")
1268			}
1269			for y := layout.Separator.Y; y < layout.Separator.Y+layout.Separator.Height; y++ {
1270				for x := layout.Separator.X; x < layout.Separator.X+layout.Separator.Width; x++ {
1271					cell := inner.At(x, y)
1272					require.Equal(t, tt.wantSep, cell.Rune)
1273					require.True(t, cell.Style.Equal(state.styles.PickerSeparator), "separator must use the cached PickerSeparator role")
1274				}
1275			}
1276		})
1277	}
1278}
1279
1280func TestPickerPreviewContainsOnlyVisibleFrameRowsWithLargeScrollback(t *testing.T) {
1281	withScrollback := newPickerPreviewTabWithHistoryRows(t, 10_000)
1282	require.Equal(t, 10_000, withScrollback.focusedPane().screen.History().Len())
1283
1284	preview := snapshotPickerPreview(withScrollback)
1285	require.Equal(t, 10, preview.Width)
1286	require.Equal(t, 3, preview.Height)
1287	require.Len(t, preview.Rows, preview.Height)
1288	cells := 0
1289	for _, row := range preview.Rows {
1290		require.Len(t, row, preview.Width)
1291		cells += len(row)
1292	}
1293	require.Equal(t, preview.Width*preview.Height, cells)
1294	got := strings.Join(func() []string {
1295		rows := make([]string, len(preview.Rows))
1296		for i, row := range preview.Rows {
1297			rows[i] = rowText(row)
1298		}
1299		return rows
1300	}(), "\n")
1301	require.Contains(t, got, "NOW")
1302	require.NotContains(t, got, "history-only-marker")
1303}
1304
1305func newPickerPreviewTabWithHistoryRows(t testing.TB, historyRows int) *tab {
1306	t.Helper()
1307	tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
1308	p := tb.focusedPane()
1309	for range historyRows {
1310		appendHistoryRow(t, p.screen.History(), testRow("history-only-marker"))
1311	}
1312	p.screen.Write([]byte("NOW"))
1313	return tb
1314}
1315
1316func TestPickerPreviewSinglePaneSnapshotsFocusedPane(t *testing.T) {
1317	tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
1318	p := tb.focusedPane()
1319	p.screen.Write([]byte("focused"))
1320
1321	preview := snapshotPickerPreview(tb)
1322
1323	require.Equal(t, 10, preview.Width)
1324	require.Equal(t, 3, preview.Height)
1325	require.Equal(t, 'f', preview.Rows[0][0].Rune)
1326	require.Equal(t, 'o', preview.Rows[0][1].Rune)
1327}
1328
1329func TestPickerPreviewMultiPaneComposesTabFrame(t *testing.T) {
1330	tb := newTab(nil, domain.Size{Cols: 41, Rows: 5})
1331	left := tb.focusedPane()
1332	left.title.processName = "one"
1333	left.screen.Write([]byte("L"))
1334	rightTop := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
1335	rightTop.title.processName = "two"
1336	rightBottom := newPane("pane-3", nil, domain.Size{Cols: 20, Rows: 2})
1337	rightBottom.title.processName = "three"
1338	rightBottom.screen.Write([]byte("R"))
1339
1340	tb.mu.Lock()
1341	tb.panes[rightTop.id] = rightTop
1342	tb.panes[rightBottom.id] = rightBottom
1343	tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
1344		layout.NewLeaf(left.id),
1345		{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(rightTop.id), layout.NewLeaf(rightBottom.id)}, Expanded: rightBottom.id},
1346	}}
1347	tb.tree.Focus = rightBottom.id
1348	tb.mu.Unlock()
1349
1350	preview := snapshotPickerPreview(tb)
1351
1352	require.Equal(t, 41, preview.Width)
1353	require.Equal(t, 5, preview.Height)
1354	require.Equal(t, 'L', preview.Rows[0][0].Rune, "left pane content should remain visible")
1355	require.Equal(t, '│', preview.Rows[0][20].Rune, "split divider should be included")
1356	require.Equal(t, "two", rowText(preview.Rows[0][21:24]), "collapsed stack title bar should be included")
1357	require.Equal(t, 'R', preview.Rows[1][21].Rune, "expanded stack member draws no title bar; its content starts where the title bar used to be")
1358}
1359
1360func TestPickerModalGeometry(t *testing.T) {
1361	base := domain.Size{Cols: 100, Rows: 40}
1362
1363	require.Equal(t, domain.Rect{X: 10, Y: 4, Width: 80, Height: 32}, pickerModal.Resolve(base).Bounds)
1364	require.Equal(t, domain.AnchorCenter, pickerModal.Anchor)
1365}
1366
1367func TestPickerResizeRecomposesModal(t *testing.T) {
1368	p, releasePTY := newBlockingPTY(t)
1369	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1370	defer releasePTY()
1371
1372	d.enterPicker(sess, ac)
1373	awaitFrame(t, sends, ports.MsgOutput)
1374	d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
1375	// Picker recomposition is the relevant event here; do not depend on a real
1376	// resize-idle timer in this synchronous rendering test.
1377	d.paint(sess, ac, false, nil)
1378
1379	out := awaitFrame(t, sends, ports.MsgOutput)
1380	msg, err := ports.UnmarshalOutput(out.Payload)
1381	require.NoError(t, err)
1382	require.Contains(t, string(msg.Data), "┌")
1383	require.Contains(t, string(msg.Data), "Sessions")
1384}
1385
1386func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
1387	sz := domain.Size{Cols: 80, Rows: 24}
1388	p1, release1 := newBlockingPTY(t)
1389	defer release1()
1390	p2, release2 := newBlockingPTY(t)
1391	defer release2()
1392	var opens [][]string
1393	f := portsmocks.NewMockPTYFactory(t)
1394	normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1395	f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1396		func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1397			opens = append(opens, append([]string(nil), env...))
1398			if len(opens) == 1 {
1399				return p1, nil
1400			}
1401			return p2, nil
1402		},
1403	).Twice()
1404	floating := newQuietPTY()
1405	f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
1406		return got != normalSize && got.Valid()
1407	})).Return(floating, nil).Once()
1408	d := newTestDaemon(t, f, stubClock{})
1409	d.stopped["old"] = stoppedSession{name: "old", cwd: t.TempDir(), createdAt: 1}
1410	tr := portsmocks.NewMockTransport(t)
1411	tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
1412	tr.EXPECT().Close().Return(nil).Maybe()
1413	sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: sz, TrueColor: true}, tr)
1414	require.NoError(t, err)
1415	d.resumeStoppedAndSwitch(sess, ac, picker.Target{Name: "old", Stopped: true})
1416	got := ac.currentSession()
1417	require.NotNil(t, got)
1418	got.mu.Lock()
1419	require.True(t, got.terminal.TrueColor)
1420	got.mu.Unlock()
1421	require.Len(t, opens, 2)
1422	require.Contains(t, opens[1], "TERM=xterm-direct")
1423	require.Contains(t, opens[1], "COLORTERM=truecolor")
1424	require.Contains(t, opens[1], "TERM_PROGRAM=vev")
1425	require.Eventually(t, func() bool {
1426		tb := testAttachmentTab(got)
1427		if tb == nil {
1428			return false
1429		}
1430		tb.mu.Lock()
1431		defer tb.mu.Unlock()
1432		return tb.floating.pane != nil && tb.floating.pane.pty == floating
1433	}, time.Second, 5*time.Millisecond)
1434	_ = d.killSession(got, ports.ReasonSessionKilled, false)
1435	release1()
1436	release2()
1437	d.sessWg.Wait()
1438	select {
1439	case <-floating.done:
1440	default:
1441		t.Fatal("floating prewarm PTY was not closed")
1442	}
1443}
1444
1445// TestPickerEnterOnStoppedSessionRestoreFailureSurfacesNoticeAndStaysPut drives
1446// Enter on a stopped picker entry whose restore spawn fails (mock PTY Open
1447// error). The failure must reach the user as a NoticeSessionUnavailable
1448// notice and toast, and the client must remain attached to its origin
1449// session rather than being left in limbo.
1450func TestPickerEnterOnStoppedSessionRestoreFailureSurfacesNoticeAndStaysPut(t *testing.T) {
1451	p, release := newBlockingPTY(t)
1452	defer release()
1453	d, from, ac, sends := newManualSessionWithPTYs(t, p)
1454	d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp/stopped", createdAt: 7}
1455	cause := errors.New("open failed")
1456	ptys := portsmocks.NewMockPTYFactory(t)
1457	ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, cause).Once()
1458	d.ptys = ptys
1459
1460	d.enterPicker(from, ac)
1461	awaitFrame(t, sends, ports.MsgOutput)
1462	d.handleInput(from, ac, []byte("j"))
1463	awaitFrame(t, sends, ports.MsgOutput)
1464	d.handleInput(from, ac, []byte("\r"))
1465	awaitFrame(t, sends, ports.MsgOutput)
1466
1467	history := d.notices.history()
1468	require.Len(t, history, 1, "failed stopped-session restore must record exactly one notice")
1469	require.Equal(t, domain.NoticeSessionUnavailable, history[0].Code)
1470	require.Equal(t, domain.NoticeError, history[0].Severity)
1471
1472	toasts := awaitToastCount(t, ac, 1)
1473	require.Equal(t, domain.NoticeSessionUnavailable, toasts[0].Code)
1474
1475	require.Same(t, from, ac.currentSession(), "a failed restore must leave the client on its origin session")
1476}
1477
1478// TestPickerNavigationRefreshAfterDeleteSelectsReplacingRow pins the post-kill
1479// contract: the cursor stays on the row index the killed session occupied, so
1480// it lands on whatever session took its place — clamped to the new last
1481// selectable row when the bottom session was killed. Session names drive the
1482// picker's alphabetical tie-break (every session here shares mruAt 0), which is
1483// what puts the victim in the middle or at the end of the list.
1484func TestPickerNavigationRefreshAfterDeleteSelectsReplacingRow(t *testing.T) {
1485	tests := []struct {
1486		name         string
1487		currentName  string
1488		otherName    string
1489		targetName   string
1490		nav          []byte // keys walked from the attached session's active tab onto the victim
1491		wantSession  domain.SessionID
1492		wantTabID    domain.TabStableID
1493		wantTabIndex int
1494	}{
1495		{
1496			// Rows: [a-other, other-tab, m-target, target-tab, z-current, current-first, current-active].
1497			// Killing target-tab (row 3) promotes z-current's header to row 2, so
1498			// row 3 becomes current-first — not the attached active tab.
1499			name:        "middle session killed selects the row taking its place",
1500			currentName: "z-current", otherName: "a-other", targetName: "m-target",
1501			nav:         []byte("kk"),
1502			wantSession: "current", wantTabID: "current-first", wantTabIndex: 0,
1503		},
1504		{
1505			// Rows: [a-current, current-first, current-active, m-other, other-tab, z-target, target-tab].
1506			// Killing the bottom row (6) clamps to the new last selectable row.
1507			name:        "last session killed selects the new last row",
1508			currentName: "a-current", otherName: "m-other", targetName: "z-target",
1509			nav:         []byte("jj"),
1510			wantSession: "other", wantTabID: "other-tab", wantTabIndex: 0,
1511		},
1512	}
1513	for _, tt := range tests {
1514		t.Run(tt.name, func(t *testing.T) {
1515			d, current, ac, _, currentReleases := newManualTabSession(t, 2)
1516			defer releaseAll(currentReleases)
1517			current.id, current.name = "current", tt.currentName
1518			current.tabs[0].stableID, current.tabs[1].stableID = "current-first", "current-active"
1519			selectTestAttachmentTab(current, 1)
1520			delete(d.sessions, domain.SessionID("manual"))
1521			d.sessions[current.id] = current
1522
1523			otherPTY, releaseOther := newBlockingPTY(t)
1524			defer releaseOther()
1525			otherTab := newTestTabWithContext(otherPTY, current.ctx, current.cancel)
1526			otherTab.stableID = "other-tab"
1527			other := &session{sessionCore: sessionCore{id: "other", name: tt.otherName, ephemeral: true}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{otherTab}}
1528			d.sessions[other.id] = other
1529
1530			targetPTY, releaseTarget := newBlockingPTY(t)
1531			defer releaseTarget()
1532			targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel)
1533			targetTab.stableID = "target-tab"
1534			target := &session{sessionCore: sessionCore{id: "target", name: tt.targetName, ephemeral: true}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}}
1535			d.sessions[target.id] = target
1536
1537			d.enterPicker(current, ac)
1538			for _, key := range tt.nav {
1539				d.handlePickerInput(ac, []byte{key})
1540			}
1541			ac.overlays.pickerMu.Lock()
1542			selected, ok := ac.overlays.picker.Selected()
1543			ac.overlays.pickerMu.Unlock()
1544			require.True(t, ok)
1545			require.Equal(t, target.id, selected.Session, "test must delete the selected non-attached session")
1546
1547			d.handlePickerInput(ac, []byte("x"))
1548
1549			d.mu.Lock()
1550			_, targetStillLive := d.sessions[target.id]
1551			d.mu.Unlock()
1552			require.False(t, targetStillLive, "ordinary picker deletion must remove the selected session")
1553
1554			ac.overlays.pickerMu.Lock()
1555			selected, ok = ac.overlays.picker.Selected()
1556			ac.overlays.pickerMu.Unlock()
1557			require.True(t, ok)
1558			require.Equal(t, tt.wantSession, selected.Session)
1559			require.Equal(t, tt.wantTabID, selected.TabID)
1560			require.Equal(t, tt.wantTabIndex, selected.TabIndex)
1561		})
1562	}
1563}
1564
1565func TestPickerAttachmentEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T) {
1566	d, current, ac, _, currentReleases := newManualTabSession(t, 1)
1567	defer releaseAll(currentReleases)
1568	current.id, current.name = "current", "z-current"
1569	delete(d.sessions, domain.SessionID("manual"))
1570	d.sessions[current.id] = current
1571
1572	targetPTY, releaseTarget := newBlockingPTY(t)
1573	defer releaseTarget()
1574	targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel)
1575	target := &session{sessionCore: sessionCore{id: "target", name: "a-target", ephemeral: true}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}}
1576	d.sessions[target.id] = target
1577	current.mu.Lock()
1578	current.registerAttachmentLocked(ac)
1579	current.mu.Unlock()
1580	require.NotNil(t, d.attachCoordinator(current, nil, ac, true))
1581
1582	d.enterPicker(current, ac)
1583	d.handlePickerInput(ac, []byte("k"))
1584	ac.overlays.pickerMu.Lock()
1585	selected, ok := ac.overlays.picker.Selected()
1586	ac.overlays.pickerMu.Unlock()
1587	require.True(t, ok)
1588	require.Equal(t, target.id, selected.Session)
1589
1590	token := current.attachmentToken(ac, ac.transport())
1591	token.lease = current.renderCoordinator().attachmentLease(ac)
1592	ac.publishAttachmentCapability(token)
1593	effect, admitted := ac.beginAttachmentEffect(token)
1594	require.True(t, admitted)
1595	d.handlePickerInput(ac, []byte("x"), effect)
1596
1597	d.mu.Lock()
1598	_, targetStillLive := d.sessions[target.id]
1599	d.mu.Unlock()
1600	require.False(t, targetStillLive, "effect-backed picker deletion must remove the selected session")
1601	require.True(t, ac.overlays.pickerActive(), "successful deletion refreshes rather than closes the picker")
1602}
1603
1604func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t *testing.T) {
1605	d, from, ac, sends, releases := newRecentNavigationTestSessions(t)
1606	defer releaseAll(releases)
1607	cause := errors.New("snapshot delete refused")
1608	WithSnapshotRepository(refusingSnapshotDeleteRepository{err: cause})(d)
1609	target := mustLocalSession(t, d.sessions[domain.SessionID("recent")])
1610	store, _ := newMockStore(t)
1611	WithStore(t, store)(d)
1612	target.mu.Lock()
1613	if target.incarnation == (domain.IncarnationID{}) {
1614		target.incarnation = domain.IncarnationID{1}
1615	}
1616	record := target.persistRecordLocked(1)
1617	target.mu.Unlock()
1618	require.NoError(t, d.catalogue.Create(record))
1619
1620	d.enterPicker(from, ac)
1621	awaitFrame(t, sends, ports.MsgOutput)
1622	// MRU-desc order is from(current), recent, older; "j" moves onto "recent".
1623	d.handleInput(from, ac, []byte("j"))
1624	awaitFrame(t, sends, ports.MsgOutput)
1625	d.handleInput(from, ac, []byte("x"))
1626
1627	history := d.notices.history()
1628	require.Len(t, history, 1, "the refused purge must be reported exactly once")
1629	require.Equal(t, domain.NoticeInternal, history[0].Code)
1630	require.Contains(t, history[0].Details, cause.Error())
1631	require.Len(t, awaitToastCount(t, ac, 1), 1, "the refusal must produce one visible notice")
1632
1633	require.Same(t, from, ac.currentSession(), "purging another session must preserve the attachment")
1634	require.True(t, ac.overlays.pickerActive(), "x refreshes the picker instead of closing it")
1635	d.mu.Lock()
1636	_, stillActive := d.sessions[target.id]
1637	d.mu.Unlock()
1638	require.False(t, stillActive, "the purge keeps its existing removal semantics despite cleanup failure")
1639}
1640
1641// TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry
1642// drives 'x' on a stopped picker entry whose persisted-record delete fails
1643// (mock store error). The failure must reach the user as a
1644// NoticePersistDelete notice and toast, and the entry must remain listed
1645// after the picker refreshes rather than silently vanishing from the daemon's
1646// bookkeeping while still present on disk.
1647func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry(t *testing.T) {
1648	p, release := newBlockingPTY(t)
1649	defer release()
1650	d, from, ac, sends := newManualSessionWithPTYs(t, p)
1651	otherPTY, releaseOther := newBlockingPTY(t)
1652	defer releaseOther()
1653	otherTab := newTestTabWithContext(otherPTY, from.ctx, from.cancel)
1654	otherTab.stableID = "other-tab"
1655	other := &session{sessionCore: sessionCore{id: "other", name: "a-other", ephemeral: true}, ctx: from.ctx, cancel: func() {}, tabs: []*tab{otherTab}}
1656	d.sessions[other.id] = other
1657	cause := errors.New("delete failed")
1658	store, state := newMockStore(t)
1659	WithStore(t, store)(d)
1660	record := domain.CatalogueRecord{Name: "stopped", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/stopped", CreatedAt: 7}
1661	require.NoError(t, d.catalogue.Create(record))
1662	state.mu.Lock()
1663	state.deleteErr = func(string) error { return cause }
1664	state.mu.Unlock()
1665	d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp/stopped", createdAt: 7, incarnation: record.IncarnationID, record: record}
1666
1667	d.enterPicker(from, ac)
1668	awaitFrame(t, sends, ports.MsgOutput)
1669	d.handleInput(from, ac, []byte("j"))
1670	awaitFrame(t, sends, ports.MsgOutput)
1671	d.handleInput(from, ac, []byte("x"))
1672	awaitFrame(t, sends, ports.MsgOutput)
1673
1674	history := d.notices.history()
1675	require.NotEmpty(t, history, "failed persisted-record delete must record a notice")
1676	require.Equal(t, domain.NoticePersistDelete, history[0].Code)
1677	require.Equal(t, domain.NoticeError, history[0].Severity)
1678
1679	toasts := awaitToastCount(t, ac, 1)
1680	require.Equal(t, domain.NoticePersistDelete, toasts[0].Code)
1681
1682	d.mu.Lock()
1683	stopped, retained := d.stopped["stopped"]
1684	d.mu.Unlock()
1685	require.True(t, retained, "failed deletion must retain the reserved name")
1686	require.True(t, stopped.purging, "an uncertain deletion stays fenced from restore")
1687
1688	from.mu.Lock()
1689	activeTabID := testAttachmentTabLocked(from).stableID
1690	from.mu.Unlock()
1691	ac.overlays.pickerMu.Lock()
1692	selected, selectedOK := ac.overlays.picker.Selected()
1693	ac.overlays.pickerMu.Unlock()
1694	require.True(t, selectedOK)
1695	require.Equal(t, from.id, selected.Session, "failed x refresh resets navigation to the attached session")
1696	require.Equal(t, domain.TabStableID(activeTabID), selected.TabID, "failed x refresh follows the attached session's stable active tab")
1697}

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