v0.3.0 99dc266

render_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close