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}