palette_test.go RawHistoryFolder

1package daemon
2
3import (
4	"errors"
5	"sync"
6	"testing"
7
8	"github.com/stretchr/testify/mock"
9	"github.com/stretchr/testify/require"
10
11	renderer "github.com/bnema/vev-vt"
12	"github.com/bnema/vev/internal/domain"
13	"github.com/bnema/vev/internal/domain/terminalcap"
14	portsmocks "github.com/bnema/vev/internal/ports/mocks"
15	"github.com/bnema/vev/internal/protocol"
16	"github.com/bnema/vev/internal/usecase/command"
17	"github.com/bnema/vev/internal/usecase/palette"
18	"github.com/bnema/vev/internal/usecase/picker"
19	themeui "github.com/bnema/vev/internal/usecase/theme"
20	"github.com/bnema/vev/internal/usecase/ui"
21)
22
23func testRecentRouteSnapshot() protocol.RecentRouteSnapshot {
24	return protocol.RecentRouteSnapshot{
25		Generation:  1,
26		Active:      protocol.RouteRef{Key: 1, Generation: 1},
27		ActiveEntry: testRouteEntry(1, 1, "current", 1, protocol.RouteKindLocal),
28		Entries: []protocol.RecentRouteEntry{
29			testRouteEntry(2, 1, "recent", 2, protocol.RouteKindLocal),
30			testRouteEntry(3, 1, "older", 3, protocol.RouteKindLocal),
31		},
32	}
33}
34
35func beginRecentRoutePaletteEffect(t *testing.T, d *Daemon, sess *session, ac *attachedClient) *attachmentEffect {
36	t.Helper()
37	transport := ac.transport()
38	rc := d.attachCoordinator(sess, nil, ac, true)
39	token := sess.captureAttachmentCapability(ac, transport)
40	token.lease = rc.attachmentLease(ac)
41	ac.installTestAttachmentCapability(token)
42	effect, admitted := ac.beginAttachmentEffect(token)
43	require.True(t, admitted)
44	t.Cleanup(effect.End)
45	return effect
46}
47
48// TestCreateSessionDestinationResultsUseClientRouteHosts pins E3: every
49// create destination besides the serving daemon is a client route host,
50// carried by reference only (no endpoint), and the serving daemon creates in
51// place.
52func TestCreateSessionDestinationResultsUseClientRouteHosts(t *testing.T) {
53	lifecycle := domain.SessionLifecycleID{1}
54	remoteActive := protocol.RecentRouteEntry{
55		Key: 2, Generation: 3, Kind: protocol.RouteKindRemote, HostLabel: "host-a",
56		Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}, Name: "work",
57	}
58	localActive := remoteActive
59	localActive.Kind, localActive.HostLabel = protocol.RouteKindLocal, ""
60	type want struct {
61		kind   palette.CreateSessionDestinationKind
62		origin string
63		route  protocol.RouteRef
64	}
65	tests := []struct {
66		name     string
67		snapshot protocol.RecentRouteSnapshot
68		want     []want
69	}{
70		{
71			name: "remote attachment creates in place, locally, or on another host",
72			snapshot: protocol.RecentRouteSnapshot{
73				Generation: 4, Active: protocol.RouteRef{Key: 2, Generation: 3}, ActiveEntry: remoteActive,
74				Hosts: []protocol.RouteHost{
75					{Key: 5, Generation: 1, Label: "local", Kind: protocol.RouteKindLocal},
76					{Key: 6, Generation: 1, Label: "ops@arch", Kind: protocol.RouteKindRemote},
77				},
78			},
79			want: []want{
80				{kind: palette.CreateSessionOnServingDaemon, origin: "host-a"},
81				{kind: palette.CreateSessionOnLocalRoute, route: protocol.RouteRef{Key: 5, Generation: 1}},
82				{kind: palette.CreateSessionOnRemoteHost, origin: "arch", route: protocol.RouteRef{Key: 6, Generation: 1}},
83			},
84		},
85		{
86			name: "local attachment is home",
87			snapshot: protocol.RecentRouteSnapshot{
88				Generation: 4, Active: protocol.RouteRef{Key: 2, Generation: 3}, Home: protocol.RouteRef{Key: 2, Generation: 3}, ActiveEntry: localActive,
89				Hosts: []protocol.RouteHost{{Key: 6, Generation: 1, Label: "host-b", Kind: protocol.RouteKindRemote}},
90			},
91			want: []want{
92				{kind: palette.CreateSessionOnLocalRoute, route: protocol.RouteRef{Key: 2, Generation: 3}},
93				{kind: palette.CreateSessionOnRemoteHost, origin: "host-b", route: protocol.RouteRef{Key: 6, Generation: 1}},
94			},
95		},
96		{
97			name: "a remote home is never inferred local",
98			snapshot: protocol.RecentRouteSnapshot{
99				Generation: 1, Active: protocol.RouteRef{Key: 2, Generation: 3}, Home: protocol.RouteRef{Key: 2, Generation: 3}, ActiveEntry: remoteActive,
100			},
101			want: []want{{kind: palette.CreateSessionOnServingDaemon, origin: "host-a"}},
102		},
103	}
104	for _, tt := range tests {
105		t.Run(tt.name, func(t *testing.T) {
106			require.NoError(t, tt.snapshot.Validate())
107			results := createSessionDestinationResults(tt.snapshot, lifecycle)
108			require.Len(t, results, len(tt.want))
109			for i, expected := range tt.want {
110				_, kind, origin, endpoint, route, ok := results[i].CreateSessionDestination()
111				require.True(t, ok)
112				require.Equal(t, expected.kind, kind)
113				require.Equal(t, expected.origin, origin)
114				require.Equal(t, expected.route, route)
115				require.Empty(t, endpoint, "a destination never carries an endpoint")
116			}
117		})
118	}
119}
120
121func TestRemoteCreateSessionDestinationRequiresAttachmentEffect(t *testing.T) {
122	result := palette.NewCreateSessionDestination(
123		palette.CreateSessionOnRemoteHost, "host-a", "", protocol.RouteRef{Key: 1, Generation: 1}, 1,
124	)
125	err := (paletteExec{}).validateCreateSessionDestination(nil, result)
126	require.ErrorIs(t, err, errCreateDestinationUnavailable)
127}
128
129func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t *testing.T) {
130	paletteColors := [16]renderer.RGB{}
131	paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120}
132	paletteColors[10] = paletteColors[2]
133	accentTheme := themeui.Theme{
134		Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
135		HasFG: true, HasBG: true, Known: true, UsePalette: true,
136		Palette: paletteColors, PaletteKnown: 1<<2 | 1<<10,
137	}
138	indexedTheme := accentTheme
139	neutralTheme := accentTheme
140	neutralTheme.PaletteKnown = 0
141	neutralTheme.SchemeKnown = false
142
143	defaults := domain.Defaults()
144	paletteOff := defaults
145	paletteOff.ThemePalette = false
146	forcedDark := defaults
147	forcedDark.Theme = domain.ThemeDark
148	forcedLight := defaults
149	forcedLight.Theme = domain.ThemeLight
150	tests := []struct {
151		name   string
152		raw    themeui.Theme
153		config domain.Config
154	}{
155		{name: "truecolor accent", raw: accentTheme, config: defaults},
156		{name: "ansi256", raw: indexedTheme, config: defaults},
157		{name: "palette off", raw: accentTheme, config: paletteOff},
158		{name: "forced dark", raw: accentTheme, config: forcedDark},
159		{name: "forced light", raw: accentTheme, config: forcedLight},
160		{name: "neutral fallback", raw: neutralTheme, config: defaults},
161	}
162
163	for _, tt := range tests {
164		t.Run(tt.name, func(t *testing.T) {
165			d := newTestDaemon(t, nil, stubClock{})
166			d.ApplyConfig(tt.config)
167			applied := d.resolveAppliedTheme(tt.raw, terminalcap.ColorCapabilities{})
168			model := palette.New(palette.CommandResults([]command.Command{
169				{Code: "ONE", Name: "One", Desc: "first description"},
170				{Code: "TWO", Name: "Two", Desc: "second description"},
171			}))
172			model.Down()
173			state := capturedRenderState{
174				theme:  applied.Raw,
175				styles: applied.Resolved.Styles,
176				layout: capturedTabLayout{area: domain.Rect{Width: 100, Height: 38}},
177			}
178			snap := &overlayRenderSnapshot{paletteActive: true, paletteModel: model}
179
180			captureOverlayLayers(&state, snap, domain.PaletteConfig{})
181
182			inactive := state.overlays.palette.inner.At(4, 1).Style
183			require.Equal(t, state.styles.PickerDescription.Canonical().Foreground, inactive.Canonical().Foreground)
184			require.Equal(t, state.styles.PickerDescription.HasForegroundRGB, inactive.HasForegroundRGB)
185			require.Equal(t, state.styles.PickerDescription.ForegroundRGB, inactive.ForegroundRGB)
186			require.False(t, inactive.HasBackgroundRGB)
187			require.True(t, state.overlays.palette.inner.At(31, 1).Style.Equal(state.styles.PickerBase), "inactive row filler keeps the terminal background")
188
189			selected := state.overlays.palette.inner.At(4, 2).Style
190			require.True(t, selected.Equal(state.styles.PickerSelectionMuted))
191		})
192	}
193}
194
195func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
196	p1, release1 := newBlockingPTY(t)
197	p2, release2 := newBlockingPTY(t)
198	d, sess, ac, sends := newManualSessionWithPTYs(t, p1, p2)
199	sess.mu.Lock()
200	sess.registerAttachmentLocked(ac)
201	sess.mu.Unlock()
202	d.ptys = newBlockingOpenFactory(t, d)
203	defer release1()
204	defer release2()
205
206	d.handleInput(sess, ac, []byte("\x1b "))
207	require.True(t, ac.overlays.paletteActive())
208	out := awaitFrame(t, sends, "Output")
209	msg := unmarshalTestOutput(t, out.Payload)
210	require.Contains(t, string(msg.Data), "Commands")
211
212	d.handleInput(sess, ac, []byte("NXT\r"))
213	require.False(t, ac.overlays.paletteActive())
214	require.Equal(t, 1, testAttachmentTabIndex(sess))
215	requireFloatingInitialized(t, sess)
216	awaitFrame(t, sends, "Output")
217
218	d.handleInput(sess, ac, []byte("\x1b "))
219	awaitFrame(t, sends, "Output")
220	d.handleInput(sess, ac, []byte("\x1b"))
221	require.False(t, ac.overlays.paletteActive())
222	awaitFrame(t, sends, "Output")
223}
224
225// TestPaletteCommandFailureSurfacesAsNotice drives a palette command whose Run
226// target fails and asserts the failure reaches the user as a notice instead of
227// only a log line. The new-tab spawn failure is wrapped as a domain.UserError
228// with NoticeTabSpawn (session.go createTab), so it surfaces under that code
229// rather than the NoticeInternal catch-all.
230func TestPaletteCommandFailureSurfacesAsNotice(t *testing.T) {
231	p, release := newBlockingPTY(t)
232	defer release()
233	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
234	ptys := portsmocks.NewMockPTYFactory(t)
235	ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("open failed")).Once()
236	d.ptys = ptys
237
238	d.handleInput(sess, ac, []byte("\x1b "))
239	awaitFrame(t, sends, "Output")
240	d.handleInput(sess, ac, []byte("CNT\r"))
241	awaitFrame(t, sends, "Output")
242
243	history := d.notices.history()
244	require.NotEmpty(t, history, "failed palette command must record a notice")
245	require.Equal(t, domain.NoticeTabSpawn, history[0].Code)
246}
247
248func TestPaletteCommandNoticeErrorPreservesTypedErrorsAndMapsMoveFailures(t *testing.T) {
249	typedTabSpawn := domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab", errors.New("open failed"))
250	tests := []struct {
251		name, slug, message string
252		err                 error
253		code                domain.NoticeCode
254		severity            domain.NoticeSeverity
255		wantSame            bool
256	}{
257		{name: "CNT typed command error", slug: "new-tab", err: typedTabSpawn, code: domain.NoticeTabSpawn, severity: domain.NoticeError, message: "couldn't open tab", wantSame: true},
258		{name: "MFP no destination", slug: "move-pane", err: errNoMoveDestination, code: domain.NoticeSessionUnavailable, severity: domain.NoticeWarn, message: "No destination available."},
259		{name: "MAT warming floating", slug: "move-tab", err: errMoveFloatingWarming, code: domain.NoticeSessionUnavailable, severity: domain.NoticeWarn, message: "Wait for the floating pane to finish opening."},
260		{name: "MFP final pane with floating slot", slug: "move-pane", err: errMoveFinalSourceFloating, code: domain.NoticeLayoutTooSmall, severity: domain.NoticeWarn, message: "Close the floating pane or move the whole tab."},
261		{name: "MFP destination too small", slug: "move-pane", err: errMoveTooSmall, code: domain.NoticeLayoutTooSmall, severity: domain.NoticeWarn, message: "Not enough space in destination tab."},
262		{name: "MAT stale destination", slug: "move-tab", err: errMoveStaleTarget, code: domain.NoticeSessionUnavailable, severity: domain.NoticeWarn, message: "Destination is no longer available."},
263		{name: "MFP generic invalid", slug: "move-pane", err: errMovePaneInvalid, code: domain.NoticeInternal, severity: domain.NoticeError, message: "Move failed."},
264		{name: "MFP typed application error", slug: "move-pane", err: typedTabSpawn, code: domain.NoticeTabSpawn, severity: domain.NoticeError, message: "couldn't open tab", wantSame: true},
265	}
266	for _, tt := range tests {
267		t.Run(tt.name, func(t *testing.T) {
268			cmd, ok := command.BySlug(tt.slug)
269			require.True(t, ok)
270			got := paletteCommandNoticeError(cmd, tt.err)
271			if tt.wantSame {
272				require.Same(t, tt.err, got)
273			}
274			var userErr *domain.UserError
275			require.ErrorAs(t, got, &userErr)
276			require.Equal(t, tt.code, userErr.Code)
277			require.Equal(t, tt.severity, userErr.Severity)
278			require.Equal(t, tt.message, userErr.Msg)
279		})
280	}
281}
282
283func TestPaletteCommandNoticeErrorUsesCommandScope(t *testing.T) {
284	t.Run("future cross-session command inherits move rejection presentation", func(t *testing.T) {
285		cmd := command.Command{Slug: "future-transfer", Scope: command.CommandScopeCrossSession}
286		got := paletteCommandNoticeError(cmd, errMoveStaleTarget)
287		var userErr *domain.UserError
288		require.ErrorAs(t, got, &userErr)
289		require.Equal(t, domain.NoticeSessionUnavailable, userErr.Code)
290		require.Equal(t, "Destination is no longer available.", userErr.Msg)
291	})
292
293	t.Run("move-like slug without metadata is unchanged", func(t *testing.T) {
294		err := errors.New("ordinary failure")
295		got := paletteCommandNoticeError(command.Command{Slug: "move-pane"}, err)
296		require.Same(t, err, got)
297	})
298}
299
300func TestPaletteEntryPublishesEligibleNamedSessionResults(t *testing.T) {
301	p, release := newBlockingPTY(t)
302	defer release()
303	d, current, ac, _ := newManualSessionWithPTYs(t, p)
304	d.sessions["active"] = &session{sessionCore: sessionCore{id: "active", name: "active", createdAt: 10}}
305	d.sessions["ephemeral"] = &session{sessionCore: sessionCore{id: "ephemeral", name: "ephemeral", ephemeral: true, createdAt: 11}}
306	d.inactive["stopped"] = inactiveSession{name: "stopped", createdAt: 12, incarnation: domain.IncarnationID{3}, state: protocol.SessionDown}
307	d.inactive["purging"] = inactiveSession{name: "purging", createdAt: 13, incarnation: domain.IncarnationID{4}, state: protocol.SessionDown, purging: true}
308	d.inactive["broken"] = inactiveSession{name: "broken", createdAt: 14, incarnation: domain.IncarnationID{5}, state: protocol.SessionBroken}
309	d.inactive["degraded"] = inactiveSession{name: "degraded", createdAt: 15, incarnation: domain.IncarnationID{6}, state: protocol.SessionDown, record: domain.CatalogueRecord{DegradedReason: "checkpoint unavailable"}}
310	ac.setRouteSnapshot(protocol.RecentRouteSnapshot{Generation: 1, Entries: []protocol.RecentRouteEntry{
311		testRouteEntry(1, 1, "purging", 4, protocol.RouteKindLocal),
312		testRouteEntry(2, 1, "broken", 5, protocol.RouteKindLocal),
313		testRouteEntry(3, 1, "degraded", 6, protocol.RouteKindLocal),
314	}})
315
316	d.enterPalette(current, ac)
317	ac.overlays.paletteMu.Lock()
318	defer ac.overlays.paletteMu.Unlock()
319
320	got := map[string]palette.ResultKind{}
321	for _, match := range ac.overlays.palette.Matches() {
322		if name, ok := match.Result.SessionName(); ok {
323			got[name] = match.Result.Kind()
324		}
325	}
326	require.Equal(t, map[string]palette.ResultKind{
327		"active":  palette.ResultKindActiveSession,
328		"stopped": palette.ResultKindStoppedSession,
329	}, got)
330}
331
332func TestPaletteSessionFailureFeedbackClearsOnQueryChange(t *testing.T) {
333	p, release := newBlockingPTY(t)
334	defer release()
335	d, current, ac, _ := newManualSessionWithPTYs(t, p)
336	target := &session{sessionCore: sessionCore{id: "active", name: "active", createdAt: 10}}
337	d.sessions[target.id] = target
338
339	d.enterPalette(current, ac)
340	d.handlePaletteInput(ac, []byte("active"))
341	ac.overlays.paletteMu.Lock()
342	selected, ok := ac.overlays.palette.Selected()
343	ac.overlays.paletteMu.Unlock()
344	_, isSession := selected.SessionName()
345	require.True(t, ok)
346	require.True(t, isSession)
347	d.mu.Lock()
348	delete(d.sessions, target.core().id)
349	d.mu.Unlock()
350
351	d.handlePaletteInput(ac, []byte("\r"))
352	ac.overlays.paletteMu.Lock()
353	require.Equal(t, "requested session is unavailable", ac.overlays.paletteFeedback)
354	ac.overlays.paletteMu.Unlock()
355	d.handlePaletteInput(ac, []byte("x"))
356	ac.overlays.paletteMu.Lock()
357	require.Empty(t, ac.overlays.paletteFeedback)
358	ac.overlays.paletteMu.Unlock()
359}
360
361func TestPaletteEnterFreezesActiveSessionSelectionAgainstTrailingInput(t *testing.T) {
362	d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
363	defer releaseAll(releases)
364	target := mustLocalSession(t, d.sessions[domain.SessionID("recent")])
365	target.createdAt = 42
366
367	d.handleInput(current, ac, []byte("\x1b "))
368	awaitFrame(t, sends, "Output")
369	d.handleInput(current, ac, []byte("recent\rx"))
370
371	require.Same(t, target, ac.currentSession(), "Enter must retain the selected session despite trailing frame bytes")
372	require.False(t, ac.overlays.paletteActive(), "trailing input must not prevent the captured selection from closing the palette")
373	awaitFrame(t, sends, "Output")
374	awaitFrame(t, sends, "Output")
375}
376
377func TestPaletteSelectedActiveSessionSwitchesWithoutRecordingCommandRecency(t *testing.T) {
378	d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
379	defer releaseAll(releases)
380	target := mustLocalSession(t, d.sessions[domain.SessionID("recent")])
381	target.createdAt = 42
382
383	d.handleInput(current, ac, []byte("\x1b "))
384	awaitFrame(t, sends, "Output")
385	d.handleInput(current, ac, []byte("recent\r"))
386
387	require.Same(t, target, ac.currentSession())
388	require.Contains(t, target.snapshotAttachments(), ac, "canonical handoff reuses the attached client")
389	require.False(t, ac.overlays.paletteActive())
390	require.Empty(t, d.paletteHistory.recent, "session selections are not command recency")
391	awaitFrame(t, sends, "Output")
392	awaitFrame(t, sends, "Output")
393}
394
395func TestPaletteStoppedSessionResumeFailureKeepsPaletteAndSourceAttachment(t *testing.T) {
396	p, release := newBlockingPTY(t)
397	defer release()
398	d, current, ac, sends := newManualSessionWithPTYs(t, p)
399	stopped := inactiveSession{name: "stopped", cwd: "/tmp", createdAt: 42, lastUsedSeq: 7, tabNames: []string{"shell", "logs"}, state: protocol.SessionDown}
400	d.inactive[stopped.name] = stopped
401	ptys := portsmocks.NewMockPTYFactory(t)
402	ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("open failed")).Once()
403	d.ptys = ptys
404
405	d.handleInput(current, ac, []byte("\x1b "))
406	awaitFrame(t, sends, "Output")
407	d.handleInput(current, ac, []byte("stopped\r"))
408
409	require.Same(t, current, ac.currentSession(), "failed resume must retain the source attachment")
410	current.mu.Lock()
411	require.Contains(t, current.snapshotAttachmentsLocked(), ac)
412	current.mu.Unlock()
413	require.True(t, ac.overlays.paletteActive())
414	ac.overlays.paletteMu.Lock()
415	require.Equal(t, "requested session is unavailable", ac.overlays.paletteFeedback)
416	ac.overlays.paletteMu.Unlock()
417	require.Equal(t, stopped, d.inactive[stopped.name], "failed resume must retain stopped lifecycle metadata")
418	awaitFrame(t, sends, "Output")
419}
420
421func TestPaletteSelectedStoppedSessionResumesAndSwitches(t *testing.T) {
422	p1, release1 := newBlockingPTY(t)
423	p2, release2 := newBlockingPTY(t)
424	defer release1()
425	defer release2()
426	d, current, ac, sends := newManualSessionWithPTYs(t, p1)
427	d.ptys = newFactory(t, p2)
428	d.inactive["stopped"] = inactiveSession{name: "stopped", cwd: "/tmp", createdAt: 42, state: protocol.SessionDown}
429
430	d.handleInput(current, ac, []byte("\x1b "))
431	awaitFrame(t, sends, "Output")
432	generation := ac.lifecycle.generationValue()
433	d.handleInput(current, ac, []byte("stopped\r"))
434
435	resumed := ac.currentSession()
436	require.Equal(t, "stopped", resumed.name)
437	require.Equal(t, int64(42), resumed.createdAt)
438	require.Equal(t, true, resumed.attachmentRegistered(ac))
439	require.Greater(t, ac.lifecycle.generationValue(), generation, "stopped-session handoff must publish through the attachment transition")
440	require.False(t, ac.overlays.paletteActive())
441	firstPaint := awaitFrame(t, sends, "Output")
442	firstOutput := unmarshalTestOutput(t, firstPaint.Payload)
443	require.Zero(t, firstOutput.Base, "stopped-session first paint must reset moving output state")
444	awaitFrame(t, sends, "Output")
445}
446
447func TestPaletteJRSActivatesOnlyExactContextualHint(t *testing.T) {
448	p, release := newBlockingPTY(t)
449	defer release()
450	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
451	ac.setRouteSnapshot(protocol.RecentRouteSnapshot{Generation: 1, Active: protocol.RouteRef{Key: 1, Generation: 1}})
452
453	d.handleInput(sess, ac, []byte("\x1b "))
454	awaitFrame(t, sends, "Output")
455	d.handleInput(sess, ac, []byte("JRS"))
456	awaitFrame(t, sends, "Output")
457
458	ac.overlays.paletteMu.Lock()
459	require.Equal(t, command.ContextHintRecentSessions, ac.overlays.paletteHints.Kind)
460	require.Equal(t, "no recent sessions", ac.overlays.paletteHints.Feedback)
461	ac.overlays.paletteMu.Unlock()
462
463	d.handleInput(sess, ac, []byte("\b\b\bRNS"))
464	awaitFrame(t, sends, "Output")
465	ac.overlays.paletteMu.Lock()
466	require.Equal(t, command.ContextHintNone, ac.overlays.paletteHints.Kind)
467	ac.overlays.paletteMu.Unlock()
468}
469
470func TestPaletteJRSUsesEffectiveOverrideOnly(t *testing.T) {
471	d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
472	defer releaseAll(releases)
473	ac.setRouteSnapshot(testRecentRouteSnapshot())
474	token := beginRecentRoutePaletteEffect(t, d, current, ac)
475	d.ApplyConfig(domain.Config{Codes: map[string]string{"jump-recent-session": "RJS"}})
476
477	d.handleInputForAttachment(token, []byte("\x1b "))
478	awaitFrame(t, sends, "Output")
479	d.handleInputForAttachment(token, []byte("RJS"))
480	awaitFrame(t, sends, "Output")
481	ac.overlays.paletteMu.Lock()
482	require.Equal(t, command.ContextHintRecentSessions, ac.overlays.paletteHints.Kind)
483	ac.overlays.paletteMu.Unlock()
484	d.handleInputForAttachment(token, []byte(" 1\r"))
485
486	actionFrame := awaitFrame(t, sends, "RouteNavigationAction")
487	action := decodeServerMessage(t, actionFrame).(protocol.RouteNavigationAction)
488	require.Equal(t, protocol.RouteNavigationAction{SnapshotGeneration: 1, Key: 2, Generation: 1}, action)
489	require.Same(t, current, ac.currentSession())
490	require.False(t, ac.overlays.paletteActive())
491
492	d.handleInputForAttachment(token, []byte("\x1b "))
493	awaitFrame(t, sends, "Output")
494	d.handleInputForAttachment(token, []byte("JRS"))
495	awaitFrame(t, sends, "Output")
496	ac.overlays.paletteMu.Lock()
497	require.Equal(t, command.ContextHintNone, ac.overlays.paletteHints.Kind)
498	ac.overlays.paletteMu.Unlock()
499	require.Same(t, current, ac.currentSession())
500	d.handleInputForAttachment(token, []byte("\r"))
501	require.True(t, ac.overlays.paletteActive(), "literal JRS must not execute an overridden jump command")
502}
503
504func TestPaletteQualifiesDaemonSessionForRemoteAttachment(t *testing.T) {
505	d := newTestDaemon(t, nil, stubClock{})
506	current := addControlSession(d, "current", "tab-current", "pane-current")
507	current.ephemeral = false
508	current.incarnation = domain.SessionLifecycleID{41}
509	target := addControlSession(d, "target", "tab-target", "pane-target")
510	target.ephemeral = false
511	target.incarnation = domain.SessionLifecycleID{42}
512
513	results := d.paletteResults(current, nil, protocol.RecentRouteSnapshot{
514		Generation: 1,
515		Active:     protocol.RouteRef{Key: 1, Generation: 1},
516		ActiveEntry: protocol.RecentRouteEntry{
517			Key: 1, Generation: 1,
518			Target: protocol.ExactSessionTarget{LifecycleID: current.incarnation, SessionName: "current"},
519			Name:   "current", HostLabel: "user@remote-host", Kind: protocol.RouteKindRemote,
520		},
521		Entries: []protocol.RecentRouteEntry{{
522			Key: 2, Generation: 1,
523			Target: protocol.ExactSessionTarget{LifecycleID: target.incarnation, SessionName: "target"},
524			Name:   "target", HostLabel: "user@remote-host", Kind: protocol.RouteKindRemote,
525		}},
526	})
527
528	var matching []palette.Result
529	for _, result := range results {
530		if result.DisplayText() == "Switch to session target@remote-host" {
531			matching = append(matching, result)
532		}
533	}
534	require.Len(t, matching, 1)
535	got, ok := matching[0].SessionTarget()
536	require.True(t, ok)
537	require.Equal(t, protocol.ExactSessionTarget{LifecycleID: target.incarnation, SessionName: "target"}, got)
538}
539
540func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) {
541	d := newTestDaemon(t, nil, stubClock{})
542	current := addControlSession(d, "hi", "tab-1", "pane-1")
543	current.ephemeral = false
544	current.incarnation = domain.SessionLifecycleID{1}
545	local := addControlSession(d, "vev", "tab-2", "pane-2")
546	local.ephemeral = false
547	local.incarnation = domain.SessionLifecycleID{2}
548	remoteOne := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{21}, SessionName: "vev"}
549	remoteTwo := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{22}, SessionName: "vev"}
550	snapshot := protocol.RecentRouteSnapshot{
551		Generation: 2,
552		Entries: []protocol.RecentRouteEntry{
553			{Key: 1, Generation: 1, Target: protocol.ExactSessionTarget{LifecycleID: current.incarnation, SessionName: "hi"}, Name: "hi", Kind: protocol.RouteKindLocal},
554			{Key: 2, Generation: 1, Target: protocol.ExactSessionTarget{LifecycleID: local.incarnation, SessionName: "vev"}, Name: "vev", Kind: protocol.RouteKindLocal},
555			{Key: 3, Generation: 1, Target: remoteOne, Name: "vev", HostLabel: "arch", Kind: protocol.RouteKindRemote},
556			{Key: 4, Generation: 1, Target: remoteTwo, Name: "vev", HostLabel: "arch", Kind: protocol.RouteKindRemote},
557		},
558	}
559
560	results := d.paletteResults(current, nil, snapshot)
561	var sessionTargets []protocol.ExactSessionTarget
562	var routeActions []protocol.RouteNavigationAction
563	for _, result := range results {
564		if target, ok := result.SessionTarget(); ok {
565			sessionTargets = append(sessionTargets, target)
566		}
567		if action, ok := result.RouteNavigationAction(); ok {
568			routeActions = append(routeActions, action)
569		}
570	}
571	require.Equal(t, []protocol.ExactSessionTarget{{LifecycleID: local.incarnation, SessionName: "vev"}}, sessionTargets)
572	require.Equal(t, []protocol.RouteNavigationAction{
573		{SnapshotGeneration: 2, Key: 3, Generation: 1},
574		{SnapshotGeneration: 2, Key: 4, Generation: 1},
575	}, routeActions)
576	require.Equal(t, "Switch to session vev@arch", results[len(results)-2].DisplayText())
577	require.Equal(t, "Switch to session vev@arch", results[len(results)-1].DisplayText())
578}
579
580func TestPaletteLifecycleTargetRejectsSameNameReplacement(t *testing.T) {
581	d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
582	defer releaseAll(releases)
583	target := d.sessions["recent"]
584	target.incarnation = domain.SessionLifecycleID{2}
585	target.createdAt = 7
586	token := beginRecentRoutePaletteEffect(t, d, current, ac)
587	expectedCreatedAt := target.createdAt
588
589	err := d.switchToTargetForAttachment(token, picker.Target{
590		Name: "recent", Incarnation: domain.SessionLifecycleID{9},
591		ExpectedCreatedAt: &expectedCreatedAt, TabIndex: -1,
592	}, sessionHandoffGuard{}, "palette-session")
593
594	require.ErrorIs(t, err, errAttachmentTransition)
595	require.Same(t, current, ac.currentSession())
596	for {
597		select {
598		case frame := <-sends:
599			require.NotEqual(t, "AttachTarget", envelopeMessageName(t, frame.Payload))
600		default:
601			return
602		}
603	}
604}
605
606func TestPaletteFuzzyRemoteRecentRouteSendsExactNavigationAction(t *testing.T) {
607	d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
608	defer releaseAll(releases)
609	ac.setRouteSnapshot(protocol.RecentRouteSnapshot{
610		Generation:  9,
611		Active:      protocol.RouteRef{Key: 1, Generation: 1},
612		ActiveEntry: testRouteEntry(1, 1, current.name, 1, protocol.RouteKindLocal),
613		Entries: []protocol.RecentRouteEntry{{
614			Key: 8, Generation: 4, Target: testRouteTarget("logs", 8), Name: "logs", HostLabel: "edge", Kind: protocol.RouteKindRemote,
615		}},
616	})
617	token := beginRecentRoutePaletteEffect(t, d, current, ac)
618
619	d.handleInputForAttachment(token, []byte("\x1b "))
620	awaitFrame(t, sends, "Output")
621	d.handleInputForAttachment(token, []byte("logs@edge\r"))
622
623	actionFrame := awaitFrame(t, sends, "RouteNavigationAction")
624	action := decodeServerMessage(t, actionFrame).(protocol.RouteNavigationAction)
625	require.Equal(t, protocol.RouteNavigationAction{SnapshotGeneration: 9, Key: 8, Generation: 4}, action)
626	require.False(t, ac.overlays.paletteActive())
627}
628
629func TestPaletteFuzzySelectedStaticCommandExecutes(t *testing.T) {
630	d, sess, ac, sends, releases := newManualTabSession(t, 2)
631	defer releases[0]()
632	defer releases[1]()
633
634	d.handleInput(sess, ac, []byte("\x1b "))
635	awaitFrame(t, sends, "Output")
636	d.handleInput(sess, ac, []byte("next\r"))
637
638	require.False(t, ac.overlays.paletteActive())
639	require.Equal(t, 1, testAttachmentTabIndex(sess))
640	awaitFrame(t, sends, "Output")
641}
642
643func TestPaletteJRSUsesCapturedRankAfterMRUChanges(t *testing.T) {
644	d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
645	defer releaseAll(releases)
646	ac.setRouteSnapshot(testRecentRouteSnapshot())
647	token := beginRecentRoutePaletteEffect(t, d, current, ac)
648	d.handleInputForAttachment(token, []byte("\x1b "))
649	awaitFrame(t, sends, "Output")
650	// Reordering live MRU after opening must not shift rank 1 from its capture.
651	d.sessions[domain.SessionID("older")].core().mruAt.Store(100)
652	d.handleInputForAttachment(token, []byte("JRS 1\r"))
653
654	actionFrame := awaitFrame(t, sends, "RouteNavigationAction")
655	action := decodeServerMessage(t, actionFrame).(protocol.RouteNavigationAction)
656	require.Equal(t, protocol.RouteNavigationAction{SnapshotGeneration: 1, Key: 2, Generation: 1}, action)
657	require.Same(t, current, ac.currentSession())
658	require.False(t, ac.overlays.paletteActive())
659}
660
661func TestPaletteDeniedPostHandoffAttachmentEffectClosesAndInvalidates(t *testing.T) {
662	p, release := newBlockingPTY(t)
663	defer release()
664	d, sess, ac, _ := newManualSessionWithPTYs(t, p)
665	invalidations := installPaletteInvalidationObserver(sess)
666
667	d.enterPalette(sess, ac)
668	d.handlePaletteInput(ac, []byte("BCK"))
669	// Make the attachment token detached while retaining currentSession. The
670	// no-op BCK command succeeds, but its post-execution beginAttachmentEffect is
671	// deterministically denied.
672	sess.mu.Lock()
673	clearAttachmentsForTestLocked(sess)
674	sess.mu.Unlock()
675
676	d.handlePaletteInput(ac, []byte("\r"))
677
678	require.False(t, ac.overlays.paletteActive(), "denied cleanup admission must still close the executed palette")
679	invalidation := awaitTestValue(t, invalidations, "denied palette cleanup did not invalidate rendering")
680	require.True(t, invalidation.reset)
681}
682
683func TestPaletteJRSDoesNotRevalidateTargetInDaemon(t *testing.T) {
684	p, release := newBlockingPTY(t)
685	defer release()
686	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
687	ac.setRouteSnapshot(protocol.RecentRouteSnapshot{
688		Generation:  7,
689		Active:      protocol.RouteRef{Key: 1, Generation: 1},
690		ActiveEntry: testRouteEntry(1, 1, sess.name, 1, protocol.RouteKindLocal),
691		Entries:     []protocol.RecentRouteEntry{testRouteEntry(9, 4, "captured", 9, protocol.RouteKindLocal)},
692	})
693
694	validated := make(chan struct{})
695	releaseHandoff := make(chan struct{})
696	d.beforeRecentSessionHandoff = func() {
697		close(validated)
698		<-releaseHandoff
699	}
700	token := beginRecentRoutePaletteEffect(t, d, sess, ac)
701	d.handleInputForAttachment(token, []byte("\x1b "))
702	awaitFrame(t, sends, "Output")
703	inputHandled := make(chan struct{})
704	go func() {
705		d.handleInputForAttachment(token, []byte("JRS 1\r"))
706		close(inputHandled)
707	}()
708	awaitTestCompletion(t, validated, "JRS did not publish the captured route action")
709	// The daemon must not consult or revalidate the target lifecycle. The
710	// client owns exact-target validation after it receives this action.
711	close(releaseHandoff)
712	awaitTestCompletion(t, inputHandled, "JRS route-action input did not complete")
713
714	actionFrame := awaitFrame(t, sends, "RouteNavigationAction")
715	action := decodeServerMessage(t, actionFrame).(protocol.RouteNavigationAction)
716	require.Equal(t, protocol.RouteNavigationAction{SnapshotGeneration: 7, Key: 9, Generation: 4}, action)
717	require.False(t, ac.overlays.paletteActive())
718}
719
720func TestPaletteJRSOutOfRangeKeepsPaletteOpenWithoutClamping(t *testing.T) {
721	d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
722	defer releaseAll(releases)
723	ac.setRouteSnapshot(testRecentRouteSnapshot())
724	token := beginRecentRoutePaletteEffect(t, d, current, ac)
725
726	d.handleInputForAttachment(token, []byte("\x1b "))
727	awaitFrame(t, sends, "Output")
728	d.handleInputForAttachment(token, []byte("JRS 3\r"))
729
730	require.Same(t, current, ac.currentSession())
731	require.True(t, ac.overlays.paletteActive())
732	ac.overlays.paletteMu.Lock()
733	require.Equal(t, "JRS 3", ac.overlays.palette.Query())
734	require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteFeedback)
735	ac.overlays.paletteMu.Unlock()
736	awaitFrame(t, sends, "Output")
737}
738
739func TestPaletteJRSMalformedRankFeedback(t *testing.T) {
740	d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
741	defer releaseAll(releases)
742
743	d.handleInput(current, ac, []byte("\x1b "))
744	awaitFrame(t, sends, "Output")
745	d.handleInput(current, ac, []byte("JRS 0\r"))
746
747	require.Same(t, current, ac.currentSession())
748	require.True(t, ac.overlays.paletteActive())
749	ac.overlays.paletteMu.Lock()
750	require.Equal(t, "JRS 0", ac.overlays.palette.Query())
751	require.Equal(t, "rank must be one positive decimal", ac.overlays.paletteFeedback)
752	ac.overlays.paletteMu.Unlock()
753	awaitFrame(t, sends, "Output")
754}
755
756func TestPaletteFailureDoesNotOverwriteChangedQueryInSameGeneration(t *testing.T) {
757	p, release := newBlockingPTY(t)
758	defer release()
759	d, sess, ac, _ := newManualSessionWithPTYs(t, p)
760
761	d.enterPalette(sess, ac)
762	ac.overlays.paletteMu.Lock()
763	generation := ac.overlays.paletteGeneration
764	ac.overlays.palette.Insert('x')
765	ac.overlays.paletteMu.Unlock()
766	ac.paletteFailure(generation, "", "stale failure")
767
768	ac.overlays.paletteMu.Lock()
769	require.Empty(t, ac.overlays.paletteFeedback)
770	ac.overlays.paletteMu.Unlock()
771}
772
773func TestPaletteFailureDoesNotOverwriteNewerInteraction(t *testing.T) {
774	p, release := newBlockingPTY(t)
775	defer release()
776	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
777
778	d.enterPalette(sess, ac)
779	ac.overlays.paletteMu.Lock()
780	staleGeneration := ac.overlays.paletteGeneration
781	ac.overlays.paletteMu.Unlock()
782	d.enterPalette(sess, ac)
783	ac.paletteFailure(staleGeneration, "JRS 1", "stale failure")
784
785	ac.overlays.paletteMu.Lock()
786	require.Empty(t, ac.overlays.paletteFeedback)
787	ac.overlays.paletteMu.Unlock()
788	awaitFrame(t, sends, "Output")
789	awaitFrame(t, sends, "Output")
790}
791
792func TestPaletteTFPExecutesFloatingToggle(t *testing.T) {
793	p, release := newBlockingPTY(t)
794	defer release()
795	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
796	tb := testAttachmentTab(sess)
797	installTestFloating(tb, newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), false)
798
799	d.handleInput(sess, ac, []byte("\x1b "))
800	awaitFrame(t, sends, "Output")
801	d.handleInput(sess, ac, []byte("TFP\r"))
802
803	require.False(t, ac.overlays.paletteActive())
804	tb.mu.Lock()
805	require.Equal(t, floatingVisible, tb.floating.state)
806	tb.mu.Unlock()
807	awaitFrame(t, sends, "Output")
808}
809
810func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
811	p1, release1 := newBlockingPTY(t)
812	p2, release2 := newBlockingPTY(t)
813	d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
814	defer release1()
815	defer release2()
816	d.ptys = newFactorySeq(t, p2)
817
818	d.handleInput(sess, ac, []byte("\x1b "))
819	awaitFrame(t, sends, "Output")
820	d.handleInput(sess, ac, []byte("CNS\r"))
821	promptFrame := awaitFrame(t, sends, "Output")
822	promptOutput := unmarshalTestOutput(t, promptFrame.Payload)
823	require.False(t, ac.overlays.paletteActive())
824	require.True(t, ac.overlays.promptActive())
825	require.Contains(t, string(promptOutput.Data), "Create session")
826
827	generation := ac.lifecycle.generationValue()
828	d.handleInput(sess, ac, []byte("scratch\r"))
829	// The submit first paints the newly attached session while the prompt is
830	// still open, then handlePromptInput closes the prompt and repaints the
831	// client's current session. The final frame must be for the new session.
832	firstPaint := awaitFrame(t, sends, "Output")
833	firstOutput := unmarshalTestOutput(t, firstPaint.Payload)
834	require.Zero(t, firstOutput.Base, "new-session first paint must reset moving output state")
835	finalRepaint := awaitFrame(t, sends, "Output")
836	finalOutput := unmarshalTestOutput(t, finalRepaint.Payload)
837	require.False(t, ac.overlays.promptActive())
838	require.Equal(t, 2, sessionCount(d))
839	require.Empty(t, sess.snapshotAttachments())
840	newSess := ac.currentSession()
841	require.NotNil(t, newSess)
842	require.NotSame(t, sess, newSess)
843	require.Equal(t, "scratch", newSess.name)
844	require.False(t, newSess.ephemeral)
845	require.Contains(t, newSess.snapshotAttachments(), ac)
846	require.Equal(t, true, newSess.attachmentRegistered(ac))
847	require.Greater(t, ac.lifecycle.generationValue(), generation, "new-session handoff must publish through the attachment transition")
848	require.Contains(t, string(finalOutput.Data), "scratch")
849	require.NotContains(t, string(finalOutput.Data), "Create session")
850}
851
852func TestPaletteDirectSessionCreation(t *testing.T) {
853	cases := []struct {
854		name, input, wantName string
855		ephemeral             bool
856	}{
857		{name: "named", input: "CNS scratch\r", wantName: "scratch"},
858		{name: "ephemeral", input: "CES\r", wantName: "0", ephemeral: true},
859	}
860	for _, tc := range cases {
861		t.Run(tc.name, func(t *testing.T) {
862			p1, release1 := newBlockingPTY(t)
863			p2, release2 := newBlockingPTY(t)
864			d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
865			defer release1()
866			defer release2()
867			d.ptys = newFactorySeq(t, p2)
868
869			d.handleInput(sess, ac, []byte("\x1b "))
870			awaitFrame(t, sends, "Output")
871			d.handleInput(sess, ac, []byte(tc.input))
872			awaitFrame(t, sends, "Output")
873
874			require.False(t, ac.overlays.paletteActive())
875			require.False(t, ac.overlays.promptActive())
876			require.Equal(t, 2, sessionCount(d))
877			newSess := ac.currentSession()
878			require.Equal(t, tc.wantName, newSess.name)
879			require.Equal(t, tc.ephemeral, newSess.ephemeral)
880		})
881	}
882}
883
884func TestPaletteReopensWithSuccessfulCommandFirst(t *testing.T) {
885	d, sess, ac, sends, releases := newManualTabSession(t, 2)
886	sess.mu.Lock()
887	sess.registerAttachmentLocked(ac)
888	sess.mu.Unlock()
889	defer releases[0]()
890	defer releases[1]()
891
892	d.handleInput(sess, ac, []byte("\x1b "))
893	awaitFrame(t, sends, "Output")
894	d.handleInput(sess, ac, []byte("NXT\r"))
895	require.False(t, ac.overlays.paletteActive())
896	awaitFrame(t, sends, "Output")
897
898	d.handleInput(sess, ac, []byte("\x1b "))
899	awaitFrame(t, sends, "Output")
900	result, ok := ac.overlays.palette.Selected()
901	require.True(t, ok)
902	cmd, ok := result.Command()
903	require.True(t, ok)
904	require.Equal(t, "NXT", cmd.Code)
905}
906
907func TestPaletteCodeOverridesRejectAPIOnlyCommands(t *testing.T) {
908	d := newTestDaemon(t, nil, stubClock{})
909
910	overrides, warnings := d.buildCodeOverrides(map[string]string{
911		"new-tab": "TAB",
912		"toast":   "API",
913	})
914
915	require.Equal(t, map[string]string{"new-tab": "TAB"}, overrides)
916	require.Len(t, warnings, 1)
917	require.Contains(t, warnings[0].Msg, `unknown command code slug "toast"`)
918}
919
920func TestCommandByEffectiveCodeExcludesAPIOnlyCommands(t *testing.T) {
921	d := newTestDaemon(t, nil, stubClock{})
922	overrides := map[string]string{
923		"new-tab": "TAB",
924		"toast":   "API",
925	}
926	updateRuntimeConfig(d, func(rc *runtimeConfig) { rc.codeOverrides = overrides })
927
928	cmd, ok := d.commandByEffectiveCode("tab")
929	require.True(t, ok)
930	require.Equal(t, "new-tab", cmd.Slug)
931	require.Equal(t, "TAB", cmd.Code)
932
933	_, ok = d.commandByEffectiveCode("API")
934	require.False(t, ok)
935}
936
937func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
938	d := &Daemon{}
939	d.paletteHistory.record("SSP")
940	d.paletteHistory.record("NXT")
941	// STALE is not a registered command code; it must be dropped from output.
942	d.paletteHistory.record("STALE")
943	d.paletteHistory.record("SSP")
944
945	commands := d.paletteCommands()
946	codes := make([]string, len(commands))
947	for i, cmd := range commands {
948		codes[i] = cmd.Code
949	}
950	require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CES", "CLT", "SPR", "SPL", "SPU", "SPD", "MPL", "MPR", "STP", "TFS", "TFP", "CFP", "MFP", "MAT", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "PVT", "BCK", "JRS", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}, codes)
951}
952
953func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
954	d := &Daemon{}
955	codes := []string{"CNT", "CNS", "CES", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TFS", "TFP", "CFP", "MFP", "MAT", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BCK", "SSP", "VIS", "RNS", "RNT", "DET"}
956
957	var wg sync.WaitGroup
958	for range 50 {
959		for _, code := range codes {
960			wg.Add(1)
961			go func(code string) {
962				defer wg.Done()
963				d.paletteHistory.record(code)
964				_ = d.paletteCommands()
965			}(code)
966		}
967	}
968	wg.Wait()
969
970	commands := d.paletteCommands()
971	require.Len(t, commands, len(command.PaletteRegistry()))
972	seen := map[string]bool{}
973	for _, cmd := range commands {
974		require.False(t, seen[cmd.Code], "duplicate command %s", cmd.Code)
975		seen[cmd.Code] = true
976	}
977}
978
979func TestPaletteCommandNoopRepaintsAfterClose(t *testing.T) {
980	d, sess, ac, sends, releases := newManualTabSession(t, 1)
981	defer releases[0]()
982
983	d.handleInput(sess, ac, []byte("\x1b "))
984	paletteFrame := awaitFrame(t, sends, "Output")
985	paletteOutput := unmarshalTestOutput(t, paletteFrame.Payload)
986	require.Contains(t, string(paletteOutput.Data), "Commands")
987
988	d.handleInput(sess, ac, []byte("NXT\r"))
989	require.False(t, ac.overlays.paletteActive())
990	repaint := awaitFrame(t, sends, "Output")
991	repaintOutput := unmarshalTestOutput(t, repaint.Payload)
992	require.NotContains(t, string(repaintOutput.Data), "Commands")
993}
994
995func TestPaletteCreateTabErrorRepaintsAfterClose(t *testing.T) {
996	d, sess, ac, sends, releases := newManualTabSession(t, 1)
997	defer releases[0]()
998	ptys := portsmocks.NewMockPTYFactory(t)
999	ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("open failed"))
1000	d.ptys = ptys
1001
1002	d.handleInput(sess, ac, []byte("\x1b "))
1003	awaitFrame(t, sends, "Output")
1004	d.handleInput(sess, ac, []byte("CNT\r"))
1005	require.False(t, ac.overlays.paletteActive())
1006	repaint := awaitFrame(t, sends, "Output")
1007	repaintOutput := unmarshalTestOutput(t, repaint.Payload)
1008	require.NotContains(t, string(repaintOutput.Data), "Commands")
1009	require.Len(t, sess.tabs, 1)
1010}
1011
1012func TestPaletteEnterNoMatchKeepsOpenAndEscapeSplit(t *testing.T) {
1013	d, sess, ac, sends, releases := newManualTabSession(t, 1)
1014	defer releases[0]()
1015
1016	d.handleInput(sess, ac, []byte("\x1b "))
1017	awaitFrame(t, sends, "Output")
1018	d.handleInput(sess, ac, []byte("zzzz\r"))
1019	require.True(t, ac.overlays.paletteActive())
1020	awaitFrame(t, sends, "Output")
1021
1022	d.handlePaletteInput(ac, []byte{0x1b, '['})
1023	require.True(t, ac.overlays.paletteActive())
1024	require.Equal(t, []byte{0x1b, '['}, ac.overlays.palettePending)
1025	d.handlePaletteInput(ac, []byte{'A'})
1026	require.True(t, ac.overlays.paletteActive())
1027	require.Empty(t, ac.overlays.palettePending)
1028	awaitFrame(t, sends, "Output")
1029}
1030
1031func TestPaletteCtrlNAndCtrlPNavigate(t *testing.T) {
1032	d, sess, ac, sends, releases := newManualTabSession(t, 1)
1033	defer releases[0]()
1034
1035	d.handleInput(sess, ac, []byte("\x1b "))
1036	awaitFrame(t, sends, "Output")
1037
1038	d.handlePaletteInput(ac, []byte{0x0e})
1039	awaitFrame(t, sends, "Output")
1040	result, ok := ac.overlays.palette.Selected()
1041	require.True(t, ok)
1042	cmd, ok := result.Command()
1043	require.True(t, ok)
1044	require.Equal(t, "CNS", cmd.Code)
1045
1046	d.handlePaletteInput(ac, []byte{0x10})
1047	awaitFrame(t, sends, "Output")
1048	result, ok = ac.overlays.palette.Selected()
1049	require.True(t, ok)
1050	cmd, ok = result.Command()
1051	require.True(t, ok)
1052	require.Equal(t, "CNT", cmd.Code)
1053}
1054
1055func TestPaletteModalGeometry(t *testing.T) {
1056	type testCase struct {
1057		name string
1058		size domain.Size
1059		cfg  domain.PaletteConfig
1060		want domain.Rect
1061	}
1062
1063	auto := domain.PaletteConfig{}
1064	tests := []testCase{
1065		{name: "auto 79 column drawer", size: domain.Size{Cols: 79, Rows: 40}, cfg: auto, want: domain.Rect{X: 0, Y: 28, Width: 79, Height: 11}},
1066		{name: "auto 80 column rail", size: domain.Size{Cols: 80, Rows: 40}, cfg: auto, want: domain.Rect{X: 15, Y: 28, Width: 64, Height: 11}},
1067		{name: "auto 95 column rail", size: domain.Size{Cols: 95, Rows: 40}, cfg: auto, want: domain.Rect{X: 30, Y: 28, Width: 64, Height: 11}},
1068		{name: "auto 96 column rail", size: domain.Size{Cols: 96, Rows: 40}, cfg: auto, want: domain.Rect{X: 31, Y: 28, Width: 64, Height: 11}},
1069		{name: "auto 120 column rail", size: domain.Size{Cols: 120, Rows: 40}, cfg: auto, want: domain.Rect{X: 55, Y: 28, Width: 64, Height: 11}},
1070		{name: "auto tiny terminal clamps", size: domain.Size{Cols: 20, Rows: 6}, cfg: auto, want: domain.Rect{X: 0, Y: 3, Width: 20, Height: 2}},
1071	}
1072	for _, anchor := range []domain.Anchor{domain.AnchorTopLeft, domain.AnchorTop, domain.AnchorTopRight, domain.AnchorLeft, domain.AnchorCenter, domain.AnchorRight, domain.AnchorBottomLeft, domain.AnchorBottom, domain.AnchorBottomRight} {
1073		wantX := map[domain.Anchor]int{domain.AnchorTopLeft: 1, domain.AnchorTop: 28, domain.AnchorTopRight: 55, domain.AnchorLeft: 1, domain.AnchorCenter: 28, domain.AnchorRight: 55, domain.AnchorBottomLeft: 1, domain.AnchorBottom: 28, domain.AnchorBottomRight: 55}[anchor]
1074		wantY := map[domain.Anchor]int{domain.AnchorTopLeft: 1, domain.AnchorTop: 1, domain.AnchorTopRight: 1, domain.AnchorLeft: 14, domain.AnchorCenter: 14, domain.AnchorRight: 14, domain.AnchorBottomLeft: 28, domain.AnchorBottom: 28, domain.AnchorBottomRight: 28}[anchor]
1075		tests = append(tests, testCase{name: anchor.String(), size: domain.Size{Cols: 120, Rows: 40}, cfg: domain.PaletteConfig{Anchor: anchor, AnchorSet: true}, want: domain.Rect{X: wantX, Y: wantY, Width: 64, Height: 11}})
1076	}
1077	for _, anchor := range []domain.Anchor{domain.AnchorLeft, domain.AnchorCenter, domain.AnchorRight} {
1078		wantX := map[domain.Anchor]int{domain.AnchorLeft: 1, domain.AnchorCenter: 15, domain.AnchorRight: 30}[anchor]
1079		tests = append(tests, testCase{name: "95 column " + anchor.String(), size: domain.Size{Cols: 95, Rows: 40}, cfg: domain.PaletteConfig{Anchor: anchor, AnchorSet: true}, want: domain.Rect{X: wantX, Y: 14, Width: 64, Height: 11}})
1080	}
1081
1082	for _, tt := range tests {
1083		t.Run(tt.name, func(t *testing.T) {
1084			modal := paletteModalFor(tt.cfg)
1085			presentation := modal.Resolve(tt.size)
1086			require.Equal(t, tt.want, presentation.Bounds)
1087			if tt.size.Cols < ui.ResponsiveDrawerBreakpoint {
1088				require.Equal(t, ui.PresentationDrawer, presentation.Mode)
1089				require.Equal(t, ui.BorderTop, presentation.Borders)
1090			} else {
1091				require.Equal(t, ui.PresentationFloating, presentation.Mode)
1092				require.Equal(t, ui.BorderAll, presentation.Borders)
1093			}
1094			require.Equal(t, " Commands ", modal.Title)
1095			require.Equal(t, 11, modal.FixedHeight)
1096			require.Equal(t, ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}, modal.Margins)
1097		})
1098	}
1099}
1100
1101func TestComposePaletteClientFrameUsesResponsivePresentation(t *testing.T) {
1102	model := palette.New(nil)
1103	for _, tt := range []struct {
1104		name      string
1105		width     int
1106		left      int
1107		topLeft   rune
1108		innerLeft rune
1109	}{
1110		{name: "79 column drawer", width: 79, left: 0, topLeft: '─', innerLeft: '>'},
1111		{name: "80 column rail", width: 80, left: 15, topLeft: '┌', innerLeft: '│'},
1112	} {
1113		t.Run(tt.name, func(t *testing.T) {
1114			frame, _ := composePaletteClientFrame(model, renderer.NewFrame(tt.width, 40), domain.PaletteConfig{}, "")
1115
1116			require.Equal(t, tt.topLeft, frame.At(tt.left, 28).Rune)
1117			require.Equal(t, tt.innerLeft, frame.At(tt.left, 29).Rune)
1118		})
1119	}
1120}
1121
1122func TestComposePaletteClientFrameUsesConfiguredPosition(t *testing.T) {
1123	model := palette.New(nil)
1124	base := renderer.NewFrame(120, 40)
1125	for _, tt := range []struct {
1126		name string
1127		cfg  domain.PaletteConfig
1128		at   domain.Rect
1129	}{
1130		{name: "top left", cfg: domain.PaletteConfig{Anchor: domain.AnchorTopLeft, AnchorSet: true}, at: domain.Rect{X: 1, Y: 1}},
1131		{name: "bottom right", cfg: domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true}, at: domain.Rect{X: 55, Y: 28}},
1132	} {
1133		t.Run(tt.name, func(t *testing.T) {
1134			frame, _ := composePaletteClientFrame(model, base, tt.cfg, "")
1135			require.Equal(t, '┌', frame.At(tt.at.X, tt.at.Y).Rune)
1136		})
1137	}
1138}
1139
1140func TestComposePaletteClientFrameUsesSelectedDescriptionTheme(t *testing.T) {
1141	model := palette.New(palette.CommandResults([]command.Command{{Code: "ONE", Desc: "first description"}}))
1142	size := domain.Size{Cols: 120, Rows: 40}
1143	base := renderer.NewFrame(size.Cols, size.Rows)
1144	styles := themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
1145
1146	frame, _ := composePaletteClientFrame(model, base, domain.PaletteConfig{}, "", styles)
1147	inner := paletteModalFor(domain.PaletteConfig{}).Resolve(size).Inner
1148
1149	require.True(t, frame.At(inner.X+4, inner.Y+1).Style.Equal(styles.PickerSelectionMuted))
1150}
1151
1152func TestPaletteUTF8PendingCompletesFilter(t *testing.T) {
1153	d, sess, ac, sends, releases := newManualTabSession(t, 1)
1154	defer releases[0]()
1155
1156	d.handleInput(sess, ac, []byte("\x1b "))
1157	awaitFrame(t, sends, "Output")
1158	d.handlePaletteInput(ac, []byte{0xc3})
1159	require.Equal(t, []byte{0xc3}, ac.overlays.palettePending)
1160	d.handlePaletteInput(ac, []byte{0xa9})
1161	require.Empty(t, ac.overlays.palettePending)
1162	require.Equal(t, "é", ac.overlays.palette.Query())
1163}
1164
1165func TestPaletteRenderAndInputCanRunConcurrently(t *testing.T) {
1166	p, release := newBlockingPTY(t)
1167	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1168	defer release()
1169	d.enterPalette(sess, ac)
1170
1171	drainDone := make(chan struct{})
1172	drainStopped := make(chan struct{})
1173	go func() {
1174		defer close(drainStopped)
1175		for {
1176			select {
1177			case <-sends:
1178			case <-drainDone:
1179				return
1180			}
1181		}
1182	}()
1183	defer func() {
1184		close(drainDone)
1185		<-drainStopped
1186	}()
1187
1188	var wg sync.WaitGroup
1189	wg.Add(2)
1190	go func() {
1191		defer wg.Done()
1192		for range 200 {
1193			d.paint(sess, ac, true, nil)
1194		}
1195	}()
1196	go func() {
1197		defer wg.Done()
1198		for range 200 {
1199			d.handlePaletteInput(ac, []byte("abc\x7f"))
1200		}
1201	}()
1202	wg.Wait()
1203}
1204
1205func TestPaletteTabCompletesSelectedCommandWithoutForwardingToPTY(t *testing.T) {
1206	tests := []struct {
1207		name                       string
1208		config                     map[string]string
1209		recent                     string
1210		query                      string
1211		want                       string
1212		wantCompletionInvalidation bool
1213		setupInvalidations         int
1214	}{
1215		{name: "changed completion invalidates once", query: "NX", want: "NXT", wantCompletionInvalidation: true},
1216		{name: "exact argument command is a no-op", query: "JRS 1", want: "JRS 1"},
1217		{name: "empty query starts at registry first", want: "CNT", wantCompletionInvalidation: true},
1218		{name: "empty query starts at daemon MRU", recent: "NXT", want: "NXT", wantCompletionInvalidation: true},
1219		{name: "effective configured override completes", config: map[string]string{"new-tab": "NEW"}, want: "NEW", wantCompletionInvalidation: true, setupInvalidations: 1},
1220		{name: "stale pre-override MRU is ignored", config: map[string]string{"new-tab": "NEW"}, recent: "CNT", want: "NEW", wantCompletionInvalidation: true, setupInvalidations: 1},
1221	}
1222
1223	for _, tt := range tests {
1224		t.Run(tt.name, func(t *testing.T) {
1225			writes := make(chan []byte, 1)
1226			p, release := newBlockingPTYWithWrites(t, writes)
1227			defer release()
1228			d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1229			invs := installPaletteInvalidationObserver(sess)
1230			if tt.recent != "" {
1231				d.paletteHistory.record(tt.recent)
1232			}
1233			if tt.config != nil {
1234				d.ApplyConfig(domain.Config{Codes: tt.config})
1235				for range tt.setupInvalidations {
1236					awaitInvalidation(t, invs)
1237				}
1238				requireNoInvalidation(t, invs)
1239			}
1240
1241			d.handleInput(sess, ac, []byte("\x1b "))
1242			awaitInvalidation(t, invs)
1243			requireNoInvalidation(t, invs)
1244			if tt.query != "" {
1245				d.handleInput(sess, ac, []byte(tt.query))
1246				awaitInvalidation(t, invs)
1247				requireNoInvalidation(t, invs)
1248			}
1249
1250			d.handleInput(sess, ac, []byte("\t"))
1251			ac.overlays.paletteMu.Lock()
1252			got := ac.overlays.palette.Query()
1253			ac.overlays.paletteMu.Unlock()
1254			require.Equal(t, tt.want, got)
1255			if tt.wantCompletionInvalidation {
1256				awaitInvalidation(t, invs)
1257			}
1258			requireNoInvalidation(t, invs)
1259			requireNoOutputFrame(t, sends)
1260			requireNoPTYWrite(t, writes)
1261		})
1262	}
1263}
1264
1265func TestPaletteBatchedTabCompletionPreservesRepaintInvalidation(t *testing.T) {
1266	writes := make(chan []byte, 1)
1267	p, release := newBlockingPTYWithWrites(t, writes)
1268	defer release()
1269	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1270	invs := installPaletteInvalidationObserver(sess)
1271
1272	d.handleInput(sess, ac, []byte("\x1b "))
1273	awaitInvalidation(t, invs)
1274	requireNoInvalidation(t, invs)
1275
1276	d.handleInput(sess, ac, []byte("NX\t\t"))
1277	ac.overlays.paletteMu.Lock()
1278	got := ac.overlays.palette.Query()
1279	ac.overlays.paletteMu.Unlock()
1280	require.Equal(t, "NXT", got)
1281	awaitInvalidation(t, invs)
1282	requireNoInvalidation(t, invs)
1283	requireNoOutputFrame(t, sends)
1284	requireNoPTYWrite(t, writes)
1285}
1286
1287func installPaletteInvalidationObserver(sess *session) chan renderInvalidation {
1288	invs := make(chan renderInvalidation, 8)
1289	sess.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{
1290		clock: nil,
1291		wake:  nil,
1292		onInvalidate: func(inv renderInvalidation) {
1293			invs <- inv
1294		},
1295	}))
1296	return invs
1297}
1298
1299func TestPaletteExecMethods(t *testing.T) {
1300	p1, release1 := newBlockingPTY(t)
1301	p2, release2 := newBlockingPTY(t)
1302	p3, release3 := newBlockingPTY(t)
1303	d, sess, ac, sends := newManualSessionWithPTYs(t, p1, p2, p3)
1304	defer release1()
1305	defer release2()
1306	defer release3()
1307	exec := paletteExec{d: d, sess: sess, ac: ac}
1308
1309	require.NoError(t, exec.NextTab())
1310	require.Equal(t, 1, testAttachmentTabIndex(sess))
1311	require.NoError(t, exec.PrevTab())
1312	require.Equal(t, 0, testAttachmentTabIndex(sess))
1313	sess.ephemeral = true
1314	require.NoError(t, exec.RenameSession())
1315	require.True(t, ac.overlays.promptActive())
1316	d.closePrompt(ac)
1317	require.True(t, sess.ephemeral)
1318	// OpenSessionPicker ends this attachment; its lifecycle contract is tested
1319	// separately by TestSessionPickerCommandDetachesToClientPicker.
1320	require.NoError(t, exec.EnterVisualMode())
1321	require.True(t, ac.overlays.copyActive())
1322	d.exitCopyMode(ac)
1323	require.NoError(t, exec.SplitRight())
1324	require.NoError(t, exec.SplitLeft())
1325	require.NoError(t, exec.SplitUp())
1326	require.NoError(t, exec.SplitDown())
1327	require.NoError(t, exec.StackPane())
1328	require.NoError(t, exec.ToggleStack())
1329	require.NoError(t, exec.ClosePane())
1330	// A single pane remains: every direction is a benign no-neighbor move,
1331	// not an error the palette should ever surface (Task 10).
1332	require.ErrorIs(t, exec.FocusPaneLeft(), errNoNeighbor)
1333	require.ErrorIs(t, exec.FocusPaneRight(), errNoNeighbor)
1334	require.ErrorIs(t, exec.FocusPaneUp(), errNoNeighbor)
1335	require.ErrorIs(t, exec.FocusPaneDown(), errNoNeighbor)
1336	require.NoError(t, exec.CloseTab())
1337	require.Len(t, sess.tabs, 2)
1338	// Drain paints generated by methods above.
1339	for len(sends) > 0 {
1340		<-sends
1341	}
1342}

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