v0.3.0 99dc266

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

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