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