1package daemon
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "io"
8 "log/slog"
9 "strconv"
10 "strings"
11 "sync"
12 "sync/atomic"
13 "testing"
14 "time"
15
16 "github.com/stretchr/testify/mock"
17 "github.com/stretchr/testify/require"
18
19 vt "github.com/bnema/vev-vt"
20 renderer "github.com/bnema/vev-vt/ansi"
21 "github.com/bnema/vev/internal/domain"
22 "github.com/bnema/vev/internal/ports"
23 portsmocks "github.com/bnema/vev/internal/ports/mocks"
24 scopy "github.com/bnema/vev/internal/usecase/copy"
25 "github.com/bnema/vev/internal/usecase/layout"
26 themeui "github.com/bnema/vev/internal/usecase/theme"
27)
28
29func TestPaletteBackdropDimsSimultaneousCopyMode(t *testing.T) {
30 p, release := newBlockingPTY(t)
31 defer release()
32 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
33 theme := backdropTheme()
34 ac.setThemeForTest(theme)
35 client := vt.NewScreen(80, 25)
36 pane := sess.tabs[0].focusedPane()
37 pane.screen.Write([]byte("\x1b[38;2;180;90;30mX"))
38
39 d.enterCopyMode(sess, ac)
40 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
41 undimmed := client.Frame.At(0, 1)
42 require.Equal(t, 'X', undimmed.Rune, "fixture must address copy-mode pane content")
43 copyBar := append([]renderer.Cell(nil), client.Frame.Row(client.Frame.Height-1)...)
44 require.Contains(t, rowText(copyBar), "[SCROLL]", "fixture must capture the copy status bar")
45
46 d.enterPalette(sess, ac)
47 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
48 dimmed := client.Frame.At(0, 1)
49 require.Equal(t, undimmed.Rune, dimmed.Rune, "palette backdrop must preserve copy content")
50 require.Equal(t, themeui.NewDimmer(theme).Dim(undimmed.Style), dimmed.Style, "palette backdrop must dim the composed copy frame")
51 for x, cell := range copyBar {
52 cell.Style = themeui.NewDimmer(theme).Dim(cell.Style)
53 require.Equal(t, cell, client.Frame.At(x, client.Frame.Height-1), "copy status bar must be part of the complete backdrop")
54 }
55 paletteVisible := false
56 for y := range client.Frame.Height {
57 paletteVisible = paletteVisible || strings.Contains(rowText(client.Frame.Row(y)), "Commands")
58 }
59 require.True(t, paletteVisible, "palette must remain composed above copy mode")
60}
61
62func TestFirstPaintRetainedFloatingPaneEmitsOneReset(t *testing.T) {
63 for _, tc := range []struct {
64 name string
65 clientSize domain.Size
66 }{
67 {name: "matching outer size", clientSize: domain.Size{Cols: 80, Rows: 25}},
68 {name: "different outer size", clientSize: domain.Size{Cols: 100, Rows: 40}},
69 } {
70 t.Run(tc.name, func(t *testing.T) {
71 pty, releasePTY := newBlockingPTY(t)
72 defer releasePTY()
73 d, sess, ac, sends := newManualSessionWithPTYs(t, pty)
74 ac.sendMu.Lock()
75 ac.size = tc.clientSize
76 ac.sendMu.Unlock()
77
78 // A retained visible popup still needs activation warmup. When the
79 // outer terminal also changes, its completed transaction must cover
80 // that popup rather than emitting a second reset-producing frame.
81 floating := newPane(layout.PaneID("floating"), nil, domain.Size{Cols: 20, Rows: 8})
82 installTestFloating(testAttachmentTab(sess), floating, true)
83
84 d.firstPaint(sess, ac)
85
86 frame := awaitFrame(t, sends, ports.MsgOutput)
87 output, err := ports.UnmarshalOutput(frame.Payload)
88 require.NoError(t, err)
89 require.Zero(t, output.Base, "first paint must be a mandatory reset")
90 select {
91 case extra := <-sends:
92 t.Fatalf("first paint emitted duplicate frame after floating activation resize: %#v", extra)
93 default:
94 }
95 })
96 }
97}
98
99func TestPaletteBackdropDimsSimultaneousPicker(t *testing.T) {
100 p, release := newBlockingPTY(t)
101 defer release()
102 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
103 ac.setThemeForTest(backdropTheme())
104 client := vt.NewScreen(80, 25)
105 pane := sess.tabs[0].focusedPane()
106 pane.screen.Write([]byte("X"))
107
108 d.enterPicker(sess, ac)
109 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
110 pickerTitle := client.Frame.At(31, 2)
111 require.Equal(t, 'S', pickerTitle.Rune, "fixture must address the picker title")
112 undimmedPane := client.Frame.At(0, 1)
113
114 d.enterPalette(sess, ac)
115 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
116 dimmedPickerTitle := pickerTitle
117 dimmedPickerTitle.Style = themeui.NewDimmer(backdropTheme()).Dim(pickerTitle.Style)
118 require.Equal(t, dimmedPickerTitle, client.Frame.At(31, 2), "the lower-priority picker must be part of the palette backdrop")
119 require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmedPane.Style), client.Frame.At(0, 1).Style, "pane content outside overlays must use the theme dim style")
120}
121
122func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) {
123 p, release := newBlockingPTY(t)
124 defer release()
125 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
126 ac.setThemeForTest(backdropTheme())
127 client := vt.NewScreen(80, 25)
128 pane := sess.tabs[0].focusedPane()
129 pane.screen.Write([]byte("X"))
130 d.paint(sess, ac, true, nil)
131 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
132 undimmed := client.Frame.At(0, 1)
133 topBar := client.Frame.At(0, 0)
134 bottomBar := client.Frame.At(0, 24)
135
136 d.handleInput(sess, ac, []byte("\x1b "))
137 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
138 dimmed := client.Frame.At(0, 1)
139 require.Equal(t, 'X', dimmed.Rune)
140 require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmed.Style), dimmed.Style, "open palette must use the theme dim style")
141 dimmedTopBar := topBar
142 dimmedTopBar.Style = themeui.NewDimmer(backdropTheme()).Dim(topBar.Style)
143 dimmedBottomBar := bottomBar
144 dimmedBottomBar.Style = themeui.NewDimmer(backdropTheme()).Dim(bottomBar.Style)
145 require.Equal(t, dimmedTopBar, client.Frame.At(0, 0), "top chrome is part of the complete backdrop")
146 require.Equal(t, dimmedBottomBar, client.Frame.At(0, 24), "bottom chrome is part of the complete backdrop")
147
148 d.handleInput(sess, ac, []byte("\x1b"))
149 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
150 require.Equal(t, undimmed, client.Frame.At(0, 1), "full redraw restores pane rune and style")
151 require.Equal(t, topBar, client.Frame.At(0, 0))
152 require.Equal(t, bottomBar, client.Frame.At(0, 24))
153}
154
155// --- test doubles -----------------------------------------------------------
156
157// stubClock returns timers whose channel never fires, so a scheduler under it
158// blocks in its debounce loop until the session context is cancelled. Used by
159
160// channelPTY drives ptyReader through generated PTY mocks. A processed token is
161// published only when ptyReader asks for its next read, which proves the
162// preceding chunk completed its parser and coordinator path without sleeping.
163type channelPTYStep struct {
164 data []byte
165 err error
166}
167
168func newChannelPTY(t *testing.T) (*portsmocks.MockPTY, chan<- channelPTYStep, <-chan struct{}) {
169 t.Helper()
170 pty := portsmocks.NewMockPTY(t)
171 steps := make(chan channelPTYStep)
172 processed := make(chan struct{}, 8)
173 read := false
174 pty.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
175 if read {
176 processed <- struct{}{}
177 }
178 read = true
179 step := <-steps
180 return copy(buf, step.data), step.err
181 }).Maybe()
182 pty.EXPECT().Close().Return(nil).Maybe()
183 return pty, steps, processed
184}
185
186func awaitPTYReadProcessed(t *testing.T, processed <-chan struct{}) {
187 t.Helper()
188 <-processed
189}
190
191func awaitOutputFrameWithoutSleep(t *testing.T, sends <-chan ports.Frame) ports.Frame {
192 t.Helper()
193 frame := <-sends
194 require.Equal(t, ports.MsgOutput, frame.Type)
195 return frame
196}
197
198func drainCoordinatorTimers(clock *coordinatorMockClock) []*coordinatorMockTimer {
199 var timers []*coordinatorMockTimer
200 for {
201 select {
202 case timer := <-clock.timers:
203 timers = append(timers, timer)
204 default:
205 return timers
206 }
207 }
208}
209
210func fireCoordinatorTimer(t *testing.T, rc *renderCoordinator, timers []*coordinatorMockTimer, duration time.Duration) {
211 t.Helper()
212 for _, timer := range timers {
213 if timer.duration != duration {
214 continue
215 }
216 var done <-chan struct{}
217 rc.mu.Lock()
218 if rc.normalLane.token.timer == timer.mock {
219 done = rc.normalLane.token.done
220 }
221 rc.mu.Unlock()
222 timer.ch <- time.Time{}
223 if done != nil {
224 <-done
225 }
226 return
227 }
228 t.Fatalf("coordinator did not arm %s timer", duration)
229}
230
231func TestPTYReaderLogsClosureWithoutNotification(t *testing.T) {
232 pty := portsmocks.NewMockPTY(t)
233 readErr := errors.New("pty master closed")
234 pty.EXPECT().Read(mock.Anything).Return(0, readErr).Once()
235 d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
236 var logs bytes.Buffer
237 d.log = slog.New(slog.NewTextHandler(&logs, nil))
238 pane := sess.tabs[0].focusedPane()
239 pane.onExit = func() {}
240
241 d.sessWg.Add(1)
242 d.ptyReader(sess, sess.tabs[0], pane)
243
244 require.Contains(t, logs.String(), "level=INFO")
245 require.Contains(t, logs.String(), "msg=\"pane pty closed\"")
246 require.Contains(t, logs.String(), "err=\"pty master closed\"")
247 require.Contains(t, logs.String(), "session=work")
248 require.Empty(t, d.notices.history(), "PTY closure is diagnostic only")
249 toasts, _ := visibleToasts(ac)
250 require.Empty(t, toasts, "PTY closure must not notify the user")
251}
252
253type concurrentExitPTY struct {
254 started chan struct{}
255 release chan struct{}
256}
257
258func newConcurrentExitPTY() *concurrentExitPTY {
259 return &concurrentExitPTY{started: make(chan struct{}), release: make(chan struct{})}
260}
261
262func (p *concurrentExitPTY) Read([]byte) (int, error) {
263 close(p.started)
264 <-p.release
265 return 0, io.EOF
266}
267func (*concurrentExitPTY) Write(b []byte) (int, error) { return len(b), nil }
268func (*concurrentExitPTY) Close() error { return nil }
269func (*concurrentExitPTY) Resize(domain.Size) error { return nil }
270func (*concurrentExitPTY) Pid() int { return 0 }
271func (*concurrentExitPTY) ForegroundPgid() (int, error) {
272 return 0, nil
273}
274
275func TestPTYReaderReadsSessionNameUnderLockDuringConcurrentRename(t *testing.T) {
276 // Repeated synchronized exits make the reader's closure log contend with a
277 // rename. Running this under -race proves the name snapshot uses sess.mu.
278 for range 100 {
279 pty := newConcurrentExitPTY()
280 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
281 pane := sess.tabs[0].focusedPane()
282 pane.onExit = func() {}
283
284 d.sessWg.Add(1)
285 go d.ptyReader(sess, sess.tabs[0], pane)
286 <-pty.started
287
288 start := make(chan struct{})
289 renameErr := make(chan error, 1)
290 var wg sync.WaitGroup
291 wg.Add(2)
292 go func() {
293 defer wg.Done()
294 <-start
295 close(pty.release)
296 }()
297 go func() {
298 defer wg.Done()
299 <-start
300 renameErr <- d.renameSession(sess, "renamed")
301 }()
302 close(start)
303 wg.Wait()
304 require.NoError(t, <-renameErr)
305 d.sessWg.Wait()
306 }
307}
308
309func TestPTYReaderSyncVisibilityTransitions(t *testing.T) {
310 t.Run("inactive synchronized pane activates only after complete urgent frame", func(t *testing.T) {
311 inactivePTY, inactiveSteps, inactiveProcessed := newChannelPTY(t)
312 activePTY, _, _ := newChannelPTY(t)
313 d, sess, ac, sends := newManualSessionWithPTYs(t, activePTY, inactivePTY)
314 clock := newCoordinatorMockClock(t, 8)
315 d.clock = clock.clock
316 rc := d.attachCoordinator(sess, nil, ac, true)
317
318 d.sessWg.Add(1)
319 go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane())
320 inactiveSteps <- channelPTYStep{data: []byte("\x1b[?2026hpartial")}
321 awaitPTYReadProcessed(t, inactiveProcessed)
322
323 sess.mu.Lock()
324 selectTestAttachmentTabLocked(sess, 1)
325 sess.mu.Unlock()
326 d.invalidateRender(sess, ac, true, "sync activation")
327 fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), urgentRenderDeadline)
328 requireNoCoordinatorOutputFrame(t, sends)
329
330 inactiveSteps <- channelPTYStep{data: []byte(" complete\x1b[?2026l")}
331 awaitPTYReadProcessed(t, inactiveProcessed)
332 fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), urgentRenderDeadline)
333 frame := awaitOutputFrameWithoutSleep(t, sends)
334 output, err := ports.UnmarshalOutput(frame.Payload)
335 require.NoError(t, err)
336 require.Contains(t, string(output.Data), "partial complete")
337 requireNoCoordinatorOutputFrame(t, sends)
338
339 inactiveSteps <- channelPTYStep{err: io.EOF}
340 d.sessWg.Wait()
341 })
342
343 t.Run("hidden synchronized pane cannot stall newly active output or flush late", func(t *testing.T) {
344 oldPTY, oldSteps, oldProcessed := newChannelPTY(t)
345 newPTY, newSteps, newProcessed := newChannelPTY(t)
346 parkedPTY, _, _ := newChannelPTY(t)
347 // Keep one unopened tab so reader EOF cleanup cannot tear down the
348 // session and introduce unrelated detach-notification timers.
349 d, sess, ac, sends := newManualSessionWithPTYs(t, oldPTY, newPTY, parkedPTY)
350 clock := newCoordinatorMockClock(t, 8)
351 d.clock = clock.clock
352 rc := d.attachCoordinator(sess, nil, ac, true)
353
354 d.sessWg.Add(2)
355 go d.ptyReader(sess, sess.tabs[0], sess.tabs[0].focusedPane())
356 go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane())
357 oldSteps <- channelPTYStep{data: []byte("\x1b[?2026hold partial")}
358 awaitPTYReadProcessed(t, oldProcessed)
359
360 sess.mu.Lock()
361 selectTestAttachmentTabLocked(sess, 1)
362 sess.mu.Unlock()
363 newSteps <- channelPTYStep{data: []byte("newly active")}
364 awaitPTYReadProcessed(t, newProcessed)
365 fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline)
366 frame := awaitOutputFrameWithoutSleep(t, sends)
367 output, err := ports.UnmarshalOutput(frame.Payload)
368 require.NoError(t, err)
369 require.Contains(t, string(output.Data), "newly active")
370 require.NotContains(t, string(output.Data), "old partial")
371
372 oldSteps <- channelPTYStep{data: []byte("\x1b[?2026l")}
373 awaitPTYReadProcessed(t, oldProcessed)
374 requireNoCoordinatorOutputFrame(t, sends)
375
376 oldSteps <- channelPTYStep{err: io.EOF}
377 newSteps <- channelPTYStep{err: io.EOF}
378 d.sessWg.Wait()
379 })
380}
381
382func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *testing.T) {
383 t.Run("detached target completes cross-session preview without later PTY output", func(t *testing.T) {
384 pty, steps, processed := newChannelPTY(t)
385 d, target, targetClient, _ := newManualSessionWithPTYs(t, pty)
386 clock := newCoordinatorMockClock(t, 8)
387 d.clock = clock.clock
388 rc := d.attachCoordinator(target, nil, targetClient, true)
389
390 // A viewer in a different session is previewing the headless target.
391 viewer := &attachedClient{}
392 viewer.initOverlays()
393 viewer.overlays.pickerMu.Lock()
394 viewer.overlays.pickerPreview = target.tabs[0]
395 viewer.overlays.pickerMu.Unlock()
396 d.sessions["viewer"] = &session{sessionCore: sessionCore{id: "viewer", attachments: map[*attachedClient]struct{}{viewer: {}}}}
397 previews := make(chan renderWake, 2)
398 rc.subscribePreviewFor(viewer, 1, func(w renderWake) { previews <- w })
399
400 d.sessWg.Add(1)
401 go d.ptyReader(target, target.tabs[0], target.tabs[0].focusedPane())
402 steps <- channelPTYStep{data: []byte("\x1b[?2026hpending")}
403 awaitPTYReadProcessed(t, processed)
404 drainCoordinatorTimers(clock) // detach must clear the pending output work.
405
406 target.mu.Lock()
407 clearAttachmentsForTestLocked(target)
408 target.mu.Unlock()
409 rc.noteDetach(targetClient)
410 steps <- channelPTYStep{data: []byte(" complete\x1b[?2026l")}
411 awaitPTYReadProcessed(t, processed)
412
413 fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), urgentRenderDeadline)
414 wake := <-previews
415 require.True(t, wake.urgent)
416 require.Equal(t, 1, wake.coalesced, "completion starts a fresh headless preview batch after detach")
417 select {
418 case duplicate := <-previews:
419 t.Fatalf("sync completion must publish exactly one preview wake: %#v", duplicate)
420 default:
421 }
422
423 steps <- channelPTYStep{err: io.EOF}
424 d.sessWg.Wait()
425 })
426
427 t.Run("remaining attachment receives shared output after peer detach", func(t *testing.T) {
428 pty, steps, processed := newChannelPTY(t)
429 d, target, detached, _ := newManualSessionWithPTYs(t, pty)
430 clock := newCoordinatorMockClock(t, 8)
431 d.clock = clock.clock
432 rc := d.attachCoordinator(target, nil, detached, true)
433
434 secondTransport, secondSends := newCapturingTransport(t)
435 remaining := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: detached.size}
436 remaining.initOverlays()
437 remaining.setSession(target)
438 target.mu.Lock()
439 require.True(t, target.registerAttachmentLocked(remaining))
440 target.mu.Unlock()
441 rc.attach(remaining)
442
443 d.sessWg.Add(1)
444 go d.ptyReader(target, target.tabs[0], target.tabs[0].focusedPane())
445 steps <- channelPTYStep{data: []byte("before detach")}
446 awaitPTYReadProcessed(t, processed)
447 timers := drainCoordinatorTimers(clock)
448
449 // Detaching one attachment must not discard the session-shared pending
450 // invalidation or stop the coordinator from painting the remaining one.
451 target.mu.Lock()
452 require.True(t, target.unregisterAttachmentLocked(detached))
453 target.mu.Unlock()
454 rc.noteDetach(detached)
455 fireCoordinatorTimer(t, rc, timers, minOutputRenderDeadline)
456 require.NotEmpty(t, secondSends, "the remaining attachment received no shared output after its peer detached")
457 before := len(secondSends)
458
459 steps <- channelPTYStep{data: []byte("after detach")}
460 awaitPTYReadProcessed(t, processed)
461 fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline)
462 require.Greater(t, len(secondSends), before, "shared output stopped after the peer detached")
463
464 steps <- channelPTYStep{err: io.EOF}
465 d.sessWg.Wait()
466 })
467
468 t.Run("headless pane without a preview stays quiet on completion", func(t *testing.T) {
469 pty, steps, processed := newChannelPTY(t)
470 d, target, client, _ := newManualSessionWithPTYs(t, pty)
471 clock := newCoordinatorMockClock(t, 8)
472 d.clock = clock.clock
473 rc := d.attachCoordinator(target, nil, client, true)
474 wakes := make(chan renderWake, 1)
475 rc.opts.wake = func(w renderWake) { wakes <- w }
476 target.mu.Lock()
477 clearAttachmentsForTestLocked(target)
478 target.mu.Unlock()
479 rc.noteDetach(client)
480
481 d.sessWg.Add(1)
482 go d.ptyReader(target, target.tabs[0], target.tabs[0].focusedPane())
483 steps <- channelPTYStep{data: []byte("\x1b[?2026hpending")}
484 awaitPTYReadProcessed(t, processed)
485 drainCoordinatorTimers(clock)
486 steps <- channelPTYStep{data: []byte(" complete\x1b[?2026l")}
487 awaitPTYReadProcessed(t, processed)
488 require.Empty(t, drainCoordinatorTimers(clock))
489 requireNoWake(t, wakes)
490
491 steps <- channelPTYStep{err: io.EOF}
492 d.sessWg.Wait()
493 })
494}
495
496// S2 keeps damage pending until the owning render capture consumes it. In
497// particular, a pane becoming invisible must not let a PTY reader erase data
498// that a later attachment or picker preview needs to render.
499func TestPaneRenderableActiveAttachmentDoesNotScanPickerPreviews(t *testing.T) {
500 p, release := newBlockingPTY(t)
501 defer release()
502 d, sess, _, _ := newManualSessionWithPTYs(t, p)
503 tb := testAttachmentTab(sess)
504 pane := tb.focusedPane()
505
506 // Holding daemon ownership makes a picker scan block. An active attached
507 // tab must decide renderability from its session state without that scan.
508 d.mu.Lock()
509 defer d.mu.Unlock()
510 done := make(chan bool, 1)
511 go func() { done <- d.paneRenderable(sess, tb, pane) }()
512 require.True(t, awaitTestValue(t, done, "active attached renderability scanned picker previews"))
513}
514
515func TestNonRenderablePaneDamageRemainsPendingForCapture(t *testing.T) {
516 newFixture := func(t *testing.T) (*Daemon, *session, *tab, *pane, chan ports.Frame) {
517 t.Helper()
518 p, release := newBlockingPTY(t)
519 t.Cleanup(release)
520 d, sess, _, sends := newManualSessionWithPTYs(t, p)
521 tb := sess.tabs[0]
522 return d, sess, tb, tb.focusedPane(), sends
523 }
524
525 t.Run("headless, inactive, collapsed, and hidden panes retain damage without output", func(t *testing.T) {
526 for _, tt := range []struct {
527 name string
528 setup func(*Daemon, *session, *tab, *pane)
529 }{
530 {name: "headless", setup: func(_ *Daemon, sess *session, _ *tab, _ *pane) { clearAttachmentsForTest(sess) }},
531 {name: "inactive tab", setup: func(_ *Daemon, sess *session, tb *tab, _ *pane) {
532 other := newTab(nil, domain.Size{Cols: 80, Rows: 23})
533 sess.tabs = append([]*tab{other}, sess.tabs...)
534 selectTestAttachmentTab(sess, 0)
535 require.NotSame(t, other, tb)
536 }},
537 {name: "collapsed", setup: func(_ *Daemon, _ *session, tb *tab, p *pane) {
538 // The expanded leaf is deliberately absent from panes: p is the
539 // inactive stack leaf whose content is not composited.
540 tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("visible"), layout.NewLeaf(p.id)}, Expanded: "visible"}, Focus: "visible"}
541 }},
542 {name: "hidden floating", setup: func(_ *Daemon, _ *session, tb *tab, p *pane) {
543 tb.floating = floatingSlot{state: floatingHidden, pane: p, generation: 1}
544 delete(tb.panes, p.id)
545 }},
546 } {
547 t.Run(tt.name, func(t *testing.T) {
548 d, sess, tb, p, sends := newFixture(t)
549 tt.setup(d, sess, tb, p)
550 p.screen.Write([]byte("damage"))
551 _ = d.paneRenderable(sess, tb, p)
552 require.NotEmpty(t, p.screen.Damage(), "only render capture may consume VT damage")
553 select {
554 case frame := <-sends:
555 t.Fatalf("non-renderable output must not compose or send: %#v", frame)
556 default:
557 }
558 })
559 }
560 })
561
562 t.Run("retains active and picker-preview pane damage", func(t *testing.T) {
563 d, sess, tb, p, _ := newFixture(t)
564 p.screen.Write([]byte("active"))
565 _ = d.paneRenderable(sess, tb, p)
566 require.NotEmpty(t, p.screen.Damage(), "active pane damage belongs to coordinator composition")
567 p.screen.ClearDamage()
568
569 clearAttachmentsForTest(sess)
570 viewer := &attachedClient{}
571 viewer.initOverlays()
572 viewer.overlays.pickerMu.Lock()
573 viewer.overlays.pickerPreview = tb
574 viewer.overlays.pickerMu.Unlock()
575 d.sessions["viewer"] = &session{sessionCore: sessionCore{id: "viewer", attachments: map[*attachedClient]struct{}{viewer: {}}}}
576 p.screen.Write([]byte("preview"))
577 _ = d.paneRenderable(sess, tb, p)
578 require.NotEmpty(t, p.screen.Damage(), "picker preview damage must remain for coordinator composition")
579 })
580}
581
582func TestAltXClosesFinalTabAndDetaches(t *testing.T) {
583 d, sess, ac, sends, releases := newManualTabSession(t, 1)
584 defer releases[0]()
585
586 d.handleInput(sess, ac, []byte("\x1b "))
587 d.handleInput(sess, ac, []byte("CLT\r"))
588
589 require.Equal(t, 0, sessionCount(d))
590 f := awaitFrame(t, sends, ports.MsgDetached)
591 det, err := ports.UnmarshalDetached(f.Payload)
592 require.NoError(t, err)
593 require.Equal(t, ports.ReasonSessionKilled, det.Reason)
594}
595
596func TestPTYEOFClosesActiveNonFinalTabAndRepaintsRemaining(t *testing.T) {
597 p1, releasePTY1 := newBlockingPTY(t)
598 p2, releasePTY2 := newBlockingPTY(t)
599 d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2)
600 defer releasePTY2()
601 selectTestAttachmentTab(sess, 0)
602 sess.tabs[1].focusedPane().screen.Write([]byte("remaining"))
603
604 d.sessWg.Add(1)
605 go d.ptyReader(sess, sess.tabs[0], sess.tabs[0].focusedPane())
606 releasePTY1()
607
608 require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond)
609 require.Equal(t, 1, sessionCount(d))
610 require.Equal(t, 0, testAttachmentTabIndex(sess))
611 f := awaitFrame(t, sends, ports.MsgOutput)
612 out, err := ports.UnmarshalOutput(f.Payload)
613 require.NoError(t, err)
614 data := string(out.Data)
615 require.Contains(t, data, "remaining")
616 require.Contains(t, data, "work")
617 require.Contains(t, data, ";7m")
618
619 d.sessWg.Wait()
620}
621
622func TestPTYEOFClosesInactiveNonFinalTabAndRepaintsStatus(t *testing.T) {
623 p1, releasePTY1 := newBlockingPTY(t)
624 p2, releasePTY2 := newBlockingPTY(t)
625 d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2)
626 defer releasePTY1()
627 selectTestAttachmentTab(sess, 0)
628 sess.tabs[0].focusedPane().screen.Write([]byte("active"))
629
630 d.sessWg.Add(1)
631 go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane())
632 releasePTY2()
633
634 require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond)
635 require.Equal(t, 1, sessionCount(d))
636 require.Equal(t, 0, testAttachmentTabIndex(sess))
637 f := awaitFrame(t, sends, ports.MsgOutput)
638 out, err := ports.UnmarshalOutput(f.Payload)
639 require.NoError(t, err)
640 data := string(out.Data)
641 require.Contains(t, data, "active")
642 require.Contains(t, data, "work")
643 require.NotContains(t, data, " 2 ")
644
645 d.sessWg.Wait()
646}
647
648func TestPTYEOFFinalTabKillsSessionAndDetaches(t *testing.T) {
649 d, sess, _, sends, releases := newManualTabSession(t, 1)
650
651 d.sessWg.Add(1)
652 go d.ptyReader(sess, sess.tabs[0], sess.tabs[0].focusedPane())
653 releases[0]()
654
655 require.Eventually(t, func() bool { return sessionCount(d) == 0 }, 2*time.Second, 5*time.Millisecond)
656 f := awaitFrame(t, sends, ports.MsgDetached)
657 det, err := ports.UnmarshalDetached(f.Payload)
658 require.NoError(t, err)
659 require.Equal(t, ports.ReasonSessionKilled, det.Reason)
660
661 d.sessWg.Wait()
662}
663
664// --- resize ordering --------------------------------------------------------
665
666func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
667 p := portsmocks.NewMockPTY(t)
668 p.EXPECT().Resize(domain.Size{Cols: 4, Rows: 2}).Return(nil).Once()
669 p.EXPECT().Resize(domain.Size{Cols: 6, Rows: 4}).Return(nil).Once()
670
671 win := newTab(p, domain.Size{Cols: 4, Rows: 4})
672 for y, text := range []string{"0000", "1111", "2222", "3333"} {
673 copy(win.focusedPane().screen.Frame.Row(y), testRow(text))
674 }
675 win.focusedPane().screen.Row = 3
676
677 tr := portsmocks.NewMockTransport(t)
678 tr.EXPECT().Close().Return(nil).Maybe()
679 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
680
681 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
682 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
683 ac.initOverlays()
684 sess := &session{sessionCore: sessionCore{id: "s", name: "s", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}}
685 ac.setSession(sess)
686
687 // Client rows are one more than the equivalent case in a single-bar
688 // layout: tabSize reserves 2 chrome rows (top + bottom bar) here, not 1,
689 // so a client height of 4 (not 3) is what yields the same 2-row tab.
690 d.resize(sess, ac, domain.Size{Cols: 4, Rows: 4})
691 require.Equal(t, "2222", frameRowString(win.focusedPane().screen.Frame, 0))
692 require.Equal(t, "3333", frameRowString(win.focusedPane().screen.Frame, 1))
693 require.Equal(t, 2, win.focusedPane().history.Len())
694 require.Equal(t, "0000", cellsString(win.focusedPane().history.View().Row(0)))
695 require.Equal(t, "1111", cellsString(win.focusedPane().history.View().Row(1)))
696
697 d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6})
698 require.Equal(t, "2222 ", frameRowString(win.focusedPane().screen.Frame, 0))
699 require.Equal(t, "3333 ", frameRowString(win.focusedPane().screen.Frame, 1))
700 require.Equal(t, 2, win.focusedPane().history.Len())
701}
702
703func frameRowString(f renderer.Frame, y int) string {
704 return cellsString(f.Row(y))
705}
706
707func cellsString(row []renderer.Cell) string {
708 runes := make([]rune, len(row))
709 for i, c := range row {
710 runes[i] = c.Rune
711 }
712 return string(runes)
713}
714
715func TestTabSizeReservesTopAndBottomChromeRows(t *testing.T) {
716 require.Equal(t, domain.Size{Cols: 80, Rows: 22}, tabSize(domain.Size{Cols: 80, Rows: 24}))
717 require.Equal(t, domain.Size{Cols: 80, Rows: 1}, tabSize(domain.Size{Cols: 80, Rows: 2}))
718}
719
720func TestOffsetDamageShiftsScreenDamageBelowTopBar(t *testing.T) {
721 damage := offsetDamage([]renderer.Damage{
722 {Kind: renderer.DamageText, X: 2, Y: 3, Width: 4, Height: 1},
723 renderer.FullRedraw(),
724 })
725 require.Equal(t, []renderer.Damage{
726 {Kind: renderer.DamageText, X: 2, Y: 4, Width: 4, Height: 1},
727 renderer.FullRedraw(),
728 }, damage)
729}
730
731func TestTranslateDamageShiftsXYAndPreservesFullRedraw(t *testing.T) {
732 damage := translateDamage([]renderer.Damage{
733 {Kind: renderer.DamageText, X: 2, Y: 3, Width: 4, Height: 1},
734 renderer.FullRedraw(),
735 }, 5, 7)
736 require.Equal(t, []renderer.Damage{
737 {Kind: renderer.DamageText, X: 7, Y: 10, Width: 4, Height: 1},
738 renderer.FullRedraw(),
739 }, damage)
740}
741
742func TestTranslatePaneDamagePreservesFullWidthScrollFastPathOnly(t *testing.T) {
743 tests := []struct {
744 name string
745 content domain.Rect
746 area domain.Rect
747 in renderer.Damage
748 want []renderer.Damage
749 }{
750 {
751 name: "half width scroll becomes pane text damage",
752 content: domain.Rect{X: 21, Y: 0, Width: 20, Height: 4},
753 area: domain.Rect{Width: 41, Height: 4},
754 in: renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: 0, Width: 20, Height: 4, Count: 1},
755 want: []renderer.Damage{{Kind: renderer.DamageText, X: 21, Y: 0, Width: 20, Height: 4}},
756 },
757 {
758 name: "full width scroll keeps translated scroll damage",
759 content: domain.Rect{X: 0, Y: 5, Width: 80, Height: 10},
760 area: domain.Rect{Width: 80, Height: 24},
761 in: renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: 0, Width: 80, Height: 10, Count: 1},
762 want: []renderer.Damage{{Kind: renderer.DamageScrollUp, X: 0, Y: 5, Width: 80, Height: 10, Count: 1}},
763 },
764 }
765 for _, tt := range tests {
766 t.Run(tt.name, func(t *testing.T) {
767 require.Equal(t, tt.want, translatePaneDamage(tt.in, tt.content, tt.area))
768 })
769 }
770}
771
772func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
773 tests := []struct {
774 name string
775 open func(*Daemon, *session, *attachedClient)
776 close func(*Daemon, *session, *attachedClient)
777 visible string
778 notVisible string
779 wantRestored string
780 prepareScreen func(*session)
781 }{
782 {
783 name: "prompt",
784 open: func(d *Daemon, sess *session, ac *attachedClient) {
785 d.enterPrompt(sess, ac, " Rename session ", "work", func(string) error { return nil })
786 },
787 close: func(d *Daemon, _ *session, ac *attachedClient) { d.handlePromptInput(ac, []byte("\x1b")) },
788 visible: "Rename session",
789 notVisible: "Rename session",
790 },
791 {
792 name: "palette",
793 open: func(d *Daemon, sess *session, ac *attachedClient) { d.enterPalette(sess, ac) },
794 close: func(d *Daemon, _ *session, ac *attachedClient) { d.handlePaletteInput(ac, []byte("\x1b")) },
795 visible: "Commands",
796 notVisible: "Commands",
797 },
798 {
799 name: "picker",
800 open: func(d *Daemon, sess *session, ac *attachedClient) { d.enterPicker(sess, ac) },
801 close: func(d *Daemon, sess *session, ac *attachedClient) { d.closePicker(ac); d.paint(sess, ac, true, nil) },
802 visible: "Sessions",
803 notVisible: "Sessions",
804 },
805 {
806 name: "copy mode",
807 open: func(d *Daemon, sess *session, ac *attachedClient) { d.enterCopyMode(sess, ac) },
808 close: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("q")) },
809 prepareScreen: func(sess *session) {
810 installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 4})
811 },
812 visible: "[SCROLL]",
813 notVisible: "[SCROLL]",
814 wantRestored: "live",
815 },
816 }
817 for _, tt := range tests {
818 t.Run(tt.name, func(t *testing.T) {
819 p, release := newBlockingPTY(t)
820 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
821 defer release()
822 if tt.prepareScreen != nil {
823 tt.prepareScreen(sess)
824 }
825 sess.tabs[0].focusedPane().screen.Write([]byte("live"))
826 if tt.wantRestored == "" {
827 tt.wantRestored = "live"
828 }
829
830 tt.open(d, sess, ac)
831 shown := string(mustOutputData(t, sends))
832 require.Contains(t, shown, tt.visible)
833
834 tt.close(d, sess, ac)
835 restored := string(mustOutputData(t, sends))
836 require.Contains(t, restored, tt.wantRestored)
837 require.NotContains(t, restored, tt.notVisible)
838 })
839 }
840}
841
842func TestOverlayPaintBypassesComposedCache(t *testing.T) {
843 p, release := newBlockingPTY(t)
844 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
845 defer release()
846 pane := sess.tabs[0].focusedPane()
847 pane.screen.Write([]byte("live"))
848
849 d.paint(sess, ac, true, nil)
850 _ = mustOutputData(t, sends)
851 require.True(t, ac.pipelineCache.valid)
852 require.NotContains(t, frameText(ac.pipelineCache.frame), "Rename session")
853
854 d.enterPrompt(sess, ac, " Rename session ", "work", func(string) error { return nil })
855 shown := string(mustOutputData(t, sends))
856 require.Contains(t, shown, "Rename session")
857 require.False(t, ac.pipelineCache.valid, "overlay paint must not store the modal-mutated frame in the composed cache")
858 require.NotContains(t, frameText(ac.pipelineCache.frame), "Rename session")
859
860 d.closePrompt(ac)
861 d.paint(sess, ac, false, nil)
862 restored := string(mustOutputData(t, sends))
863 require.NotContains(t, restored, "Rename session")
864 require.True(t, ac.pipelineCache.valid)
865 cached := frameText(ac.pipelineCache.frame)
866 require.Contains(t, cached, "live")
867 require.NotContains(t, cached, "Rename session")
868}
869
870func frameText(frame renderer.Frame) string {
871 var b strings.Builder
872 for y := 0; y < frame.Height; y++ {
873 b.WriteString(rowText(frame.Row(y)))
874 b.WriteByte('\n')
875 }
876 return b.String()
877}
878
879func TestPaintEarlyReturnReleasesOverlayRenderLocks(t *testing.T) {
880 tests := []struct {
881 name string
882 open func(*Daemon, *session, *attachedClient)
883 try func(*overlayRuntime) bool
884 give func(*overlayRuntime)
885 }{
886 {
887 name: "prompt",
888 open: func(d *Daemon, sess *session, ac *attachedClient) {
889 d.enterPrompt(sess, ac, " Rename session ", "work", func(string) error { return nil })
890 },
891 try: func(rt *overlayRuntime) bool { return rt.promptMu.TryLock() },
892 give: func(rt *overlayRuntime) { rt.promptMu.Unlock() },
893 },
894 {
895 name: "palette",
896 open: func(d *Daemon, sess *session, ac *attachedClient) {
897 d.enterPalette(sess, ac)
898 },
899 try: func(rt *overlayRuntime) bool { return rt.paletteMu.TryLock() },
900 give: func(rt *overlayRuntime) { rt.paletteMu.Unlock() },
901 },
902 }
903 for _, tt := range tests {
904 t.Run(tt.name, func(t *testing.T) {
905 p, release := newBlockingPTY(t)
906 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
907 defer release()
908 sess.tabs[0].tree.Focus = layout.PaneID("missing")
909
910 tt.open(d, sess, ac)
911
912 require.True(t, tt.try(ac.overlays), "overlay render lock should be released after paint returns early")
913 tt.give(ac.overlays)
914 })
915 }
916}
917
918func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
919 newSize := domain.Size{Cols: 100, Rows: 30}
920
921 p := portsmocks.NewMockPTY(t)
922 var screenWidthAtResize int
923 win := newTab(newScriptPTY(nil), domain.Size{Cols: 80, Rows: 24})
924 p.EXPECT().Resize(domain.Size{Cols: 100, Rows: 28}).RunAndReturn(func(sz domain.Size) error {
925 // The screen must not yet be resized when the PTY is: proves order.
926 screenWidthAtResize = win.focusedPane().screen.Frame.Width
927 return nil
928 }).Once()
929 win.focusedPane().pty = p
930
931 var gotOutput atomic.Bool
932 tr := portsmocks.NewMockTransport(t)
933 tr.EXPECT().Close().Return(nil).Maybe()
934 tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {
935 if f.Type == ports.MsgOutput {
936 gotOutput.Store(true)
937 }
938 return nil
939 }).Maybe()
940
941 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
942 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
943 ac.initOverlays()
944 sess := &session{sessionCore: sessionCore{id: "s", name: "s", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}}
945 ac.setSession(sess)
946
947 d.resize(sess, ac, newSize)
948 // This test verifies resize ordering, not the idle fallback. Consume the
949 // pending resize paint deterministically under the non-firing stub clock.
950 d.paint(sess, ac, false, nil)
951
952 require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize")
953 require.Equal(t, 100, win.focusedPane().screen.Frame.Width, "screen resized after pty")
954 require.Equal(t, 28, win.focusedPane().screen.Frame.Height, "screen reserves top and bottom chrome rows")
955 require.True(t, gotOutput.Load(), "resize forces a full redraw output")
956}
957
958// --- reader EOF -> registry-empty shutdown ----------------------------------
959
960func TestSendErrorKeepsEphemeralHeadless(t *testing.T) {
961 p := portsmocks.NewMockPTY(t)
962 p.EXPECT().Close().Return(nil).Maybe()
963
964 tr := portsmocks.NewMockTransport(t)
965 tr.EXPECT().Send(mock.Anything).Return(io.ErrClosedPipe).Maybe()
966 tr.EXPECT().Close().Return(nil).Maybe()
967
968 d := newTestDaemon(t, nil, stubClock{})
969 win := newTab(p, domain.Size{Cols: 20, Rows: 5})
970 sctx, cancel := context.WithCancel(context.Background())
971 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
972 ac.initOverlays()
973 sess := &session{sessionCore: sessionCore{id: "e", name: "0", ephemeral: true, attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}, ctx: sctx, cancel: cancel}
974 ac.setSession(sess)
975 d.sessions[sess.id] = sess
976
977 win.mu.Lock()
978 win.focusedPane().screen.Write([]byte("x"))
979 win.mu.Unlock()
980
981 d.paint(sess, ac, true, nil)
982
983 require.Equal(t, 1, sessionCount(d), "ephemeral session survives failed client send")
984 sess.mu.Lock()
985 require.Empty(t, sess.snapshotAttachmentsLocked())
986 sess.mu.Unlock()
987
988 _ = d.killSession(sess, ports.ReasonServerShutdown, false)
989 cancel()
990 d.waitNotifies()
991}
992
993func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
994 d := newTestDaemon(t, nil, stubClock{})
995 p := portsmocks.NewMockPTY(t)
996 chunks := [][]byte{[]byte("\x1b[6n")}
997 writes := make(chan []byte, 1)
998 p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
999 if len(chunks) == 0 {
1000 return 0, io.EOF
1001 }
1002 n := copy(buf, chunks[0])
1003 chunks = chunks[1:]
1004 return n, nil
1005 })
1006 p.EXPECT().Write(mock.Anything).RunAndReturn(func(b []byte) (int, error) {
1007 writes <- append([]byte(nil), b...)
1008 return len(b), nil
1009 }).Once()
1010 p.EXPECT().Close().Return(nil).Maybe()
1011
1012 tr, sends := newCapturingTransport(t)
1013 sctx, cancel := context.WithCancel(context.Background())
1014 win := newTestTabWithContext(p, sctx, cancel)
1015 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
1016 ac.initOverlays()
1017 sess := &session{sessionCore: sessionCore{id: "query", name: "query", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}, ctx: sctx, cancel: cancel}
1018 ac.setSession(sess)
1019 d.sessions[sess.id] = sess
1020 d.sessWg.Add(1)
1021 d.ptyReader(sess, win, win.focusedPane())
1022
1023 select {
1024 case got := <-writes:
1025 require.Equal(t, []byte("\x1b[1;1R"), got)
1026 case <-time.After(time.Second):
1027 t.Fatal("timed out waiting for PTY response write")
1028 }
1029 select {
1030 case f := <-sends:
1031 require.NotEqual(t, ports.MsgOutput, f.Type)
1032 default:
1033 }
1034}
1035
1036func TestCaptureCursorInputsHidesFocusedCursorForNoticesOverlay(t *testing.T) {
1037 _, sess, _, _ := newManualSessionWithPTYs(t, nil)
1038 p := sess.tabs[0].focusedPane()
1039
1040 p.mu.Lock()
1041 cursor := captureCursorInputsLocked(p, domain.Rect{Width: 80, Height: 23}, capturedOverlayRenderState{noticesOverlayActive: true})
1042 p.mu.Unlock()
1043
1044 require.True(t, cursor.hiddenByOverlay)
1045}
1046
1047func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {
1048 p, _ := newBlockingPTY(t)
1049 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1050 win := sess.tabs[0]
1051 win.focusedPane().screen.Write([]byte("A"))
1052
1053 d.paint(sess, ac, true, nil)
1054 data := mustOutputData(t, sends)
1055 require.Contains(t, string(data), "\x1b[1 q")
1056 require.Contains(t, string(data), "\x1b[?25h")
1057
1058 win.focusedPane().screen.Write([]byte("\x1b[2;3H"))
1059 d.paint(sess, ac, false, nil)
1060 data = mustOutputData(t, sends)
1061 require.Contains(t, string(data), "\x1b[3;3H")
1062 require.Contains(t, string(data), "\x1b[?25h")
1063
1064 win.focusedPane().screen.Write([]byte("\x1b[?25l"))
1065 d.paint(sess, ac, false, nil)
1066 data = mustOutputData(t, sends)
1067 require.Contains(t, string(data), "\x1b[?25l")
1068}
1069
1070func TestPaintAlignsFloatingCursorWithCommittedGeometry(t *testing.T) {
1071 p, release := newBlockingPTY(t)
1072 defer release()
1073 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1074 contentArea := domain.Rect{Y: 1, Width: 80, Height: 23}
1075 cfg := d.currentFloatingConfig()
1076 committed := calculateContentFloatingGeometry(domain.Size{Cols: contentArea.Width, Rows: contentArea.Height}, cfg)
1077 floating := newPane("floating", nil, rectSize(committed.Inner))
1078 floating.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'F'})
1079 floating.popupGeometry = committed
1080 installTestFloating(testAttachmentTab(sess), floating, true)
1081
1082 d.paint(sess, ac, true, nil)
1083 data := mustOutputData(t, sends)
1084 want := committed.translate(contentArea.X, contentArea.Y)
1085 require.Contains(t, string(data), cursorCSI(want.Inner.Y+1, 1))
1086 require.Contains(t, string(data), "F")
1087 require.Equal(t, want.Inner.Y, ac.lastCursor.row)
1088 require.Equal(t, want.Inner.X, ac.lastCursor.col)
1089 require.Contains(t, string(data), cursorCSI(want.Inner.Y+1, want.Inner.X+1))
1090}
1091
1092func TestCursorTailUsesFocusedPanePlacement(t *testing.T) {
1093 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
1094 win := sess.tabs[0]
1095 win.size = domain.Size{Cols: 80, Rows: 23}
1096 left := win.focusedPane()
1097 right := newPane("pane-2", nil, domain.Size{Cols: 39, Rows: 23})
1098 win.panes[right.id] = right
1099 win.tree = layout.NewTree(left.id)
1100 require.NoError(t, win.tree.Split(left.id, layout.Right, true, right.id, domain.Rect{Width: 80, Height: 23}))
1101 win.tree.Focus = right.id
1102 right.screen.Write([]byte("\x1b[2;3H"))
1103 placements, ok := layout.Solve(win.tree.Root, domain.Rect{Width: 80, Height: 23})
1104 require.True(t, ok)
1105 rightContent := placementContent(placements, right.id)
1106
1107 d.paint(sess, ac, true, nil)
1108 data := mustOutputData(t, sends)
1109 want := cursorCSI(rightContent.Y+right.screen.CursorRow()+2, rightContent.X+right.screen.CursorCol()+1)
1110 require.Contains(t, string(data), want)
1111}
1112
1113func TestCursorTailUsesExpandedStackContentPlacement(t *testing.T) {
1114 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
1115 win := sess.tabs[0]
1116 win.size = domain.Size{Cols: 80, Rows: 23}
1117 one := win.focusedPane()
1118 two := newPane("pane-2", nil, domain.Size{Cols: 80, Rows: 20})
1119 three := newPane("pane-3", nil, domain.Size{Cols: 80, Rows: 20})
1120 win.panes[two.id] = two
1121 win.panes[three.id] = three
1122 win.tree = &layout.Tree{
1123 Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{
1124 layout.NewLeaf(one.id),
1125 layout.NewLeaf(two.id),
1126 layout.NewLeaf(three.id),
1127 }, Expanded: two.id},
1128 Focus: two.id,
1129 }
1130 two.screen.Write([]byte("\x1b[2;3H"))
1131 placements, ok := layout.Solve(win.tree.Root, domain.Rect{Width: 80, Height: 23})
1132 require.True(t, ok)
1133 twoContent := placementContent(placements, two.id)
1134 require.Greater(t, twoContent.Y, 0, "stack title bars should offset content")
1135
1136 d.paint(sess, ac, true, nil)
1137 data := mustOutputData(t, sends)
1138 want := cursorCSI(twoContent.Y+two.screen.CursorRow()+2, twoContent.X+two.screen.CursorCol()+1)
1139 require.Contains(t, string(data), want)
1140}
1141
1142func cursorCSI(row, col int) string {
1143 return "\x1b[" + strconv.Itoa(row) + ";" + strconv.Itoa(col) + "H"
1144}
1145
1146func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
1147 base := renderer.NewFrame(20, 8)
1148 for y := range base.Height {
1149 for x := range base.Width {
1150 base.Set(x, y, renderer.Cell{Rune: '#', Style: renderer.DefaultStyle()})
1151 }
1152 }
1153 p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2})
1154 p.screen.Write([]byte("ab\r\ncd"))
1155 document := scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil)
1156 mode := scopy.NewMode(scopy.NewDocument(document, domain.DefaultWordSeparators))
1157 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}
1158
1159 frame, damage := composeCopyClientFrame(mode, target, base, resolveStyles(nil))
1160
1161 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
1162 require.Equal(t, "##ab ", rowText(frame.Row(3)))
1163 require.Equal(t, "##cd ", rowText(frame.Row(4)))
1164 require.Equal(t, strings.Repeat("#", 20), rowText(frame.Row(2)))
1165 require.Equal(t, strings.Repeat("#", 20), rowText(frame.Row(5)))
1166 require.Equal(t, strings.Repeat("#", 20), rowText(frame.Row(6)))
1167 status := rowText(frame.Row(7))
1168 require.Contains(t, status, "[SCROLL]")
1169 require.NotContains(t, status, "#")
1170 require.Equal(t, renderer.BlankCell(), frame.At(19, 7), "status filler must be blank cells, not zero cells")
1171}
1172
1173func TestPaintComposesCopyBodyAboveFloating(t *testing.T) {
1174 normal, releaseNormal := newBlockingPTY(t)
1175 floatingPTY, releaseFloating := newBlockingPTY(t)
1176 defer releaseNormal()
1177 defer releaseFloating()
1178 d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
1179 fp := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 3})
1180 appendHistoryRow(t, fp.history, testRow("flt-old"))
1181 fp.screen.Write([]byte("flt-live"))
1182 installTestFloating(testAttachmentTab(sess), fp, true)
1183
1184 d.enterCopyMode(sess, ac)
1185 data := mustOutputData(t, sends)
1186 require.Contains(t, string(data), "[SCROLL]")
1187 require.Contains(t, string(data), "\x1b[?25l")
1188
1189 d.handleInput(sess, ac, []byte("g"))
1190 data = mustOutputData(t, sends)
1191 require.Contains(t, string(data), "lt-old", "copy viewport of the captured floating pane must compose above the popup")
1192}