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/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}

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