v0.3.0 99dc266

input_test.go RawHistoryFolder

1package daemon
2
3import (
4	"context"
5	"encoding/base64"
6	"errors"
7	"fmt"
8	"strings"
9	"sync"
10	"testing"
11	"time"
12
13	"github.com/stretchr/testify/mock"
14	"github.com/stretchr/testify/require"
15
16	vt "github.com/bnema/vev-vt"
17	"github.com/bnema/vev/internal/domain"
18	"github.com/bnema/vev/internal/ports"
19	portsmocks "github.com/bnema/vev/internal/ports/mocks"
20	scopy "github.com/bnema/vev/internal/usecase/copy"
21	"github.com/bnema/vev/internal/usecase/keys"
22	"github.com/bnema/vev/internal/usecase/layout"
23	"github.com/bnema/vev/internal/usecase/mouse"
24	"github.com/bnema/vev/internal/usecase/picker"
25	"github.com/bnema/vev/internal/usecase/ui"
26)
27
28// --- test doubles -----------------------------------------------------------
29
30type blockingInputActionRunner struct {
31	admitted chan<- struct{}
32	release  <-chan struct{}
33	mu       *sync.Mutex
34	order    *[]string
35}
36
37func (r *blockingInputActionRunner) Run(daemonActionRequest) error {
38	close(r.admitted)
39	<-r.release
40	r.mu.Lock()
41	*r.order = append(*r.order, "keyboard")
42	r.mu.Unlock()
43	return nil
44}
45
46func TestAttachedKeyboardMutationSharesDispatchBoundaryWithCommand(t *testing.T) {
47	d := newTestDaemon(t, nil, stubClock{})
48	sess := addControlSession(d, "work", "t_work", "p_work")
49	ac := &attachedClient{}
50	ac.setSession(sess)
51
52	var orderMu sync.Mutex
53	var order []string
54	admitted := make(chan struct{})
55	release := make(chan struct{})
56	runner := &blockingInputActionRunner{admitted: admitted, release: release, mu: &orderMu, order: &order}
57	handler := daemonKeyHandler{d: d, ac: ac, actions: runner}
58	keyboardDone := make(chan struct{})
59	go func() {
60		handler.Action(keys.ActionGrowPaneWidth, nil)
61		close(keyboardDone)
62	}()
63	<-admitted
64	require.False(t, sess.dispatchMu.TryLock(), "keyboard mutation must hold the session dispatch boundary")
65
66	commandDone := make(chan struct{})
67	go func() {
68		_ = sess.runMutation(func() error {
69			orderMu.Lock()
70			order = append(order, "command")
71			orderMu.Unlock()
72			return nil
73		})
74		close(commandDone)
75	}()
76	close(release)
77	<-keyboardDone
78	<-commandDone
79
80	require.Equal(t, []string{"keyboard", "command"}, order)
81}
82
83func TestConfiguredConsumeOrExpelActionsRouteThroughDaemonInput(t *testing.T) {
84	tests := []struct {
85		name       string
86		binding    string
87		key        byte
88		focus      layout.PaneID
89		wantTarget layout.PaneID
90	}{
91		{name: "left", binding: "consume-or-expel-pane-left", key: 'H', focus: "pane-3", wantTarget: "pane-3"},
92		{name: "right", binding: "consume-or-expel-pane-right", key: 'L', focus: "pane-1", wantTarget: "pane-1"},
93	}
94
95	for _, tt := range tests {
96		t.Run(tt.name, func(t *testing.T) {
97			h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
98			h.tab.mu.Lock()
99			h.tab.tree.Focus = tt.focus
100			beforeGeneration := h.tab.layoutGeneration
101			h.tab.mu.Unlock()
102			bindings, warnings := keys.BuildBindings(map[string]string{tt.binding: "alt+" + string(tt.key)})
103			require.Empty(t, warnings)
104			h.daemon.bindings.Store(bindings)
105			ac := &attachedClient{}
106			ac.setSession(h.session)
107			ac.keys = keys.NewRouter(h.daemon.clock, daemonKeyHandler{d: h.daemon, ac: ac}, &h.daemon.bindings)
108			h.session.mu.Lock()
109			h.session.registerAttachmentLocked(ac)
110			h.session.mu.Unlock()
111			invalidations := make(chan renderInvalidation, 1)
112			rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
113			rc.attach(ac)
114			h.session.installRenderCoordinator(rc)
115
116			h.daemon.handleInput(h.session, ac, []byte{keys.ESC, tt.key})
117
118			h.tab.mu.Lock()
119			require.Equal(t, tt.wantTarget, h.tab.tree.Focus)
120			require.Equal(t, beforeGeneration+1, h.tab.layoutGeneration)
121			require.Len(t, h.tab.tree.Root.Children, 2)
122			h.tab.mu.Unlock()
123			awaitInvalidation(t, invalidations)
124			requireNoInvalidation(t, invalidations)
125		})
126	}
127}
128
129func TestConfiguredConsumeOrExpelEdgeActionIsSilent(t *testing.T) {
130	h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
131	bindings, warnings := keys.BuildBindings(map[string]string{"consume-or-expel-pane-left": "alt+H"})
132	require.Empty(t, warnings)
133	h.daemon.bindings.Store(bindings)
134	ac := &attachedClient{}
135	ac.setSession(h.session)
136	ac.keys = keys.NewRouter(h.daemon.clock, daemonKeyHandler{d: h.daemon, ac: ac}, &h.daemon.bindings)
137	h.session.mu.Lock()
138	h.session.registerAttachmentLocked(ac)
139	h.session.mu.Unlock()
140	invalidations := make(chan renderInvalidation, 1)
141	rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
142	rc.attach(ac)
143	h.session.installRenderCoordinator(rc)
144	before := h.snapshot()
145
146	h.daemon.handleInput(h.session, ac, []byte{keys.ESC, 'H'})
147
148	require.Equal(t, before, h.snapshot())
149	require.Empty(t, h.daemon.notices.history())
150	requireNoInvalidation(t, invalidations)
151}
152
153func TestConsumeOrExpelKeyActionPreservesRearrangementWarning(t *testing.T) {
154	tests := []struct {
155		name   string
156		action keys.Action
157	}{
158		{name: "left", action: keys.ActionConsumeOrExpelPaneLeft},
159		{name: "right", action: keys.ActionConsumeOrExpelPaneRight},
160	}
161
162	for _, tt := range tests {
163		t.Run(tt.name, func(t *testing.T) {
164			d := newTestDaemon(t, nil, stubClock{})
165			sess := addControlSession(d, "work", "t_work", "p_work")
166			ac := &attachedClient{}
167			ac.setSession(sess)
168			runner := &actionRunnerSpy{err: domain.UserWarn(
169				domain.NoticeLayoutTooSmall,
170				"not enough space to rearrange pane",
171				layout.ErrTooSmall,
172			)}
173
174			daemonKeyHandler{d: d, ac: ac, actions: runner}.Action(tt.action, nil)
175
176			history := d.notices.history()
177			require.Len(t, history, 1)
178			require.Equal(t, domain.NoticeLayoutTooSmall, history[0].Code)
179			require.Equal(t, domain.NoticeWarn, history[0].Severity)
180			require.Equal(t, "not enough space to rearrange pane", history[0].Message)
181		})
182	}
183}
184
185// stubClock returns timers whose channel never fires, so a scheduler under it
186// blocks in its debounce loop until the session context is cancelled. Used by
187
188func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) {
189	d, sess, ac, sends := newManualSessionWithPTYs(t, nil, nil)
190	source := sess.tabs[0].focusedPane()
191	target := sess.tabs[1].focusedPane()
192	require.Equal(t, source.id, target.id, "tabs deliberately reuse pane-1")
193
194	source.mu.Lock()
195	source.screen.Write([]byte("source"))
196	source.mu.Unlock()
197	d.paint(sess, ac, true, nil)
198	first := awaitFrame(t, sends, ports.MsgOutput)
199	firstOutput, err := ports.UnmarshalOutput(first.Payload)
200	require.NoError(t, err)
201	terminal := vt.NewScreen(ac.size.Cols, ac.size.Rows)
202	terminal.Write(firstOutput.Data)
203
204	// A clean pane relies on the attachment capture cache for its retained
205	// snapshot. Both tabs deliberately use the tab-local default pane ID.
206	target.mu.Lock()
207	target.screen.ClearDamage()
208	target.mu.Unlock()
209	// Use the real key action: it requests the mandatory complete repaint for
210	// the first target-tab frame.
211	daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2, nil)
212	require.Equal(t, 1, testAttachmentTabIndex(sess))
213
214	second := awaitFrame(t, sends, ports.MsgOutput)
215	secondOutput, err := ports.UnmarshalOutput(second.Payload)
216	require.NoError(t, err)
217	require.Zero(t, secondOutput.Base, "tab switch must emit the complete target frame first")
218	terminal.Write(secondOutput.Data)
219	require.NotContains(t, strings.Join(frameRows(terminal.Frame), "\n"), "source", "the first target-tab frame must not retain source pane cells")
220}
221
222func TestClosePanePrunesAttachedCaptureFrameAndKeepsSurvivor(t *testing.T) {
223	d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
224	tb := sess.tabs[0]
225	survivor := tb.panes["pane-1"]
226	closed := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 23})
227	tb.mu.Lock()
228	tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
229	tb.panes[closed.id] = closed
230	tb.mu.Unlock()
231
232	cachedSurvivor := capturedPaneRenderState{title: "survivor"}
233	ac.captureFrames = map[*pane]capturedPaneRenderState{
234		survivor: cachedSurvivor,
235		closed:   {title: "closed"},
236	}
237
238	require.NoError(t, d.closePane(sess, tb, closed.id, ac, false))
239	require.NotContains(t, ac.captureFrames, closed, "closed pane must not remain strongly retained by its attachment")
240	require.Equal(t, cachedSurvivor, ac.captureFrames[survivor], "surviving pane keeps its incremental capture")
241}
242
243func TestCloseTabPrunesAttachedCaptureFrames(t *testing.T) {
244	d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil)
245	closedTab, survivorTab := sess.tabs[0], sess.tabs[1]
246	closed := closedTab.panes["pane-1"]
247	survivor := survivorTab.panes["pane-1"]
248	cachedSurvivor := capturedPaneRenderState{title: "survivor"}
249	ac.captureFrames = map[*pane]capturedPaneRenderState{
250		closed:   {title: "closed"},
251		survivor: cachedSurvivor,
252	}
253
254	require.NoError(t, d.closeTab(sess, closedTab, false))
255	require.NotContains(t, ac.captureFrames, closed, "closed tab panes must not remain strongly retained by its attachment")
256	require.Equal(t, cachedSurvivor, ac.captureFrames[survivor], "surviving tab pane keeps its incremental capture")
257}
258
259func TestAltCForwardsToPTY(t *testing.T) {
260	writes := make(chan []byte, 2)
261	p, releasePTY := newBlockingPTYWithWrites(t, writes)
262	d, sess, ac, _ := newManualSessionWithPTYs(t, p)
263
264	d.handleInput(sess, ac, []byte("\x1bc"))
265
266	requirePTYWrite(t, writes, []byte("\x1bc"))
267	releasePTY()
268}
269
270// TestWriteToPaneFailureNotifiesDroppedInput drives forwarded keystrokes into
271// a pane whose pty.Write always fails (a wedged pty, or a dead child). The
272// user's typing must not silently vanish: each failed write records a
273// NoticeInputDropped notice, and repeated failures on the same code coalesce
274// into a single toast with a rising count rather than spamming one per key.
275func TestWriteToPaneFailureNotifiesDroppedInput(t *testing.T) {
276	writeErr := errors.New("write /dev/ptmx: input/output error")
277	p, releasePTY := newBlockingPTYWithFailingWrite(t, writeErr)
278	defer releasePTY()
279	d, sess, ac, _ := newManualSessionWithPTYs(t, p)
280
281	for range 5 {
282		d.handleInput(sess, ac, []byte("x"))
283	}
284
285	toasts := awaitToastCount(t, ac, 1)
286	require.Equal(t, domain.NoticeInputDropped, toasts[0].Code)
287	require.Equal(t, domain.NoticeError, toasts[0].Severity)
288	require.Equal(t, "input not delivered to pane", toasts[0].Message)
289	require.Equal(t, 5, toasts[0].Count, "5 failed writes must coalesce into one toast, not one per keystroke")
290
291	hist := d.notices.history()
292	require.Len(t, hist, 5, "coalescing is display-only; history keeps every occurrence")
293}
294
295func TestAltFToggleRetainedFloatingPaneRepaintsImmediately(t *testing.T) {
296	normal, releaseNormal := newBlockingPTY(t)
297	floatingPTY, releaseFloating := newBlockingPTY(t)
298	defer releaseNormal()
299	defer releaseFloating()
300	d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
301	client := vt.NewScreen(80, 25)
302	floatingCtx, cancelFloating := context.WithCancel(sess.ctx)
303	defer cancelFloating()
304	floating := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 5})
305	floating.ctx = floatingCtx
306	floating.screen.Write([]byte("popup-content"))
307	installTestFloating(testAttachmentTab(sess), floating, false)
308
309	// Establish the client shadow while the retained popup is hidden.
310	d.paint(sess, ac, true, nil)
311	mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
312
313	// Route the real Alt+F binding. Showing a retained pane must paint once.
314	d.handleInput(sess, ac, []byte("\x1bf"))
315	mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
316	require.Contains(t, strings.Join(frameRows(client.Frame), "\n"), "popup-content")
317	require.Contains(t, strings.Join(frameRows(client.Frame), "\n"), "┌")
318	select {
319	case extra := <-sends:
320		t.Fatalf("show emitted duplicate output: %#v", extra)
321	default:
322	}
323
324	// The second real key must repaint immediately, rather than waiting for
325	// unrelated output, while retaining the existing PTY and context.
326	d.handleInput(sess, ac, []byte("\x1bf"))
327	mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
328	require.NotContains(t, strings.Join(frameRows(client.Frame), "\n"), "popup-content")
329	tb := testAttachmentTab(sess)
330	tb.mu.Lock()
331	require.Equal(t, floatingHidden, tb.floating.state)
332	require.Same(t, floating, tb.floating.pane)
333	generation := tb.floating.generation
334	tb.mu.Unlock()
335	require.Same(t, floatingPTY, floating.pty)
336	select {
337	case <-floating.ctx.Done():
338		t.Fatal("hiding retained popup cancelled its context")
339	default:
340	}
341
342	// A third show uses the installed pane; it must not launch or emit a second
343	// resize/start frame.
344	d.handleInput(sess, ac, []byte("\x1bf"))
345	mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
346	require.Contains(t, strings.Join(frameRows(client.Frame), "\n"), "popup-content")
347	tb.mu.Lock()
348	require.Equal(t, floatingVisible, tb.floating.state)
349	require.Same(t, floating, tb.floating.pane)
350	require.Equal(t, generation, tb.floating.generation)
351	tb.mu.Unlock()
352	select {
353	case extra := <-sends:
354		t.Fatalf("retained show emitted duplicate output: %#v", extra)
355	default:
356	}
357}
358
359func requireFloatingInitialized(t *testing.T, tb *tab) {
360	t.Helper()
361	tb.mu.Lock()
362	defer tb.mu.Unlock()
363	require.NotEqual(t, floatingUninitialized, tb.floating.state)
364}
365
366func installTestFloating(tb *tab, p *pane, visible bool) {
367	tb.mu.Lock()
368	defer tb.mu.Unlock()
369	tb.floating.pane = p
370	tb.floating.generation++
371	if visible {
372		tb.floating.state = floatingVisible
373	} else {
374		tb.floating.state = floatingHidden
375	}
376}
377
378func TestFloatingKeyboardRoutesVisibleAndHiddenInput(t *testing.T) {
379	for _, tc := range []struct {
380		name    string
381		visible bool
382		input   []byte
383	}{
384		{name: "visible ordinary bytes", visible: true, input: []byte("hello")},
385		{name: "visible escape", visible: true, input: []byte("\x1b")},
386		{name: "hidden ordinary bytes", visible: false, input: []byte("hello")},
387		{name: "hidden escape", visible: false, input: []byte("\x1b")},
388	} {
389		t.Run(tc.name, func(t *testing.T) {
390			normalWrites := make(chan []byte, 1)
391			floatingWrites := make(chan []byte, 1)
392			normal, releaseNormal := newBlockingPTYWithWrites(t, normalWrites)
393			floating, releaseFloating := newBlockingPTYWithWrites(t, floatingWrites)
394			defer releaseNormal()
395			defer releaseFloating()
396			d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
397			installTestFloating(testAttachmentTab(sess), newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), tc.visible)
398
399			daemonKeyHandler{d: d, ac: ac}.Forward(tc.input)
400			if tc.visible {
401				requirePTYWrite(t, floatingWrites, tc.input)
402				requireNoPTYWrite(t, normalWrites)
403			} else {
404				requirePTYWrite(t, normalWrites, tc.input)
405				requireNoPTYWrite(t, floatingWrites)
406			}
407		})
408	}
409}
410
411func TestFloatingBracketedPasteAndGlobalActions(t *testing.T) {
412	normalWrites := make(chan []byte, 1)
413	floatingWrites := make(chan []byte, 1)
414	normal, releaseNormal := newBlockingPTYWithWrites(t, normalWrites)
415	floating, releaseFloating := newBlockingPTYWithWrites(t, floatingWrites)
416	defer releaseNormal()
417	defer releaseFloating()
418	d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
419	installTestFloating(testAttachmentTab(sess), newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), true)
420
421	paste := []byte("\x1b[200~paste\x1bf\x1b[201~")
422	d.handleInput(sess, ac, paste)
423	requirePTYWrite(t, floatingWrites, paste)
424	d.handleInput(sess, ac, []byte("\x1b "))
425	require.True(t, ac.overlays.paletteActive(), "global palette action must still intercept")
426	requireNoPTYWrite(t, normalWrites)
427}
428
429func TestFloatingStaysTerminalTargetAfterDirectionalFocus(t *testing.T) {
430	writes := make(chan []byte, 1)
431	floatingWrites := make(chan []byte, 1)
432	normal, releaseNormal := newBlockingPTYWithWrites(t, writes)
433	floating, releaseFloating := newBlockingPTYWithWrites(t, floatingWrites)
434	defer releaseNormal()
435	defer releaseFloating()
436	d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
437	tb := testAttachmentTab(sess)
438	second := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 5})
439	tb.mu.Lock()
440	tb.size = domain.Size{Cols: 81, Rows: 5}
441	tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
442	tb.panes["pane-2"] = second
443	tb.mu.Unlock()
444	installTestFloating(tb, newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), true)
445
446	daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneRight, nil)
447	require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
448	daemonKeyHandler{d: d, ac: ac}.Forward([]byte("x"))
449	requirePTYWrite(t, floatingWrites, []byte("x"))
450}
451
452// TestActionFocusPaneAtEdgeStaysSilent drives a directional focus move with
453// no pane on that side (the session has a single, unsplit pane). This is
454// routine navigation, not a failure, and must never produce a notice.
455func TestActionFocusPaneAtEdgeStaysSilent(t *testing.T) {
456	d, _, ac, _ := newManualSessionWithPTYs(t, nil)
457
458	daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneLeft, nil)
459
460	require.Empty(t, d.notices.history(), "no-neighbor focus move must stay silent")
461}
462
463// TestActionFocusPaneGenuineErrorReportsNoticeInternal drives a directional
464// focus move against a tab with no layout tree at all — a genuine structural
465// error distinct from "no pane in that direction" — and asserts it reaches
466// the user as a notice instead of being silently discarded.
467func TestActionFocusPaneGenuineErrorReportsNoticeInternal(t *testing.T) {
468	d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
469	tb := testAttachmentTab(sess)
470	tb.mu.Lock()
471	tb.tree = nil
472	tb.mu.Unlock()
473
474	daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneLeft, nil)
475
476	history := d.notices.history()
477	require.Len(t, history, 2, "the layout error and its failed direct display update must both surface")
478	require.Equal(t, domain.NoticeInternal, history[0].Code)
479	require.Equal(t, "display update failed", history[0].Message)
480	require.Equal(t, domain.NoticeInternal, history[1].Code)
481	require.Equal(t, "internal error", history[1].Message)
482}
483
484func TestFloatingVisibilityRemainsIndependentAcrossTabSwitches(t *testing.T) {
485	d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
486	first := testAttachmentTab(sess)
487	second := newTab(nil, first.size)
488	installTestFloating(first, newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
489	installTestFloating(second, newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), false)
490	sess.mu.Lock()
491	sess.tabs = append(sess.tabs, second)
492	sess.mu.Unlock()
493
494	daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2, nil)
495	second.mu.Lock()
496	require.Equal(t, floatingHidden, second.floating.state)
497	second.mu.Unlock()
498
499	daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab1, nil)
500	first.mu.Lock()
501	require.Equal(t, floatingVisible, first.floating.state)
502	first.mu.Unlock()
503}
504
505func TestFloatingMouseTranslatesSGRToInnerCoordinates(t *testing.T) {
506	normal := portsmocks.NewMockPTY(t)
507	floatingPTY := portsmocks.NewMockPTY(t)
508	floatingPTY.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once()
509	d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
510	tb := testAttachmentTab(sess)
511	tb.mu.Lock()
512	tb.size = domain.Size{Cols: 100, Rows: 20}
513	tb.mu.Unlock()
514	floating := newPane("floating", floatingPTY, domain.Size{Cols: 78, Rows: 14})
515	floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
516	installTestFloating(tb, floating, true)
517	geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig())
518	raw := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+clientTopBarRows+1)
519
520	d.handleInput(sess, ac, raw)
521}
522
523func TestFloatingMouseIgnoresBorderAndOutside(t *testing.T) {
524	normal := portsmocks.NewMockPTY(t)
525	floatingPTY := portsmocks.NewMockPTY(t)
526	d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
527	tb := testAttachmentTab(sess)
528	tb.mu.Lock()
529	tb.size = domain.Size{Cols: 100, Rows: 20}
530	tb.mu.Unlock()
531	floating := newPane("floating", floatingPTY, domain.Size{Cols: 78, Rows: 14})
532	floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
533	installTestFloating(tb, floating, true)
534	geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig())
535
536	border := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Bounds.X+1, geometry.Bounds.Y+clientTopBarRows+1)
537	outside := []byte("\x1b[<0;1;2M")
538	d.handleInput(sess, ac, border)
539	d.handleInput(sess, ac, outside)
540}
541
542func TestResponsiveDrawerFloatingMouseRoutesOnlyInnerClientCells(t *testing.T) {
543	normal := portsmocks.NewMockPTY(t)
544	floatingPTY := portsmocks.NewMockPTY(t)
545	floatingPTY.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once()
546	d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
547	tb := testAttachmentTab(sess)
548	complete := domain.Size{Cols: 79, Rows: 25}
549	content := tabSize(complete)
550	geometry := calculateContentFloatingGeometry(content, d.currentFloatingConfig())
551	require.Equal(t, ui.PresentationDrawer, geometry.Mode)
552	tb.mu.Lock()
553	tb.size = content
554	tb.mu.Unlock()
555	floating := newPane("floating", floatingPTY, rectSize(geometry.Inner))
556	floating.popupGeometry = geometry
557	floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
558	installTestFloating(tb, floating, true)
559
560	firstInner := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+clientTopBarRows+1)
561	d.handleInput(sess, ac, firstInner)
562	for _, frameRow := range []int{0, 1, 2, geometry.Bounds.Y + clientTopBarRows, complete.Rows - 1} {
563		d.handleInput(sess, ac, fmt.Appendf(nil, "\x1b[<0;1;%dM", frameRow+1))
564	}
565}
566
567func TestResponsiveDrawerCopyMouseMapsInnerAndPinsDragAwayFromChrome(t *testing.T) {
568	p, release := newBlockingPTY(t)
569	defer release()
570	d, sess, ac, _ := newManualSessionWithPTYs(t, p)
571	tb := testAttachmentTab(sess)
572	complete := domain.Size{Cols: 79, Rows: 25}
573	content := tabSize(complete)
574	geometry := calculateContentFloatingGeometry(content, d.currentFloatingConfig())
575	tb.mu.Lock()
576	tb.size = content
577	tb.mu.Unlock()
578	floating := newPane("floating", nil, rectSize(geometry.Inner))
579	floating.popupGeometry = geometry
580	floating.screen.Write([]byte("drawer row zero\r\nsecond row"))
581	installTestFloating(tb, floating, true)
582
583	for _, frameRow := range []int{0, 1, 2, geometry.Bounds.Y + clientTopBarRows, complete.Rows - 1} {
584		d.handleInput(sess, ac, fmt.Appendf(nil, "\x1b[<0;1;%dM", frameRow+1))
585		ac.overlays.copyMu.Lock()
586		require.False(t, ac.overlays.copyPointer.valid, "frame row %d is drawer chrome, not copy content", frameRow)
587		ac.overlays.copyMu.Unlock()
588	}
589
590	firstInner := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+clientTopBarRows+1)
591	d.handleInput(sess, ac, firstInner)
592	ac.overlays.copyMu.Lock()
593	pointer := ac.overlays.copyPointer
594	ac.overlays.copyMu.Unlock()
595	require.True(t, pointer.valid)
596	require.Same(t, floating, pointer.pane)
597	require.Equal(t, scopy.Pos{Row: 0, Col: 0}, pointer.press)
598	require.Equal(t, copyContentToClientRect(geometry.Inner), pointer.geometry.content)
599
600	// Motion onto the protected top row clamps against the press-owned drawer
601	// geometry instead of retargeting the underlying tiled terminal.
602	d.handleInput(sess, ac, []byte("\x1b[<32;79;1M"))
603	ac.overlays.copyMu.Lock()
604	require.NotNil(t, ac.overlays.copyMode)
605	require.Same(t, floating, ac.overlays.copyPane)
606	require.Same(t, floating, ac.overlays.copyPointer.pane)
607	require.Equal(t, pointer.geometry, ac.overlays.copyPointer.geometry)
608	selection := ac.overlays.copyMode.Selection()
609	ac.overlays.copyMu.Unlock()
610	require.Equal(t, scopy.Pos{Row: 0, Col: 0}, selection.Anchor)
611	require.Equal(t, 0, selection.Active.Row, "drag above the drawer must stay pinned to its first document row")
612}
613
614func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) {
615	writes := make(chan []byte, 1)
616	p, releasePTY := newBlockingPTYWithWrites(t, writes)
617	d, sess, ac, _ := newManualSessionWithPTYs(t, p)
618	pane := sess.tabs[0].focusedPane()
619	pane.screen.Write([]byte("\x1b[?2004h"))
620	require.True(t, pane.screen.BracketedPasteMode())
621
622	paste := []byte("\x1b[200~first line\nsecond line\n\x1b[201~")
623	d.handleInput(sess, ac, paste)
624
625	requirePTYWrite(t, writes, paste)
626	releasePTY()
627}
628
629func TestOverlayInputPrecedence(t *testing.T) {
630	cases := []struct {
631		name  string
632		setup func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan ports.Frame)
633		input []byte
634		check func(t *testing.T, ac *attachedClient, writes chan []byte)
635	}{
636		{
637			name: "prompt before palette picker copy and normal",
638			setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan ports.Frame) {
639				t.Helper()
640				d.enterCopyMode(sess, ac)
641				d.enterPicker(sess, ac)
642				d.enterPalette(sess, ac)
643				d.enterPrompt(sess, ac, "Rename", "", func(string) error { return nil })
644			},
645			input: []byte("x"),
646			check: func(t *testing.T, ac *attachedClient, writes chan []byte) {
647				t.Helper()
648				require.Equal(t, "x", ac.overlays.prompt.Value())
649				require.Equal(t, "", ac.overlays.palette.Query())
650				require.True(t, ac.overlays.pickerActive())
651				require.True(t, ac.overlays.copyActive())
652				requireNoPTYWrite(t, writes)
653			},
654		},
655		{
656			name: "palette before picker copy and normal",
657			setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan ports.Frame) {
658				t.Helper()
659				d.enterCopyMode(sess, ac)
660				d.enterPicker(sess, ac)
661				d.enterPalette(sess, ac)
662			},
663			input: []byte("x"),
664			check: func(t *testing.T, ac *attachedClient, writes chan []byte) {
665				t.Helper()
666				require.Equal(t, "x", ac.overlays.palette.Query())
667				require.True(t, ac.overlays.pickerActive())
668				require.True(t, ac.overlays.copyActive())
669				requireNoPTYWrite(t, writes)
670			},
671		},
672		{
673			name: "picker before copy and normal",
674			setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan ports.Frame) {
675				t.Helper()
676				d.enterCopyMode(sess, ac)
677				d.enterPicker(sess, ac)
678			},
679			input: []byte("j"),
680			check: func(t *testing.T, ac *attachedClient, writes chan []byte) {
681				t.Helper()
682				target, ok := ac.overlays.picker.Selected()
683				require.True(t, ok)
684				require.Equal(t, 1, target.TabIndex)
685				require.True(t, ac.overlays.copyActive())
686				requireNoPTYWrite(t, writes)
687			},
688		},
689		{
690			name: "copy before normal",
691			setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan ports.Frame) {
692				t.Helper()
693				d.enterCopyMode(sess, ac)
694			},
695			input: []byte("q"),
696			check: func(t *testing.T, ac *attachedClient, writes chan []byte) {
697				t.Helper()
698				require.False(t, ac.overlays.copyActive())
699				requireNoPTYWrite(t, writes)
700			},
701		},
702		{
703			name:  "normal keys when no overlay is active",
704			input: []byte("Z"),
705			check: func(t *testing.T, ac *attachedClient, writes chan []byte) {
706				t.Helper()
707				requirePTYWrite(t, writes, []byte("Z"))
708			},
709		},
710	}
711
712	for _, tc := range cases {
713		t.Run(tc.name, func(t *testing.T) {
714			writes := make(chan []byte, 1)
715			p, releasePTY := newBlockingPTYWithWrites(t, writes)
716			p2, releasePTY2 := newBlockingPTY(t)
717			d, sess, ac, sends := newManualSessionWithPTYs(t, p, p2)
718			defer releasePTY()
719			defer releasePTY2()
720			if tc.setup != nil {
721				tc.setup(t, d, sess, ac, sends)
722			}
723
724			d.handleInput(sess, ac, tc.input)
725
726			tc.check(t, ac, writes)
727		})
728	}
729}
730
731func requirePTYWrite(t *testing.T, writes chan []byte, want []byte) {
732	t.Helper()
733	select {
734	case got := <-writes:
735		require.Equal(t, want, got)
736	case <-time.After(time.Second):
737		t.Fatalf("PTY did not receive %q", want)
738	}
739}
740
741func requireNoPTYWrite(t *testing.T, writes chan []byte) {
742	t.Helper()
743	select {
744	case got := <-writes:
745		t.Fatalf("input forwarded to PTY: %q", got)
746	default:
747	}
748}
749
750func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) {
751	cases := []struct {
752		name      string
753		start     int
754		query     []byte
755		wantIndex int
756	}{
757		{name: "next advances", start: 0, query: []byte("NXT\r"), wantIndex: 1},
758		{name: "next wraps", start: 2, query: []byte("NXT\r"), wantIndex: 0},
759		{name: "previous moves back", start: 2, query: []byte("PVT\r"), wantIndex: 1},
760		{name: "previous wraps", start: 0, query: []byte("PVT\r"), wantIndex: 2},
761	}
762
763	for _, tc := range cases {
764		t.Run(tc.name, func(t *testing.T) {
765			d, sess, ac, _, releases := newManualTabSession(t, 3)
766			defer func() {
767				for _, release := range releases {
768					release()
769				}
770			}()
771			selectTestAttachmentTab(sess, tc.start)
772
773			d.handleInput(sess, ac, []byte("\x1b "))
774			d.handleInput(sess, ac, tc.query)
775
776			require.Equal(t, tc.wantIndex, testAttachmentTabIndex(sess))
777		})
778	}
779}
780
781func TestPaletteBackSessionSendsClientPreviousRouteAction(t *testing.T) {
782	d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
783	defer releaseAll(releases)
784	transport := ac.transport()
785	rc := d.attachCoordinator(current, nil, ac, true)
786	token := current.attachmentToken(ac, transport)
787	token.lease = rc.attachmentLease(ac)
788	ac.publishAttachmentCapability(token)
789	effect, admitted := ac.beginAttachmentEffect(token)
790	require.True(t, admitted)
791	defer effect.End()
792	token.effect = effect
793	ac.setRouteSnapshot(ports.RecentRouteSnapshot{
794		Generation: 3,
795		Active:     ports.RouteRef{Key: 3, Generation: 3},
796		Previous:   ports.RouteRef{Key: 2, Generation: 2},
797		Entries:    []ports.RecentRouteEntry{{Key: 2, Generation: 2, Name: "recent", Kind: ports.RouteKindLocal}},
798	})
799
800	require.NoError(t, d.backSessionForAttachment(token))
801	frame := awaitFrame(t, sends, ports.MsgNavigateRecentRoute)
802	action, err := ports.UnmarshalRouteNavigationAction(frame.Payload)
803	require.NoError(t, err)
804	require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 3, Key: 2, Generation: 2}, action)
805}
806
807func TestBackSessionWithoutClientHistoryIsNoop(t *testing.T) {
808	d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
809	defer releaseAll(releases)
810
811	d.backSession(current, ac)
812
813	require.Same(t, current, ac.currentSession())
814	require.Empty(t, d.notices.history())
815	requireNoOutputFrame(t, sends)
816}
817
818func TestSwitchSourceDoesNotOwnPreviousRoute(t *testing.T) {
819	d, current, ac, _, releases := newRecentNavigationTestSessions(t)
820	defer releaseAll(releases)
821	recent := mustLocalSession(t, d.sessions[domain.SessionID("recent")])
822
823	require.NoError(t, d.switchToTarget(current, ac, picker.Target{Session: recent.id, TabIndex: -1}))
824	require.Same(t, recent, ac.currentSession())
825	require.Equal(t, uint64(0), ac.routeSnapshotCopy().Generation, "route ownership starts in the client")
826}
827
828func newRecentNavigationTestSessions(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) {
829	t.Helper()
830	p1, release1 := newBlockingPTY(t)
831	p2, release2 := newBlockingPTY(t)
832	p3, release3 := newBlockingPTY(t)
833	d, current, ac, sends := newManualSessionWithPTYs(t, p1)
834	current.id = "current"
835	delete(d.sessions, domain.SessionID("manual"))
836	d.sessions[current.id] = current
837	recent := &session{sessionCore: sessionCore{id: "recent", name: "recent"}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}}
838	older := &session{sessionCore: sessionCore{id: "older", name: "older"}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p3, domain.Size{Cols: 80, Rows: 23})}}
839	d.sessions[recent.id] = recent
840	d.sessions[older.id] = older
841	current.mruAt.Store(30)
842	recent.mruAt.Store(20)
843	older.mruAt.Store(10)
844	return d, current, ac, sends, []func(){release1, release2, release3}
845}
846
847func releaseAll(releases []func()) {
848	for _, release := range releases {
849		release()
850	}
851}
852
853func TestAltXClosesActiveTabAndSelectsRemaining(t *testing.T) {
854	writes := make(chan []byte, 1)
855	p1, releasePTY1 := newBlockingPTY(t)
856	p2, releasePTY2 := newBlockingPTY(t)
857	p3, releasePTY3 := newBlockingPTYWithWrites(t, writes)
858	d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2, p3)
859	selectTestAttachmentTab(sess, 1)
860
861	d.handleInput(sess, ac, []byte("\x1b "))
862	d.handleInput(sess, ac, []byte("CLT\r"))
863
864	require.Equal(t, 1, sessionCount(d))
865	require.Len(t, sess.tabs, 2)
866	require.Equal(t, 1, testAttachmentTabIndex(sess), "closing middle tab selects the next remaining tab")
867	d.handleInput(sess, ac, []byte("Z"))
868	require.Eventually(t, func() bool { return len(writes) == 1 }, 2*time.Second, 5*time.Millisecond)
869	requirePTYWrite(t, writes, []byte("Z"))
870
871	releasePTY1()
872	releasePTY2()
873	releasePTY3()
874}
875
876func TestAltDDetachesCurrentClient(t *testing.T) {
877	d, sess, ac, sends, releases := newManualTabSession(t, 1)
878	defer releases[0]()
879
880	d.handleInput(sess, ac, []byte("\x1b "))
881	d.handleInput(sess, ac, []byte("DET\r"))
882
883	require.Empty(t, sess.snapshotAttachments())
884	f := awaitFrame(t, sends, ports.MsgDetached)
885	det, err := ports.UnmarshalDetached(f.Payload)
886	require.NoError(t, err)
887	require.Equal(t, ports.ReasonDetach, det.Reason)
888}
889
890func TestRNSOpensPromptAndEnterPromotesEphemeralSession(t *testing.T) {
891	d, sess, ac, sends, releases := newManualTabSession(t, 1)
892	defer releases[0]()
893	sess.ephemeral = true
894	sess.name = "0"
895
896	d.handleInput(sess, ac, []byte("\x1b "))
897	awaitFrame(t, sends, ports.MsgOutput)
898	d.handleInput(sess, ac, []byte("RNS\r"))
899	awaitFrame(t, sends, ports.MsgOutput)
900	require.True(t, ac.overlays.promptActive())
901
902	d.handleInput(sess, ac, []byte("\r"))
903	awaitFrame(t, sends, ports.MsgOutput)
904
905	require.False(t, ac.overlays.promptActive())
906	require.False(t, sess.ephemeral)
907	require.Equal(t, "0", sess.name)
908}
909
910func TestRNTOpensPromptAndRenamesActiveTab(t *testing.T) {
911	d, sess, ac, sends, releases := newManualTabSession(t, 2)
912	defer func() {
913		for _, release := range releases {
914			release()
915		}
916	}()
917	selectTestAttachmentTab(sess, 1)
918
919	d.handleInput(sess, ac, []byte("\x1b "))
920	awaitFrame(t, sends, ports.MsgOutput)
921	d.handleInput(sess, ac, []byte("RNT\r"))
922	out := awaitFrame(t, sends, ports.MsgOutput)
923	require.True(t, ac.overlays.promptActive())
924	msg, err := ports.UnmarshalOutput(out.Payload)
925	require.NoError(t, err)
926	require.Contains(t, string(msg.Data), "> 2")
927
928	d.handleInput(sess, ac, []byte("\x7flogs\r"))
929	awaitFrame(t, sends, ports.MsgOutput)
930
931	require.False(t, ac.overlays.promptActive())
932	require.Equal(t, "logs", sess.tabs[1].name)
933	require.Empty(t, sess.tabs[0].name)
934}
935
936func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) {
937	writes := make(chan []byte, 4)
938	p, _ := newBlockingPTYWithWrites(t, writes)
939	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
940	win := sess.tabs[0]
941	win.focusedPane().screen.Write([]byte("live"))
942
943	d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
944	data := mustOutputData(t, sends)
945	require.NotNil(t, ac.overlays.copyMode)
946	require.Equal(t, 19, ac.overlays.copyMode.Cursor().Row)
947	require.Contains(t, string(data), "[SCROLL]")
948
949	d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
950	mustOutputData(t, sends)
951	require.Nil(t, ac.overlays.copyMode)
952
953	d.handleInput(sess, ac, []byte("\x1b[<64;1;1Mq"))
954	mustOutputData(t, sends)
955	mustOutputData(t, sends)
956	require.Nil(t, ac.overlays.copyMode, "q after wheel in same input must be routed after copy mode is entered")
957	select {
958	case got := <-writes:
959		t.Fatalf("mouse/copy input forwarded to PTY: %q", got)
960	default:
961	}
962}
963
964func TestMouseAltScreenWheelMapsToArrows(t *testing.T) {
965	writes := make(chan []byte, 2)
966	p, _ := newBlockingPTYWithWrites(t, writes)
967	d, sess, ac, _ := newManualSessionWithPTYs(t, p)
968	sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1049h"))
969
970	d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
971	d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
972
973	requirePTYWrite(t, writes, []byte("\x1b[A\x1b[A\x1b[A"))
974	requirePTYWrite(t, writes, []byte("\x1b[B\x1b[B\x1b[B"))
975	require.Nil(t, ac.overlays.copyMode)
976}
977
978func TestSGRRowOffset(t *testing.T) {
979	t.Parallel()
980
981	cases := []struct {
982		name  string
983		raw   []byte
984		delta int
985		want  []byte
986	}{
987		{name: "decrements press row", raw: []byte("\x1b[<0;2;3M"), delta: -1, want: []byte("\x1b[<0;2;2M")},
988		{name: "increments release row", raw: []byte("\x1b[<0;2;3m"), delta: 2, want: []byte("\x1b[<0;2;5m")},
989		{name: "leaves empty unchanged", raw: []byte(""), delta: -1, want: []byte("")},
990		{name: "leaves non sgr unchanged", raw: []byte("abc"), delta: -1, want: []byte("abc")},
991		{name: "leaves malformed fields unchanged", raw: []byte("\x1b[<0;2M"), delta: -1, want: []byte("\x1b[<0;2M")},
992		{name: "leaves non numeric row unchanged", raw: []byte("\x1b[<0;2;xM"), delta: -1, want: []byte("\x1b[<0;2;xM")},
993		{name: "leaves invalid shifted row unchanged", raw: []byte("\x1b[<0;2;1M"), delta: -1, want: []byte("\x1b[<0;2;1M")},
994		{name: "handles digit width increase", raw: []byte("\x1b[<0;2;9M"), delta: 1, want: []byte("\x1b[<0;2;10M")},
995		{name: "handles digit width decrease", raw: []byte("\x1b[<0;2;10m"), delta: -1, want: []byte("\x1b[<0;2;9m")},
996	}
997
998	for _, tc := range cases {
999		t.Run(tc.name, func(t *testing.T) {
1000			require.Equal(t, tc.want, sgrRowOffset(tc.raw, tc.delta))
1001		})
1002	}
1003}
1004
1005func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
1006	writes := make(chan []byte, 4)
1007	p, _ := newBlockingPTYWithWrites(t, writes)
1008	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1009
1010	d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))
1011	select {
1012	case got := <-writes:
1013		t.Fatalf("press without child mouse mode forwarded: %q", got)
1014	default:
1015	}
1016	require.Nil(t, ac.overlays.copyMode, "press alone must not enter visual mode")
1017
1018	sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1000h"))
1019	raw := []byte("\x1b[<0;2;3M")
1020	d.handleInput(sess, ac, raw)
1021	select {
1022	case got := <-writes:
1023		t.Fatalf("SGR report forwarded to child without SGR mode: %q", got)
1024	default:
1025	}
1026
1027	sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1006h"))
1028	d.handleInput(sess, ac, raw)
1029	requirePTYWrite(t, writes, []byte("\x1b[<0;2;2M"))
1030
1031	d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))
1032	select {
1033	case got := <-writes:
1034		t.Fatalf("top-row mouse report forwarded: %q", got)
1035	default:
1036	}
1037
1038	statusRowReport := []byte("\x1b[<0;1;25M")
1039	d.handleInput(sess, ac, statusRowReport)
1040	select {
1041	case got := <-writes:
1042		t.Fatalf("status-row mouse report forwarded: %q", got)
1043	default:
1044	}
1045
1046	sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1006l\x1b[?1000l"))
1047	d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
1048	require.Nil(t, ac.overlays.copyMode, "press alone must still not enter visual mode")
1049	d.handleInput(sess, ac, []byte("\x1b[<32;1;4M"))
1050	mustOutputData(t, sends)
1051	require.NotNil(t, ac.overlays.copyMode)
1052	selection := ac.overlays.copyMode.Selection()
1053	require.True(t, selection.Enabled)
1054	lo, hi := selection.Anchor.Row, selection.Active.Row
1055	if lo > hi {
1056		lo, hi = hi, lo
1057	}
1058	require.Equal(t, 0, lo)
1059	require.Equal(t, 2, hi)
1060}
1061
1062func TestCopyModeMousePressReleaseWithoutMotionEmitsNoOSC52(t *testing.T) {
1063	p, release := newBlockingPTY(t)
1064	defer release()
1065	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1066	copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha"))
1067	d.enterCopyMode(sess, ac)
1068	mustOutputData(t, sends)
1069
1070	d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<0;1;2m"))
1071	d.handleInput(sess, ac, []byte("y"))
1072
1073	for range 2 { // press repaint, then copy-mode exit repaint
1074		require.NotContains(t, string(mustOutputData(t, sends)), "\x1b]52;c;", "a click without motion has no selection to copy")
1075	}
1076	require.False(t, ac.overlays.copyActive())
1077}
1078
1079func TestCopyModeMouseHorizontalReverseDragUsesExactOSC52(t *testing.T) {
1080	p, _ := newBlockingPTY(t)
1081	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1082	copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha"))
1083	d.enterCopyMode(sess, ac)
1084	mustOutputData(t, sends)
1085
1086	// SGR is one based: Cy2 is the first body row. Dragging from column 3
1087	// back to column 1 is the same stream as the forward endpoints 1..3.
1088	d.handleInput(sess, ac, []byte("\x1b[<0;4;2M\x1b[<32;2;2M"))
1089	mustOutputData(t, sends)
1090	d.handleInput(sess, ac, []byte("\x1b[<0;2;2m"))
1091	d.handleInput(sess, ac, []byte("y"))
1092	var msg ports.Output
1093	require.Eventually(t, func() bool {
1094		frame := awaitFrame(t, sends, ports.MsgOutput)
1095		var err error
1096		msg, err = ports.UnmarshalOutput(frame.Payload)
1097		return err == nil && string(msg.Data) == string(scopy.OSC52("lph")[0])
1098	}, 2*time.Second, 5*time.Millisecond)
1099	require.Equal(t, scopy.OSC52("lph")[0], msg.Data)
1100	require.Nil(t, ac.overlays.copyPointer.pane)
1101}
1102
1103func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {
1104	p, _ := newBlockingPTY(t)
1105	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1106	copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha"))
1107	copy(sess.tabs[0].focusedPane().screen.Frame.Row(1), testRow("bravo"))
1108	copy(sess.tabs[0].focusedPane().screen.Frame.Row(2), testRow("charlie"))
1109
1110	d.enterCopyMode(sess, ac)
1111	mustOutputData(t, sends)
1112	d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<32;1;3M"))
1113	mustOutputData(t, sends)
1114	require.NotNil(t, ac.overlays.copyMode)
1115	selection := ac.overlays.copyMode.Selection()
1116	require.True(t, selection.Enabled)
1117	lo, hi := selection.Anchor.Row, selection.Active.Row
1118	if lo > hi {
1119		lo, hi = hi, lo
1120	}
1121	require.Equal(t, 0, lo)
1122	require.Equal(t, 1, hi)
1123
1124	d.handleInput(sess, ac, []byte("y"))
1125	data := ""
1126	require.Eventually(t, func() bool {
1127		data = string(mustOutputData(t, sends))
1128		return strings.HasPrefix(data, "\x1b]52;c;")
1129	}, 2*time.Second, 5*time.Millisecond, "OSC52 output = %q", data)
1130	decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(data, "\x1b]52;c;"), "\a"))
1131	require.NoError(t, err)
1132	require.Equal(t, "alpha\nb", string(decoded))
1133	require.Nil(t, ac.overlays.copyMode)
1134
1135	exitPaint := string(mustOutputData(t, sends))
1136	require.NotContains(t, exitPaint, "[SELECT]")
1137}
1138
1139// TestMouseNormalScreenStatusRowClearsStalePressState covers a regression
1140// where a Press or Release landing on the status row (row >= childRows)
1141// returned before the inner event-type switch ran, leaving
1142// normalMousePressValid untouched. A later Motion on a content row would
1143// then resurrect the stale anchor and start a selection out of thin air.
1144// The fix requires Release to always clear the press state and Press on the
1145// status row to clear it too, so a following Motion is a no-op.
1146func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) {
1147	p, _ := newBlockingPTY(t)
1148	d, sess, ac, _ := newManualSessionWithPTYs(t, p)
1149	require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")
1150
1151	// Press on a content row establishes a (soon to be stale) anchor.
1152	d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
1153	require.Nil(t, ac.overlays.copyMode)
1154
1155	// Release lands on the status row: must still clear the press state,
1156	// regardless of the row it landed on.
1157	d.handleInput(sess, ac, []byte("\x1b[<0;1;25m"))
1158
1159	// A brand new button hold starts with a Press on the status row: must
1160	// clear (not silently preserve) any prior press state.
1161	d.handleInput(sess, ac, []byte("\x1b[<0;1;25M"))
1162
1163	// Motion onto a content row must not resurrect a stale anchor.
1164	d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
1165
1166	require.Nil(t, ac.overlays.copyMode, "stale press state must not resurrect a selection")
1167}
1168
1169// TestCopyModeStatusRowPressClearsDragState covers the copy-mode counterpart
1170// of the same bug class: copyMouse ignored any event whose row landed on the
1171// status row before updating copyPressRow/copyPressRowValid/copyDragging, so
1172// a Press on the status row left the previous drag state in place and a
1173// following Motion on a content row silently extended the old selection
1174// instead of being a no-op.
1175func TestCopyModeRejectedReleaseClearsPointerAndClickBeforeMotion(t *testing.T) {
1176	p, release := newBlockingPTY(t)
1177	defer release()
1178	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1179	d.enterCopyMode(sess, ac)
1180	mustOutputData(t, sends)
1181
1182	d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
1183	ac.overlays.copyMu.Lock()
1184	require.True(t, ac.overlays.copyPointer.valid)
1185	ac.overlays.copyMu.Unlock()
1186
1187	// Wire row 25 is below the focused pane content, so the release is not a
1188	// valid endpoint and must not leave a drag that later motion can revive.
1189	d.handleInput(sess, ac, []byte("\x1b[<0;1;25m\x1b[<32;1;3M"))
1190	ac.overlays.copyMu.Lock()
1191	require.False(t, ac.overlays.copyPointer.valid)
1192	selection := ac.overlays.copyMode.Selection()
1193	require.False(t, selection.Enabled)
1194	ac.overlays.copyMu.Unlock()
1195}
1196
1197func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
1198	p, _ := newBlockingPTY(t)
1199	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1200	copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha"))
1201	copy(sess.tabs[0].focusedPane().screen.Frame.Row(1), testRow("bravo"))
1202	copy(sess.tabs[0].focusedPane().screen.Frame.Row(2), testRow("charlie"))
1203	require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")
1204
1205	d.enterCopyMode(sess, ac)
1206	mustOutputData(t, sends)
1207
1208	// Full drag: press row0, motion to row1 -> selection [0,1].
1209	d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<32;1;3M"))
1210	mustOutputData(t, sends)
1211	require.NotNil(t, ac.overlays.copyMode)
1212	selection := ac.overlays.copyMode.Selection()
1213	require.True(t, selection.Enabled)
1214	lo, hi := selection.Anchor.Row, selection.Active.Row
1215	if lo > hi {
1216		lo, hi = hi, lo
1217	}
1218	require.Equal(t, 0, lo)
1219	require.Equal(t, 1, hi)
1220
1221	// Release, then a new button hold starting with Press on the status row.
1222	d.handleInput(sess, ac, []byte("\x1b[<0;1;3m"))
1223	d.handleInput(sess, ac, []byte("\x1b[<0;1;25M"))
1224
1225	// Motion onto a content row must be a no-op: the status-row press must
1226	// have cleared copyPressRowValid/copyDragging.
1227	d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
1228
1229	require.NotNil(t, ac.overlays.copyMode)
1230	selection = ac.overlays.copyMode.Selection()
1231	require.True(t, selection.Enabled)
1232	lo, hi = selection.Anchor.Row, selection.Active.Row
1233	if lo > hi {
1234		lo, hi = hi, lo
1235	}
1236	require.Equal(t, 0, lo, "anchor must not have moved")
1237	require.Equal(t, 1, hi, "status-row press must invalidate the drag so the motion is a no-op")
1238}
1239
1240// TestMouseNormalScreenDragUsesPressOwnedDocumentAfterOutputEviction verifies
1241// that output eviction after Press does not change endpoint mapping: the
1242// immutable press-owned Document keeps pointer rows 0→1 mapped to rows 0→1.
1243func TestMouseNormalScreenDragUsesPressOwnedDocumentAfterOutputEviction(t *testing.T) {
1244	p, _ := newBlockingPTY(t)
1245	d, sess, ac, _ := newManualSessionWithPTYs(t, p)
1246	installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 50})
1247	require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")
1248
1249	// Press anchors document row 0 before output eviction.
1250	d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
1251
1252	// Output evicts five lines after Press; the Document remains immutable.
1253	for range 5 {
1254		appendHistoryRow(t, sess.tabs[0].focusedPane().history, testRow("evicted"))
1255	}
1256
1257	// Motion maps the next endpoint through the press-owned Document.
1258	d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
1259
1260	require.NotNil(t, ac.overlays.copyMode)
1261	selection := ac.overlays.copyMode.Selection()
1262	require.True(t, selection.Enabled)
1263	lo, hi := selection.Anchor.Row, selection.Active.Row
1264	if lo > hi {
1265		lo, hi = hi, lo
1266	}
1267	require.Equal(t, 0, lo, "anchor stays content-stable at the row under the pointer at press time")
1268	require.Equal(t, 1, hi, "the press-owned immutable document keeps the mapped endpoint stable")
1269}
1270
1271func TestMouseSplitReportPreservesOrder(t *testing.T) {
1272	writes := make(chan []byte, 2)
1273	p, _ := newBlockingPTYWithWrites(t, writes)
1274	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1275	sess.tabs[0].focusedPane().screen.Write([]byte("live"))
1276
1277	d.handleInput(sess, ac, []byte("\x1b[<64;"))
1278	d.handleInput(sess, ac, []byte("1;1Mq"))
1279
1280	mustOutputData(t, sends)
1281	mustOutputData(t, sends)
1282	require.Nil(t, ac.overlays.copyMode)
1283	select {
1284	case got := <-writes:
1285		t.Fatalf("split mouse/copy bytes forwarded to PTY: %q", got)
1286	default:
1287	}
1288}
1289
1290func TestMouseWheelOverUnfocusedPaneDoesNotFocusAndForwardsChildMouse(t *testing.T) {
1291	p1 := portsmocks.NewMockPTY(t)
1292	p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
1293	p2 := portsmocks.NewMockPTY(t)
1294	p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
1295	p2.EXPECT().Write([]byte("\x1b[<64;1;1M")).Return(len("\x1b[<64;1;1M"), nil).Once()
1296	d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
1297	d.procComm = nil
1298	tb := testAttachmentTab(sess)
1299	p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5})
1300	p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
1301	tb.mu.Lock()
1302	tb.size = domain.Size{Cols: 41, Rows: 5}
1303	tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
1304	tb.tree.Focus = "pane-1"
1305	tb.panes["pane-2"] = p2pane
1306	tb.mu.Unlock()
1307
1308	d.handleInput(sess, ac, []byte("\x1b[<64;22;2M"))
1309
1310	require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)
1311}
1312
1313func TestRejectedLeftReleaseInvalidatesFreshPointerBeforeMotion(t *testing.T) {
1314	for _, tc := range []struct {
1315		name   string
1316		setup  func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, press *[]byte) []byte
1317		reject []byte
1318	}{
1319		{
1320			name: "split divider",
1321			setup: func(t *testing.T, d *Daemon, sess *session, _ *attachedClient, _ *[]byte) []byte {
1322				tb := testAttachmentTab(sess)
1323				p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
1324				tb.mu.Lock()
1325				tb.size = domain.Size{Cols: 41, Rows: 10}
1326				tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
1327				tb.panes["pane-2"] = p2
1328				tb.mu.Unlock()
1329				return []byte("\x1b[<0;21;2m")
1330			},
1331		},
1332		{
1333			name: "split title bar",
1334			setup: func(t *testing.T, d *Daemon, sess *session, _ *attachedClient, _ *[]byte) []byte {
1335				tb := testAttachmentTab(sess)
1336				p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
1337				tb.mu.Lock()
1338				tb.size = domain.Size{Cols: 41, Rows: 10}
1339				tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
1340				tb.panes["pane-2"] = p2
1341				tb.mu.Unlock()
1342				return []byte("\x1b[<0;1;1m")
1343			},
1344		},
1345		{
1346			name: "floating exterior",
1347			setup: func(t *testing.T, d *Daemon, sess *session, _ *attachedClient, press *[]byte) []byte {
1348				tb := testAttachmentTab(sess)
1349				floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5})
1350				installTestFloating(tb, floating, true)
1351				tb.mu.Lock()
1352				_, geometry, visible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())
1353				tb.mu.Unlock()
1354				require.True(t, visible)
1355				*press = fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+2)
1356				return []byte("\x1b[<0;1;1m")
1357			},
1358		},
1359	} {
1360		t.Run(tc.name, func(t *testing.T) {
1361			p, release := newBlockingPTY(t)
1362			defer release()
1363			d, sess, ac, _ := newManualSessionWithPTYs(t, p)
1364			press := []byte("\x1b[<0;1;2M")
1365			reject := tc.setup(t, d, sess, ac, &press)
1366
1367			// A content press is fresh state. The rejected release must clear it
1368			// before a following motion has an opportunity to publish copy mode.
1369			d.handleInput(sess, ac, press)
1370			ac.overlays.copyMu.Lock()
1371			require.True(t, ac.overlays.copyPointer.valid)
1372			ac.overlays.copyMu.Unlock()
1373
1374			d.handleInput(sess, ac, reject)
1375			d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
1376
1377			ac.overlays.copyMu.Lock()
1378			require.False(t, ac.overlays.copyPointer.valid)
1379			require.Nil(t, ac.overlays.copyMode, "rejected release followed by motion must not publish")
1380			ac.overlays.copyMu.Unlock()
1381		})
1382	}
1383}
1384
1385func TestRejectedLeftPressAndInactiveReleaseInvalidateStalePointer(t *testing.T) {
1386	p, release := newBlockingPTY(t)
1387	defer release()
1388	d, sess, ac, _ := newManualSessionWithPTYs(t, p)
1389
1390	d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
1391	d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) // a new rejected title-bar press
1392	ac.overlays.copyMu.Lock()
1393	require.False(t, ac.overlays.copyPointer.valid)
1394	ac.overlays.copyMu.Unlock()
1395
1396	d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
1397	ac.setSession(nil)
1398	d.handleMouse(ac, mouse.Event{Button: mouse.Left, Type: mouse.Release})
1399	ac.overlays.copyMu.Lock()
1400	require.False(t, ac.overlays.copyPointer.valid)
1401	ac.overlays.copyMu.Unlock()
1402}
1403
1404func TestPressAtFormerStackTitleRowIsTreatedAsExpandedContent(t *testing.T) {
1405	p, release := newBlockingPTY(t)
1406	defer release()
1407	d, sess, ac, _ := newManualSessionWithPTYs(t, p)
1408	tb := testAttachmentTab(sess)
1409	p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 9})
1410	p3 := newPane("pane-3", nil, domain.Size{Cols: 20, Rows: 1})
1411	tb.mu.Lock()
1412	tb.size = domain.Size{Cols: 41, Rows: 10}
1413	tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
1414		layout.NewLeaf("pane-1"),
1415		{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("pane-2"), layout.NewLeaf("pane-3")}, Expanded: "pane-2"},
1416	}}
1417	tb.tree.Focus = "pane-2"
1418	tb.panes["pane-2"] = p2
1419	tb.panes["pane-3"] = p3
1420	tb.mu.Unlock()
1421
1422	// pane-2 is the stack's expanded member, so it draws no title bar and its
1423	// content now starts at the row that used to hold that title bar (row 0 of
1424	// the stack, screen row 1, i.e. cy=2 in 1-based SGR coordinates). A content
1425	// press gives pane-2 a fresh candidate.
1426	d.handleInput(sess, ac, []byte("\x1b[<0;22;3M"))
1427	ac.overlays.copyMu.Lock()
1428	require.True(t, ac.overlays.copyPointer.valid)
1429	epoch := ac.overlays.copyPointerEpoch
1430	ac.overlays.copyMu.Unlock()
1431
1432	// Pressing the row that used to be the title bar must be routed as an
1433	// ordinary content press on the already-focused pane-2, not a title-bar
1434	// hit: it replaces the candidate with a fresh one rather than leaving a
1435	// stale/invalidated pointer, and focus is unchanged since pane-2 was
1436	// already focused.
1437	d.handleInput(sess, ac, []byte("\x1b[<0;22;2M"))
1438	ac.overlays.copyMu.Lock()
1439	require.True(t, ac.overlays.copyPointer.valid)
1440	require.Greater(t, ac.overlays.copyPointerEpoch, epoch)
1441	ac.overlays.copyMu.Unlock()
1442	require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus, "press on already-focused pane's content must preserve focus")
1443
1444	// A drag from that press is a legitimate content selection now (there is
1445	// no title bar to have blocked it), so it publishes copy mode.
1446	d.handleInput(sess, ac, []byte("\x1b[<32;22;4M"))
1447	ac.overlays.copyMu.Lock()
1448	require.NotNil(t, ac.overlays.copyMode, "drag from a content press must publish copy mode")
1449	ac.overlays.copyMu.Unlock()
1450}
1451
1452func TestMouseDividerAndTitleBarDoNotForwardBogusCoordinates(t *testing.T) {
1453	cases := []struct {
1454		name string
1455		raw  []byte
1456		root *layout.Node
1457		size domain.Size
1458	}{
1459		{
1460			name: "divider",
1461			raw:  []byte("\x1b[<0;21;2M"),
1462			root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}},
1463			size: domain.Size{Cols: 41, Rows: 5},
1464		},
1465		{
1466			name: "expanded title bar",
1467			raw:  []byte("\x1b[<0;1;1M"),
1468			root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}, Expanded: "pane-1"},
1469			size: domain.Size{Cols: 20, Rows: 5},
1470		},
1471	}
1472	for _, tc := range cases {
1473		t.Run(tc.name, func(t *testing.T) {
1474			p1 := portsmocks.NewMockPTY(t)
1475			p1.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
1476			p2 := portsmocks.NewMockPTY(t)
1477			p2.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
1478			d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
1479			d.procComm = nil
1480			tb := testAttachmentTab(sess)
1481			tb.focusedPane().screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
1482			tb.mu.Lock()
1483			tb.size = tc.size
1484			tb.tree.Root = tc.root
1485			tb.tree.Focus = "pane-1"
1486			tb.panes["pane-2"] = newPane("pane-2", p2, tc.size)
1487			tb.mu.Unlock()
1488
1489			d.handleInput(sess, ac, tc.raw)
1490
1491			require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)
1492		})
1493	}
1494}
1495
1496func TestCopyModeDragOutsideSplitPaneClampsToPaneContent(t *testing.T) {
1497	p1 := portsmocks.NewMockPTY(t)
1498	p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Maybe()
1499	p2 := portsmocks.NewMockPTY(t)
1500	p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Maybe()
1501	d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
1502	d.procComm = nil
1503	tb := testAttachmentTab(sess)
1504	for i := range 10 {
1505		copy(tb.focusedPane().screen.Frame.Row(i), testRow(string(rune('a'+i))))
1506	}
1507	tb.mu.Lock()
1508	tb.size = domain.Size{Cols: 41, Rows: 10}
1509	tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
1510	tb.tree.Focus = "pane-1"
1511	tb.panes["pane-2"] = newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 10})
1512	tb.mu.Unlock()
1513	d.enterCopyMode(sess, ac)
1514	mustOutputData(t, sends)
1515
1516	d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<32;1;12M"))
1517	mustOutputData(t, sends)
1518
1519	selection := ac.overlays.copyMode.Selection()
1520	require.True(t, selection.Enabled)
1521	lo, hi := selection.Anchor.Row, selection.Active.Row
1522	if lo > hi {
1523		lo, hi = hi, lo
1524	}
1525	require.Equal(t, 0, lo)
1526	require.Equal(t, 9, hi)
1527}
1528
1529func TestMouseHitTestFocusesPaneAndTranslatesSGRColumns(t *testing.T) {
1530	p1 := portsmocks.NewMockPTY(t)
1531	p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
1532	p2 := portsmocks.NewMockPTY(t)
1533	p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
1534	p2.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once()
1535	d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
1536	d.procComm = nil
1537	tb := testAttachmentTab(sess)
1538	p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5})
1539	p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
1540	tb.mu.Lock()
1541	tb.size = domain.Size{Cols: 41, Rows: 5}
1542	tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
1543	tb.tree.Focus = "pane-1"
1544	tb.panes["pane-2"] = p2pane
1545	tb.mu.Unlock()
1546
1547	d.handleInput(sess, ac, []byte("\x1b[<0;22;2M"))
1548
1549	require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
1550}
1551
1552// TestMouseGatedWhileNoticesOverlayActive guards against a click on the
1553// notifications modal falling through to pane hit-testing underneath it: the
1554// modal has no mouse handler of its own, so without the gate a click would
1555// silently retarget focus and forward SGR bytes to whichever pane sits under
1556// the modal's coordinates.
1557func TestMouseGatedWhileNoticesOverlayActive(t *testing.T) {
1558	p1 := portsmocks.NewMockPTY(t)
1559	p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
1560	p2 := portsmocks.NewMockPTY(t)
1561	p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
1562	d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
1563	d.procComm = nil
1564	tb := testAttachmentTab(sess)
1565	p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5})
1566	p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
1567	tb.mu.Lock()
1568	tb.size = domain.Size{Cols: 41, Rows: 5}
1569	tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
1570	tb.tree.Focus = "pane-1"
1571	tb.panes["pane-2"] = p2pane
1572	tb.mu.Unlock()
1573
1574	d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "m", Time: time.Unix(1, 0)})
1575	d.enterNotices(sess, ac)
1576	awaitFrame(t, sends, ports.MsgOutput)
1577	require.True(t, ac.overlays.noticesActive())
1578
1579	// Same coordinates that TestMouseHitTestFocusesPaneAndTranslatesSGRColumns
1580	// proves would otherwise focus pane-2 and forward the SGR report to it.
1581	d.handleInput(sess, ac, []byte("\x1b[<0;22;2M"))
1582
1583	require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus, "mouse must not reach pane hit-testing while the notices overlay is open")
1584	require.True(t, ac.overlays.noticesActive(), "mouse click must not close the notices overlay")
1585}
1586
1587func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) {
1588	p1 := portsmocks.NewMockPTY(t)
1589	p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 4}).Return(nil).Maybe()
1590	p2 := portsmocks.NewMockPTY(t)
1591	p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 4}).Return(nil).Maybe()
1592	d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
1593	d.procComm = nil
1594	tb := testAttachmentTab(sess)
1595	p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 3})
1596	tb.mu.Lock()
1597	tb.size = domain.Size{Cols: 20, Rows: 5}
1598	tb.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}, Expanded: "pane-1"}
1599	tb.tree.Focus = "pane-1"
1600	tb.panes["pane-2"] = p2pane
1601	tb.mu.Unlock()
1602
1603	d.handleInput(sess, ac, []byte("\x1b[<0;1;6M"))
1604
1605	require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
1606	require.Equal(t, layout.PaneID("pane-2"), tb.tree.Root.Expanded)
1607}
1608
1609func TestActiveCopyPressOnOtherPaneFocusesAndExitsBeforeFutureInput(t *testing.T) {
1610	p1, release := newBlockingPTY(t)
1611	defer release()
1612	d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
1613	tb := testAttachmentTab(sess)
1614	p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
1615	tb.mu.Lock()
1616	tb.size = domain.Size{Cols: 41, Rows: 10}
1617	tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
1618	tb.panes["pane-2"] = p2
1619	tb.mu.Unlock()
1620
1621	d.enterCopyMode(sess, ac)
1622	require.True(t, ac.overlays.copyActive())
1623	d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
1624	ac.overlays.copyMu.Lock()
1625	require.True(t, ac.overlays.copyPointer.valid)
1626	require.True(t, ac.overlays.copyClick.valid)
1627	ac.overlays.copyMu.Unlock()
1628
1629	// A press in the right split must focus it, then leave the old pane's copy
1630	// snapshot behind. The following press is therefore a fresh p2 candidate.
1631	d.handleInput(sess, ac, []byte("\x1b[<0;22;2M"))
1632	require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
1633	require.False(t, ac.overlays.copyActive())
1634	ac.overlays.copyMu.Lock()
1635	require.False(t, ac.overlays.copyPointer.valid)
1636	require.False(t, ac.overlays.copyClick.valid)
1637	ac.overlays.copyMu.Unlock()
1638
1639	d.handleInput(sess, ac, []byte("\x1b[<0;22;2M"))
1640	ac.overlays.copyMu.Lock()
1641	pointer := ac.overlays.copyPointer
1642	ac.overlays.copyMu.Unlock()
1643	require.True(t, pointer.valid)
1644	require.Same(t, p2, pointer.pane)
1645}
1646
1647func TestCopyModeReleaseMapsFinalEndpointAndInvalidatesPointer(t *testing.T) {
1648	d, sess, ac, _ := mouseCopyHarness(t, "alpha bravo")
1649
1650	// Motion starts an active drag; Release then arrives farther away without
1651	// another Motion and must still extend its final endpoint.
1652	copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<32;5;2M")
1653	copyMouseInput(d, sess, ac, "\x1b[<0;8;2m")
1654
1655	ac.overlays.copyMu.Lock()
1656	selection := ac.overlays.copyMode.Selection()
1657	pointerValid := ac.overlays.copyPointer.valid
1658	ac.overlays.copyMu.Unlock()
1659	require.True(t, selection.Enabled)
1660	require.Equal(t, scopy.Pos{Row: 0, Col: 0}, selection.Anchor)
1661	require.Equal(t, scopy.Pos{Row: 0, Col: 7}, selection.Active)
1662	require.False(t, pointerValid)
1663}
1664
1665func TestCopySearchReleaseMapsFinalEndpointAndInvalidatesPointer(t *testing.T) {
1666	for _, tc := range []struct {
1667		name    string
1668		release string
1669		wantCol int
1670	}{
1671		{name: "in pane", release: "\x1b[<0;8;2m", wantCol: 7},
1672		{name: "outside pane clamps", release: "\x1b[<0;99;2m", wantCol: 79},
1673	} {
1674		t.Run(tc.name, func(t *testing.T) {
1675			d, sess, ac, _ := mouseCopyHarness(t, "alpha bravo")
1676
1677			copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<32;5;2M")
1678			copyMouseInput(d, sess, ac, "/")
1679			require.True(t, ac.overlays.copySearchActive())
1680
1681			// Search owns ordinary mouse input, but the release still completes
1682			// the active drag against its press-owned geometry.
1683			copyMouseInput(d, sess, ac, tc.release)
1684			copyMouseInput(d, sess, ac, "\x03")
1685
1686			ac.overlays.copyMu.Lock()
1687			selection := ac.overlays.copyMode.Selection()
1688			pointerValid := ac.overlays.copyPointer.valid
1689			ac.overlays.copyMu.Unlock()
1690			require.True(t, selection.Enabled)
1691			require.Equal(t, scopy.Pos{Row: 0, Col: 0}, selection.Anchor)
1692			require.Equal(t, scopy.Pos{Row: 0, Col: tc.wantCol}, selection.Active)
1693			require.False(t, pointerValid)
1694			require.False(t, ac.overlays.copySearchActive())
1695		})
1696	}
1697}
1698
1699// TestActiveCopyMouseIgnoresStalePointerResets makes the mapping/reset race
1700// deterministic. Each path captures a copy-input snapshot, yields while that
1701// snapshot is outside copyMu, then starts a new pointer. The old event must
1702// never invalidate the new interaction.
1703func TestActiveCopyMouseIgnoresStalePointerResets(t *testing.T) {
1704	for _, tc := range []struct {
1705		name    string
1706		prepare func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) mouse.Event
1707	}{
1708		{
1709			name: "press-owned release rejects mapping",
1710			prepare: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) mouse.Event {
1711				t.Helper()
1712				p := testAttachmentTab(sess).focusedPane()
1713				tb := testAttachmentTab(sess)
1714				tb.mu.Lock()
1715				geometry, ok := hitTestCopyMouseGeometryLocked(tb, d.currentFloatingConfig(), 1, 2)
1716				tb.mu.Unlock()
1717				require.True(t, ok)
1718				ac.overlays.copyMu.Lock()
1719				ac.overlays.beginCopyPointerLocked(copyPointerState{pane: p, document: ac.overlays.copyDocument, geometry: geometry})
1720				ac.overlays.copyMu.Unlock()
1721				return mouse.Event{Button: mouse.Left, Type: mouse.Release, Col: geometry.content.X - 1, Row: geometry.content.Y}
1722			},
1723		},
1724		{
1725			name: "current geometry disappears",
1726			prepare: func(t *testing.T, _ *Daemon, sess *session, _ *attachedClient) mouse.Event {
1727				t.Helper()
1728				tb := testAttachmentTab(sess)
1729				tb.mu.Lock()
1730				tb.tree = nil
1731				tb.mu.Unlock()
1732				return mouse.Event{Button: mouse.Left, Type: mouse.Press, Col: 1, Row: 2}
1733			},
1734		},
1735		{
1736			name: "fresh press rejects document mapping",
1737			prepare: func(t *testing.T, _ *Daemon, sess *session, _ *attachedClient) mouse.Event {
1738				t.Helper()
1739				tb := testAttachmentTab(sess)
1740				tb.mu.Lock()
1741				tb.size.Rows = 40 // valid layout coordinate, outside the old document.
1742				tb.mu.Unlock()
1743				return mouse.Event{Button: mouse.Left, Type: mouse.Press, Col: 1, Row: 30}
1744			},
1745		},
1746	} {
1747		t.Run(tc.name, func(t *testing.T) {
1748			p, release := newBlockingPTY(t)
1749			defer release()
1750			d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1751			d.enterCopyMode(sess, ac)
1752			mustOutputData(t, sends)
1753			ev := tc.prepare(t, d, sess, ac)
1754
1755			var want copyPointerState
1756			d.beforeCopyMouseMap = func() {
1757				ac.overlays.copyMu.Lock()
1758				ac.overlays.beginCopyPointerLocked(copyPointerState{
1759					pane:     ac.overlays.copyPane,
1760					document: ac.overlays.copyDocument,
1761					press:    scopy.Pos{Row: 7, Col: 7},
1762				})
1763				want = ac.overlays.copyPointer
1764				ac.overlays.copyMu.Unlock()
1765			}
1766			defer func() { d.beforeCopyMouseMap = nil }()
1767
1768			d.handleActiveCopyMouse(sess, ac, testAttachmentTab(sess), ev)
1769
1770			ac.overlays.copyMu.Lock()
1771			defer ac.overlays.copyMu.Unlock()
1772			require.True(t, want.valid, "test seam must start the newer pointer")
1773			require.True(t, ac.overlays.copyPointer.valid)
1774			require.Equal(t, want.epoch, ac.overlays.copyPointerEpoch)
1775			require.Equal(t, want.epoch, ac.overlays.copyPointer.epoch)
1776			require.Same(t, want.pane, ac.overlays.copyPointer.pane)
1777			require.Same(t, want.document, ac.overlays.copyPointer.document)
1778			require.Equal(t, want.press, ac.overlays.copyPointer.press)
1779		})
1780	}
1781}
1782
1783func TestActiveCopyMouseRejectsViewportChangeAfterMappingSnapshot(t *testing.T) {
1784	p, release := newBlockingPTY(t)
1785	defer release()
1786	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1787	pane := testAttachmentTab(sess).focusedPane()
1788	for range 8 {
1789		appendHistoryRow(t, pane.history, testRow("history"))
1790	}
1791	d.enterCopyMode(sess, ac)
1792	mustOutputData(t, sends)
1793
1794	ac.overlays.copyMu.Lock()
1795	before := ac.overlays.copyMode.Cursor()
1796	ac.overlays.copyMu.Unlock()
1797	d.beforeCopyMouseMap = func() {
1798		ac.overlays.copyMu.Lock()
1799		ac.overlays.copyMode.ViewportTop--
1800		ac.overlays.copyMu.Unlock()
1801	}
1802	defer func() { d.beforeCopyMouseMap = nil }()
1803
1804	// The seam changes ViewportTop after the map snapshot but before copyMouse
1805	// revalidates it. The stale mapped row must not move the selection cursor.
1806	d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
1807	ac.overlays.copyMu.Lock()
1808	require.Equal(t, before, ac.overlays.copyMode.Cursor())
1809	require.False(t, ac.overlays.copyPointer.valid)
1810	ac.overlays.copyMu.Unlock()
1811}
1812
1813func TestFreshCopyPressUsesFrameAbsoluteHitTestGeometry(t *testing.T) {
1814	for _, tc := range []struct {
1815		name  string
1816		setup func(t *testing.T, d *Daemon, sess *session) (*pane, mouse.Event)
1817	}{
1818		{
1819			name: "mono",
1820			setup: func(_ *testing.T, _ *Daemon, sess *session) (*pane, mouse.Event) {
1821				return testAttachmentTab(sess).focusedPane(), mouse.Event{Button: mouse.Left, Type: mouse.Press, Col: 1, Row: 1}
1822			},
1823		},
1824		{
1825			name: "split",
1826			setup: func(_ *testing.T, _ *Daemon, sess *session) (*pane, mouse.Event) {
1827				tb := testAttachmentTab(sess)
1828				p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
1829				tb.mu.Lock()
1830				tb.size = domain.Size{Cols: 41, Rows: 10}
1831				tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
1832				tb.panes["pane-2"] = p2
1833				tb.mu.Unlock()
1834				return p2, mouse.Event{Button: mouse.Left, Type: mouse.Press, Col: 22, Row: 3}
1835			},
1836		},
1837		{
1838			name: "floating",
1839			setup: func(t *testing.T, d *Daemon, sess *session) (*pane, mouse.Event) {
1840				p := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5})
1841				installTestFloating(testAttachmentTab(sess), p, true)
1842				tb := testAttachmentTab(sess)
1843				tb.mu.Lock()
1844				_, geometry, visible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())
1845				tb.mu.Unlock()
1846				require.True(t, visible)
1847				return p, mouse.Event{Button: mouse.Left, Type: mouse.Press, Col: geometry.Inner.X + 1, Row: geometry.Inner.Y + 2}
1848			},
1849		},
1850	} {
1851		t.Run(tc.name, func(t *testing.T) {
1852			p, release := newBlockingPTY(t)
1853			defer release()
1854			d, sess, ac, _ := newManualSessionWithPTYs(t, p)
1855			wantPane, ev := tc.setup(t, d, sess)
1856			tb := testAttachmentTab(sess)
1857			tb.mu.Lock()
1858			wantGeometry, ok := hitTestCopyMouseGeometryLocked(tb, d.currentFloatingConfig(), ev.Col, ev.Row)
1859			tb.mu.Unlock()
1860			require.True(t, ok)
1861			require.Same(t, wantPane, wantGeometry.pane)
1862
1863			d.handleMouse(ac, ev)
1864			ac.overlays.copyMu.Lock()
1865			pointer := ac.overlays.copyPointer
1866			ac.overlays.copyMu.Unlock()
1867			require.True(t, pointer.valid)
1868			require.Same(t, wantPane, pointer.pane)
1869			require.Equal(t, wantGeometry, pointer.geometry)
1870			mapped, ok := mapCopyMouse(ev, wantGeometry, max(pointer.document.Len()-pointer.document.Height(), 0), pointer.document, false)
1871			require.True(t, ok)
1872			require.Equal(t, mapped.pos, pointer.press)
1873		})
1874	}
1875}

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