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