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