v0.2.0 80c61e3

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: "MPN no destination", slug: "move-pane", err: errNoMoveDestination, code: domain.NoticeSessionUnavailable, severity: domain.NoticeWarn, message: "No destination available."},
182		{name: "MTB warming floating", slug: "move-tab", err: errMoveFloatingWarming, code: domain.NoticeSessionUnavailable, severity: domain.NoticeWarn, message: "Wait for the floating pane to finish opening."},
183		{name: "MPN 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: "MPN destination too small", slug: "move-pane", err: errMoveTooSmall, code: domain.NoticeLayoutTooSmall, severity: domain.NoticeWarn, message: "Not enough space in destination tab."},
185		{name: "MTB stale destination", slug: "move-tab", err: errMoveStaleTarget, code: domain.NoticeSessionUnavailable, severity: domain.NoticeWarn, message: "Destination is no longer available."},
186		{name: "MPN generic invalid", slug: "move-pane", err: errMovePaneInvalid, code: domain.NoticeInternal, severity: domain.NoticeError, message: "Move failed."},
187		{name: "MPN 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: "home daemon keeps distinct remote", want: []string{"Switch to session remote-manual", "Switch to session remote-manual@remote"}},
442	} {
443		t.Run(test.name, func(t *testing.T) {
444			results := d.paletteResults(current, nil, snapshot, test.currentOrigin)
445			var matching []string
446			for _, result := range results {
447				if result.DisplayText() == "Switch to session remote-manual" || result.DisplayText() == "Switch to session remote-manual@remote" {
448					matching = append(matching, result.DisplayText())
449				}
450			}
451			require.Equal(t, test.want, matching)
452		})
453	}
454}
455
456func TestPaletteFuzzyRemoteRecentRouteSendsExactNavigationAction(t *testing.T) {
457	d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
458	defer releaseAll(releases)
459	ac.setRouteSnapshot(ports.RecentRouteSnapshot{
460		Generation: 9,
461		Active:     ports.RouteRef{Key: 1, Generation: 1},
462		Entries: []ports.RecentRouteEntry{{
463			Key: 8, Generation: 4, Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote,
464		}},
465	})
466	token := beginRecentRoutePaletteEffect(t, d, current, ac)
467
468	d.handleInputForAttachment(token, []byte("\x1b "))
469	awaitFrame(t, sends, ports.MsgOutput)
470	d.handleInputForAttachment(token, []byte("logs@edge\r"))
471
472	actionFrame := awaitFrame(t, sends, ports.MsgNavigateRecentRoute)
473	action, err := ports.UnmarshalRouteNavigationAction(actionFrame.Payload)
474	require.NoError(t, err)
475	require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 9, Key: 8, Generation: 4}, action)
476	require.False(t, ac.overlays.paletteActive())
477}
478
479func TestPaletteFuzzySelectedStaticCommandExecutes(t *testing.T) {
480	d, sess, ac, sends, releases := newManualTabSession(t, 2)
481	defer releases[0]()
482	defer releases[1]()
483
484	d.handleInput(sess, ac, []byte("\x1b "))
485	awaitFrame(t, sends, ports.MsgOutput)
486	d.handleInput(sess, ac, []byte("next\r"))
487
488	require.False(t, ac.overlays.paletteActive())
489	require.Equal(t, 1, testAttachmentTabIndex(sess))
490	awaitFrame(t, sends, ports.MsgOutput)
491}
492
493func TestPaletteJRSUsesCapturedRankAfterMRUChanges(t *testing.T) {
494	d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
495	defer releaseAll(releases)
496	ac.setRouteSnapshot(testRecentRouteSnapshot())
497	token := beginRecentRoutePaletteEffect(t, d, current, ac)
498	d.handleInputForAttachment(token, []byte("\x1b "))
499	awaitFrame(t, sends, ports.MsgOutput)
500	// Reordering live MRU after opening must not shift rank 1 from its capture.
501	d.sessions[domain.SessionID("older")].core().mruAt.Store(100)
502	d.handleInputForAttachment(token, []byte("JRS 1\r"))
503
504	actionFrame := awaitFrame(t, sends, ports.MsgNavigateRecentRoute)
505	action, err := ports.UnmarshalRouteNavigationAction(actionFrame.Payload)
506	require.NoError(t, err)
507	require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 1, Key: 2, Generation: 1}, action)
508	require.Same(t, current, ac.currentSession())
509	require.False(t, ac.overlays.paletteActive())
510}
511
512func TestPaletteDeniedPostHandoffAttachmentEffectClosesAndInvalidates(t *testing.T) {
513	p, release := newBlockingPTY(t)
514	defer release()
515	d, sess, ac, _ := newManualSessionWithPTYs(t, p)
516	invalidations := installPaletteInvalidationObserver(sess)
517
518	d.enterPalette(sess, ac)
519	d.handlePaletteInput(ac, []byte("BSK"))
520	// Make the attachment token detached while retaining currentSession. The
521	// no-op BSK command succeeds, but its post-execution beginAttachmentEffect is
522	// deterministically denied.
523	sess.mu.Lock()
524	clearAttachmentsForTestLocked(sess)
525	sess.mu.Unlock()
526
527	d.handlePaletteInput(ac, []byte("\r"))
528
529	require.False(t, ac.overlays.paletteActive(), "denied cleanup admission must still close the executed palette")
530	invalidation := awaitTestValue(t, invalidations, "denied palette cleanup did not invalidate rendering")
531	require.True(t, invalidation.reset)
532}
533
534func TestPaletteJRSDoesNotRevalidateTargetInDaemon(t *testing.T) {
535	p, release := newBlockingPTY(t)
536	defer release()
537	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
538	ac.setRouteSnapshot(ports.RecentRouteSnapshot{
539		Generation: 7,
540		Active:     ports.RouteRef{Key: 1, Generation: 1},
541		Entries:    []ports.RecentRouteEntry{{Key: 9, Generation: 4, Name: "captured", Kind: ports.RouteKindLocal}},
542	})
543
544	validated := make(chan struct{})
545	releaseHandoff := make(chan struct{})
546	d.beforeRecentSessionHandoff = func() {
547		close(validated)
548		<-releaseHandoff
549	}
550	token := beginRecentRoutePaletteEffect(t, d, sess, ac)
551	d.handleInputForAttachment(token, []byte("\x1b "))
552	awaitFrame(t, sends, ports.MsgOutput)
553	inputHandled := make(chan struct{})
554	go func() {
555		d.handleInputForAttachment(token, []byte("JRS 1\r"))
556		close(inputHandled)
557	}()
558	awaitTestCompletion(t, validated, "JRS did not publish the captured route action")
559	// The daemon must not consult or revalidate the target lifecycle. The
560	// client owns exact-target validation after it receives this action.
561	close(releaseHandoff)
562	awaitTestCompletion(t, inputHandled, "JRS route-action input did not complete")
563
564	actionFrame := awaitFrame(t, sends, ports.MsgNavigateRecentRoute)
565	action, err := ports.UnmarshalRouteNavigationAction(actionFrame.Payload)
566	require.NoError(t, err)
567	require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 7, Key: 9, Generation: 4}, action)
568	require.False(t, ac.overlays.paletteActive())
569}
570
571func TestPaletteJRSOutOfRangeKeepsPaletteOpenWithoutClamping(t *testing.T) {
572	d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
573	defer releaseAll(releases)
574	ac.setRouteSnapshot(testRecentRouteSnapshot())
575	token := beginRecentRoutePaletteEffect(t, d, current, ac)
576
577	d.handleInputForAttachment(token, []byte("\x1b "))
578	awaitFrame(t, sends, ports.MsgOutput)
579	d.handleInputForAttachment(token, []byte("JRS 3\r"))
580
581	require.Same(t, current, ac.currentSession())
582	require.True(t, ac.overlays.paletteActive())
583	ac.overlays.paletteMu.Lock()
584	require.Equal(t, "JRS 3", ac.overlays.palette.Query())
585	require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteFeedback)
586	ac.overlays.paletteMu.Unlock()
587	awaitFrame(t, sends, ports.MsgOutput)
588}
589
590func TestPaletteJRSMalformedRankFeedback(t *testing.T) {
591	d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
592	defer releaseAll(releases)
593
594	d.handleInput(current, ac, []byte("\x1b "))
595	awaitFrame(t, sends, ports.MsgOutput)
596	d.handleInput(current, ac, []byte("JRS 0\r"))
597
598	require.Same(t, current, ac.currentSession())
599	require.True(t, ac.overlays.paletteActive())
600	ac.overlays.paletteMu.Lock()
601	require.Equal(t, "JRS 0", ac.overlays.palette.Query())
602	require.Equal(t, "rank must be one positive decimal", ac.overlays.paletteFeedback)
603	ac.overlays.paletteMu.Unlock()
604	awaitFrame(t, sends, ports.MsgOutput)
605}
606
607func TestPaletteFailureDoesNotOverwriteChangedQueryInSameGeneration(t *testing.T) {
608	p, release := newBlockingPTY(t)
609	defer release()
610	d, sess, ac, _ := newManualSessionWithPTYs(t, p)
611
612	d.enterPalette(sess, ac)
613	ac.overlays.paletteMu.Lock()
614	generation := ac.overlays.paletteGeneration
615	ac.overlays.palette.Insert('x')
616	ac.overlays.paletteMu.Unlock()
617	ac.paletteFailure(generation, "", "stale failure")
618
619	ac.overlays.paletteMu.Lock()
620	require.Empty(t, ac.overlays.paletteFeedback)
621	ac.overlays.paletteMu.Unlock()
622}
623
624func TestPaletteFailureDoesNotOverwriteNewerInteraction(t *testing.T) {
625	p, release := newBlockingPTY(t)
626	defer release()
627	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
628
629	d.enterPalette(sess, ac)
630	ac.overlays.paletteMu.Lock()
631	staleGeneration := ac.overlays.paletteGeneration
632	ac.overlays.paletteMu.Unlock()
633	d.enterPalette(sess, ac)
634	ac.paletteFailure(staleGeneration, "JRS 1", "stale failure")
635
636	ac.overlays.paletteMu.Lock()
637	require.Empty(t, ac.overlays.paletteFeedback)
638	ac.overlays.paletteMu.Unlock()
639	awaitFrame(t, sends, ports.MsgOutput)
640	awaitFrame(t, sends, ports.MsgOutput)
641}
642
643func TestPaletteFLTExecutesFloatingToggle(t *testing.T) {
644	p, release := newBlockingPTY(t)
645	defer release()
646	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
647	tb := testAttachmentTab(sess)
648	installTestFloating(tb, newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), false)
649
650	d.handleInput(sess, ac, []byte("\x1b "))
651	awaitFrame(t, sends, ports.MsgOutput)
652	d.handleInput(sess, ac, []byte("FLT\r"))
653
654	require.False(t, ac.overlays.paletteActive())
655	tb.mu.Lock()
656	require.Equal(t, floatingVisible, tb.floating.state)
657	tb.mu.Unlock()
658	awaitFrame(t, sends, ports.MsgOutput)
659}
660
661func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
662	p1, release1 := newBlockingPTY(t)
663	p2, release2 := newBlockingPTY(t)
664	d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
665	defer release1()
666	defer release2()
667	d.ptys = newFactorySeq(t, p2)
668
669	d.handleInput(sess, ac, []byte("\x1b "))
670	awaitFrame(t, sends, ports.MsgOutput)
671	d.handleInput(sess, ac, []byte("CNS\r"))
672	promptFrame := awaitFrame(t, sends, ports.MsgOutput)
673	promptOutput, err := ports.UnmarshalOutput(promptFrame.Payload)
674	require.NoError(t, err)
675	require.False(t, ac.overlays.paletteActive())
676	require.True(t, ac.overlays.promptActive())
677	require.Contains(t, string(promptOutput.Data), "Create session")
678
679	generation := ac.connectionGeneration.Load()
680	d.handleInput(sess, ac, []byte("scratch\r"))
681	// The submit first paints the newly attached session while the prompt is
682	// still open, then handlePromptInput closes the prompt and repaints the
683	// client's current session. The final frame must be for the new session.
684	firstPaint := awaitFrame(t, sends, ports.MsgOutput)
685	firstOutput, err := ports.UnmarshalOutput(firstPaint.Payload)
686	require.NoError(t, err)
687	require.Zero(t, firstOutput.Base, "new-session first paint must reset moving output state")
688	finalRepaint := awaitFrame(t, sends, ports.MsgOutput)
689	finalOutput, err := ports.UnmarshalOutput(finalRepaint.Payload)
690	require.NoError(t, err)
691	require.False(t, ac.overlays.promptActive())
692	require.Equal(t, 2, sessionCount(d))
693	require.Empty(t, sess.snapshotAttachments())
694	newSess := ac.currentSession()
695	require.NotNil(t, newSess)
696	require.NotSame(t, sess, newSess)
697	require.Equal(t, "scratch", newSess.name)
698	require.False(t, newSess.ephemeral)
699	require.Contains(t, newSess.snapshotAttachments(), ac)
700	require.Equal(t, true, newSess.attachmentRegistered(ac))
701	require.Greater(t, ac.connectionGeneration.Load(), generation, "new-session handoff must publish through the attachment transition")
702	require.Contains(t, string(finalOutput.Data), "scratch")
703	require.NotContains(t, string(finalOutput.Data), "Create session")
704}
705
706func TestPaletteReopensWithSuccessfulCommandFirst(t *testing.T) {
707	d, sess, ac, sends, releases := newManualTabSession(t, 2)
708	sess.mu.Lock()
709	sess.registerAttachmentLocked(ac)
710	sess.mu.Unlock()
711	defer releases[0]()
712	defer releases[1]()
713
714	d.handleInput(sess, ac, []byte("\x1b "))
715	awaitFrame(t, sends, ports.MsgOutput)
716	d.handleInput(sess, ac, []byte("NXT\r"))
717	require.False(t, ac.overlays.paletteActive())
718	awaitFrame(t, sends, ports.MsgOutput)
719
720	d.handleInput(sess, ac, []byte("\x1b "))
721	awaitFrame(t, sends, ports.MsgOutput)
722	result, ok := ac.overlays.palette.Selected()
723	require.True(t, ok)
724	cmd, ok := result.Command()
725	require.True(t, ok)
726	require.Equal(t, "NXT", cmd.Code)
727}
728
729func TestPaletteCodeOverridesRejectAPIOnlyCommands(t *testing.T) {
730	d := newTestDaemon(t, nil, stubClock{})
731
732	overrides, warnings := d.buildCodeOverrides(map[string]string{
733		"new-tab": "TAB",
734		"toast":   "API",
735	})
736
737	require.Equal(t, map[string]string{"new-tab": "TAB"}, overrides)
738	require.Len(t, warnings, 1)
739	require.Contains(t, warnings[0].Msg, `unknown command code slug "toast"`)
740}
741
742func TestCommandByEffectiveCodeExcludesAPIOnlyCommands(t *testing.T) {
743	d := newTestDaemon(t, nil, stubClock{})
744	overrides := map[string]string{
745		"new-tab": "TAB",
746		"toast":   "API",
747	}
748	d.codeOverrides.Store(&overrides)
749
750	cmd, ok := d.commandByEffectiveCode("tab")
751	require.True(t, ok)
752	require.Equal(t, "new-tab", cmd.Slug)
753	require.Equal(t, "TAB", cmd.Code)
754
755	_, ok = d.commandByEffectiveCode("API")
756	require.False(t, ok)
757}
758
759func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
760	d := &Daemon{}
761	d.recordPaletteUse("SSP")
762	d.recordPaletteUse("NXT")
763	// STALE is not a registered command code; it must be dropped from output.
764	d.recordPaletteUse("STALE")
765	d.recordPaletteUse("SSP")
766
767	commands := d.paletteCommands()
768	codes := make([]string, len(commands))
769	for i, cmd := range commands {
770		codes[i] = cmd.Code
771	}
772	require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "CEL", "CER", "STP", "TST", "FLT", "CLP", "MPN", "MTB", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "PVT", "BSK", "JRS", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}, codes)
773}
774
775func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
776	d := &Daemon{}
777	codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "MPN", "MTB", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BSK", "SSP", "VIS", "RNS", "RNT", "DET"}
778
779	var wg sync.WaitGroup
780	for range 50 {
781		for _, code := range codes {
782			wg.Add(1)
783			go func(code string) {
784				defer wg.Done()
785				d.recordPaletteUse(code)
786				_ = d.paletteCommands()
787			}(code)
788		}
789	}
790	wg.Wait()
791
792	commands := d.paletteCommands()
793	require.Len(t, commands, len(command.PaletteRegistry()))
794	seen := map[string]bool{}
795	for _, cmd := range commands {
796		require.False(t, seen[cmd.Code], "duplicate command %s", cmd.Code)
797		seen[cmd.Code] = true
798	}
799}
800
801func TestPaletteCommandNoopRepaintsAfterClose(t *testing.T) {
802	d, sess, ac, sends, releases := newManualTabSession(t, 1)
803	defer releases[0]()
804
805	d.handleInput(sess, ac, []byte("\x1b "))
806	paletteFrame := awaitFrame(t, sends, ports.MsgOutput)
807	paletteOutput, err := ports.UnmarshalOutput(paletteFrame.Payload)
808	require.NoError(t, err)
809	require.Contains(t, string(paletteOutput.Data), "Commands")
810
811	d.handleInput(sess, ac, []byte("NXT\r"))
812	require.False(t, ac.overlays.paletteActive())
813	repaint := awaitFrame(t, sends, ports.MsgOutput)
814	repaintOutput, err := ports.UnmarshalOutput(repaint.Payload)
815	require.NoError(t, err)
816	require.NotContains(t, string(repaintOutput.Data), "Commands")
817}
818
819func TestPaletteCreateTabErrorRepaintsAfterClose(t *testing.T) {
820	d, sess, ac, sends, releases := newManualTabSession(t, 1)
821	defer releases[0]()
822	ptys := portsmocks.NewMockPTYFactory(t)
823	ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("open failed"))
824	d.ptys = ptys
825
826	d.handleInput(sess, ac, []byte("\x1b "))
827	awaitFrame(t, sends, ports.MsgOutput)
828	d.handleInput(sess, ac, []byte("CNT\r"))
829	require.False(t, ac.overlays.paletteActive())
830	repaint := awaitFrame(t, sends, ports.MsgOutput)
831	repaintOutput, err := ports.UnmarshalOutput(repaint.Payload)
832	require.NoError(t, err)
833	require.NotContains(t, string(repaintOutput.Data), "Commands")
834	require.Len(t, sess.tabs, 1)
835}
836
837func TestPaletteEnterNoMatchKeepsOpenAndEscapeSplit(t *testing.T) {
838	d, sess, ac, sends, releases := newManualTabSession(t, 1)
839	defer releases[0]()
840
841	d.handleInput(sess, ac, []byte("\x1b "))
842	awaitFrame(t, sends, ports.MsgOutput)
843	d.handleInput(sess, ac, []byte("zzzz\r"))
844	require.True(t, ac.overlays.paletteActive())
845	awaitFrame(t, sends, ports.MsgOutput)
846
847	d.handlePaletteInput(ac, []byte{0x1b, '['})
848	require.True(t, ac.overlays.paletteActive())
849	require.Equal(t, []byte{0x1b, '['}, ac.overlays.palettePending)
850	d.handlePaletteInput(ac, []byte{'A'})
851	require.True(t, ac.overlays.paletteActive())
852	require.Empty(t, ac.overlays.palettePending)
853	awaitFrame(t, sends, ports.MsgOutput)
854}
855
856func TestPaletteCtrlNAndCtrlPNavigate(t *testing.T) {
857	d, sess, ac, sends, releases := newManualTabSession(t, 1)
858	defer releases[0]()
859
860	d.handleInput(sess, ac, []byte("\x1b "))
861	awaitFrame(t, sends, ports.MsgOutput)
862
863	d.handlePaletteInput(ac, []byte{0x0e})
864	awaitFrame(t, sends, ports.MsgOutput)
865	result, ok := ac.overlays.palette.Selected()
866	require.True(t, ok)
867	cmd, ok := result.Command()
868	require.True(t, ok)
869	require.Equal(t, "CNS", cmd.Code)
870
871	d.handlePaletteInput(ac, []byte{0x10})
872	awaitFrame(t, sends, ports.MsgOutput)
873	result, ok = ac.overlays.palette.Selected()
874	require.True(t, ok)
875	cmd, ok = result.Command()
876	require.True(t, ok)
877	require.Equal(t, "CNT", cmd.Code)
878}
879
880func TestPaletteModalGeometry(t *testing.T) {
881	type testCase struct {
882		name string
883		size domain.Size
884		cfg  domain.PaletteConfig
885		want domain.Rect
886	}
887
888	auto := domain.PaletteConfig{}
889	tests := []testCase{
890		{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}},
891		{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}},
892		{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}},
893		{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}},
894		{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}},
895		{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}},
896	}
897	for _, anchor := range []domain.Anchor{domain.AnchorTopLeft, domain.AnchorTop, domain.AnchorTopRight, domain.AnchorLeft, domain.AnchorCenter, domain.AnchorRight, domain.AnchorBottomLeft, domain.AnchorBottom, domain.AnchorBottomRight} {
898		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]
899		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]
900		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}})
901	}
902	for _, anchor := range []domain.Anchor{domain.AnchorLeft, domain.AnchorCenter, domain.AnchorRight} {
903		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}})
904	}
905
906	for _, tt := range tests {
907		t.Run(tt.name, func(t *testing.T) {
908			modal := paletteModalFor(tt.size, tt.cfg)
909			presentation := modal.Resolve(tt.size)
910			require.Equal(t, tt.want, presentation.Bounds)
911			if tt.size.Cols < ui.ResponsiveDrawerBreakpoint {
912				require.Equal(t, ui.PresentationDrawer, presentation.Mode)
913				require.Equal(t, ui.BorderTop, presentation.Borders)
914			} else {
915				require.Equal(t, ui.PresentationFloating, presentation.Mode)
916				require.Equal(t, ui.BorderAll, presentation.Borders)
917			}
918			require.Equal(t, " Commands ", modal.Title)
919			require.Equal(t, 11, modal.FixedHeight)
920			require.Equal(t, ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}, modal.Margins)
921		})
922	}
923}
924
925func TestComposePaletteClientFrameUsesResponsivePresentation(t *testing.T) {
926	model := palette.New(nil)
927	for _, tt := range []struct {
928		name      string
929		width     int
930		topLeft   rune
931		innerLeft rune
932	}{
933		{name: "79 column drawer", width: 79, topLeft: '─', innerLeft: '>'},
934		{name: "80 column shelf", width: 80, topLeft: '┌', innerLeft: '│'},
935	} {
936		t.Run(tt.name, func(t *testing.T) {
937			frame, _ := composePaletteClientFrame(model, renderer.NewFrame(tt.width, 40), domain.PaletteConfig{}, "")
938
939			require.Equal(t, tt.topLeft, frame.At(0, 28).Rune)
940			require.Equal(t, tt.innerLeft, frame.At(0, 29).Rune)
941		})
942	}
943}
944
945func TestComposePaletteClientFrameUsesConfiguredPosition(t *testing.T) {
946	model := palette.New(nil)
947	base := renderer.NewFrame(120, 40)
948	for _, tt := range []struct {
949		name string
950		cfg  domain.PaletteConfig
951		at   domain.Rect
952	}{
953		{name: "top left", cfg: domain.PaletteConfig{Anchor: domain.AnchorTopLeft, AnchorSet: true}, at: domain.Rect{X: 1, Y: 1}},
954		{name: "bottom right", cfg: domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true}, at: domain.Rect{X: 55, Y: 28}},
955	} {
956		t.Run(tt.name, func(t *testing.T) {
957			frame, _ := composePaletteClientFrame(model, base, tt.cfg, "")
958			require.Equal(t, '┌', frame.At(tt.at.X, tt.at.Y).Rune)
959		})
960	}
961}
962
963func TestComposePaletteClientFrameUsesSelectedDescriptionTheme(t *testing.T) {
964	model := palette.New(palette.CommandResults([]command.Command{{Code: "ONE", Desc: "first description"}}))
965	size := domain.Size{Cols: 120, Rows: 40}
966	base := renderer.NewFrame(size.Cols, size.Rows)
967	styles := themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
968
969	frame, _ := composePaletteClientFrame(model, base, domain.PaletteConfig{}, "", styles)
970	inner := paletteModalFor(size, domain.PaletteConfig{}).Resolve(size).Inner
971
972	require.True(t, frame.At(inner.X+4, inner.Y+1).Style.Equal(styles.PickerSelectionMuted))
973}
974
975func TestPaletteUTF8PendingCompletesFilter(t *testing.T) {
976	d, sess, ac, sends, releases := newManualTabSession(t, 1)
977	defer releases[0]()
978
979	d.handleInput(sess, ac, []byte("\x1b "))
980	awaitFrame(t, sends, ports.MsgOutput)
981	d.handlePaletteInput(ac, []byte{0xc3})
982	require.Equal(t, []byte{0xc3}, ac.overlays.palettePending)
983	d.handlePaletteInput(ac, []byte{0xa9})
984	require.Empty(t, ac.overlays.palettePending)
985	require.Equal(t, "é", ac.overlays.palette.Query())
986}
987
988func TestPaletteRenderAndInputCanRunConcurrently(t *testing.T) {
989	p, release := newBlockingPTY(t)
990	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
991	defer release()
992	d.enterPalette(sess, ac)
993
994	drainDone := make(chan struct{})
995	drainStopped := make(chan struct{})
996	go func() {
997		defer close(drainStopped)
998		for {
999			select {
1000			case <-sends:
1001			case <-drainDone:
1002				return
1003			}
1004		}
1005	}()
1006	defer func() {
1007		close(drainDone)
1008		<-drainStopped
1009	}()
1010
1011	var wg sync.WaitGroup
1012	wg.Add(2)
1013	go func() {
1014		defer wg.Done()
1015		for range 200 {
1016			d.paint(sess, ac, true, nil)
1017		}
1018	}()
1019	go func() {
1020		defer wg.Done()
1021		for range 200 {
1022			d.handlePaletteInput(ac, []byte("abc\x7f"))
1023		}
1024	}()
1025	wg.Wait()
1026}
1027
1028func TestPaletteTabCompletesSelectedCommandWithoutForwardingToPTY(t *testing.T) {
1029	tests := []struct {
1030		name                       string
1031		config                     map[string]string
1032		recent                     string
1033		query                      string
1034		want                       string
1035		wantCompletionInvalidation bool
1036		setupInvalidations         int
1037	}{
1038		{name: "changed completion invalidates once", query: "NX", want: "NXT", wantCompletionInvalidation: true},
1039		{name: "exact argument command is a no-op", query: "JRS 1", want: "JRS 1"},
1040		{name: "empty query starts at registry first", want: "CNT", wantCompletionInvalidation: true},
1041		{name: "empty query starts at daemon MRU", recent: "NXT", want: "NXT", wantCompletionInvalidation: true},
1042		{name: "effective configured override completes", config: map[string]string{"new-tab": "NEW"}, want: "NEW", wantCompletionInvalidation: true, setupInvalidations: 1},
1043		{name: "stale pre-override MRU is ignored", config: map[string]string{"new-tab": "NEW"}, recent: "CNT", want: "NEW", wantCompletionInvalidation: true, setupInvalidations: 1},
1044	}
1045
1046	for _, tt := range tests {
1047		t.Run(tt.name, func(t *testing.T) {
1048			writes := make(chan []byte, 1)
1049			p, release := newBlockingPTYWithWrites(t, writes)
1050			defer release()
1051			d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1052			invs := installPaletteInvalidationObserver(sess)
1053			if tt.recent != "" {
1054				d.recordPaletteUse(tt.recent)
1055			}
1056			if tt.config != nil {
1057				d.ApplyConfig(domain.Config{Codes: tt.config})
1058				for range tt.setupInvalidations {
1059					awaitInvalidation(t, invs)
1060				}
1061				requireNoInvalidation(t, invs)
1062			}
1063
1064			d.handleInput(sess, ac, []byte("\x1b "))
1065			awaitInvalidation(t, invs)
1066			requireNoInvalidation(t, invs)
1067			if tt.query != "" {
1068				d.handleInput(sess, ac, []byte(tt.query))
1069				awaitInvalidation(t, invs)
1070				requireNoInvalidation(t, invs)
1071			}
1072
1073			d.handleInput(sess, ac, []byte("\t"))
1074			ac.overlays.paletteMu.Lock()
1075			got := ac.overlays.palette.Query()
1076			ac.overlays.paletteMu.Unlock()
1077			require.Equal(t, tt.want, got)
1078			if tt.wantCompletionInvalidation {
1079				awaitInvalidation(t, invs)
1080			}
1081			requireNoInvalidation(t, invs)
1082			requireNoOutputFrame(t, sends)
1083			requireNoPTYWrite(t, writes)
1084		})
1085	}
1086}
1087
1088func TestPaletteBatchedTabCompletionPreservesRepaintInvalidation(t *testing.T) {
1089	writes := make(chan []byte, 1)
1090	p, release := newBlockingPTYWithWrites(t, writes)
1091	defer release()
1092	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1093	invs := installPaletteInvalidationObserver(sess)
1094
1095	d.handleInput(sess, ac, []byte("\x1b "))
1096	awaitInvalidation(t, invs)
1097	requireNoInvalidation(t, invs)
1098
1099	d.handleInput(sess, ac, []byte("NX\t\t"))
1100	ac.overlays.paletteMu.Lock()
1101	got := ac.overlays.palette.Query()
1102	ac.overlays.paletteMu.Unlock()
1103	require.Equal(t, "NXT", got)
1104	awaitInvalidation(t, invs)
1105	requireNoInvalidation(t, invs)
1106	requireNoOutputFrame(t, sends)
1107	requireNoPTYWrite(t, writes)
1108}
1109
1110func installPaletteInvalidationObserver(sess *session) chan renderInvalidation {
1111	invs := make(chan renderInvalidation, 8)
1112	sess.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{
1113		clock: nil,
1114		wake:  nil,
1115		onInvalidate: func(inv renderInvalidation) {
1116			invs <- inv
1117		},
1118	}))
1119	return invs
1120}
1121
1122func TestPaletteExecMethods(t *testing.T) {
1123	p1, release1 := newBlockingPTY(t)
1124	p2, release2 := newBlockingPTY(t)
1125	p3, release3 := newBlockingPTY(t)
1126	d, sess, ac, sends := newManualSessionWithPTYs(t, p1, p2, p3)
1127	defer release1()
1128	defer release2()
1129	defer release3()
1130	exec := paletteExec{d: d, sess: sess, ac: ac}
1131
1132	require.NoError(t, exec.NextTab())
1133	require.Equal(t, 1, testAttachmentTabIndex(sess))
1134	require.NoError(t, exec.PrevTab())
1135	require.Equal(t, 0, testAttachmentTabIndex(sess))
1136	sess.ephemeral = true
1137	require.NoError(t, exec.RenameSession())
1138	require.True(t, ac.overlays.promptActive())
1139	d.closePrompt(ac)
1140	require.True(t, sess.ephemeral)
1141	require.NoError(t, exec.OpenSessionPicker())
1142	require.True(t, ac.overlays.pickerActive())
1143	d.closePicker(ac)
1144	require.NoError(t, exec.EnterVisualMode())
1145	require.True(t, ac.overlays.copyActive())
1146	d.exitCopyMode(ac)
1147	require.NoError(t, exec.SplitRight())
1148	require.NoError(t, exec.SplitLeft())
1149	require.NoError(t, exec.SplitUp())
1150	require.NoError(t, exec.SplitDown())
1151	require.NoError(t, exec.StackPane())
1152	require.NoError(t, exec.ToggleStack())
1153	require.NoError(t, exec.ClosePane())
1154	// A single pane remains: every direction is a benign no-neighbor move,
1155	// not an error the palette should ever surface (Task 10).
1156	require.ErrorIs(t, exec.FocusPaneLeft(), errNoNeighbor)
1157	require.ErrorIs(t, exec.FocusPaneRight(), errNoNeighbor)
1158	require.ErrorIs(t, exec.FocusPaneUp(), errNoNeighbor)
1159	require.ErrorIs(t, exec.FocusPaneDown(), errNoNeighbor)
1160	require.NoError(t, exec.CloseTab())
1161	require.Len(t, sess.tabs, 2)
1162	// Drain paints generated by methods above.
1163	for len(sends) > 0 {
1164		<-sends
1165	}
1166}

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