detached e8e16e8

Merge pull request #9 from bnema/feature/split-stacked-panes

feat(panes): add split and stacked panes
commit
e8e16e822c4091073b45d8a1313c6737012754e1
author
bnema · · 2026-07-04 09:55 UTC
committer
GitHub · · 2026-07-04 09:55 UTC
merge of
56bc502 + 6389371 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
48 files · +5087 -482
M .mockery.yml +7 -0
@@ -28,3 +28,10 @@ packages:
2828 dir: internal/ports/mocks
2929 filename: mocks.go
3030 pkgname: portsmocks
31+ github.com/bnema/vev/internal/usecase/command:
32+ interfaces:
33+ Context:
34+ config:
35+ dir: internal/usecase/command
36+ filename: context_mock_test.go
37+ pkgname: command
M internal/adapters/pty/pty_linux.go +20 -0
@@ -161,6 +161,26 @@ func (p *linuxPTY) Pid() int {
161161 return p.cmd.Process.Pid
162162 }
163163
164+// ForegroundPgid reports the process group currently in the foreground for the
165+// pty's controlling terminal.
166+func (p *linuxPTY) ForegroundPgid() (int, error) {
167+ rc, err := p.master.SyscallConn()
168+ if err != nil {
169+ return 0, fmt.Errorf("pty: foreground pgid: %w", err)
170+ }
171+ var pgid int
172+ var ioctlErr error
173+ if err := rc.Control(func(fd uintptr) {
174+ pgid, ioctlErr = unix.IoctlGetInt(int(fd), unix.TIOCGPGRP)
175+ }); err != nil {
176+ return 0, fmt.Errorf("pty: foreground pgid: %w", err)
177+ }
178+ if ioctlErr != nil {
179+ return 0, fmt.Errorf("pty: foreground pgid: %w", ioctlErr)
180+ }
181+ return pgid, nil
182+}
183+
164184 // Close terminates the child and releases the master. It signals the child's
165185 // process group with SIGHUP, waits up to killGracePeriod for it to exit, then
166186 // escalates to SIGKILL. The child is reaped (via cmd.Wait) to avoid a zombie,
M internal/adapters/pty/pty_linux_test.go +14 -0
@@ -98,6 +98,20 @@ func TestOpen_EchoRoundtrip(t *testing.T) {
9898 require.NoError(t, p.Close())
9999 }
100100
101+func TestForegroundPgid(t *testing.T) {
102+ if testing.Short() {
103+ t.Skip("skipping pty integration test in -short mode")
104+ }
105+ f := newFactory()
106+ p, err := f.Open("sh", []string{"-c", "sleep 2"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
107+ require.NoError(t, err)
108+ t.Cleanup(func() { _ = p.Close() })
109+
110+ pgid, err := p.ForegroundPgid()
111+ require.NoError(t, err)
112+ require.Greater(t, pgid, 0)
113+}
114+
101115 func TestResize_SttySize(t *testing.T) {
102116 if testing.Short() {
103117 t.Skip("skipping pty integration test in -short mode")
M internal/platform/proc_linux.go +17 -0
@@ -3,9 +3,26 @@ package platform
33 import (
44 "fmt"
55 "os"
6+ "strings"
67 )
78
89 // ProcessCwd returns the current working directory for a Linux process.
910 func ProcessCwd(pid int) (string, error) {
1011 return os.Readlink(fmt.Sprintf("/proc/%d/cwd", pid))
1112 }
13+
14+// ProcessComm returns the command name from /proc/<pid>/comm.
15+func ProcessComm(pid int) (string, error) {
16+ if pid <= 0 {
17+ return "", fmt.Errorf("process comm: invalid pid %d", pid)
18+ }
19+ data, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid))
20+ if err != nil {
21+ return "", fmt.Errorf("process comm: read pid %d: %w", pid, err)
22+ }
23+ comm := strings.TrimSpace(string(data))
24+ if comm == "" {
25+ return "", fmt.Errorf("process comm: empty comm for pid %d", pid)
26+ }
27+ return comm, nil
28+}
A internal/platform/proc_linux_test.go +23 -0
@@ -0,0 +1,23 @@
1+//go:build linux
2+
3+package platform
4+
5+import (
6+ "os"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestProcessComm_CurrentProcess(t *testing.T) {
13+ comm, err := ProcessComm(os.Getpid())
14+ require.NoError(t, err)
15+ require.NotEmpty(t, comm)
16+ require.NotContains(t, comm, "\n")
17+}
18+
19+func TestProcessComm_InvalidPID(t *testing.T) {
20+ comm, err := ProcessComm(0)
21+ require.Error(t, err)
22+ require.Empty(t, comm)
23+}
M internal/ports/mocks/mocks.go +53 -0
@@ -84,6 +84,59 @@ func (_c *MockPTY_Close_Call) RunAndReturn(run func() error) *MockPTY_Close_Call
8484 return _c
8585 }
8686
87+// ForegroundPgid provides a mock function for the type MockPTY
88+func (_mock *MockPTY) ForegroundPgid() (int, error) {
89+ ret := _mock.Called()
90+
91+ if len(ret) == 0 {
92+ panic("no return value specified for ForegroundPgid")
93+ }
94+
95+ var r0 int
96+ var r1 error
97+ if returnFunc, ok := ret.Get(0).(func() (int, error)); ok {
98+ return returnFunc()
99+ }
100+ if returnFunc, ok := ret.Get(0).(func() int); ok {
101+ r0 = returnFunc()
102+ } else {
103+ r0 = ret.Get(0).(int)
104+ }
105+ if returnFunc, ok := ret.Get(1).(func() error); ok {
106+ r1 = returnFunc()
107+ } else {
108+ r1 = ret.Error(1)
109+ }
110+ return r0, r1
111+}
112+
113+// MockPTY_ForegroundPgid_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ForegroundPgid'
114+type MockPTY_ForegroundPgid_Call struct {
115+ *mock.Call
116+}
117+
118+// ForegroundPgid is a helper method to define mock.On call
119+func (_e *MockPTY_Expecter) ForegroundPgid() *MockPTY_ForegroundPgid_Call {
120+ return &MockPTY_ForegroundPgid_Call{Call: _e.mock.On("ForegroundPgid")}
121+}
122+
123+func (_c *MockPTY_ForegroundPgid_Call) Run(run func()) *MockPTY_ForegroundPgid_Call {
124+ _c.Call.Run(func(args mock.Arguments) {
125+ run()
126+ })
127+ return _c
128+}
129+
130+func (_c *MockPTY_ForegroundPgid_Call) Return(n int, err error) *MockPTY_ForegroundPgid_Call {
131+ _c.Call.Return(n, err)
132+ return _c
133+}
134+
135+func (_c *MockPTY_ForegroundPgid_Call) RunAndReturn(run func() (int, error)) *MockPTY_ForegroundPgid_Call {
136+ _c.Call.Return(run)
137+ return _c
138+}
139+
87140 // Pid provides a mock function for the type MockPTY
88141 func (_mock *MockPTY) Pid() int {
89142 ret := _mock.Called()
M internal/ports/ports.go +1 -0
@@ -12,6 +12,7 @@ type PTY interface {
1212 io.ReadWriteCloser // Read = child output, Write = child input
1313 Resize(sz domain.Size) error
1414 Pid() int
15+ ForegroundPgid() (int, error)
1516 }
1617
1718 // PTYFactory creates PTYs by spawning a command attached to a new pseudo-terminal.
M internal/usecase/command/command.go +11 -0
@@ -5,6 +5,17 @@ type Context interface {
55 CreateTab() error
66 CreateSession() error
77 CloseTab() error
8+ SplitRight() error
9+ SplitLeft() error
10+ SplitUp() error
11+ SplitDown() error
12+ StackPane() error
13+ ToggleStack() error
14+ ClosePane() error
15+ FocusPaneLeft() error
16+ FocusPaneRight() error
17+ FocusPaneUp() error
18+ FocusPaneDown() error
819 NextTab() error
920 PrevTab() error
1021 Detach() error
A internal/usecase/command/context_mock_test.go +916 -0
@@ -0,0 +1,916 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package command
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// NewMockContext creates a new instance of MockContext. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func NewMockContext(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *MockContext {
17+ mock := &MockContext{}
18+ mock.Mock.Test(t)
19+
20+ t.Cleanup(func() { mock.AssertExpectations(t) })
21+
22+ return mock
23+}
24+
25+// MockContext is an autogenerated mock type for the Context type
26+type MockContext struct {
27+ mock.Mock
28+}
29+
30+type MockContext_Expecter struct {
31+ mock *mock.Mock
32+}
33+
34+func (_m *MockContext) EXPECT() *MockContext_Expecter {
35+ return &MockContext_Expecter{mock: &_m.Mock}
36+}
37+
38+// ClosePane provides a mock function for the type MockContext
39+func (_mock *MockContext) ClosePane() error {
40+ ret := _mock.Called()
41+
42+ if len(ret) == 0 {
43+ panic("no return value specified for ClosePane")
44+ }
45+
46+ var r0 error
47+ if returnFunc, ok := ret.Get(0).(func() error); ok {
48+ r0 = returnFunc()
49+ } else {
50+ r0 = ret.Error(0)
51+ }
52+ return r0
53+}
54+
55+// MockContext_ClosePane_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClosePane'
56+type MockContext_ClosePane_Call struct {
57+ *mock.Call
58+}
59+
60+// ClosePane is a helper method to define mock.On call
61+func (_e *MockContext_Expecter) ClosePane() *MockContext_ClosePane_Call {
62+ return &MockContext_ClosePane_Call{Call: _e.mock.On("ClosePane")}
63+}
64+
65+func (_c *MockContext_ClosePane_Call) Run(run func()) *MockContext_ClosePane_Call {
66+ _c.Call.Run(func(args mock.Arguments) {
67+ run()
68+ })
69+ return _c
70+}
71+
72+func (_c *MockContext_ClosePane_Call) Return(err error) *MockContext_ClosePane_Call {
73+ _c.Call.Return(err)
74+ return _c
75+}
76+
77+func (_c *MockContext_ClosePane_Call) RunAndReturn(run func() error) *MockContext_ClosePane_Call {
78+ _c.Call.Return(run)
79+ return _c
80+}
81+
82+// CloseTab provides a mock function for the type MockContext
83+func (_mock *MockContext) CloseTab() error {
84+ ret := _mock.Called()
85+
86+ if len(ret) == 0 {
87+ panic("no return value specified for CloseTab")
88+ }
89+
90+ var r0 error
91+ if returnFunc, ok := ret.Get(0).(func() error); ok {
92+ r0 = returnFunc()
93+ } else {
94+ r0 = ret.Error(0)
95+ }
96+ return r0
97+}
98+
99+// MockContext_CloseTab_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CloseTab'
100+type MockContext_CloseTab_Call struct {
101+ *mock.Call
102+}
103+
104+// CloseTab is a helper method to define mock.On call
105+func (_e *MockContext_Expecter) CloseTab() *MockContext_CloseTab_Call {
106+ return &MockContext_CloseTab_Call{Call: _e.mock.On("CloseTab")}
107+}
108+
109+func (_c *MockContext_CloseTab_Call) Run(run func()) *MockContext_CloseTab_Call {
110+ _c.Call.Run(func(args mock.Arguments) {
111+ run()
112+ })
113+ return _c
114+}
115+
116+func (_c *MockContext_CloseTab_Call) Return(err error) *MockContext_CloseTab_Call {
117+ _c.Call.Return(err)
118+ return _c
119+}
120+
121+func (_c *MockContext_CloseTab_Call) RunAndReturn(run func() error) *MockContext_CloseTab_Call {
122+ _c.Call.Return(run)
123+ return _c
124+}
125+
126+// CreateSession provides a mock function for the type MockContext
127+func (_mock *MockContext) CreateSession() error {
128+ ret := _mock.Called()
129+
130+ if len(ret) == 0 {
131+ panic("no return value specified for CreateSession")
132+ }
133+
134+ var r0 error
135+ if returnFunc, ok := ret.Get(0).(func() error); ok {
136+ r0 = returnFunc()
137+ } else {
138+ r0 = ret.Error(0)
139+ }
140+ return r0
141+}
142+
143+// MockContext_CreateSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateSession'
144+type MockContext_CreateSession_Call struct {
145+ *mock.Call
146+}
147+
148+// CreateSession is a helper method to define mock.On call
149+func (_e *MockContext_Expecter) CreateSession() *MockContext_CreateSession_Call {
150+ return &MockContext_CreateSession_Call{Call: _e.mock.On("CreateSession")}
151+}
152+
153+func (_c *MockContext_CreateSession_Call) Run(run func()) *MockContext_CreateSession_Call {
154+ _c.Call.Run(func(args mock.Arguments) {
155+ run()
156+ })
157+ return _c
158+}
159+
160+func (_c *MockContext_CreateSession_Call) Return(err error) *MockContext_CreateSession_Call {
161+ _c.Call.Return(err)
162+ return _c
163+}
164+
165+func (_c *MockContext_CreateSession_Call) RunAndReturn(run func() error) *MockContext_CreateSession_Call {
166+ _c.Call.Return(run)
167+ return _c
168+}
169+
170+// CreateTab provides a mock function for the type MockContext
171+func (_mock *MockContext) CreateTab() error {
172+ ret := _mock.Called()
173+
174+ if len(ret) == 0 {
175+ panic("no return value specified for CreateTab")
176+ }
177+
178+ var r0 error
179+ if returnFunc, ok := ret.Get(0).(func() error); ok {
180+ r0 = returnFunc()
181+ } else {
182+ r0 = ret.Error(0)
183+ }
184+ return r0
185+}
186+
187+// MockContext_CreateTab_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateTab'
188+type MockContext_CreateTab_Call struct {
189+ *mock.Call
190+}
191+
192+// CreateTab is a helper method to define mock.On call
193+func (_e *MockContext_Expecter) CreateTab() *MockContext_CreateTab_Call {
194+ return &MockContext_CreateTab_Call{Call: _e.mock.On("CreateTab")}
195+}
196+
197+func (_c *MockContext_CreateTab_Call) Run(run func()) *MockContext_CreateTab_Call {
198+ _c.Call.Run(func(args mock.Arguments) {
199+ run()
200+ })
201+ return _c
202+}
203+
204+func (_c *MockContext_CreateTab_Call) Return(err error) *MockContext_CreateTab_Call {
205+ _c.Call.Return(err)
206+ return _c
207+}
208+
209+func (_c *MockContext_CreateTab_Call) RunAndReturn(run func() error) *MockContext_CreateTab_Call {
210+ _c.Call.Return(run)
211+ return _c
212+}
213+
214+// Detach provides a mock function for the type MockContext
215+func (_mock *MockContext) Detach() error {
216+ ret := _mock.Called()
217+
218+ if len(ret) == 0 {
219+ panic("no return value specified for Detach")
220+ }
221+
222+ var r0 error
223+ if returnFunc, ok := ret.Get(0).(func() error); ok {
224+ r0 = returnFunc()
225+ } else {
226+ r0 = ret.Error(0)
227+ }
228+ return r0
229+}
230+
231+// MockContext_Detach_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Detach'
232+type MockContext_Detach_Call struct {
233+ *mock.Call
234+}
235+
236+// Detach is a helper method to define mock.On call
237+func (_e *MockContext_Expecter) Detach() *MockContext_Detach_Call {
238+ return &MockContext_Detach_Call{Call: _e.mock.On("Detach")}
239+}
240+
241+func (_c *MockContext_Detach_Call) Run(run func()) *MockContext_Detach_Call {
242+ _c.Call.Run(func(args mock.Arguments) {
243+ run()
244+ })
245+ return _c
246+}
247+
248+func (_c *MockContext_Detach_Call) Return(err error) *MockContext_Detach_Call {
249+ _c.Call.Return(err)
250+ return _c
251+}
252+
253+func (_c *MockContext_Detach_Call) RunAndReturn(run func() error) *MockContext_Detach_Call {
254+ _c.Call.Return(run)
255+ return _c
256+}
257+
258+// EnterVisualMode provides a mock function for the type MockContext
259+func (_mock *MockContext) EnterVisualMode() error {
260+ ret := _mock.Called()
261+
262+ if len(ret) == 0 {
263+ panic("no return value specified for EnterVisualMode")
264+ }
265+
266+ var r0 error
267+ if returnFunc, ok := ret.Get(0).(func() error); ok {
268+ r0 = returnFunc()
269+ } else {
270+ r0 = ret.Error(0)
271+ }
272+ return r0
273+}
274+
275+// MockContext_EnterVisualMode_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnterVisualMode'
276+type MockContext_EnterVisualMode_Call struct {
277+ *mock.Call
278+}
279+
280+// EnterVisualMode is a helper method to define mock.On call
281+func (_e *MockContext_Expecter) EnterVisualMode() *MockContext_EnterVisualMode_Call {
282+ return &MockContext_EnterVisualMode_Call{Call: _e.mock.On("EnterVisualMode")}
283+}
284+
285+func (_c *MockContext_EnterVisualMode_Call) Run(run func()) *MockContext_EnterVisualMode_Call {
286+ _c.Call.Run(func(args mock.Arguments) {
287+ run()
288+ })
289+ return _c
290+}
291+
292+func (_c *MockContext_EnterVisualMode_Call) Return(err error) *MockContext_EnterVisualMode_Call {
293+ _c.Call.Return(err)
294+ return _c
295+}
296+
297+func (_c *MockContext_EnterVisualMode_Call) RunAndReturn(run func() error) *MockContext_EnterVisualMode_Call {
298+ _c.Call.Return(run)
299+ return _c
300+}
301+
302+// FocusPaneDown provides a mock function for the type MockContext
303+func (_mock *MockContext) FocusPaneDown() error {
304+ ret := _mock.Called()
305+
306+ if len(ret) == 0 {
307+ panic("no return value specified for FocusPaneDown")
308+ }
309+
310+ var r0 error
311+ if returnFunc, ok := ret.Get(0).(func() error); ok {
312+ r0 = returnFunc()
313+ } else {
314+ r0 = ret.Error(0)
315+ }
316+ return r0
317+}
318+
319+// MockContext_FocusPaneDown_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FocusPaneDown'
320+type MockContext_FocusPaneDown_Call struct {
321+ *mock.Call
322+}
323+
324+// FocusPaneDown is a helper method to define mock.On call
325+func (_e *MockContext_Expecter) FocusPaneDown() *MockContext_FocusPaneDown_Call {
326+ return &MockContext_FocusPaneDown_Call{Call: _e.mock.On("FocusPaneDown")}
327+}
328+
329+func (_c *MockContext_FocusPaneDown_Call) Run(run func()) *MockContext_FocusPaneDown_Call {
330+ _c.Call.Run(func(args mock.Arguments) {
331+ run()
332+ })
333+ return _c
334+}
335+
336+func (_c *MockContext_FocusPaneDown_Call) Return(err error) *MockContext_FocusPaneDown_Call {
337+ _c.Call.Return(err)
338+ return _c
339+}
340+
341+func (_c *MockContext_FocusPaneDown_Call) RunAndReturn(run func() error) *MockContext_FocusPaneDown_Call {
342+ _c.Call.Return(run)
343+ return _c
344+}
345+
346+// FocusPaneLeft provides a mock function for the type MockContext
347+func (_mock *MockContext) FocusPaneLeft() error {
348+ ret := _mock.Called()
349+
350+ if len(ret) == 0 {
351+ panic("no return value specified for FocusPaneLeft")
352+ }
353+
354+ var r0 error
355+ if returnFunc, ok := ret.Get(0).(func() error); ok {
356+ r0 = returnFunc()
357+ } else {
358+ r0 = ret.Error(0)
359+ }
360+ return r0
361+}
362+
363+// MockContext_FocusPaneLeft_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FocusPaneLeft'
364+type MockContext_FocusPaneLeft_Call struct {
365+ *mock.Call
366+}
367+
368+// FocusPaneLeft is a helper method to define mock.On call
369+func (_e *MockContext_Expecter) FocusPaneLeft() *MockContext_FocusPaneLeft_Call {
370+ return &MockContext_FocusPaneLeft_Call{Call: _e.mock.On("FocusPaneLeft")}
371+}
372+
373+func (_c *MockContext_FocusPaneLeft_Call) Run(run func()) *MockContext_FocusPaneLeft_Call {
374+ _c.Call.Run(func(args mock.Arguments) {
375+ run()
376+ })
377+ return _c
378+}
379+
380+func (_c *MockContext_FocusPaneLeft_Call) Return(err error) *MockContext_FocusPaneLeft_Call {
381+ _c.Call.Return(err)
382+ return _c
383+}
384+
385+func (_c *MockContext_FocusPaneLeft_Call) RunAndReturn(run func() error) *MockContext_FocusPaneLeft_Call {
386+ _c.Call.Return(run)
387+ return _c
388+}
389+
390+// FocusPaneRight provides a mock function for the type MockContext
391+func (_mock *MockContext) FocusPaneRight() error {
392+ ret := _mock.Called()
393+
394+ if len(ret) == 0 {
395+ panic("no return value specified for FocusPaneRight")
396+ }
397+
398+ var r0 error
399+ if returnFunc, ok := ret.Get(0).(func() error); ok {
400+ r0 = returnFunc()
401+ } else {
402+ r0 = ret.Error(0)
403+ }
404+ return r0
405+}
406+
407+// MockContext_FocusPaneRight_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FocusPaneRight'
408+type MockContext_FocusPaneRight_Call struct {
409+ *mock.Call
410+}
411+
412+// FocusPaneRight is a helper method to define mock.On call
413+func (_e *MockContext_Expecter) FocusPaneRight() *MockContext_FocusPaneRight_Call {
414+ return &MockContext_FocusPaneRight_Call{Call: _e.mock.On("FocusPaneRight")}
415+}
416+
417+func (_c *MockContext_FocusPaneRight_Call) Run(run func()) *MockContext_FocusPaneRight_Call {
418+ _c.Call.Run(func(args mock.Arguments) {
419+ run()
420+ })
421+ return _c
422+}
423+
424+func (_c *MockContext_FocusPaneRight_Call) Return(err error) *MockContext_FocusPaneRight_Call {
425+ _c.Call.Return(err)
426+ return _c
427+}
428+
429+func (_c *MockContext_FocusPaneRight_Call) RunAndReturn(run func() error) *MockContext_FocusPaneRight_Call {
430+ _c.Call.Return(run)
431+ return _c
432+}
433+
434+// FocusPaneUp provides a mock function for the type MockContext
435+func (_mock *MockContext) FocusPaneUp() error {
436+ ret := _mock.Called()
437+
438+ if len(ret) == 0 {
439+ panic("no return value specified for FocusPaneUp")
440+ }
441+
442+ var r0 error
443+ if returnFunc, ok := ret.Get(0).(func() error); ok {
444+ r0 = returnFunc()
445+ } else {
446+ r0 = ret.Error(0)
447+ }
448+ return r0
449+}
450+
451+// MockContext_FocusPaneUp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FocusPaneUp'
452+type MockContext_FocusPaneUp_Call struct {
453+ *mock.Call
454+}
455+
456+// FocusPaneUp is a helper method to define mock.On call
457+func (_e *MockContext_Expecter) FocusPaneUp() *MockContext_FocusPaneUp_Call {
458+ return &MockContext_FocusPaneUp_Call{Call: _e.mock.On("FocusPaneUp")}
459+}
460+
461+func (_c *MockContext_FocusPaneUp_Call) Run(run func()) *MockContext_FocusPaneUp_Call {
462+ _c.Call.Run(func(args mock.Arguments) {
463+ run()
464+ })
465+ return _c
466+}
467+
468+func (_c *MockContext_FocusPaneUp_Call) Return(err error) *MockContext_FocusPaneUp_Call {
469+ _c.Call.Return(err)
470+ return _c
471+}
472+
473+func (_c *MockContext_FocusPaneUp_Call) RunAndReturn(run func() error) *MockContext_FocusPaneUp_Call {
474+ _c.Call.Return(run)
475+ return _c
476+}
477+
478+// NextTab provides a mock function for the type MockContext
479+func (_mock *MockContext) NextTab() error {
480+ ret := _mock.Called()
481+
482+ if len(ret) == 0 {
483+ panic("no return value specified for NextTab")
484+ }
485+
486+ var r0 error
487+ if returnFunc, ok := ret.Get(0).(func() error); ok {
488+ r0 = returnFunc()
489+ } else {
490+ r0 = ret.Error(0)
491+ }
492+ return r0
493+}
494+
495+// MockContext_NextTab_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NextTab'
496+type MockContext_NextTab_Call struct {
497+ *mock.Call
498+}
499+
500+// NextTab is a helper method to define mock.On call
501+func (_e *MockContext_Expecter) NextTab() *MockContext_NextTab_Call {
502+ return &MockContext_NextTab_Call{Call: _e.mock.On("NextTab")}
503+}
504+
505+func (_c *MockContext_NextTab_Call) Run(run func()) *MockContext_NextTab_Call {
506+ _c.Call.Run(func(args mock.Arguments) {
507+ run()
508+ })
509+ return _c
510+}
511+
512+func (_c *MockContext_NextTab_Call) Return(err error) *MockContext_NextTab_Call {
513+ _c.Call.Return(err)
514+ return _c
515+}
516+
517+func (_c *MockContext_NextTab_Call) RunAndReturn(run func() error) *MockContext_NextTab_Call {
518+ _c.Call.Return(run)
519+ return _c
520+}
521+
522+// OpenSessionPicker provides a mock function for the type MockContext
523+func (_mock *MockContext) OpenSessionPicker() error {
524+ ret := _mock.Called()
525+
526+ if len(ret) == 0 {
527+ panic("no return value specified for OpenSessionPicker")
528+ }
529+
530+ var r0 error
531+ if returnFunc, ok := ret.Get(0).(func() error); ok {
532+ r0 = returnFunc()
533+ } else {
534+ r0 = ret.Error(0)
535+ }
536+ return r0
537+}
538+
539+// MockContext_OpenSessionPicker_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OpenSessionPicker'
540+type MockContext_OpenSessionPicker_Call struct {
541+ *mock.Call
542+}
543+
544+// OpenSessionPicker is a helper method to define mock.On call
545+func (_e *MockContext_Expecter) OpenSessionPicker() *MockContext_OpenSessionPicker_Call {
546+ return &MockContext_OpenSessionPicker_Call{Call: _e.mock.On("OpenSessionPicker")}
547+}
548+
549+func (_c *MockContext_OpenSessionPicker_Call) Run(run func()) *MockContext_OpenSessionPicker_Call {
550+ _c.Call.Run(func(args mock.Arguments) {
551+ run()
552+ })
553+ return _c
554+}
555+
556+func (_c *MockContext_OpenSessionPicker_Call) Return(err error) *MockContext_OpenSessionPicker_Call {
557+ _c.Call.Return(err)
558+ return _c
559+}
560+
561+func (_c *MockContext_OpenSessionPicker_Call) RunAndReturn(run func() error) *MockContext_OpenSessionPicker_Call {
562+ _c.Call.Return(run)
563+ return _c
564+}
565+
566+// PrevTab provides a mock function for the type MockContext
567+func (_mock *MockContext) PrevTab() error {
568+ ret := _mock.Called()
569+
570+ if len(ret) == 0 {
571+ panic("no return value specified for PrevTab")
572+ }
573+
574+ var r0 error
575+ if returnFunc, ok := ret.Get(0).(func() error); ok {
576+ r0 = returnFunc()
577+ } else {
578+ r0 = ret.Error(0)
579+ }
580+ return r0
581+}
582+
583+// MockContext_PrevTab_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PrevTab'
584+type MockContext_PrevTab_Call struct {
585+ *mock.Call
586+}
587+
588+// PrevTab is a helper method to define mock.On call
589+func (_e *MockContext_Expecter) PrevTab() *MockContext_PrevTab_Call {
590+ return &MockContext_PrevTab_Call{Call: _e.mock.On("PrevTab")}
591+}
592+
593+func (_c *MockContext_PrevTab_Call) Run(run func()) *MockContext_PrevTab_Call {
594+ _c.Call.Run(func(args mock.Arguments) {
595+ run()
596+ })
597+ return _c
598+}
599+
600+func (_c *MockContext_PrevTab_Call) Return(err error) *MockContext_PrevTab_Call {
601+ _c.Call.Return(err)
602+ return _c
603+}
604+
605+func (_c *MockContext_PrevTab_Call) RunAndReturn(run func() error) *MockContext_PrevTab_Call {
606+ _c.Call.Return(run)
607+ return _c
608+}
609+
610+// RenameSession provides a mock function for the type MockContext
611+func (_mock *MockContext) RenameSession() error {
612+ ret := _mock.Called()
613+
614+ if len(ret) == 0 {
615+ panic("no return value specified for RenameSession")
616+ }
617+
618+ var r0 error
619+ if returnFunc, ok := ret.Get(0).(func() error); ok {
620+ r0 = returnFunc()
621+ } else {
622+ r0 = ret.Error(0)
623+ }
624+ return r0
625+}
626+
627+// MockContext_RenameSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RenameSession'
628+type MockContext_RenameSession_Call struct {
629+ *mock.Call
630+}
631+
632+// RenameSession is a helper method to define mock.On call
633+func (_e *MockContext_Expecter) RenameSession() *MockContext_RenameSession_Call {
634+ return &MockContext_RenameSession_Call{Call: _e.mock.On("RenameSession")}
635+}
636+
637+func (_c *MockContext_RenameSession_Call) Run(run func()) *MockContext_RenameSession_Call {
638+ _c.Call.Run(func(args mock.Arguments) {
639+ run()
640+ })
641+ return _c
642+}
643+
644+func (_c *MockContext_RenameSession_Call) Return(err error) *MockContext_RenameSession_Call {
645+ _c.Call.Return(err)
646+ return _c
647+}
648+
649+func (_c *MockContext_RenameSession_Call) RunAndReturn(run func() error) *MockContext_RenameSession_Call {
650+ _c.Call.Return(run)
651+ return _c
652+}
653+
654+// SplitDown provides a mock function for the type MockContext
655+func (_mock *MockContext) SplitDown() error {
656+ ret := _mock.Called()
657+
658+ if len(ret) == 0 {
659+ panic("no return value specified for SplitDown")
660+ }
661+
662+ var r0 error
663+ if returnFunc, ok := ret.Get(0).(func() error); ok {
664+ r0 = returnFunc()
665+ } else {
666+ r0 = ret.Error(0)
667+ }
668+ return r0
669+}
670+
671+// MockContext_SplitDown_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SplitDown'
672+type MockContext_SplitDown_Call struct {
673+ *mock.Call
674+}
675+
676+// SplitDown is a helper method to define mock.On call
677+func (_e *MockContext_Expecter) SplitDown() *MockContext_SplitDown_Call {
678+ return &MockContext_SplitDown_Call{Call: _e.mock.On("SplitDown")}
679+}
680+
681+func (_c *MockContext_SplitDown_Call) Run(run func()) *MockContext_SplitDown_Call {
682+ _c.Call.Run(func(args mock.Arguments) {
683+ run()
684+ })
685+ return _c
686+}
687+
688+func (_c *MockContext_SplitDown_Call) Return(err error) *MockContext_SplitDown_Call {
689+ _c.Call.Return(err)
690+ return _c
691+}
692+
693+func (_c *MockContext_SplitDown_Call) RunAndReturn(run func() error) *MockContext_SplitDown_Call {
694+ _c.Call.Return(run)
695+ return _c
696+}
697+
698+// SplitLeft provides a mock function for the type MockContext
699+func (_mock *MockContext) SplitLeft() error {
700+ ret := _mock.Called()
701+
702+ if len(ret) == 0 {
703+ panic("no return value specified for SplitLeft")
704+ }
705+
706+ var r0 error
707+ if returnFunc, ok := ret.Get(0).(func() error); ok {
708+ r0 = returnFunc()
709+ } else {
710+ r0 = ret.Error(0)
711+ }
712+ return r0
713+}
714+
715+// MockContext_SplitLeft_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SplitLeft'
716+type MockContext_SplitLeft_Call struct {
717+ *mock.Call
718+}
719+
720+// SplitLeft is a helper method to define mock.On call
721+func (_e *MockContext_Expecter) SplitLeft() *MockContext_SplitLeft_Call {
722+ return &MockContext_SplitLeft_Call{Call: _e.mock.On("SplitLeft")}
723+}
724+
725+func (_c *MockContext_SplitLeft_Call) Run(run func()) *MockContext_SplitLeft_Call {
726+ _c.Call.Run(func(args mock.Arguments) {
727+ run()
728+ })
729+ return _c
730+}
731+
732+func (_c *MockContext_SplitLeft_Call) Return(err error) *MockContext_SplitLeft_Call {
733+ _c.Call.Return(err)
734+ return _c
735+}
736+
737+func (_c *MockContext_SplitLeft_Call) RunAndReturn(run func() error) *MockContext_SplitLeft_Call {
738+ _c.Call.Return(run)
739+ return _c
740+}
741+
742+// SplitRight provides a mock function for the type MockContext
743+func (_mock *MockContext) SplitRight() error {
744+ ret := _mock.Called()
745+
746+ if len(ret) == 0 {
747+ panic("no return value specified for SplitRight")
748+ }
749+
750+ var r0 error
751+ if returnFunc, ok := ret.Get(0).(func() error); ok {
752+ r0 = returnFunc()
753+ } else {
754+ r0 = ret.Error(0)
755+ }
756+ return r0
757+}
758+
759+// MockContext_SplitRight_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SplitRight'
760+type MockContext_SplitRight_Call struct {
761+ *mock.Call
762+}
763+
764+// SplitRight is a helper method to define mock.On call
765+func (_e *MockContext_Expecter) SplitRight() *MockContext_SplitRight_Call {
766+ return &MockContext_SplitRight_Call{Call: _e.mock.On("SplitRight")}
767+}
768+
769+func (_c *MockContext_SplitRight_Call) Run(run func()) *MockContext_SplitRight_Call {
770+ _c.Call.Run(func(args mock.Arguments) {
771+ run()
772+ })
773+ return _c
774+}
775+
776+func (_c *MockContext_SplitRight_Call) Return(err error) *MockContext_SplitRight_Call {
777+ _c.Call.Return(err)
778+ return _c
779+}
780+
781+func (_c *MockContext_SplitRight_Call) RunAndReturn(run func() error) *MockContext_SplitRight_Call {
782+ _c.Call.Return(run)
783+ return _c
784+}
785+
786+// SplitUp provides a mock function for the type MockContext
787+func (_mock *MockContext) SplitUp() error {
788+ ret := _mock.Called()
789+
790+ if len(ret) == 0 {
791+ panic("no return value specified for SplitUp")
792+ }
793+
794+ var r0 error
795+ if returnFunc, ok := ret.Get(0).(func() error); ok {
796+ r0 = returnFunc()
797+ } else {
798+ r0 = ret.Error(0)
799+ }
800+ return r0
801+}
802+
803+// MockContext_SplitUp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SplitUp'
804+type MockContext_SplitUp_Call struct {
805+ *mock.Call
806+}
807+
808+// SplitUp is a helper method to define mock.On call
809+func (_e *MockContext_Expecter) SplitUp() *MockContext_SplitUp_Call {
810+ return &MockContext_SplitUp_Call{Call: _e.mock.On("SplitUp")}
811+}
812+
813+func (_c *MockContext_SplitUp_Call) Run(run func()) *MockContext_SplitUp_Call {
814+ _c.Call.Run(func(args mock.Arguments) {
815+ run()
816+ })
817+ return _c
818+}
819+
820+func (_c *MockContext_SplitUp_Call) Return(err error) *MockContext_SplitUp_Call {
821+ _c.Call.Return(err)
822+ return _c
823+}
824+
825+func (_c *MockContext_SplitUp_Call) RunAndReturn(run func() error) *MockContext_SplitUp_Call {
826+ _c.Call.Return(run)
827+ return _c
828+}
829+
830+// StackPane provides a mock function for the type MockContext
831+func (_mock *MockContext) StackPane() error {
832+ ret := _mock.Called()
833+
834+ if len(ret) == 0 {
835+ panic("no return value specified for StackPane")
836+ }
837+
838+ var r0 error
839+ if returnFunc, ok := ret.Get(0).(func() error); ok {
840+ r0 = returnFunc()
841+ } else {
842+ r0 = ret.Error(0)
843+ }
844+ return r0
845+}
846+
847+// MockContext_StackPane_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StackPane'
848+type MockContext_StackPane_Call struct {
849+ *mock.Call
850+}
851+
852+// StackPane is a helper method to define mock.On call
853+func (_e *MockContext_Expecter) StackPane() *MockContext_StackPane_Call {
854+ return &MockContext_StackPane_Call{Call: _e.mock.On("StackPane")}
855+}
856+
857+func (_c *MockContext_StackPane_Call) Run(run func()) *MockContext_StackPane_Call {
858+ _c.Call.Run(func(args mock.Arguments) {
859+ run()
860+ })
861+ return _c
862+}
863+
864+func (_c *MockContext_StackPane_Call) Return(err error) *MockContext_StackPane_Call {
865+ _c.Call.Return(err)
866+ return _c
867+}
868+
869+func (_c *MockContext_StackPane_Call) RunAndReturn(run func() error) *MockContext_StackPane_Call {
870+ _c.Call.Return(run)
871+ return _c
872+}
873+
874+// ToggleStack provides a mock function for the type MockContext
875+func (_mock *MockContext) ToggleStack() error {
876+ ret := _mock.Called()
877+
878+ if len(ret) == 0 {
879+ panic("no return value specified for ToggleStack")
880+ }
881+
882+ var r0 error
883+ if returnFunc, ok := ret.Get(0).(func() error); ok {
884+ r0 = returnFunc()
885+ } else {
886+ r0 = ret.Error(0)
887+ }
888+ return r0
889+}
890+
891+// MockContext_ToggleStack_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ToggleStack'
892+type MockContext_ToggleStack_Call struct {
893+ *mock.Call
894+}
895+
896+// ToggleStack is a helper method to define mock.On call
897+func (_e *MockContext_Expecter) ToggleStack() *MockContext_ToggleStack_Call {
898+ return &MockContext_ToggleStack_Call{Call: _e.mock.On("ToggleStack")}
899+}
900+
901+func (_c *MockContext_ToggleStack_Call) Run(run func()) *MockContext_ToggleStack_Call {
902+ _c.Call.Run(func(args mock.Arguments) {
903+ run()
904+ })
905+ return _c
906+}
907+
908+func (_c *MockContext_ToggleStack_Call) Return(err error) *MockContext_ToggleStack_Call {
909+ _c.Call.Return(err)
910+ return _c
911+}
912+
913+func (_c *MockContext_ToggleStack_Call) RunAndReturn(run func() error) *MockContext_ToggleStack_Call {
914+ _c.Call.Return(run)
915+ return _c
916+}
M internal/usecase/command/registry.go +11 -0
@@ -8,6 +8,17 @@ func Registry() []Command {
88 {Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context) error { return ctx.CreateTab() }},
99 {Code: "CNS", Name: "New session", Desc: "Create and switch to a named session", Run: func(ctx Context) error { return ctx.CreateSession() }},
1010 {Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context) error { return ctx.CloseTab() }},
11+ {Code: "SPR", Name: "Split right", Desc: "Split the focused pane to the right", Run: func(ctx Context) error { return ctx.SplitRight() }},
12+ {Code: "SPL", Name: "Split left", Desc: "Split the focused pane to the left", Run: func(ctx Context) error { return ctx.SplitLeft() }},
13+ {Code: "SPU", Name: "Split up", Desc: "Split the focused pane upward", Run: func(ctx Context) error { return ctx.SplitUp() }},
14+ {Code: "SPD", Name: "Split down", Desc: "Split the focused pane downward", Run: func(ctx Context) error { return ctx.SplitDown() }},
15+ {Code: "STP", Name: "Stack pane", Desc: "Create a new pane in a stack", Run: func(ctx Context) error { return ctx.StackPane() }},
16+ {Code: "TST", Name: "Toggle stack", Desc: "Toggle the focused pane stack", Run: func(ctx Context) error { return ctx.ToggleStack() }},
17+ {Code: "CLP", Name: "Close pane", Desc: "Close the focused pane", Run: func(ctx Context) error { return ctx.ClosePane() }},
18+ {Code: "FPL", Name: "Focus pane left", Desc: "Focus the pane to the left", Run: func(ctx Context) error { return ctx.FocusPaneLeft() }},
19+ {Code: "FPR", Name: "Focus pane right", Desc: "Focus the pane to the right", Run: func(ctx Context) error { return ctx.FocusPaneRight() }},
20+ {Code: "FPU", Name: "Focus pane up", Desc: "Focus the pane above", Run: func(ctx Context) error { return ctx.FocusPaneUp() }},
21+ {Code: "FPD", Name: "Focus pane down", Desc: "Focus the pane below", Run: func(ctx Context) error { return ctx.FocusPaneDown() }},
1122 {Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }},
1223 {Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }},
1324 {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},
M internal/usecase/command/registry_test.go +25 -43
@@ -1,14 +1,13 @@
11 package command
22
33 import (
4- "fmt"
54 "regexp"
65 "testing"
76 )
87
98 func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) {
109 commands := Registry()
11- wantCodes := []string{"CNT", "CNS", "CLT", "NXT", "PVT", "SSP", "VIS", "RNS", "DET"}
10+ wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "SSP", "VIS", "RNS", "DET"}
1211
1312 if len(commands) != len(wantCodes) {
1413 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
@@ -48,18 +47,29 @@ func TestByCodeIsCaseInsensitive(t *testing.T) {
4847
4948 func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
5049 tests := []struct {
51- code string
52- want string
50+ code string
51+ expect func(*MockContext)
5352 }{
54- {code: "CNT", want: "CreateTab"},
55- {code: "CNS", want: "CreateSession"},
56- {code: "CLT", want: "CloseTab"},
57- {code: "NXT", want: "NextTab"},
58- {code: "PVT", want: "PrevTab"},
59- {code: "SSP", want: "OpenSessionPicker"},
60- {code: "VIS", want: "EnterVisualMode"},
61- {code: "RNS", want: "RenameSession"},
62- {code: "DET", want: "Detach"},
53+ {code: "CNT", expect: func(ctx *MockContext) { ctx.EXPECT().CreateTab().Return(nil).Once() }},
54+ {code: "CNS", expect: func(ctx *MockContext) { ctx.EXPECT().CreateSession().Return(nil).Once() }},
55+ {code: "CLT", expect: func(ctx *MockContext) { ctx.EXPECT().CloseTab().Return(nil).Once() }},
56+ {code: "SPR", expect: func(ctx *MockContext) { ctx.EXPECT().SplitRight().Return(nil).Once() }},
57+ {code: "SPL", expect: func(ctx *MockContext) { ctx.EXPECT().SplitLeft().Return(nil).Once() }},
58+ {code: "SPU", expect: func(ctx *MockContext) { ctx.EXPECT().SplitUp().Return(nil).Once() }},
59+ {code: "SPD", expect: func(ctx *MockContext) { ctx.EXPECT().SplitDown().Return(nil).Once() }},
60+ {code: "STP", expect: func(ctx *MockContext) { ctx.EXPECT().StackPane().Return(nil).Once() }},
61+ {code: "TST", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleStack().Return(nil).Once() }},
62+ {code: "CLP", expect: func(ctx *MockContext) { ctx.EXPECT().ClosePane().Return(nil).Once() }},
63+ {code: "FPL", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneLeft().Return(nil).Once() }},
64+ {code: "FPR", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneRight().Return(nil).Once() }},
65+ {code: "FPU", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneUp().Return(nil).Once() }},
66+ {code: "FPD", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneDown().Return(nil).Once() }},
67+ {code: "NXT", expect: func(ctx *MockContext) { ctx.EXPECT().NextTab().Return(nil).Once() }},
68+ {code: "PVT", expect: func(ctx *MockContext) { ctx.EXPECT().PrevTab().Return(nil).Once() }},
69+ {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }},
70+ {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }},
71+ {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }},
72+ {code: "DET", expect: func(ctx *MockContext) { ctx.EXPECT().Detach().Return(nil).Once() }},
6373 }
6474
6575 for _, tt := range tests {
@@ -69,39 +79,11 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
6979 t.Fatalf("ByCode(%q) ok = false, want true", tt.code)
7080 }
7181
72- ctx := &spyContext{}
82+ ctx := NewMockContext(t)
83+ tt.expect(ctx)
7384 if err := cmd.Run(ctx); err != nil {
7485 t.Fatalf("Run() error = %v, want nil", err)
7586 }
76- if got := ctx.onlyCall(); got != tt.want {
77- t.Fatalf("Run() called %q, want %q", got, tt.want)
78- }
7987 })
8088 }
8189 }
82-
83-type spyContext struct {
84- calls []string
85-}
86-
87-func (s *spyContext) record(name string) error {
88- s.calls = append(s.calls, name)
89- return nil
90-}
91-
92-func (s *spyContext) onlyCall() string {
93- if len(s.calls) != 1 {
94- return fmt.Sprintf("%v", s.calls)
95- }
96- return s.calls[0]
97-}
98-
99-func (s *spyContext) CreateTab() error { return s.record("CreateTab") }
100-func (s *spyContext) CreateSession() error { return s.record("CreateSession") }
101-func (s *spyContext) CloseTab() error { return s.record("CloseTab") }
102-func (s *spyContext) NextTab() error { return s.record("NextTab") }
103-func (s *spyContext) PrevTab() error { return s.record("PrevTab") }
104-func (s *spyContext) Detach() error { return s.record("Detach") }
105-func (s *spyContext) EnterVisualMode() error { return s.record("EnterVisualMode") }
106-func (s *spyContext) RenameSession() error { return s.record("RenameSession") }
107-func (s *spyContext) OpenSessionPicker() error { return s.record("OpenSessionPicker") }
M internal/usecase/daemon/attention_test.go +16 -31
@@ -15,7 +15,6 @@ import (
1515 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1616 "github.com/bnema/vev/internal/usecase/keys"
1717 "github.com/bnema/vev/pkg/renderer"
18- "github.com/bnema/vev/pkg/vt"
1918 )
2019
2120 func TestNoteAttentionFlagsBackgroundTab(t *testing.T) {
@@ -68,7 +67,7 @@ func TestPTYReaderBellMarksBackgroundTab(t *testing.T) {
6867 sess.active = 0
6968
7069 d.sessWg.Add(1)
71- go d.ptyReader(sess, sess.tabs[1])
70+ go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane())
7271
7372 require.Eventually(t, func() bool {
7473 sess.mu.Lock()
@@ -104,7 +103,7 @@ func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) {
104103 acW := &attachedClient{tr: trW, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
105104 sctxW, cancelW := context.WithCancel(d.serveCtx)
106105 t.Cleanup(cancelW)
107- tabW := &tab{screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctxW, cancel: cancelW}
106+ tabW := newTestTabWithContext(newScriptPTY(nil), sctxW, cancelW)
108107 sessW := &session{id: "wedged", name: "wedged", ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}, client: acW}
109108 acW.setSession(sessW)
110109 acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW})
@@ -156,8 +155,9 @@ func (p *scriptPTY) Close() error {
156155 p.once.Do(func() { close(p.closed) })
157156 return nil
158157 }
159-func (p *scriptPTY) Resize(domain.Size) error { return nil }
160-func (p *scriptPTY) Pid() int { return 4242 }
158+func (p *scriptPTY) Resize(domain.Size) error { return nil }
159+func (p *scriptPTY) Pid() int { return 4242 }
160+func (p *scriptPTY) ForegroundPgid() (int, error) { return 4242, nil }
161161
162162 func TestAckAttentionClearsOnlyPaintedVisibleTab(t *testing.T) {
163163 d, sess, ac, sends, releases := newManualTabSession(t, 2)
@@ -269,7 +269,7 @@ func TestAnimationRepaintConfinedToBarRows(t *testing.T) {
269269 defer releases[0]()
270270 defer releases[1]()
271271 sess.active = 0
272- sess.tabs[0].screen.Write([]byte("MIDSCREENMARKER"))
272+ sess.tabs[0].focusedPane().screen.Write([]byte("MIDSCREENMARKER"))
273273 sess.mu.Lock()
274274 sess.tabs[1].attention = true
275275 sess.tabs[1].attentionAt = time.Unix(1, 0)
@@ -314,12 +314,12 @@ func TestComposeClientFrameWithStateAttentionFrameChangeDamagesOnlyBars(t *testi
314314 bars := d.barStateFor(sess, "")
315315 _, damage := composeClientFrameWithState(bars, tb, true, &cache)
316316 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
317- tb.screen.ClearDamage()
317+ tb.focusedPane().screen.ClearDamage()
318318
319319 bars.attentionFrame++
320320 _, damage = composeClientFrameWithState(bars, tb, false, &cache)
321321 require.NotEmpty(t, damage)
322- lastRow := tb.screen.Frame.Height + 1
322+ lastRow := tb.focusedPane().screen.Frame.Height + 1
323323 for _, dmg := range damage {
324324 require.Contains(t, []int{0, lastRow}, dmg.Y, "expected damage confined to bar rows, got y=%d", dmg.Y)
325325 }
@@ -350,7 +350,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) {
350350 acB := &attachedClient{tr: trB, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
351351 sctxB, cancelB := context.WithCancel(d.serveCtx)
352352 t.Cleanup(cancelB)
353- tbB := &tab{pty: pB, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctxB, cancel: cancelB}
353+ tbB := newTestTabWithContext(pB, sctxB, cancelB)
354354 sessB := &session{id: "sessB", name: "other", ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}, client: acB}
355355 acB.setSession(sessB)
356356 acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB})
@@ -396,33 +396,18 @@ func TestJumpAttentionCrossesSessionsWhenNoLocalBells(t *testing.T) {
396396 d, sess1, ac, sends := newManualSessionWithPTYs(t, p1)
397397 sctx2, cancel2 := context.WithCancel(d.serveCtx)
398398 defer cancel2()
399+ tab2a := newTestTabWithContext(p2, sctx2, cancel2)
400+ tab2b := newTestTabWithContext(p3, sctx2, cancel2)
401+ tab2a.attention = true
402+ tab2a.attentionAt = time.Unix(9, 0)
403+ tab2b.attention = true
404+ tab2b.attentionAt = time.Unix(5, 0)
399405 sess2 := &session{
400406 id: "other",
401407 name: "other",
402408 ctx: sctx2,
403409 cancel: cancel2,
404- tabs: []*tab{
405- {
406- pty: p2,
407- screen: vt.NewScreen(80, 23),
408- dirty: make(chan struct{}, 1),
409- size: domain.Size{Cols: 80, Rows: 23},
410- ctx: sctx2,
411- cancel: cancel2,
412- attention: true,
413- attentionAt: time.Unix(9, 0),
414- },
415- {
416- pty: p3,
417- screen: vt.NewScreen(80, 23),
418- dirty: make(chan struct{}, 1),
419- size: domain.Size{Cols: 80, Rows: 23},
420- ctx: sctx2,
421- cancel: cancel2,
422- attention: true,
423- attentionAt: time.Unix(5, 0),
424- },
425- },
410+ tabs: []*tab{tab2a, tab2b},
426411 }
427412 d.sessions[sess2.id] = sess2
428413
M internal/usecase/daemon/client.go +30 -15
@@ -17,12 +17,11 @@
1717 // when the parent context is cancelled (graceful shutdown notifies any
1818 // attached clients with ReasonServerShutdown).
1919 //
20-// Locking: a session's screen and per-client renderer shadow are both guarded
21-// by tab.mu; the attached-client pointer by session.mu; the registry by
22-// Daemon.mu. When more than one is held the order is always
23-// Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >
24-// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on
25-// a slow client.
20+// Locking: a pane's screen/scrollback and per-client renderer shadow are
21+// guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
22+// session.mu; the registry by Daemon.mu. When more than one is held the order
23+// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
24+// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
2625 package daemon
2726
2827 import (
@@ -254,8 +253,13 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size)
254253 sess.mu.Unlock()
255254 for _, tb := range tabs {
256255 tb.mu.Lock()
257- tb.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false)
256+ panes := tb.panesSnapshot()
258257 tb.mu.Unlock()
258+ for _, p := range panes {
259+ p.mu.Lock()
260+ p.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false)
261+ p.mu.Unlock()
262+ }
259263 }
260264 return ac, old
261265 }
@@ -281,8 +285,13 @@ func (d *Daemon) resetScreenDefaultColors(sess *session) {
281285 sess.mu.Unlock()
282286 for _, tb := range tabs {
283287 tb.mu.Lock()
284- tb.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false)
288+ panes := tb.panesSnapshot()
285289 tb.mu.Unlock()
290+ for _, p := range panes {
291+ p.mu.Lock()
292+ p.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false)
293+ p.mu.Unlock()
294+ }
286295 }
287296 }
288297
@@ -410,8 +419,13 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme)
410419 ac.setTheme(t)
411420 for _, tb := range tabs {
412421 tb.mu.Lock()
413- tb.screen.SetDefaultColors(t.Foreground, t.Background, knownDefaultColors)
422+ panes := tb.panesSnapshot()
414423 tb.mu.Unlock()
424+ for _, p := range panes {
425+ p.mu.Lock()
426+ p.screen.SetDefaultColors(t.Foreground, t.Background, knownDefaultColors)
427+ p.mu.Unlock()
428+ }
415429 }
416430 d.paint(sess, ac, true)
417431 }
@@ -421,7 +435,9 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
421435 return
422436 }
423437 tbSize := tabSize(sz)
424- ac.size = sz
438+ if ac != nil {
439+ ac.size = sz
440+ }
425441
426442 sess.mu.Lock()
427443 tabs := append([]*tab(nil), sess.tabs...)
@@ -431,15 +447,14 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
431447 }
432448
433449 for _, tb := range tabs {
434- if err := tb.pty.Resize(tbSize); err != nil {
435- d.log.Warn("pty resize failed", "err", err, "session", sess.name)
436- }
437450 tb.mu.Lock()
438- tb.screen.Resize(tbSize.Cols, tbSize.Rows)
439451 tb.size = tbSize
452+ d.applyLayoutLocked(tb)
440453 tb.mu.Unlock()
441454 }
442- d.paint(sess, ac, true)
455+ if ac != nil {
456+ d.paint(sess, ac, true)
457+ }
443458 }
444459
445460 // detachOnSendError drops a client whose transport failed. Like every other
M internal/usecase/daemon/copymode.go +68 -23
@@ -17,20 +17,21 @@
1717 // when the parent context is cancelled (graceful shutdown notifies any
1818 // attached clients with ReasonServerShutdown).
1919 //
20-// Locking: a session's screen and per-client renderer shadow are both guarded
21-// by tab.mu; the attached-client pointer by session.mu; the registry by
22-// Daemon.mu. When more than one is held the order is always
23-// Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >
24-// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on
25-// a slow client.
20+// Locking: a pane's screen/scrollback and per-client renderer shadow are
21+// guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
22+// session.mu; the registry by Daemon.mu. When more than one is held the order
23+// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
24+// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
2625 package daemon
2726
2827 import (
2928 "strconv"
3029
30+ "github.com/bnema/vev/internal/domain"
3131 "github.com/bnema/vev/internal/ports"
3232 scopy "github.com/bnema/vev/internal/usecase/copy"
3333 "github.com/bnema/vev/internal/usecase/keys"
34+ "github.com/bnema/vev/internal/usecase/layout"
3435 "github.com/bnema/vev/internal/usecase/mouse"
3536 "github.com/bnema/vev/pkg/renderer"
3637 )
@@ -42,17 +43,23 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
4243 }
4344
4445 tb.mu.Lock()
46+ p := tb.focusedPane()
47+ tb.mu.Unlock()
48+ if p == nil {
49+ return
50+ }
51+ p.mu.Lock()
4552 ac.copyMu.Lock()
4653 if ac.copyMode == nil {
4754 ac.copyMu.Unlock()
48- tb.mu.Unlock()
55+ p.mu.Unlock()
4956 return
5057 }
51- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
58+ snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
5259 if delta > 0 && ac.copyMode.AtBottom(snap) {
5360 ac.copyMode = nil
5461 ac.copyMu.Unlock()
55- tb.mu.Unlock()
62+ p.mu.Unlock()
5663 d.paint(sess, ac, true)
5764 return
5865 }
@@ -62,7 +69,7 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
6269 ac.copyMode = nil
6370 }
6471 ac.copyMu.Unlock()
65- tb.mu.Unlock()
72+ p.mu.Unlock()
6673
6774 d.paint(sess, ac, true)
6875 }
@@ -73,8 +80,14 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
7380 return
7481 }
7582 tb.mu.Lock()
76- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
83+ p := tb.focusedPane()
7784 tb.mu.Unlock()
85+ if p == nil {
86+ return
87+ }
88+ p.mu.Lock()
89+ snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
90+ p.mu.Unlock()
7891 ac.copyMu.Lock()
7992 ac.copyMode = scopy.NewMode(snap)
8093 ac.copyPressRowValid = false
@@ -94,23 +107,29 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
94107 }
95108
96109 tb.mu.Lock()
97- if ev.Row >= tb.screen.Frame.Height {
110+ p := tb.focusedPane()
111+ tb.mu.Unlock()
112+ if p == nil {
113+ return
114+ }
115+ p.mu.Lock()
116+ if ev.Row >= p.screen.Frame.Height {
98117 if ev.Type == mouse.Press {
99118 ac.copyMu.Lock()
100119 ac.copyPressRowValid = false
101120 ac.copyDragging = false
102121 ac.copyMu.Unlock()
103122 }
104- tb.mu.Unlock()
123+ p.mu.Unlock()
105124 return
106125 }
107126 ac.copyMu.Lock()
108127 if ac.copyMode == nil {
109128 ac.copyMu.Unlock()
110- tb.mu.Unlock()
129+ p.mu.Unlock()
111130 return
112131 }
113- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
132+ snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
114133 absRow := ac.copyMode.ViewportTop + ev.Row
115134 changed := false
116135 switch ev.Type {
@@ -134,7 +153,7 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
134153 // Button release intentionally has no visual effect.
135154 }
136155 ac.copyMu.Unlock()
137- tb.mu.Unlock()
156+ p.mu.Unlock()
138157
139158 if changed {
140159 d.paint(sess, ac, true)
@@ -152,12 +171,18 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
152171 }
153172
154173 tb.mu.Lock()
174+ p := tb.focusedPane()
175+ tb.mu.Unlock()
176+ if p == nil {
177+ return
178+ }
179+ p.mu.Lock()
155180 ac.copyMu.Lock()
156181 if ac.copyMode == nil {
157182 ac.copyPending = nil
158183 d.stopCopyPendingTimerLocked(ac)
159184 ac.copyMu.Unlock()
160- tb.mu.Unlock()
185+ p.mu.Unlock()
161186 return
162187 }
163188 if len(ac.copyPending) > 0 {
@@ -168,7 +193,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
168193 data = combined
169194 ac.copyPending = nil
170195 }
171- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
196+ snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
172197 changed := false
173198 copyOut := false
174199 exit := false
@@ -222,7 +247,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
222247 ac.copyMode = nil
223248 }
224249 ac.copyMu.Unlock()
225- tb.mu.Unlock()
250+ p.mu.Unlock()
226251
227252 if copyOut && text != "" {
228253 chunks := scopy.OSC52(text)
@@ -309,14 +334,34 @@ func isCopyEscapePrefix(data []byte) bool {
309334
310335 func composeCopyClientFrame(mode *scopy.Mode, tb *tab, bars barState) (renderer.Frame, []renderer.Damage) {
311336 styles := newThemeStyles(bars.theme)
312- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
337+ p := tb.focusedPane()
338+ if p == nil {
339+ return renderer.NewFrame(0, 0), nil
340+ }
341+ p.mu.Lock()
342+ paneFrame := p.screen.Frame
343+ paneWidth, paneRows := paneFrame.Width, paneFrame.Height
344+ snap := scopy.NewSnapshot(p.scrollback, paneFrame)
345+ p.mu.Unlock()
313346 copyFrame := mode.Render(snap, styles.copyStatus, styles.selection)
314- width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height
347+ pl, ok := focusedPlacementLocked(tb)
348+ if !ok {
349+ pl = layout.Placement{Content: domain.Rect{Width: paneWidth, Height: paneRows}}
350+ }
351+ width, screenRows := paneWidth, paneRows
352+ if len(tb.panes) > 1 && tb.size.Valid() {
353+ width, screenRows = tb.size.Cols, tb.size.Rows
354+ }
315355 frame := renderer.NewFrame(width, screenRows+2)
316356 drawTopBarSnapshot(frame.Row(0), bars.status, bars.attentionFrame, styles)
357+ base, _ := composeTabFrame(tb, domain.Rect{Width: width, Height: screenRows}, bars.theme)
317358 for y := range screenRows {
318- copy(frame.Row(y+1), copyFrame.Row(y))
359+ copy(frame.Row(y+1), base.Row(y))
360+ }
361+ for y := 0; y < pl.Content.Height && y < copyFrame.Height-1; y++ {
362+ copy(frame.Row(pl.Content.Y + 1 + y)[pl.Content.X:pl.Content.X+min(pl.Content.Width, copyFrame.Width)], copyFrame.Row(y)[:min(pl.Content.Width, copyFrame.Width)])
319363 }
320- copy(frame.Row(screenRows+1), copyFrame.Row(screenRows))
364+ statusY := screenRows + 1
365+ copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(width, copyFrame.Width)])
321366 return frame, []renderer.Damage{renderer.FullRedraw()}
322367 }
M internal/usecase/daemon/copymode_test.go +68 -29
@@ -26,15 +26,48 @@ import (
2626 // stubClock returns timers whose channel never fires, so a scheduler under it
2727 // blocks in its debounce loop until the session context is cancelled. Used by
2828
29+func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
30+ p, release := newBlockingPTY(t)
31+ defer release()
32+ _, sess, _, _ := newManualSessionWithPTYs(t, p)
33+ tb := sess.activeTab()
34+ pane := tb.focusedPane()
35+ pane.mu.Lock()
36+ snap := scopy.NewSnapshot(pane.scrollback, pane.screen.Frame)
37+ pane.mu.Unlock()
38+ mode := scopy.NewMode(snap)
39+ bars := barState{status: sess.statusSegments()}
40+
41+ var wg sync.WaitGroup
42+ wg.Add(2)
43+ go func() {
44+ defer wg.Done()
45+ for range 200 {
46+ pane.mu.Lock()
47+ pane.screen.Write([]byte("output\n"))
48+ pane.mu.Unlock()
49+ }
50+ }()
51+ go func() {
52+ defer wg.Done()
53+ for range 200 {
54+ tb.mu.Lock()
55+ _, _ = composeCopyClientFrame(mode, tb, bars)
56+ tb.mu.Unlock()
57+ }
58+ }()
59+ wg.Wait()
60+}
61+
2962 func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
3063 p, release := newBlockingPTY(t)
3164 _, sess, _, _ := newManualSessionWithPTYs(t, p)
3265 defer release()
3366 sess.name = "work"
3467 tb := sess.activeTab()
35- tb.screen = vt.NewScreen(12, 3)
36- tb.screen.Write([]byte("live"))
37- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
68+ tb.focusedPane().screen = vt.NewScreen(12, 3)
69+ tb.focusedPane().screen.Write([]byte("live"))
70+ snap := scopy.NewSnapshot(tb.focusedPane().scrollback, tb.focusedPane().screen.Frame)
3871 mode := scopy.NewMode(snap)
3972
4073 frame, damage := composeCopyClientFrame(mode, tb, barState{status: sess.statusSegments()})
@@ -50,8 +83,8 @@ func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) {
5083 writes := make(chan []byte, 1)
5184 p, _ := newBlockingPTYWithWrites(t, writes)
5285 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
53- sess.tabs[0].scrollback = scopy.NewScrollback(4)
54- sess.tabs[0].screen.Write([]byte("live"))
86+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
87+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
5588
5689 d.handleInput(sess, ac, []byte("\x1b "))
5790 awaitFrame(t, sends, ports.MsgOutput)
@@ -85,10 +118,10 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
85118 writes := make(chan []byte, 1)
86119 p, _ := newBlockingPTYWithWrites(t, writes)
87120 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
88- sess.tabs[0].scrollback = scopy.NewScrollback(4)
89- sess.tabs[0].scrollback.Append(testRow("old1 "))
90- sess.tabs[0].scrollback.Append(testRow("old2 "))
91- sess.tabs[0].screen.Write([]byte("live"))
121+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
122+ sess.tabs[0].focusedPane().scrollback.Append(testRow("old1 "))
123+ sess.tabs[0].focusedPane().scrollback.Append(testRow("old2 "))
124+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
92125
93126 d.enterCopyMode(sess, ac)
94127 awaitFrame(t, sends, ports.MsgOutput)
@@ -168,8 +201,14 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
168201 return false
169202 }
170203 win.mu.Lock()
171- defer win.mu.Unlock()
172- return len(scopy.NewSnapshot(win.scrollback, win.screen.Frame).Rows) >= 12
204+ p := win.focusedPane()
205+ win.mu.Unlock()
206+ if p == nil {
207+ return false
208+ }
209+ p.mu.Lock()
210+ defer p.mu.Unlock()
211+ return len(scopy.NewSnapshot(p.scrollback, p.screen.Frame).Rows) >= 12
173212 }, 2*time.Second, 5*time.Millisecond)
174213
175214 sess := firstSession(d)
@@ -205,8 +244,8 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
205244 func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {
206245 p, _ := newBlockingPTY(t)
207246 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
208- sess.tabs[0].scrollback = scopy.NewScrollback(4)
209- sess.tabs[0].screen.Write([]byte("live"))
247+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
248+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
210249
211250 d.enterCopyMode(sess, ac)
212251 awaitFrame(t, sends, ports.MsgOutput)
@@ -239,10 +278,10 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
239278 t.Run(tc.name, func(t *testing.T) {
240279 p, _ := newBlockingPTY(t)
241280 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
242- sess.tabs[0].scrollback = scopy.NewScrollback(4)
243- sess.tabs[0].scrollback.Append(testRow("old1 "))
244- sess.tabs[0].scrollback.Append(testRow("old2 "))
245- sess.tabs[0].screen.Write([]byte("live"))
281+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
282+ sess.tabs[0].focusedPane().scrollback.Append(testRow("old1 "))
283+ sess.tabs[0].focusedPane().scrollback.Append(testRow("old2 "))
284+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
246285
247286 d.enterCopyMode(sess, ac)
248287 awaitFrame(t, sends, ports.MsgOutput)
@@ -259,10 +298,10 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
259298 func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) {
260299 p, _ := newBlockingPTY(t)
261300 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
262- sess.tabs[0].scrollback = scopy.NewScrollback(1)
301+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(1)
263302 longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1)
264- sess.tabs[0].screen.Frame = renderer.NewFrame(len(longLine), 1)
265- copy(sess.tabs[0].screen.Frame.Row(0), testRow(longLine))
303+ sess.tabs[0].focusedPane().screen.Frame = renderer.NewFrame(len(longLine), 1)
304+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow(longLine))
266305
267306 d.enterCopyMode(sess, ac)
268307 awaitFrame(t, sends, ports.MsgOutput)
@@ -284,8 +323,8 @@ func TestCopyModeLoneEscapeExitsAfterDelay(t *testing.T) {
284323 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
285324 clk := &signalClock{timers: make(chan *signalTimer, 1)}
286325 d.clock = clk
287- sess.tabs[0].scrollback = scopy.NewScrollback(4)
288- sess.tabs[0].screen.Write([]byte("live"))
326+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
327+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
289328
290329 d.enterCopyMode(sess, ac)
291330 awaitFrame(t, sends, ports.MsgOutput)
@@ -307,8 +346,8 @@ func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) {
307346 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
308347 clk := &signalClock{timers: make(chan *signalTimer, 1)}
309348 d.clock = clk
310- sess.tabs[0].scrollback = scopy.NewScrollback(4)
311- sess.tabs[0].screen.Write([]byte("live"))
349+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
350+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
312351
313352 d.enterCopyMode(sess, ac)
314353 awaitFrame(t, sends, ports.MsgOutput)
@@ -342,8 +381,8 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
342381 t.Run(tc.name, func(t *testing.T) {
343382 p, _ := newBlockingPTY(t)
344383 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
345- sess.tabs[0].scrollback = scopy.NewScrollback(4)
346- sess.tabs[0].screen.Write([]byte("live"))
384+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
385+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
347386
348387 d.enterCopyMode(sess, ac)
349388 awaitFrame(t, sends, ports.MsgOutput)
@@ -368,8 +407,8 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
368407 func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
369408 p, _ := newBlockingPTY(t)
370409 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
371- sess.tabs[0].scrollback = scopy.NewScrollback(4)
372- sess.tabs[0].screen.Write([]byte("live"))
410+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
411+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
373412
374413 done := make(chan struct{})
375414 var drain sync.WaitGroup
@@ -401,7 +440,7 @@ func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
401440 func TestCursorTailHidesInCopyMode(t *testing.T) {
402441 p, _ := newBlockingPTY(t)
403442 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
404- sess.tabs[0].screen.Write([]byte("live"))
443+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
405444
406445 d.enterCopyMode(sess, ac)
407446 data := mustOutputData(t, sends)
M internal/usecase/daemon/daemon.go +10 -6
@@ -17,12 +17,11 @@
1717 // when the parent context is cancelled (graceful shutdown notifies any
1818 // attached clients with ReasonServerShutdown).
1919 //
20-// Locking: a session's screen and per-client renderer shadow are both guarded
21-// by tab.mu; the attached-client pointer by session.mu; the registry by
22-// Daemon.mu. When more than one is held the order is always
23-// Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >
24-// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on
25-// a slow client.
20+// Locking: a pane's screen/scrollback and per-client renderer shadow are
21+// guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
22+// session.mu; the registry by Daemon.mu. When more than one is held the order
23+// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
24+// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
2625 package daemon
2726
2827 import (
@@ -100,6 +99,7 @@ type Daemon struct {
10099 persist *persist.Persister
101100 persistEnabled bool
102101 procCwd func(int) (string, error)
102+ procComm func(int) (string, error)
103103
104104 serveCtx context.Context
105105 serveCancel context.CancelFunc
@@ -176,6 +176,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
176176 shell: shell,
177177 persist: persist.New(nil),
178178 procCwd: platform.ProcessCwd,
179+ procComm: platform.ProcessComm,
179180 done: make(chan struct{}),
180181 animWake: make(chan struct{}, 1),
181182 }
@@ -188,6 +189,9 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
188189 if d.procCwd == nil {
189190 d.procCwd = platform.ProcessCwd
190191 }
192+ if d.procComm == nil {
193+ d.procComm = platform.ProcessComm
194+ }
191195 if records, err := d.persist.LoadAll(); err != nil {
192196 d.log.Warn("loading persisted sessions failed", "err", err)
193197 } else {
M internal/usecase/daemon/daemon_test.go +6 -1
@@ -243,7 +243,12 @@ func newManualSessionWithPTYs(t *testing.T, ptys ...ports.PTY) (*Daemon, *sessio
243243 tabs := make([]*tab, 0, len(ptys))
244244 for _, p := range ptys {
245245 wctx, wcancel := context.WithCancel(sctx)
246- tabs = append(tabs, &tab{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: wctx, cancel: wcancel})
246+ tb := newTab(p, domain.Size{Cols: 80, Rows: 23})
247+ tb.ctx, tb.cancel = wctx, wcancel
248+ for _, pane := range tb.panes {
249+ pane.ctx, pane.cancel = wctx, wcancel
250+ }
251+ tabs = append(tabs, tb)
247252 }
248253 sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: tabs, client: ac}
249254 ac.setSession(sess)
M internal/usecase/daemon/input.go +168 -24
@@ -17,12 +17,11 @@
1717 // when the parent context is cancelled (graceful shutdown notifies any
1818 // attached clients with ReasonServerShutdown).
1919 //
20-// Locking: a session's screen and per-client renderer shadow are both guarded
21-// by tab.mu; the attached-client pointer by session.mu; the registry by
22-// Daemon.mu. When more than one is held the order is always
23-// Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >
24-// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on
25-// a slow client.
20+// Locking: a pane's screen/scrollback and per-client renderer shadow are
21+// guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
22+// session.mu; the registry by Daemon.mu. When more than one is held the order
23+// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
24+// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
2625 package daemon
2726
2827 import (
@@ -31,6 +30,7 @@ import (
3130
3231 scopy "github.com/bnema/vev/internal/usecase/copy"
3332 "github.com/bnema/vev/internal/usecase/keys"
33+ "github.com/bnema/vev/internal/usecase/layout"
3434 "github.com/bnema/vev/internal/usecase/mouse"
3535 )
3636
@@ -73,6 +73,39 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
7373 }
7474
7575 if ac.copyModeActive() {
76+ if ev.Button == mouse.Left {
77+ contentRow := ev.Row - 1
78+ tb.mu.Lock()
79+ p := tb.focusedPane()
80+ var pl layout.Placement
81+ var ok bool
82+ multi := len(tb.panes) > 1
83+ if p != nil {
84+ pl, ok = focusedPlacementLocked(tb)
85+ }
86+ if ok && multi && !pointInRect(ev.Col, contentRow, pl.Content) && ev.Type == mouse.Press {
87+ if target, hit := hitTestPlacementLocked(tb, ev.Col, contentRow); hit {
88+ oldFocus := tb.tree.Focus
89+ focusPlacementLocked(tb, target.ID)
90+ d.applyLayoutLocked(tb)
91+ tb.mu.Unlock()
92+ d.exitCopyMode(ac)
93+ if target.ID != oldFocus {
94+ d.refreshPaneTitleOnFocus(sess, target.ID)
95+ }
96+ d.paint(sess, ac, true)
97+ return
98+ }
99+ }
100+ tb.mu.Unlock()
101+ if ok && multi {
102+ clampedCol := clampInt(ev.Col, pl.Content.X, pl.Content.X+pl.Content.Width-1)
103+ clampedRow := clampInt(contentRow, pl.Content.Y, pl.Content.Y+pl.Content.Height-1)
104+ ev.Col = clampedCol
105+ ev.Row = clampedRow
106+ ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y)
107+ }
108+ }
76109 switch ev.Button {
77110 case mouse.Left:
78111 d.copyMouse(sess, ac, ev)
@@ -85,20 +118,83 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
85118 }
86119
87120 tb.mu.Lock()
88- childRows := tb.screen.Frame.Height
89- mouseMode, mouseSGR := tb.screen.MouseMode()
90- altScreen := tb.screen.AltScreenActive()
121+ contentRow := ev.Row - 1
122+ pl, hit := hitTestPlacementLocked(tb, ev.Col, contentRow)
123+ multi := len(tb.panes) > 1
124+ focusedID := layout.PaneID("")
125+ if tb.tree != nil {
126+ focusedID = tb.tree.Focus
127+ }
128+ if hit && pointInRect(ev.Col, contentRow, pl.TitleBar) {
129+ if !isMouseFocusPress(ev) {
130+ tb.mu.Unlock()
131+ return
132+ }
133+ oldFocus := focusedID
134+ focusPlacementLocked(tb, pl.ID)
135+ d.applyLayoutLocked(tb)
136+ tb.mu.Unlock()
137+ if pl.ID != oldFocus {
138+ d.exitCopyMode(ac)
139+ d.refreshPaneTitleOnFocus(sess, pl.ID)
140+ }
141+ d.paint(sess, ac, true)
142+ return
143+ }
144+ var p *pane
145+ translated := false
146+ hoveredFocused := true
147+ if hit && !pl.Collapsed && pointInRect(ev.Col, contentRow, pl.Content) {
148+ oldFocus := focusedID
149+ if isMouseFocusPress(ev) {
150+ focusPlacementLocked(tb, pl.ID)
151+ d.applyLayoutLocked(tb)
152+ }
153+ p = tb.panes[pl.ID]
154+ hoveredFocused = pl.ID == oldFocus
155+ tb.mu.Unlock()
156+ if p == nil {
157+ return
158+ }
159+ if isMouseFocusPress(ev) && pl.ID != oldFocus {
160+ d.exitCopyMode(ac)
161+ d.refreshPaneTitleOnFocus(sess, pl.ID)
162+ d.paint(sess, ac, true)
163+ }
164+ if multi {
165+ ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y)
166+ translated = true
167+ }
168+ } else {
169+ if multi {
170+ tb.mu.Unlock()
171+ return
172+ }
173+ p = tb.focusedPane()
174+ tb.mu.Unlock()
175+ if p == nil {
176+ return
177+ }
178+ }
179+ p.mu.Lock()
180+ childRows := p.screen.Frame.Height
181+ mouseMode, mouseSGR := p.screen.MouseMode()
182+ altScreen := p.screen.AltScreenActive()
91183 scrollbackRows := 0
92- if tb.scrollback != nil {
93- scrollbackRows = tb.scrollback.Len()
184+ if p.scrollback != nil {
185+ scrollbackRows = p.scrollback.Len()
94186 }
95- tb.mu.Unlock()
187+ p.mu.Unlock()
96188
97189 if mouseMode != 0 {
98190 if !mouseSGR || ev.Row == 0 || ev.Row > childRows {
99191 return
100192 }
101- daemonKeyHandler{d: d, ac: ac}.Forward(sgrRowOffset(ev.Raw, -1))
193+ if translated {
194+ d.writeToPane(sess, p, ev.Raw)
195+ } else {
196+ d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -1))
197+ }
102198 return
103199 }
104200
@@ -130,9 +226,9 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
130226 return
131227 }
132228
133- tb.mu.Lock()
134- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
135- tb.mu.Unlock()
229+ p.mu.Lock()
230+ snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
231+ p.mu.Unlock()
136232
137233 ac.copyMu.Lock()
138234 mode := scopy.NewMode(snap)
@@ -152,19 +248,53 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
152248 }
153249 case mouse.WheelUp:
154250 if altScreen {
155- daemonKeyHandler{d: d, ac: ac}.Forward([]byte("\x1b[A\x1b[A\x1b[A"))
251+ d.writeToPane(sess, p, []byte("\x1b[A\x1b[A\x1b[A"))
252+ return
253+ }
254+ if !hoveredFocused {
156255 return
157256 }
158257 d.enterCopyMode(sess, ac)
159258 d.copyWheel(sess, ac, -3)
160259 case mouse.WheelDown:
161260 if altScreen {
162- daemonKeyHandler{d: d, ac: ac}.Forward([]byte("\x1b[B\x1b[B\x1b[B"))
261+ d.writeToPane(sess, p, []byte("\x1b[B\x1b[B\x1b[B"))
163262 }
164263 }
165264 }
166265
266+func isMouseFocusPress(ev mouse.Event) bool {
267+ return ev.Type == mouse.Press && (ev.Button == mouse.Left || ev.Button == mouse.Middle || ev.Button == mouse.Right)
268+}
269+
270+func clampInt(v, lo, hi int) int {
271+ if v < lo {
272+ return lo
273+ }
274+ if v > hi {
275+ return hi
276+ }
277+ return v
278+}
279+
280+func (d *Daemon) writeToPane(sess *session, p *pane, data []byte) {
281+ if p == nil || p.pty == nil {
282+ return
283+ }
284+ if _, err := p.pty.Write(data); err != nil {
285+ name := ""
286+ if sess != nil {
287+ name = sess.name
288+ }
289+ d.log.Error("pty write failed", "err", err, "session", name)
290+ }
291+}
292+
167293 func sgrRowOffset(raw []byte, delta int) []byte {
294+ return sgrOffset(raw, 0, delta)
295+}
296+
297+func sgrOffset(raw []byte, colDelta, rowDelta int) []byte {
168298 if len(raw) < len("\x1b[<0;1;1M") {
169299 return raw
170300 }
@@ -177,12 +307,17 @@ func sgrRowOffset(raw []byte, delta int) []byte {
177307 if len(parts) != 3 {
178308 return raw
179309 }
310+ cx, err := strconv.Atoi(string(parts[1]))
311+ if err != nil {
312+ return raw
313+ }
180314 cy, err := strconv.Atoi(string(parts[2]))
181315 if err != nil {
182316 return raw
183317 }
184- cy += delta
185- if cy < 1 {
318+ cx += colDelta
319+ cy += rowDelta
320+ if cx < 1 || cy < 1 {
186321 return raw
187322 }
188323
@@ -190,7 +325,7 @@ func sgrRowOffset(raw []byte, delta int) []byte {
190325 out = append(out, raw[:3]...)
191326 out = append(out, parts[0]...)
192327 out = append(out, ';')
193- out = append(out, parts[1]...)
328+ out = strconv.AppendInt(out, int64(cx), 10)
194329 out = append(out, ';')
195330 out = strconv.AppendInt(out, int64(cy), 10)
196331 out = append(out, raw[end])
@@ -211,9 +346,10 @@ func (h daemonKeyHandler) Forward(data []byte) {
211346 if tb == nil {
212347 return
213348 }
214- if _, err := tb.pty.Write(data); err != nil {
215- h.d.log.Error("pty write failed", "err", err, "session", sess.name)
216- }
349+ tb.mu.Lock()
350+ p := tb.focusedPane()
351+ tb.mu.Unlock()
352+ h.d.writeToPane(sess, p, data)
217353 }
218354
219355 func (h daemonKeyHandler) Action(action keys.Action) {
@@ -226,6 +362,14 @@ func (h daemonKeyHandler) Action(action keys.Action) {
226362 h.d.enterPalette(sess, h.ac)
227363 case keys.ActionJumpAttention:
228364 h.d.jumpAttention(sess, h.ac)
365+ case keys.ActionFocusPaneLeft:
366+ _ = h.d.focusDir(sess, h.ac, layout.Left)
367+ case keys.ActionFocusPaneRight:
368+ _ = h.d.focusDir(sess, h.ac, layout.Right)
369+ case keys.ActionFocusPaneUp:
370+ _ = h.d.focusDir(sess, h.ac, layout.Up)
371+ case keys.ActionFocusPaneDown:
372+ _ = h.d.focusDir(sess, h.ac, layout.Down)
229373 case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,
230374 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
231375 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
M internal/usecase/daemon/input_test.go +162 -18
@@ -7,11 +7,14 @@ import (
77 "testing"
88 "time"
99
10+ "github.com/stretchr/testify/mock"
1011 "github.com/stretchr/testify/require"
1112
1213 "github.com/bnema/vev/internal/domain"
1314 "github.com/bnema/vev/internal/ports"
15+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
1416 scopy "github.com/bnema/vev/internal/usecase/copy"
17+ "github.com/bnema/vev/internal/usecase/layout"
1518 )
1619
1720 // --- test doubles -----------------------------------------------------------
@@ -169,7 +172,7 @@ func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) {
169172 p, _ := newBlockingPTYWithWrites(t, writes)
170173 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
171174 win := sess.tabs[0]
172- win.screen.Write([]byte("live"))
175+ win.focusedPane().screen.Write([]byte("live"))
173176
174177 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
175178 data := mustOutputData(t, sends)
@@ -196,7 +199,7 @@ func TestMouseAltScreenWheelMapsToArrows(t *testing.T) {
196199 writes := make(chan []byte, 2)
197200 p, _ := newBlockingPTYWithWrites(t, writes)
198201 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
199- sess.tabs[0].screen.Write([]byte("\x1b[?1049h"))
202+ sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1049h"))
200203
201204 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
202205 d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
@@ -246,7 +249,7 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
246249 }
247250 require.Nil(t, ac.copyMode, "press alone must not enter visual mode")
248251
249- sess.tabs[0].screen.Write([]byte("\x1b[?1000h"))
252+ sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1000h"))
250253 raw := []byte("\x1b[<0;2;3M")
251254 d.handleInput(sess, ac, raw)
252255 select {
@@ -255,7 +258,7 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
255258 default:
256259 }
257260
258- sess.tabs[0].screen.Write([]byte("\x1b[?1006h"))
261+ sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1006h"))
259262 d.handleInput(sess, ac, raw)
260263 require.Equal(t, []byte("\x1b[<0;2;2M"), <-writes)
261264
@@ -274,7 +277,7 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
274277 default:
275278 }
276279
277- sess.tabs[0].screen.Write([]byte("\x1b[?1006l\x1b[?1000l"))
280+ sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1006l\x1b[?1000l"))
278281 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))
279282 require.Nil(t, ac.copyMode, "press alone must still not enter visual mode")
280283 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
@@ -289,9 +292,9 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
289292 func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {
290293 p, _ := newBlockingPTY(t)
291294 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
292- copy(sess.tabs[0].screen.Frame.Row(0), testRow("alpha"))
293- copy(sess.tabs[0].screen.Frame.Row(1), testRow("bravo"))
294- copy(sess.tabs[0].screen.Frame.Row(2), testRow("charlie"))
295+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha"))
296+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(1), testRow("bravo"))
297+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(2), testRow("charlie"))
295298
296299 d.enterCopyMode(sess, ac)
297300 mustOutputData(t, sends)
@@ -328,7 +331,7 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {
328331 func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) {
329332 p, _ := newBlockingPTY(t)
330333 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
331- require.Equal(t, 23, sess.tabs[0].screen.Frame.Height, "fixture assumption: status row is wire row 24")
334+ require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")
332335
333336 // Press on a content row establishes a (soon to be stale) anchor.
334337 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))
@@ -357,10 +360,10 @@ func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) {
357360 func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
358361 p, _ := newBlockingPTY(t)
359362 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
360- copy(sess.tabs[0].screen.Frame.Row(0), testRow("alpha"))
361- copy(sess.tabs[0].screen.Frame.Row(1), testRow("bravo"))
362- copy(sess.tabs[0].screen.Frame.Row(2), testRow("charlie"))
363- require.Equal(t, 23, sess.tabs[0].screen.Frame.Height, "fixture assumption: status row is wire row 24")
363+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha"))
364+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(1), testRow("bravo"))
365+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(2), testRow("charlie"))
366+ require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")
364367
365368 d.enterCopyMode(sess, ac)
366369 mustOutputData(t, sends)
@@ -397,8 +400,8 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
397400 func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {
398401 p, _ := newBlockingPTY(t)
399402 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
400- sess.tabs[0].scrollback = scopy.NewScrollback(50)
401- require.Equal(t, 23, sess.tabs[0].screen.Frame.Height, "fixture assumption: status row is wire row 24")
403+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(50)
404+ require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")
402405
403406 // Press on a content row while scrollback is empty: the anchor is
404407 // content-stable at pressTop(0)+pressRow(0) = row 0.
@@ -406,8 +409,8 @@ func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {
406409
407410 // Simulate 5 lines evicted into scrollback between the Press and the
408411 // first Motion (e.g. the child kept producing output).
409- for i := 0; i < 5; i++ {
410- sess.tabs[0].scrollback.Append(testRow("evicted"))
412+ for range 5 {
413+ sess.tabs[0].focusedPane().scrollback.Append(testRow("evicted"))
411414 }
412415
413416 // Motion lands on wire row 3 (0-based row 2 of the *current* screen).
@@ -427,7 +430,7 @@ func TestMouseSplitReportPreservesOrder(t *testing.T) {
427430 writes := make(chan []byte, 2)
428431 p, _ := newBlockingPTYWithWrites(t, writes)
429432 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
430- sess.tabs[0].screen.Write([]byte("live"))
433+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
431434
432435 d.handleInput(sess, ac, []byte("\x1b[<64;"))
433436 d.handleInput(sess, ac, []byte("1;1Mq"))
@@ -441,3 +444,144 @@ func TestMouseSplitReportPreservesOrder(t *testing.T) {
441444 default:
442445 }
443446 }
447+
448+func TestMouseWheelOverUnfocusedPaneDoesNotFocusAndForwardsChildMouse(t *testing.T) {
449+ p1 := portsmocks.NewMockPTY(t)
450+ p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
451+ p2 := portsmocks.NewMockPTY(t)
452+ p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
453+ p2.EXPECT().Write([]byte("\x1b[<64;1;1M")).Return(len("\x1b[<64;1;1M"), nil).Once()
454+ d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
455+ d.procComm = nil
456+ tb := sess.activeTab()
457+ p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5})
458+ p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
459+ tb.mu.Lock()
460+ tb.size = domain.Size{Cols: 41, Rows: 5}
461+ tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
462+ tb.tree.Focus = "pane-1"
463+ tb.panes["pane-2"] = p2pane
464+ tb.mu.Unlock()
465+
466+ d.handleInput(sess, ac, []byte("\x1b[<64;22;2M"))
467+
468+ require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)
469+}
470+
471+func TestMouseDividerAndTitleBarDoNotForwardBogusCoordinates(t *testing.T) {
472+ cases := []struct {
473+ name string
474+ raw []byte
475+ root *layout.Node
476+ size domain.Size
477+ }{
478+ {
479+ name: "divider",
480+ raw: []byte("\x1b[<0;21;2M"),
481+ root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}},
482+ size: domain.Size{Cols: 41, Rows: 5},
483+ },
484+ {
485+ name: "expanded title bar",
486+ raw: []byte("\x1b[<0;1;1M"),
487+ root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}, Expanded: "pane-1"},
488+ size: domain.Size{Cols: 20, Rows: 5},
489+ },
490+ }
491+ for _, tc := range cases {
492+ t.Run(tc.name, func(t *testing.T) {
493+ p1 := portsmocks.NewMockPTY(t)
494+ p1.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
495+ p2 := portsmocks.NewMockPTY(t)
496+ p2.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
497+ d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
498+ d.procComm = nil
499+ tb := sess.activeTab()
500+ tb.focusedPane().screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
501+ tb.mu.Lock()
502+ tb.size = tc.size
503+ tb.tree.Root = tc.root
504+ tb.tree.Focus = "pane-1"
505+ tb.panes["pane-2"] = newPane("pane-2", p2, tc.size)
506+ tb.mu.Unlock()
507+
508+ d.handleInput(sess, ac, tc.raw)
509+
510+ require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)
511+ })
512+ }
513+}
514+
515+func TestCopyModeDragOutsideSplitPaneClampsToPaneContent(t *testing.T) {
516+ p1 := portsmocks.NewMockPTY(t)
517+ p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Maybe()
518+ p2 := portsmocks.NewMockPTY(t)
519+ p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Maybe()
520+ d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
521+ d.procComm = nil
522+ tb := sess.activeTab()
523+ for i := range 10 {
524+ copy(tb.focusedPane().screen.Frame.Row(i), testRow(string(rune('a'+i))))
525+ }
526+ tb.mu.Lock()
527+ tb.size = domain.Size{Cols: 41, Rows: 10}
528+ tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
529+ tb.tree.Focus = "pane-1"
530+ tb.panes["pane-2"] = newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 10})
531+ tb.mu.Unlock()
532+ d.enterCopyMode(sess, ac)
533+ mustOutputData(t, sends)
534+
535+ d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;12M"))
536+ mustOutputData(t, sends)
537+
538+ lo, hi, ok := ac.copyMode.SelectedBounds()
539+ require.True(t, ok)
540+ require.Equal(t, 0, lo)
541+ require.Equal(t, 9, hi)
542+}
543+
544+func TestMouseHitTestFocusesPaneAndTranslatesSGRColumns(t *testing.T) {
545+ p1 := portsmocks.NewMockPTY(t)
546+ p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
547+ p2 := portsmocks.NewMockPTY(t)
548+ p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
549+ p2.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once()
550+ d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
551+ d.procComm = nil
552+ tb := sess.activeTab()
553+ p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5})
554+ p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
555+ tb.mu.Lock()
556+ tb.size = domain.Size{Cols: 41, Rows: 5}
557+ tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
558+ tb.tree.Focus = "pane-1"
559+ tb.panes["pane-2"] = p2pane
560+ tb.mu.Unlock()
561+
562+ d.handleInput(sess, ac, []byte("\x1b[<0;22;2M"))
563+
564+ require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
565+}
566+
567+func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) {
568+ p1 := portsmocks.NewMockPTY(t)
569+ p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 3}).Return(nil).Maybe()
570+ p2 := portsmocks.NewMockPTY(t)
571+ p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 3}).Return(nil).Maybe()
572+ d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
573+ d.procComm = nil
574+ tb := sess.activeTab()
575+ p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 3})
576+ tb.mu.Lock()
577+ tb.size = domain.Size{Cols: 20, Rows: 5}
578+ tb.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}, Expanded: "pane-1"}
579+ tb.tree.Focus = "pane-1"
580+ tb.panes["pane-2"] = p2pane
581+ tb.mu.Unlock()
582+
583+ d.handleInput(sess, ac, []byte("\x1b[<0;1;6M"))
584+
585+ require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
586+ require.Equal(t, layout.PaneID("pane-2"), tb.tree.Root.Expanded)
587+}
A internal/usecase/daemon/mouse_panes.go +151 -0
@@ -0,0 +1,151 @@
1+package daemon
2+
3+import (
4+ "strconv"
5+ "strings"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/usecase/layout"
9+ "github.com/bnema/vev/internal/usecase/mouse"
10+)
11+
12+func focusedPlacementLocked(tb *tab) (layout.Placement, bool) {
13+ if tb == nil || tb.tree == nil {
14+ return layout.Placement{}, false
15+ }
16+ placements, ok := solvedPlacementsLocked(tb)
17+ if !ok {
18+ return layout.Placement{}, false
19+ }
20+ for _, pl := range placements {
21+ if pl.ID == tb.tree.Focus {
22+ return pl, true
23+ }
24+ }
25+ return layout.Placement{}, false
26+}
27+
28+func hitTestPlacementLocked(tb *tab, col, row int) (layout.Placement, bool) {
29+ if tb == nil || tb.tree == nil || tb.tree.Root == nil || !tb.size.Valid() {
30+ return layout.Placement{}, false
31+ }
32+ placements, ok := solvedPlacementsLocked(tb)
33+ if !ok {
34+ return layout.Placement{}, false
35+ }
36+ for _, pl := range placements {
37+ if pointInRect(col, row, pl.TitleBar) || pointInRect(col, row, pl.Content) {
38+ return pl, true
39+ }
40+ }
41+ return layout.Placement{}, false
42+}
43+
44+type tabLayoutSnapshot struct {
45+ root *layout.Node
46+ fingerprint string
47+ area domain.Rect
48+ focus layout.PaneID
49+ placements []layout.Placement
50+ ok bool
51+}
52+
53+func solvedPlacementsLocked(tb *tab) ([]layout.Placement, bool) {
54+ snap := solveTabLayoutLocked(tb)
55+ return snap.placements, snap.ok
56+}
57+
58+func solveTabLayoutLocked(tb *tab) tabLayoutSnapshot {
59+ if tb == nil || tb.tree == nil || tb.tree.Root == nil || !tb.size.Valid() {
60+ return tabLayoutSnapshot{}
61+ }
62+ area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
63+ placements, ok := layout.Solve(tb.tree.Root, area)
64+ return tabLayoutSnapshot{root: tb.tree.Root, fingerprint: layoutFingerprint(tb.tree.Root), area: area, focus: tb.tree.Focus, placements: placements, ok: ok}
65+}
66+
67+func (snap tabLayoutSnapshot) matchesLocked(tb *tab) bool {
68+ return snap.ok && tb != nil && tb.tree != nil && tb.tree.Root == snap.root && tb.tree.Focus == snap.focus && tb.size.Cols == snap.area.Width && tb.size.Rows == snap.area.Height && snap.fingerprint == layoutFingerprint(tb.tree.Root)
69+}
70+
71+func layoutFingerprint(root *layout.Node) string {
72+ var b strings.Builder
73+ writeLayoutFingerprint(&b, root)
74+ return b.String()
75+}
76+
77+func writeLayoutFingerprint(b *strings.Builder, n *layout.Node) {
78+ if n == nil {
79+ b.WriteByte('0')
80+ return
81+ }
82+ b.WriteByte(byte('0' + n.Kind))
83+ b.WriteByte(byte('0' + n.Dir))
84+ writePaneIDFingerprint(b, n.Leaf)
85+ b.WriteByte('|')
86+ writePaneIDFingerprint(b, n.Expanded)
87+ b.WriteByte('[')
88+ for _, child := range n.Children {
89+ writeLayoutFingerprint(b, child)
90+ b.WriteByte(',')
91+ }
92+ b.WriteByte(']')
93+}
94+
95+func writePaneIDFingerprint(b *strings.Builder, id layout.PaneID) {
96+ s := string(id)
97+ b.WriteString(strconv.Itoa(len(s)))
98+ b.WriteByte(':')
99+ b.WriteString(s)
100+}
101+
102+func pointInRect(col, row int, r domain.Rect) bool {
103+ return r.Width > 0 && r.Height > 0 && col >= r.X && col < r.X+r.Width && row >= r.Y && row < r.Y+r.Height
104+}
105+
106+func focusPlacementLocked(tb *tab, id layout.PaneID) {
107+ if tb == nil || tb.tree == nil {
108+ return
109+ }
110+ tb.tree.Focus = id
111+ setExpandedLocked(tb.tree.Root, id)
112+}
113+
114+func setExpandedLocked(n *layout.Node, id layout.PaneID) bool {
115+ if n == nil {
116+ return false
117+ }
118+ if n.Kind == layout.Stack {
119+ for _, child := range n.Children {
120+ if child.Kind == layout.Leaf && child.Leaf == id {
121+ n.Expanded = id
122+ return true
123+ }
124+ }
125+ }
126+ for _, child := range n.Children {
127+ if setExpandedLocked(child, id) {
128+ return true
129+ }
130+ }
131+ return false
132+}
133+
134+func translateMouseEvent(ev mouse.Event, colOffset, contentYOffset int) mouse.Event {
135+ ev.Col -= colOffset
136+ ev.Row -= contentYOffset
137+ ev.Raw = sgrOffset(ev.Raw, -colOffset, -contentYOffset-1)
138+ return ev
139+}
140+
141+func (d *Daemon) exitCopyMode(ac *attachedClient) {
142+ if ac == nil {
143+ return
144+ }
145+ ac.copyMu.Lock()
146+ ac.copyMode = nil
147+ ac.copyPressRowValid = false
148+ ac.copyDragging = false
149+ ac.normalMousePressValid = false
150+ ac.copyMu.Unlock()
151+}
A internal/usecase/daemon/nil_pty_test.go +65 -0
@@ -0,0 +1,65 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "io"
6+ "log/slog"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/internal/usecase/layout"
14+)
15+
16+func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) {
17+ d := New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
18+ ctx, cancel := context.WithCancel(context.Background())
19+ defer cancel()
20+ sess := &session{id: "s", name: "s", ctx: ctx, cancel: cancel}
21+ first := newTab(nil, domain.Size{Cols: 41, Rows: 10})
22+ second := newTab(nil, domain.Size{Cols: 41, Rows: 10})
23+ sess.tabs = []*tab{first, second}
24+
25+ require.NotPanics(t, func() {
26+ d.resizePane(first.focusedPane(), domain.Rect{Width: 20, Height: 5})
27+ })
28+ require.Equal(t, 20, first.focusedPane().screen.Frame.Width)
29+ require.Equal(t, 5, first.focusedPane().screen.Frame.Height)
30+
31+ first.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"}
32+ first.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
33+ require.NotPanics(t, func() {
34+ require.NoError(t, d.closePane(sess, first, "pane-2", nil, false))
35+ })
36+ require.NotContains(t, first.panes, layout.PaneID("pane-2"))
37+
38+ require.NotPanics(t, func() { d.closeTab(sess, second, false) })
39+ require.Len(t, sess.tabs, 1)
40+}
41+
42+func TestKillSessionIgnoresNilPanePTYs(t *testing.T) {
43+ d := New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
44+ ctx, cancel := context.WithCancel(context.Background())
45+ sess := &session{id: "s", name: "s", ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{newTab(nil, domain.Size{Cols: 10, Rows: 3})}}
46+ d.sessions[sess.id] = sess
47+
48+ require.NotPanics(t, func() {
49+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))
50+ })
51+ require.NotContains(t, d.sessions, sess.id)
52+}
53+
54+func TestNilPTYInputPathsDoNotPanic(t *testing.T) {
55+ d := New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
56+ ctx, cancel := context.WithCancel(context.Background())
57+ defer cancel()
58+ tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
59+ sess := &session{id: "s", name: "s", ctx: ctx, cancel: cancel, tabs: []*tab{tb}}
60+ ac := &attachedClient{}
61+ ac.setSession(sess)
62+
63+ require.NotPanics(t, func() { d.writeToPane(sess, tb.focusedPane(), []byte("x")) })
64+ require.NotPanics(t, func() { daemonKeyHandler{d: d, ac: ac}.Forward([]byte("x")) })
65+}
M internal/usecase/daemon/palette.go +45 -0
@@ -3,6 +3,7 @@ package daemon
33 import (
44 "github.com/bnema/vev/internal/domain"
55 "github.com/bnema/vev/internal/usecase/command"
6+ "github.com/bnema/vev/internal/usecase/layout"
67 "github.com/bnema/vev/internal/usecase/palette"
78 "github.com/bnema/vev/internal/usecase/ui"
89 "github.com/bnema/vev/pkg/renderer"
@@ -159,6 +160,50 @@ func (e paletteExec) CloseTab() error {
159160 return nil
160161 }
161162
163+func (e paletteExec) SplitRight() error {
164+ return e.d.splitPane(e.sess, e.ac, layout.Right)
165+}
166+
167+func (e paletteExec) SplitLeft() error {
168+ return e.d.splitPane(e.sess, e.ac, layout.Left)
169+}
170+
171+func (e paletteExec) SplitUp() error {
172+ return e.d.splitPane(e.sess, e.ac, layout.Up)
173+}
174+
175+func (e paletteExec) SplitDown() error {
176+ return e.d.splitPane(e.sess, e.ac, layout.Down)
177+}
178+
179+func (e paletteExec) StackPane() error {
180+ return e.d.stackPane(e.sess, e.ac)
181+}
182+
183+func (e paletteExec) ToggleStack() error {
184+ return e.d.toggleStack(e.sess, e.ac)
185+}
186+
187+func (e paletteExec) ClosePane() error {
188+ return e.d.closeFocusedPane(e.sess, e.ac)
189+}
190+
191+func (e paletteExec) FocusPaneLeft() error {
192+ return e.d.focusDir(e.sess, e.ac, layout.Left)
193+}
194+
195+func (e paletteExec) FocusPaneRight() error {
196+ return e.d.focusDir(e.sess, e.ac, layout.Right)
197+}
198+
199+func (e paletteExec) FocusPaneUp() error {
200+ return e.d.focusDir(e.sess, e.ac, layout.Up)
201+}
202+
203+func (e paletteExec) FocusPaneDown() error {
204+ return e.d.focusDir(e.sess, e.ac, layout.Down)
205+}
206+
162207 func (e paletteExec) NextTab() error {
163208 e.sess.switchRelative(1)
164209 e.d.paint(e.sess, e.ac, true)
M internal/usecase/daemon/palette_test.go +13 -2
@@ -112,12 +112,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
112112 for i, cmd := range commands {
113113 codes[i] = cmd.Code
114114 }
115- require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "PVT", "VIS", "RNS", "DET"}, codes)
115+ require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "VIS", "RNS", "DET"}, codes)
116116 }
117117
118118 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
119119 d := &Daemon{}
120- codes := []string{"CNT", "CNS", "CLT", "NXT", "PVT", "SSP", "VIS", "RNS", "DET"}
120+ codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "SSP", "VIS", "RNS", "DET"}
121121
122122 var wg sync.WaitGroup
123123 for range 50 {
@@ -307,6 +307,17 @@ func TestPaletteExecMethods(t *testing.T) {
307307 ac.copyMu.Lock()
308308 ac.copyMode = nil
309309 ac.copyMu.Unlock()
310+ require.NoError(t, exec.SplitRight())
311+ require.NoError(t, exec.SplitLeft())
312+ require.NoError(t, exec.SplitUp())
313+ require.NoError(t, exec.SplitDown())
314+ require.NoError(t, exec.StackPane())
315+ require.NoError(t, exec.ToggleStack())
316+ require.NoError(t, exec.ClosePane())
317+ require.NoError(t, exec.FocusPaneLeft())
318+ require.NoError(t, exec.FocusPaneRight())
319+ require.NoError(t, exec.FocusPaneUp())
320+ require.NoError(t, exec.FocusPaneDown())
310321 require.NoError(t, exec.CloseTab())
311322 require.Len(t, sess.tabs, 2)
312323 // Drain paints generated by methods above.
A internal/usecase/daemon/pane.go +56 -0
@@ -0,0 +1,56 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+ scopy "github.com/bnema/vev/internal/usecase/copy"
11+ "github.com/bnema/vev/internal/usecase/layout"
12+ "github.com/bnema/vev/pkg/vt"
13+)
14+
15+// pane owns one PTY, its terminal screen state, and render scheduling channels.
16+// Lock order when multiple locks are held is:
17+// attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
18+// The PTY reader takes only pane.mu, so child output never waits on client IO.
19+type pane struct {
20+ id layout.PaneID
21+ pty ports.PTY
22+ mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title
23+ screen *vt.Screen
24+ scrollback *scopy.Scrollback
25+ dirty chan struct{}
26+ flush chan struct{}
27+ syncGen uint64
28+ rect domain.Rect
29+ title string
30+ titleAt time.Time
31+ titleValid bool
32+ ctx context.Context
33+ cancel context.CancelFunc
34+}
35+
36+func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane {
37+ sb := scopy.NewScrollback(defaultScrollbackRows)
38+ screen := vt.NewScreen(sz.Cols, sz.Rows)
39+ screen.OnLineEvicted = sb.Append
40+ return &pane{
41+ id: id,
42+ pty: pty,
43+ screen: screen,
44+ scrollback: sb,
45+ dirty: make(chan struct{}, 1),
46+ flush: make(chan struct{}, 1),
47+ rect: domain.Rect{Width: sz.Cols, Height: sz.Rows},
48+ }
49+}
50+
51+func paneDone(p *pane) <-chan struct{} {
52+ if p.ctx == nil {
53+ return nil
54+ }
55+ return p.ctx.Done()
56+}
A internal/usecase/daemon/pane_actions.go +285 -0
@@ -0,0 +1,285 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/usecase/layout"
10+)
11+
12+func (d *Daemon) splitPane(sess *session, ac *attachedClient, dir layout.Direction) error {
13+ after := dir == layout.Right || dir == layout.Down
14+ return d.spawnPaneOp(sess, ac, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error {
15+ return tree.Split(oldFocus, dir, after, newID, area)
16+ })
17+}
18+
19+func (d *Daemon) spawnPaneOp(
20+ sess *session,
21+ ac *attachedClient,
22+ mutate func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error,
23+) error {
24+ if d.ptys == nil {
25+ if ac != nil {
26+ d.paint(sess, ac, true)
27+ }
28+ return nil
29+ }
30+ tb := sess.activeTab()
31+ if tb == nil {
32+ return layout.ErrNotFound
33+ }
34+
35+ sess.mu.Lock()
36+ name, cwd := sess.name, sess.cwd
37+ sess.mu.Unlock()
38+
39+ tb.mu.Lock()
40+ if tb.tree == nil || tb.tree.Root == nil {
41+ tb.mu.Unlock()
42+ return layout.ErrNotFound
43+ }
44+ oldFocus := tb.tree.Focus
45+ newID := layout.PaneID(fmt.Sprintf("pane-%d", tb.nextPaneID))
46+ area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
47+ if err := mutate(tb.tree, oldFocus, newID, area); err != nil {
48+ tb.mu.Unlock()
49+ return err
50+ }
51+ tb.nextPaneID++
52+ placements, ok := layout.Solve(tb.tree.Root, area)
53+ if !ok {
54+ _ = tb.tree.Close(newID)
55+ tb.tree.Focus = oldFocus
56+ tb.mu.Unlock()
57+ return layout.ErrTooSmall
58+ }
59+ newRect := placementContent(placements, newID)
60+ tb.mu.Unlock()
61+
62+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, rectSize(newRect))
63+ if err != nil {
64+ tb.mu.Lock()
65+ _ = tb.tree.Close(newID)
66+ tb.tree.Focus = oldFocus
67+ tb.mu.Unlock()
68+ return err
69+ }
70+
71+ pctx, cancel := context.WithCancel(tb.ctx)
72+ p := newPane(newID, pty, rectSize(newRect))
73+ p.ctx, p.cancel = pctx, cancel
74+ p.rect = newRect
75+
76+ tb.mu.Lock()
77+ if _, ok := tb.panes[newID]; ok || !layout.ContainsLeaf(tb.tree.Root, newID) {
78+ tb.mu.Unlock()
79+ cancel()
80+ _ = pty.Close()
81+ return layout.ErrNotFound
82+ }
83+ tb.panes[newID] = p
84+ tb.tree.Focus = newID
85+ d.applyLayoutLocked(tb)
86+ tb.mu.Unlock()
87+
88+ d.startPaneGoroutines(sess, tb, p)
89+ if ac != nil {
90+ d.paint(sess, ac, true)
91+ }
92+ return nil
93+}
94+
95+func (d *Daemon) applyLayoutLocked(tb *tab) {
96+ if tb == nil || tb.tree == nil || tb.tree.Root == nil || !tb.size.Valid() {
97+ return
98+ }
99+ placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
100+ if !ok {
101+ for _, p := range tb.panes {
102+ d.resizePane(p, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
103+ }
104+ return
105+ }
106+ for _, pl := range placements {
107+ if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {
108+ continue
109+ }
110+ if p := tb.panes[pl.ID]; p != nil {
111+ d.resizePane(p, pl.Content)
112+ }
113+ }
114+}
115+
116+func (d *Daemon) resizePane(p *pane, r domain.Rect) {
117+ if p == nil {
118+ return
119+ }
120+ p.mu.Lock()
121+ old := p.rect
122+ p.mu.Unlock()
123+ if old == r {
124+ return
125+ }
126+ sz := rectSize(r)
127+ if p.pty != nil {
128+ if err := p.pty.Resize(sz); err != nil {
129+ d.log.Warn("pty resize failed", "err", err)
130+ }
131+ }
132+ p.mu.Lock()
133+ p.screen.Resize(sz.Cols, sz.Rows)
134+ p.rect = r
135+ p.mu.Unlock()
136+}
137+
138+func placementContent(placements []layout.Placement, id layout.PaneID) domain.Rect {
139+ for _, pl := range placements {
140+ if pl.ID == id {
141+ return pl.Content
142+ }
143+ }
144+ return domain.Rect{}
145+}
146+
147+func rectSize(r domain.Rect) domain.Size { return domain.Size{Cols: r.Width, Rows: r.Height} }
148+
149+func (d *Daemon) stackPane(sess *session, ac *attachedClient) error {
150+ return d.spawnPaneOp(sess, ac, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error {
151+ return tree.StackNew(oldFocus, newID, area)
152+ })
153+}
154+
155+func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {
156+ if d.ptys == nil {
157+ if ac != nil {
158+ d.paint(sess, ac, true)
159+ }
160+ return nil
161+ }
162+ tb := sess.activeTab()
163+ if tb == nil {
164+ return layout.ErrNotFound
165+ }
166+ tb.mu.Lock()
167+ if tb.tree == nil {
168+ tb.mu.Unlock()
169+ return layout.ErrNotFound
170+ }
171+ err := tb.tree.ToggleStack(tb.tree.Focus, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
172+ if err == nil {
173+ d.applyLayoutLocked(tb)
174+ }
175+ tb.mu.Unlock()
176+ if err == nil && ac != nil {
177+ d.paint(sess, ac, true)
178+ }
179+ return err
180+}
181+
182+func (d *Daemon) closeFocusedPane(sess *session, ac *attachedClient) error {
183+ if d.ptys == nil {
184+ if ac != nil {
185+ d.paint(sess, ac, true)
186+ }
187+ return nil
188+ }
189+ tb := sess.activeTab()
190+ if tb == nil {
191+ return layout.ErrNotFound
192+ }
193+ tb.mu.Lock()
194+ if tb.tree == nil {
195+ tb.mu.Unlock()
196+ return layout.ErrNotFound
197+ }
198+ id := tb.tree.Focus
199+ tb.mu.Unlock()
200+ return d.closePane(sess, tb, id, ac, true)
201+}
202+
203+func (d *Daemon) reapPane(sess *session, tb *tab, p *pane) {
204+ if p == nil {
205+ return
206+ }
207+ _ = d.closePane(sess, tb, p.id, nil, true)
208+}
209+
210+func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attachedClient, repaint bool) error {
211+ if tb == nil {
212+ return layout.ErrNotFound
213+ }
214+ tb.mu.Lock()
215+ p := tb.panes[id]
216+ if p == nil || tb.tree == nil || !layout.ContainsLeaf(tb.tree.Root, id) {
217+ tb.mu.Unlock()
218+ return nil
219+ }
220+ if len(layout.LeafIDs(tb.tree.Root)) <= 1 {
221+ tb.mu.Unlock()
222+ d.closeTab(sess, tb, repaint)
223+ return nil
224+ }
225+ if err := tb.tree.Close(id); err != nil {
226+ tb.mu.Unlock()
227+ return err
228+ }
229+ delete(tb.panes, id)
230+ d.applyLayoutLocked(tb)
231+ tb.mu.Unlock()
232+
233+ if p.cancel != nil {
234+ p.cancel()
235+ }
236+ if p.pty != nil {
237+ _ = p.pty.Close()
238+ }
239+ if repaint {
240+ if ac == nil {
241+ sess.mu.Lock()
242+ ac = sess.client
243+ sess.mu.Unlock()
244+ }
245+ if ac != nil {
246+ d.paint(sess, ac, true)
247+ }
248+ }
249+ return nil
250+}
251+
252+func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction) error {
253+ tb := sess.activeTab()
254+ if tb == nil {
255+ return layout.ErrNotFound
256+ }
257+ tb.mu.Lock()
258+ if tb.tree == nil {
259+ tb.mu.Unlock()
260+ return layout.ErrNotFound
261+ }
262+ oldFocus := tb.tree.Focus
263+ err := tb.tree.FocusDir(dir, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
264+ newFocus := tb.tree.Focus
265+ if err == nil {
266+ d.applyLayoutLocked(tb)
267+ }
268+ tb.mu.Unlock()
269+ if err == nil && ac != nil {
270+ if newFocus != oldFocus {
271+ d.exitCopyMode(ac)
272+ tb.mu.Lock()
273+ pl, ok := focusedPlacementLocked(tb)
274+ tb.mu.Unlock()
275+ if ok && pl.TitleBar.Height > 0 {
276+ d.refreshPaneTitleOnFocus(sess, newFocus)
277+ }
278+ }
279+ d.paint(sess, ac, true)
280+ }
281+ if errors.Is(err, layout.ErrNoPane) {
282+ return nil
283+ }
284+ return err
285+}
A internal/usecase/daemon/pane_actions_test.go +319 -0
@@ -0,0 +1,319 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "log/slog"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/vev/internal/domain"
15+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
16+ scopy "github.com/bnema/vev/internal/usecase/copy"
17+ "github.com/bnema/vev/internal/usecase/layout"
18+ "github.com/bnema/vev/pkg/renderer"
19+)
20+
21+func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) {
22+ tests := []struct {
23+ name string
24+ dir layout.Direction
25+ size domain.Size
26+ wantNew domain.Rect
27+ wantOld domain.Rect
28+ isNewSide func(oldRect, newRect domain.Rect) bool
29+ }{
30+ {name: "right", dir: layout.Right, size: domain.Size{Cols: 41, Rows: 10}, wantOld: domain.Rect{Width: 20, Height: 10}, wantNew: domain.Rect{X: 21, Width: 20, Height: 10}, isNewSide: func(oldRect, newRect domain.Rect) bool { return newRect.X > oldRect.X }},
31+ {name: "left", dir: layout.Left, size: domain.Size{Cols: 41, Rows: 10}, wantOld: domain.Rect{X: 21, Width: 20, Height: 10}, wantNew: domain.Rect{Width: 20, Height: 10}, isNewSide: func(oldRect, newRect domain.Rect) bool { return newRect.X < oldRect.X }},
32+ {name: "down", dir: layout.Down, size: domain.Size{Cols: 80, Rows: 5}, wantOld: domain.Rect{Width: 80, Height: 2}, wantNew: domain.Rect{Y: 3, Width: 80, Height: 2}, isNewSide: func(oldRect, newRect domain.Rect) bool { return newRect.Y > oldRect.Y }},
33+ {name: "up", dir: layout.Up, size: domain.Size{Cols: 80, Rows: 5}, wantOld: domain.Rect{Y: 3, Width: 80, Height: 2}, wantNew: domain.Rect{Width: 80, Height: 2}, isNewSide: func(oldRect, newRect domain.Rect) bool { return newRect.Y < oldRect.Y }},
34+ }
35+
36+ for _, tt := range tests {
37+ t.Run(tt.name, func(t *testing.T) {
38+ d, sess, oldPTY, factory := newSplitTestDaemon(t, tt.size)
39+ newPTY := portsmocks.NewMockPTY(t)
40+ oldPTY.EXPECT().Resize(rectSize(tt.wantOld)).Return(nil).Once()
41+ newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
42+ factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", rectSize(tt.wantNew)).Return(newPTY, nil).Once()
43+
44+ require.NoError(t, d.splitPane(sess, nil, tt.dir))
45+
46+ tb := sess.activeTab()
47+ tb.mu.Lock()
48+ defer tb.mu.Unlock()
49+ require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
50+ require.Len(t, tb.panes, 2)
51+ oldRect := tb.panes["pane-1"].rect
52+ newRect := tb.panes["pane-2"].rect
53+ require.Equal(t, tt.wantOld, oldRect)
54+ require.Equal(t, tt.wantNew, newRect)
55+ require.True(t, tt.isNewSide(oldRect, newRect))
56+ })
57+ }
58+}
59+
60+func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) {
61+ d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 4})
62+ tb := sess.activeTab()
63+ stackPTY := portsmocks.NewMockPTY(t)
64+ stackPane := newPane("pane-2", stackPTY, domain.Size{Cols: 41, Rows: 2})
65+ tb.panes[stackPane.id] = stackPane
66+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}, Expanded: "pane-2"}, Focus: "pane-2"}
67+ tb.nextPaneID = 3
68+ newPTY := portsmocks.NewMockPTY(t)
69+ stackPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once()
70+ newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
71+ factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 4}).Return(newPTY, nil).Once()
72+
73+ require.NoError(t, d.splitPane(sess, nil, layout.Right))
74+
75+ tb.mu.Lock()
76+ defer tb.mu.Unlock()
77+ placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
78+ require.True(t, ok)
79+ require.Equal(t, layout.PaneID("pane-3"), tb.tree.Focus)
80+ require.Len(t, tb.panes, 3)
81+ require.Equal(t, domain.Rect{Y: 2, Width: 20, Height: 2}, placementContent(placements, "pane-2"))
82+ require.Equal(t, domain.Rect{X: 21, Width: 20, Height: 4}, placementContent(placements, "pane-3"))
83+}
84+
85+func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) {
86+ d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
87+ factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(nil, errors.New("open failed")).Once()
88+
89+ tb := sess.activeTab()
90+ tb.mu.Lock()
91+ beforeRoot, beforeFocus, beforeNext, beforePaneCount := tb.tree.Root.Leaf, tb.tree.Focus, tb.nextPaneID, len(tb.panes)
92+ tb.mu.Unlock()
93+
94+ require.Error(t, d.splitPane(sess, nil, layout.Right))
95+
96+ tb.mu.Lock()
97+ defer tb.mu.Unlock()
98+ require.Equal(t, beforeRoot, tb.tree.Root.Leaf)
99+ require.Equal(t, beforeFocus, tb.tree.Focus)
100+ require.Equal(t, beforeNext+1, tb.nextPaneID)
101+ require.Len(t, tb.panes, beforePaneCount)
102+ require.Nil(t, tb.panes["pane-2"])
103+}
104+
105+func TestApplyLayoutResizesPTYsAndScreens(t *testing.T) {
106+ d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
107+ tb := sess.activeTab()
108+ newPTY := portsmocks.NewMockPTY(t)
109+ newPane := newPane("pane-2", newPTY, domain.Size{Cols: 41, Rows: 10})
110+ 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"}
111+ tb.panes["pane-2"] = newPane
112+ oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
113+ newPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
114+
115+ tb.mu.Lock()
116+ d.applyLayoutLocked(tb)
117+ tb.mu.Unlock()
118+
119+ require.Equal(t, 20, tb.panes["pane-1"].screen.Frame.Width)
120+ require.Equal(t, 20, tb.panes["pane-2"].screen.Frame.Width)
121+ require.Equal(t, domain.Rect{X: 21, Width: 20, Height: 10}, tb.panes["pane-2"].rect)
122+}
123+
124+func TestResizeReflowsAllPanes(t *testing.T) {
125+ d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
126+ tb := sess.activeTab()
127+ newPTY := portsmocks.NewMockPTY(t)
128+ 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"}
129+ tb.panes["pane-2"] = newPane("pane-2", newPTY, domain.Size{Cols: 20, Rows: 10})
130+ tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
131+ tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
132+ oldPTY.EXPECT().Resize(domain.Size{Cols: 30, Rows: 18}).Return(nil).Once()
133+ newPTY.EXPECT().Resize(domain.Size{Cols: 29, Rows: 18}).Return(nil).Once()
134+
135+ d.resize(sess, nil, domain.Size{Cols: 60, Rows: 20})
136+
137+ require.Equal(t, domain.Size{Cols: 60, Rows: 18}, tb.size)
138+ require.Equal(t, domain.Rect{Width: 30, Height: 18}, tb.panes["pane-1"].rect)
139+ require.Equal(t, domain.Rect{X: 31, Width: 29, Height: 18}, tb.panes["pane-2"].rect)
140+ require.Equal(t, 30, tb.panes["pane-1"].screen.Frame.Width)
141+ require.Equal(t, 29, tb.panes["pane-2"].screen.Frame.Width)
142+}
143+
144+func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) {
145+ d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
146+ tb := sess.activeTab()
147+ newPTY := portsmocks.NewMockPTY(t)
148+ 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-1"}
149+ tb.panes["pane-2"] = newPane("pane-2", newPTY, domain.Size{Cols: 20, Rows: 10})
150+ tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
151+ tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
152+ tr, _ := newCapturingTransport(t)
153+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 41, Rows: 12}, copyMode: &scopy.Mode{}}
154+
155+ require.NoError(t, d.focusDir(sess, ac, layout.Right))
156+
157+ require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
158+ require.Nil(t, ac.copyMode)
159+ oldPTY.AssertNotCalled(t, "Resize", mock.Anything)
160+ newPTY.AssertNotCalled(t, "Resize", mock.Anything)
161+}
162+
163+func TestCloseFocusedPaneRemovesPaneReflowsAndIsIdempotent(t *testing.T) {
164+ d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
165+ tb := sess.activeTab()
166+ closingPTY := portsmocks.NewMockPTY(t)
167+ 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"}
168+ tb.panes["pane-2"] = newPane("pane-2", closingPTY, domain.Size{Cols: 20, Rows: 10})
169+ tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
170+ tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
171+ oldPTY.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Return(nil).Once()
172+ closingPTY.EXPECT().Close().Return(nil).Once()
173+
174+ require.NoError(t, d.closeFocusedPane(sess, nil))
175+ require.NoError(t, d.closePane(sess, tb, "pane-2", nil, true))
176+
177+ require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)
178+ require.Len(t, tb.panes, 1)
179+ require.Equal(t, domain.Rect{Width: 41, Height: 10}, tb.panes["pane-1"].rect)
180+}
181+
182+func TestReapPaneSharesClosePathAndIsIdempotent(t *testing.T) {
183+ d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
184+ tb := sess.activeTab()
185+ reapedPTY := portsmocks.NewMockPTY(t)
186+ reaped := newPane("pane-2", reapedPTY, domain.Size{Cols: 20, Rows: 10})
187+ 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"}
188+ tb.panes["pane-2"] = reaped
189+ tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
190+ tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
191+ oldPTY.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Return(nil).Once()
192+ reapedPTY.EXPECT().Close().Return(nil).Once()
193+
194+ d.reapPane(sess, tb, reaped)
195+ d.reapPane(sess, tb, reaped)
196+
197+ require.Len(t, tb.panes, 1)
198+ require.NotContains(t, tb.panes, layout.PaneID("pane-2"))
199+}
200+
201+func TestClosePaneKeepsTabOpenWhenPendingSpawnLeafExists(t *testing.T) {
202+ d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
203+ tb := sess.activeTab()
204+ 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"}
205+ oldPTY.EXPECT().Close().Return(nil).Once()
206+
207+ require.NoError(t, d.closePane(sess, tb, "pane-1", nil, false))
208+
209+ require.Len(t, sess.tabs, 1)
210+ require.Empty(t, tb.panes)
211+ require.True(t, layout.ContainsLeaf(tb.tree.Root, "pane-2"))
212+}
213+
214+func TestCloseFocusedLastPaneDelegatesToCloseTab(t *testing.T) {
215+ d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
216+ otherPTY := portsmocks.NewMockPTY(t)
217+ other := newTab(otherPTY, domain.Size{Cols: 41, Rows: 10})
218+ sess.tabs = append(sess.tabs, other)
219+ oldPTY.EXPECT().Close().Return(nil).Once()
220+
221+ require.NoError(t, d.closeFocusedPane(sess, nil))
222+
223+ require.Len(t, sess.tabs, 1)
224+ require.Same(t, other, sess.tabs[0])
225+}
226+
227+func TestStackPaneCreatesStackAndToggleRestoresSplit(t *testing.T) {
228+ d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 5})
229+ newPTY := portsmocks.NewMockPTY(t)
230+ newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
231+ factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 3}).Return(newPTY, nil).Once()
232+
233+ require.NoError(t, d.stackPane(sess, nil))
234+ tb := sess.activeTab()
235+ require.Equal(t, layout.Stack, tb.tree.Root.Kind)
236+ require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
237+ require.Equal(t, layout.PaneID("pane-2"), tb.tree.Root.Expanded)
238+ require.Len(t, tb.panes, 2)
239+
240+ oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once()
241+ newPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once()
242+ require.NoError(t, d.toggleStack(sess, nil))
243+ require.Equal(t, layout.Split, tb.tree.Root.Kind)
244+ require.Equal(t, layout.Vertical, tb.tree.Root.Dir)
245+}
246+
247+func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) {
248+ d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 3})
249+ newPTY := portsmocks.NewMockPTY(t)
250+ newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
251+ factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 1}).Return(newPTY, nil).Once()
252+ require.NoError(t, d.stackPane(sess, nil))
253+ oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 1}).Return(nil).Once()
254+
255+ require.NoError(t, d.focusDir(sess, nil, layout.Up))
256+
257+ tb := sess.activeTab()
258+ require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)
259+ require.Equal(t, layout.PaneID("pane-1"), tb.tree.Root.Expanded)
260+ thirdPTY := portsmocks.NewMockPTY(t)
261+ factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", mock.Anything).Return(thirdPTY, nil).Maybe()
262+ require.ErrorIs(t, d.stackPane(sess, nil), layout.ErrTooSmall)
263+ require.Len(t, tb.panes, 2)
264+}
265+
266+func newSplitTestDaemon(t *testing.T, sz domain.Size) (*Daemon, *session, *portsmocks.MockPTY, *portsmocks.MockPTYFactory) {
267+ t.Helper()
268+ factory := portsmocks.NewMockPTYFactory(t)
269+ clock := portsmocks.NewMockClock(t)
270+ clock.EXPECT().Now().Return(time.Unix(0, 0)).Maybe()
271+ d := New(factory, clock, slog.New(slog.NewTextHandler(io.Discard, nil)), WithShell("/bin/sh", nil))
272+ oldPTY := portsmocks.NewMockPTY(t)
273+ tb := newTab(oldPTY, sz)
274+ tctx, cancel := context.WithCancel(context.Background())
275+ t.Cleanup(cancel)
276+ tb.ctx, tb.cancel = tctx, cancel
277+ for _, p := range tb.panes {
278+ p.ctx, p.cancel = tctx, cancel
279+ }
280+ sess := &session{id: "s", name: "s", cwd: "/work", tabs: []*tab{tb}, ctx: tctx, cancel: cancel}
281+ return d, sess, oldPTY, factory
282+}
283+
284+func TestCloseOriginalPaneLeavesSurvivorFunctional(t *testing.T) {
285+ writes := make(chan []byte, 1)
286+ d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
287+ newPTY, releaseNew := newBlockingPTYWithWrites(t, writes)
288+ defer releaseNew()
289+ oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
290+ newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
291+ factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()
292+
293+ require.NoError(t, d.splitPane(sess, nil, layout.Right))
294+ tb := sess.activeTab()
295+ tb.mu.Lock()
296+ tb.tree.Focus = "pane-1"
297+ tb.mu.Unlock()
298+ oldPTY.EXPECT().Close().Return(nil).Once()
299+
300+ require.NoError(t, d.closePane(sess, tb, "pane-1", nil, false))
301+ require.Equal(t, layout.PaneID("pane-2"), tb.focusedPane().id)
302+
303+ ac := &attachedClient{}
304+ ac.setSession(sess)
305+ daemonKeyHandler{d: d, ac: ac}.Forward([]byte("Z"))
306+ require.Equal(t, []byte("Z"), <-writes)
307+ tb.focusedPane().screen.Write([]byte("survivor"))
308+ frame, _ := composeClientFrame(sess, tb, false, "")
309+ require.Contains(t, frameRowString(frame, 1), "survivor")
310+}
311+
312+func blockingRead(t *testing.T) func([]byte) (int, error) {
313+ t.Helper()
314+ ch := make(chan struct{})
315+ return func([]byte) (int, error) {
316+ <-ch
317+ return 0, io.EOF
318+ }
319+}
A internal/usecase/daemon/pane_titles.go +64 -0
@@ -0,0 +1,64 @@
1+package daemon
2+
3+import (
4+ "path/filepath"
5+ "strings"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/usecase/layout"
9+)
10+
11+const paneTitleCacheTTL = time.Second
12+
13+func (d *Daemon) refreshPaneTitle(sess *session, id layout.PaneID) string {
14+ return d.refreshPaneTitleCached(sess, id, false)
15+}
16+
17+func (d *Daemon) refreshPaneTitleOnFocus(sess *session, id layout.PaneID) string {
18+ return d.refreshPaneTitleCached(sess, id, true)
19+}
20+
21+func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force bool) string {
22+ fallback := filepath.Base(d.shell)
23+ if fallback == "." || fallback == string(filepath.Separator) || fallback == "" {
24+ fallback = d.shell
25+ }
26+ if sess == nil {
27+ return fallback
28+ }
29+ tb := sess.activeTab()
30+ if tb == nil {
31+ return fallback
32+ }
33+ tb.mu.Lock()
34+ p := tb.panes[id]
35+ tb.mu.Unlock()
36+ if p == nil {
37+ return fallback
38+ }
39+ now := d.clock.Now()
40+ p.mu.Lock()
41+ if !force && p.titleValid && now.Sub(p.titleAt) < paneTitleCacheTTL {
42+ title := p.title
43+ p.mu.Unlock()
44+ if title == "" {
45+ return fallback
46+ }
47+ return title
48+ }
49+ p.mu.Unlock()
50+ title := fallback
51+ if d.procComm != nil && p.pty != nil {
52+ if pgid, err := p.pty.ForegroundPgid(); err == nil && pgid > 0 {
53+ if comm, err := d.procComm(pgid); err == nil && strings.TrimSpace(comm) != "" {
54+ title = strings.TrimSpace(comm)
55+ }
56+ }
57+ }
58+ p.mu.Lock()
59+ p.title = title
60+ p.titleAt = now
61+ p.titleValid = true
62+ p.mu.Unlock()
63+ return title
64+}
A internal/usecase/daemon/pane_titles_test.go +70 -0
@@ -0,0 +1,70 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "sync/atomic"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
12+)
13+
14+func TestRefreshPaneTitleUsesForegroundProcessComm(t *testing.T) {
15+ pty := portsmocks.NewMockPTY(t)
16+ pty.EXPECT().ForegroundPgid().Return(1234, nil).Once()
17+ _, sess, _, _ := newManualSessionWithPTYs(t, pty)
18+ clk := portsmocks.NewMockClock(t)
19+ clk.EXPECT().Now().Return(time.Time{}).Maybe()
20+ d := newTestDaemon(t, nil, clk)
21+ d.shell = "/bin/zsh"
22+ d.procComm = func(pid int) (string, error) {
23+ require.Equal(t, 1234, pid)
24+ return "vim\n", nil
25+ }
26+
27+ title := d.refreshPaneTitle(sess, "pane-1")
28+
29+ require.Equal(t, "vim", title)
30+ require.Equal(t, "vim", sess.activeTab().focusedPane().title)
31+}
32+
33+func TestRefreshPaneTitleCachesByTTLAndRefreshesOnFocus(t *testing.T) {
34+ pty := portsmocks.NewMockPTY(t)
35+ pty.EXPECT().ForegroundPgid().Return(1234, nil).Twice()
36+ _, sess, _, _ := newManualSessionWithPTYs(t, pty)
37+ clk := portsmocks.NewMockClock(t)
38+ now := time.Unix(10, 0)
39+ clk.EXPECT().Now().RunAndReturn(func() time.Time { return now }).Maybe()
40+ d := newTestDaemon(t, nil, clk)
41+ var calls atomic.Int32
42+ d.procComm = func(pid int) (string, error) {
43+ require.Equal(t, 1234, pid)
44+ calls.Add(1)
45+ return "vim\n", nil
46+ }
47+
48+ require.Equal(t, "vim", d.refreshPaneTitle(sess, "pane-1"))
49+ now = now.Add(paneTitleCacheTTL / 2)
50+ require.Equal(t, "vim", d.refreshPaneTitle(sess, "pane-1"))
51+ require.Equal(t, int32(1), calls.Load(), "paint refresh inside TTL should use cached title")
52+ require.Equal(t, "vim", d.refreshPaneTitleOnFocus(sess, "pane-1"))
53+ require.Equal(t, int32(2), calls.Load(), "focus refresh should bypass TTL")
54+}
55+
56+func TestRefreshPaneTitleFallsBackToShellBase(t *testing.T) {
57+ pty := portsmocks.NewMockPTY(t)
58+ pty.EXPECT().ForegroundPgid().Return(0, errors.New("no foreground process")).Once()
59+ _, sess, _, _ := newManualSessionWithPTYs(t, pty)
60+ clk := portsmocks.NewMockClock(t)
61+ clk.EXPECT().Now().Return(time.Time{}).Maybe()
62+ d := newTestDaemon(t, nil, clk)
63+ d.shell = "/usr/bin/fish"
64+ d.procComm = func(int) (string, error) { return "", errors.New("unused") }
65+
66+ title := d.refreshPaneTitle(sess, "pane-1")
67+
68+ require.Equal(t, "fish", title)
69+ require.Equal(t, "fish", sess.activeTab().focusedPane().title)
70+}
M internal/usecase/daemon/picker.go +35 -9
@@ -17,12 +17,11 @@
1717 // when the parent context is cancelled (graceful shutdown notifies any
1818 // attached clients with ReasonServerShutdown).
1919 //
20-// Locking: a session's screen and per-client renderer shadow are both guarded
21-// by tab.mu; the attached-client pointer by session.mu; the registry by
22-// Daemon.mu. When more than one is held the order is always
23-// Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >
24-// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on
25-// a slow client.
20+// Locking: a pane's screen/scrollback and per-client renderer shadow are
21+// guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
22+// session.mu; the registry by Daemon.mu. When more than one is held the order
23+// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
24+// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
2625 package daemon
2726
2827 import (
@@ -33,7 +32,9 @@ import (
3332 "github.com/bnema/vev/internal/platform"
3433 "github.com/bnema/vev/internal/ports"
3534 "github.com/bnema/vev/internal/usecase/keys"
35+ "github.com/bnema/vev/internal/usecase/layout"
3636 "github.com/bnema/vev/internal/usecase/picker"
37+ themeui "github.com/bnema/vev/internal/usecase/theme"
3738 "github.com/bnema/vev/internal/usecase/ui"
3839 "github.com/bnema/vev/pkg/renderer"
3940 )
@@ -500,9 +501,34 @@ func snapshotPickerPreview(tb *tab) picker.Preview {
500501 }
501502
502503 func pickerPreviewFromLockedTab(tb *tab) picker.Preview {
503- rows := make([][]renderer.Cell, tb.screen.Frame.Height)
504+ p := tb.focusedPane()
505+ if p == nil {
506+ return picker.Preview{}
507+ }
508+ if tb.tree == nil || tb.tree.Root == nil || tb.tree.Root.Kind == layout.Leaf {
509+ p.mu.Lock()
510+ defer p.mu.Unlock()
511+ return pickerPreviewFromLockedPane(p)
512+ }
513+
514+ area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
515+ if area.Width <= 0 || area.Height <= 0 {
516+ p.mu.Lock()
517+ defer p.mu.Unlock()
518+ return pickerPreviewFromLockedPane(p)
519+ }
520+ frame, _ := composeTabFrame(tb, area, themeui.Theme{})
521+ return pickerPreviewFromFrame(frame)
522+}
523+
524+func pickerPreviewFromLockedPane(p *pane) picker.Preview {
525+ return pickerPreviewFromFrame(p.screen.Frame)
526+}
527+
528+func pickerPreviewFromFrame(frame renderer.Frame) picker.Preview {
529+ rows := make([][]renderer.Cell, frame.Height)
504530 for y := range rows {
505- rows[y] = append([]renderer.Cell(nil), tb.screen.Frame.Row(y)...)
531+ rows[y] = append([]renderer.Cell(nil), frame.Row(y)...)
506532 }
507- return picker.Preview{Rows: rows, Width: tb.screen.Frame.Width, Height: tb.screen.Frame.Height}
533+ return picker.Preview{Rows: rows, Width: frame.Width, Height: frame.Height}
508534 }
M internal/usecase/daemon/picker_test.go +70 -11
@@ -11,12 +11,21 @@ import (
1111 "github.com/bnema/vev/internal/domain"
1212 "github.com/bnema/vev/internal/ports"
1313 "github.com/bnema/vev/internal/usecase/keys"
14+ "github.com/bnema/vev/internal/usecase/layout"
1415 "github.com/bnema/vev/pkg/renderer"
15- "github.com/bnema/vev/pkg/vt"
1616 )
1717
1818 // --- test doubles -----------------------------------------------------------
1919
20+func newTestTabWithContext(p ports.PTY, ctx context.Context, cancel context.CancelFunc) *tab {
21+ tb := newTab(p, domain.Size{Cols: 80, Rows: 23})
22+ tb.ctx, tb.cancel = ctx, cancel
23+ for _, pane := range tb.panes {
24+ pane.ctx, pane.cancel = ctx, cancel
25+ }
26+ return tb
27+}
28+
2029 // stubClock returns timers whose channel never fires, so a scheduler under it
2130 // blocks in its debounce loop until the session context is cancelled. Used by
2231
@@ -144,8 +153,8 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
144153 sctx2, cancel2 := context.WithCancel(d.serveCtx)
145154 defer cancel1()
146155 defer cancel2()
147- sess1 := &session{id: "s1", name: "alpha", ephemeral: true, ctx: sctx1, cancel: cancel1, tabs: []*tab{{pty: p1, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx1, cancel: cancel1}}, client: ac1}
148- sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{{pty: p2, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx2, cancel: cancel2}}, client: ac2}
156+ sess1 := &session{id: "s1", name: "alpha", ephemeral: true, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1}
157+ sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2}
149158 ac1.setSession(sess1)
150159 ac2.setSession(sess2)
151160 ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1})
@@ -170,6 +179,51 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
170179 awaitFrame(t, sends1, ports.MsgOutput)
171180 }
172181
182+func TestPickerPreviewSinglePaneSnapshotsFocusedPane(t *testing.T) {
183+ tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
184+ p := tb.focusedPane()
185+ p.screen.Write([]byte("focused"))
186+
187+ preview := snapshotPickerPreview(tb)
188+
189+ require.Equal(t, 10, preview.Width)
190+ require.Equal(t, 3, preview.Height)
191+ require.Equal(t, 'f', preview.Rows[0][0].Rune)
192+ require.Equal(t, 'o', preview.Rows[0][1].Rune)
193+}
194+
195+func TestPickerPreviewMultiPaneComposesTabFrame(t *testing.T) {
196+ tb := newTab(nil, domain.Size{Cols: 41, Rows: 5})
197+ left := tb.focusedPane()
198+ left.title = "one"
199+ left.screen.Write([]byte("L"))
200+ rightTop := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
201+ rightTop.title = "two"
202+ rightBottom := newPane("pane-3", nil, domain.Size{Cols: 20, Rows: 2})
203+ rightBottom.title = "three"
204+ rightBottom.screen.Write([]byte("R"))
205+
206+ tb.mu.Lock()
207+ tb.panes[rightTop.id] = rightTop
208+ tb.panes[rightBottom.id] = rightBottom
209+ tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
210+ layout.NewLeaf(left.id),
211+ {Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(rightTop.id), layout.NewLeaf(rightBottom.id)}, Expanded: rightBottom.id},
212+ }}
213+ tb.tree.Focus = rightBottom.id
214+ tb.mu.Unlock()
215+
216+ preview := snapshotPickerPreview(tb)
217+
218+ require.Equal(t, 41, preview.Width)
219+ require.Equal(t, 5, preview.Height)
220+ require.Equal(t, 'L', preview.Rows[0][0].Rune, "left pane content should remain visible")
221+ require.Equal(t, '│', preview.Rows[0][20].Rune, "split divider should be included")
222+ require.Equal(t, "two", rowText(preview.Rows[0][21:24]), "collapsed stack title bar should be included")
223+ require.Equal(t, "three", rowText(preview.Rows[1][21:26]), "expanded stack title bar should be included")
224+ require.Equal(t, 'R', preview.Rows[2][21].Rune, "expanded stacked pane content should be included")
225+}
226+
173227 func TestPickerLivePreviewRepaintsInactiveTab(t *testing.T) {
174228 d, sess, ac, sends, releases := newManualTabSession(t, 2)
175229 defer func() {
@@ -185,9 +239,9 @@ func TestPickerLivePreviewRepaintsInactiveTab(t *testing.T) {
185239
186240 previewTab := sess.tabs[1]
187241 previewTab.mu.Lock()
188- previewTab.screen.Write([]byte("inactive-preview-live"))
242+ previewTab.focusedPane().screen.Write([]byte("inactive-preview-live"))
189243 previewTab.mu.Unlock()
190- d.render(sess, previewTab)
244+ d.render(sess, previewTab, previewTab.focusedPane())
191245
192246 previewOut := awaitFrame(t, sends, ports.MsgOutput)
193247 previewMsg, err := ports.UnmarshalOutput(previewOut.Payload)
@@ -209,8 +263,8 @@ func TestPickerLivePreviewRepaintsCrossSessionTab(t *testing.T) {
209263 sctx2, cancel2 := context.WithCancel(d.serveCtx)
210264 defer cancel1()
211265 defer cancel2()
212- sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{{pty: p1, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx1, cancel: cancel1}}, client: ac1}
213- sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{{pty: p2, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx2, cancel: cancel2}}, client: ac2}
266+ sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1}
267+ sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2}
214268 ac1.setSession(sess1)
215269 ac2.setSession(sess2)
216270 ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1})
@@ -225,9 +279,9 @@ func TestPickerLivePreviewRepaintsCrossSessionTab(t *testing.T) {
225279
226280 previewTab := sess2.tabs[0]
227281 previewTab.mu.Lock()
228- previewTab.screen.Write([]byte("cross-session-preview-live"))
282+ previewTab.focusedPane().screen.Write([]byte("cross-session-preview-live"))
229283 previewTab.mu.Unlock()
230- d.render(sess2, previewTab)
284+ d.render(sess2, previewTab, previewTab.focusedPane())
231285
232286 previewOut := awaitFrame(t, sends1, ports.MsgOutput)
233287 previewMsg, err := ports.UnmarshalOutput(previewOut.Payload)
@@ -270,9 +324,14 @@ func TestPickerOpenCloseNavigationConcurrentWithRenderRace(t *testing.T) {
270324 tb := sess.tabs[i%len(sess.tabs)]
271325 wg.Go(func() {
272326 tb.mu.Lock()
273- tb.screen.Write([]byte("render-race"))
327+ p := tb.focusedPane()
274328 tb.mu.Unlock()
275- d.render(sess, tb)
329+ if p != nil {
330+ p.mu.Lock()
331+ p.screen.Write([]byte("render-race"))
332+ p.mu.Unlock()
333+ }
334+ d.render(sess, tb, p)
276335 })
277336 }
278337 wg.Wait()
M internal/usecase/daemon/prompt_test.go +1 -3
@@ -4,9 +4,7 @@ import (
44 "context"
55 "testing"
66
7- "github.com/bnema/vev/internal/domain"
87 "github.com/bnema/vev/internal/ports"
9- "github.com/bnema/vev/pkg/vt"
108 "github.com/stretchr/testify/require"
119 )
1210
@@ -53,7 +51,7 @@ func TestPromptSubmitErrorKeepsPromptOpen(t *testing.T) {
5351 defer release2()
5452 ctx, cancel := context.WithCancel(context.Background())
5553 defer cancel()
56- d.sessions["other"] = &session{id: "other", name: "taken", ctx: ctx, cancel: cancel, tabs: []*tab{{pty: p2, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: ctx, cancel: cancel}}}
54+ d.sessions["other"] = &session{id: "other", name: "taken", ctx: ctx, cancel: cancel, tabs: []*tab{newTestTabWithContext(p2, ctx, cancel)}}
5755
5856 d.enterPrompt(sess, ac, " Rename session ", "", func(name string) error { return d.renameSession(sess, name) })
5957 awaitFrame(t, sends, ports.MsgOutput)
M internal/usecase/daemon/render.go +309 -80
@@ -17,12 +17,11 @@
1717 // when the parent context is cancelled (graceful shutdown notifies any
1818 // attached clients with ReasonServerShutdown).
1919 //
20-// Locking: a session's screen and per-client renderer shadow are both guarded
21-// by tab.mu; the attached-client pointer by session.mu; the registry by
22-// Daemon.mu. When more than one is held the order is always
23-// Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >
24-// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on
25-// a slow client.
20+// Locking: a pane's screen/scrollback and per-client renderer shadow are
21+// guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
22+// session.mu; the registry by Daemon.mu. When more than one is held the order
23+// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
24+// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
2625 package daemon
2726
2827 import (
@@ -30,8 +29,11 @@ import (
3029 "strconv"
3130 "time"
3231
32+ "github.com/bnema/vev/internal/domain"
3333 scopy "github.com/bnema/vev/internal/usecase/copy"
34+ "github.com/bnema/vev/internal/usecase/layout"
3435 themeui "github.com/bnema/vev/internal/usecase/theme"
36+ "github.com/bnema/vev/internal/usecase/ui"
3537 "github.com/bnema/vev/pkg/renderer"
3638 "github.com/bnema/vev/pkg/vt"
3739 )
@@ -43,56 +45,59 @@ func signal(ch chan struct{}) {
4345 }
4446 }
4547
46-func (d *Daemon) ptyReader(sess *session, tb *tab) {
48+func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
4749 defer d.sessWg.Done()
50+ if p == nil {
51+ return
52+ }
4853 buf := make([]byte, ptyReadBufSize)
4954 var resp []byte
5055 attentionCh := make(chan struct{}, 1)
51- tb.mu.Lock()
52- tb.screen.OnResponse = func(b []byte) { resp = append(resp, b...) }
53- tb.screen.OnBell = func() { signal(attentionCh) }
54- tb.screen.OnNotify = func(string, string) { signal(attentionCh) }
55- tb.mu.Unlock()
56+ p.mu.Lock()
57+ p.screen.OnResponse = func(b []byte) { resp = append(resp, b...) }
58+ p.screen.OnBell = func() { signal(attentionCh) }
59+ p.screen.OnNotify = func(string, string) { signal(attentionCh) }
60+ p.mu.Unlock()
5661 for {
57- n, err := tb.pty.Read(buf)
62+ n, err := p.pty.Read(buf)
5863 if n > 0 {
5964 data := buf[:n]
60- tb.mu.Lock()
61- wasSyncing := tb.screen.SyncUpdateActive()
62- tb.screen.Write(data)
63- isSyncing := tb.screen.SyncUpdateActive()
64- tb.mu.Unlock()
65+ p.mu.Lock()
66+ wasSyncing := p.screen.SyncUpdateActive()
67+ p.screen.Write(data)
68+ isSyncing := p.screen.SyncUpdateActive()
69+ p.mu.Unlock()
6570 select {
6671 case <-attentionCh:
6772 d.noteAttention(sess, tb)
6873 default:
6974 }
7075 if len(resp) > 0 {
71- if _, writeErr := tb.pty.Write(resp); writeErr != nil {
76+ if _, writeErr := p.pty.Write(resp); writeErr != nil {
7277 d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name)
7378 }
7479 resp = resp[:0]
7580 }
7681 if wasSyncing != isSyncing {
77- tb.mu.Lock()
78- tb.syncGen++
79- gen := tb.syncGen
80- tb.mu.Unlock()
82+ p.mu.Lock()
83+ p.syncGen++
84+ gen := p.syncGen
85+ p.mu.Unlock()
8186 if isSyncing {
82- go d.syncWatchdog(tb, gen)
87+ go d.syncWatchdog(p, gen)
8388 }
8489 }
8590 if (wasSyncing && !isSyncing) || (!isSyncing && syncUpdateEndIn(data)) {
86- signal(tb.flush)
91+ signal(p.flush)
8792 continue
8893 }
8994 if isSyncing {
9095 continue
9196 }
92- signal(tb.dirty)
97+ signal(p.dirty)
9398 }
9499 if err != nil {
95- d.closeTab(sess, tb, true)
100+ d.reapPane(sess, tb, p)
96101 return
97102 }
98103 }
@@ -101,8 +106,11 @@ func (d *Daemon) ptyReader(sess *session, tb *tab) {
101106 // scheduler debounces dirty signals. The first dirty opens a short tab;
102107 // sustained floods progressively widen that tab, while isolated updates
103108 // return to the minimum delay for interactive latency.
104-func (d *Daemon) scheduler(sess *session, tb *tab) {
109+func (d *Daemon) scheduler(sess *session, tb *tab, p *pane) {
105110 defer d.sessWg.Done()
111+ if p == nil {
112+ return
113+ }
106114 delay := minDebounceInterval
107115 lastRender := d.clock.Now()
108116 outer:
@@ -110,13 +118,13 @@ outer:
110118 select {
111119 case <-sess.ctx.Done():
112120 return
113- case <-tabDone(tb):
121+ case <-paneDone(p):
114122 return
115- case <-tb.flush:
116- d.render(sess, tb)
123+ case <-p.flush:
124+ d.render(sess, tb, p)
117125 lastRender = d.clock.Now()
118126 continue
119- case <-tb.dirty:
127+ case <-p.dirty:
120128 if d.clock.Now().Sub(lastRender) >= maxDebounceInterval {
121129 delay = minDebounceInterval
122130 }
@@ -130,27 +138,27 @@ outer:
130138 case <-sess.ctx.Done():
131139 timer.Stop()
132140 return
133- case <-tabDone(tb):
141+ case <-paneDone(p):
134142 timer.Stop()
135143 return
136- case <-tb.flush:
144+ case <-p.flush:
137145 if !timer.Stop() {
138146 select {
139147 case <-timer.C():
140148 default:
141149 }
142150 }
143- d.render(sess, tb)
151+ d.render(sess, tb, p)
144152 lastRender = d.clock.Now()
145153 continue outer
146- case <-tb.dirty:
154+ case <-p.dirty:
147155 coalesced++
148156 case <-timer.C():
149157 break absorb
150158 }
151159 }
152160 delay = nextDebounceDelay(delay, coalesced)
153- d.render(sess, tb)
161+ d.render(sess, tb, p)
154162 lastRender = d.clock.Now()
155163 }
156164 }
@@ -169,30 +177,23 @@ func nextDebounceDelay(delay time.Duration, coalesced int) time.Duration {
169177 return delay
170178 }
171179
172-func (d *Daemon) syncWatchdog(tb *tab, gen uint64) {
180+func (d *Daemon) syncWatchdog(p *pane, gen uint64) {
173181 timer := d.clock.NewTimer(maxSyncUpdateDuration)
174182 select {
175- case <-tabDone(tb):
183+ case <-paneDone(p):
176184 timer.Stop()
177185 return
178186 case <-timer.C():
179187 }
180188
181- tb.mu.Lock()
182- if tb.syncGen != gen || !tb.screen.SyncUpdateActive() {
183- tb.mu.Unlock()
189+ p.mu.Lock()
190+ if p.syncGen != gen || !p.screen.SyncUpdateActive() {
191+ p.mu.Unlock()
184192 return
185193 }
186- tb.screen.ForceSyncEnd()
187- tb.mu.Unlock()
188- signal(tb.flush)
189-}
190-
191-func tabDone(tb *tab) <-chan struct{} {
192- if tb.ctx == nil {
193- return nil
194- }
195- return tb.ctx.Done()
194+ p.screen.ForceSyncEnd()
195+ p.mu.Unlock()
196+ signal(p.flush)
196197 }
197198
198199 func syncUpdateEndIn(data []byte) bool {
@@ -201,17 +202,19 @@ func syncUpdateEndIn(data []byte) bool {
201202
202203 // render paints the current client, or (when detached) just clears accumulated
203204 // damage so it never grows unbounded while headless.
204-func (d *Daemon) render(sess *session, tb *tab) {
205- tb.mu.Lock()
206- if tb.screen.SyncUpdateActive() {
207- tb.mu.Unlock()
208- return
209- }
210- tb.mu.Unlock()
211-
205+func (d *Daemon) render(sess *session, tb *tab, p *pane) {
212206 tb.mu.Lock()
213207 previewer := tb.previewClient
214208 tb.mu.Unlock()
209+ if p == nil {
210+ return
211+ }
212+ p.mu.Lock()
213+ if p.screen.SyncUpdateActive() {
214+ p.mu.Unlock()
215+ return
216+ }
217+ p.mu.Unlock()
215218
216219 sess.mu.Lock()
217220 ac := sess.client
@@ -226,16 +229,16 @@ func (d *Daemon) render(sess *session, tb *tab) {
226229 d.paint(sess, ac, false)
227230 return
228231 }
229- tb.mu.Lock()
230- tb.screen.ClearDamage()
231- tb.mu.Unlock()
232+ p.mu.Lock()
233+ p.screen.ClearDamage()
234+ p.mu.Unlock()
232235 return
233236 }
234237
235238 if ac == nil || !active {
236- tb.mu.Lock()
237- tb.screen.ClearDamage()
238- tb.mu.Unlock()
239+ p.mu.Lock()
240+ p.screen.ClearDamage()
241+ p.mu.Unlock()
239242 return
240243 }
241244 d.paint(sess, ac, false)
@@ -244,6 +247,19 @@ func (d *Daemon) render(sess *session, tb *tab) {
244247 // paint draws the composed client frame (active tab plus status bar) and
245248 // sends the resulting bytes. The renderer shadow is reset on explicit invalidations
246249 // such as switch/create/close/rename/resize so the repaint is complete.
250+func titleBarPaneIDs(placements []layout.Placement, ok bool) []layout.PaneID {
251+ if !ok {
252+ return nil
253+ }
254+ ids := make([]layout.PaneID, 0, len(placements))
255+ for _, pl := range placements {
256+ if pl.TitleBar.Height > 0 {
257+ ids = append(ids, pl.ID)
258+ }
259+ }
260+ return ids
261+}
262+
247263 func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
248264 tb := sess.activeTab()
249265 if tb == nil {
@@ -268,7 +284,10 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
268284 pickerModel := ac.picker.Clone()
269285 previewTab := ac.pickerPreview
270286 ac.pickerMu.Unlock()
271- preview := snapshotPickerPreview(previewTab)
287+ preview := snapshotPickerPreview(nil)
288+ if previewTab != tb {
289+ preview = snapshotPickerPreview(previewTab)
290+ }
272291 ac.paletteMu.Lock()
273292 paletteModel := ac.palette
274293 paletteActive := paletteModel != nil
@@ -288,8 +307,30 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
288307 bars.theme = ac.getTheme()
289308
290309 styles := newThemeStyles(ac.getTheme())
310+ tb.mu.Lock()
311+ layoutSnap := solveTabLayoutLocked(tb)
312+ titleIDs := titleBarPaneIDs(layoutSnap.placements, layoutSnap.ok)
313+ tb.mu.Unlock()
314+ for _, id := range titleIDs {
315+ d.refreshPaneTitle(sess, id)
316+ }
291317
292318 tb.mu.Lock()
319+ if !layoutSnap.matchesLocked(tb) {
320+ layoutSnap = solveTabLayoutLocked(tb)
321+ }
322+ p := tb.focusedPane()
323+ if p == nil {
324+ tb.mu.Unlock()
325+ if paletteActive {
326+ ac.paletteMu.Unlock()
327+ }
328+ if promptActive {
329+ ac.promptMu.Unlock()
330+ }
331+ ac.sendMu.Unlock()
332+ return
333+ }
293334 if reset || copyActive || pickerActive || paletteActive || promptActive {
294335 ac.rend.Reset()
295336 ac.bars.Reset()
@@ -297,13 +338,18 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
297338 if reset || pickerActive || paletteActive || promptActive {
298339 ac.lastCursor.valid = false
299340 }
300- frame, damage := composeClientFrameWithState(bars, tb, reset, &ac.bars)
341+ frame, damage := composeClientFrameWithLayout(bars, tb, reset, layoutSnap, &ac.bars)
301342 if copyActive {
302343 frame, damage = composeCopyClientFrame(copyMode, tb, bars)
303344 }
304345 if pickerActive {
305346 if previewTab == tb {
306- preview = pickerPreviewFromLockedTab(tb)
347+ if layoutSnap.ok && tb.tree != nil && tb.tree.Root != nil && tb.tree.Root.Kind != layout.Leaf {
348+ previewFrame, _ := composeTabFrameWithLayout(tb, layoutSnap.area, themeui.Theme{}, layoutSnap)
349+ preview = pickerPreviewFromFrame(previewFrame)
350+ } else {
351+ preview = pickerPreviewFromLockedTab(tb)
352+ }
307353 }
308354 frame, damage = composePickerClientFrame(pickerModel, preview, frame, styles)
309355 }
@@ -315,13 +361,20 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
315361 frame, damage = composePromptClientFrame(promptModel, frame, styles)
316362 ac.promptMu.Unlock()
317363 }
318- desiredCursor := desiredCursorOut(tb.screen, copyActive || pickerActive || paletteActive || promptActive)
364+ cursorContent, cursorVisible := focusedPaneContentRect(layoutSnap, p.id)
365+ p.mu.Lock()
366+ desiredCursor := desiredCursorOut(p.screen, cursorContent, !cursorVisible || copyActive || pickerActive || paletteActive || promptActive)
367+ p.mu.Unlock()
319368 data, err := ac.rend.Draw(frame, damage)
320369 var cursorTail []byte
321370 if err == nil {
322371 cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0)
323372 }
324- tb.screen.ClearDamage()
373+ for _, pane := range tb.panesSnapshot() {
374+ pane.mu.Lock()
375+ pane.screen.ClearDamage()
376+ pane.mu.Unlock()
377+ }
325378 tb.mu.Unlock()
326379
327380 var serr error
@@ -342,17 +395,29 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
342395 }
343396 }
344397
398+func focusedPaneContentRect(layoutSnap tabLayoutSnapshot, id layout.PaneID) (domain.Rect, bool) {
399+ if !layoutSnap.ok {
400+ return layoutSnap.area, layoutSnap.area.Width > 0 && layoutSnap.area.Height > 0
401+ }
402+ for _, pl := range layoutSnap.placements {
403+ if pl.ID == id && !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 {
404+ return pl.Content, true
405+ }
406+ }
407+ return domain.Rect{}, false
408+}
409+
345410 // desiredCursorOut computes the terminal cursor state that should be shown to
346-// the client for the current tab and overlay mode.
347-func desiredCursorOut(s *vt.Screen, copyActive bool) cursorOut {
348- if copyActive || !s.CursorVisible() {
411+// the client for the current pane placement and overlay mode.
412+func desiredCursorOut(s *vt.Screen, content domain.Rect, hide bool) cursorOut {
413+ if hide || !s.CursorVisible() {
349414 return cursorOut{hidden: true}
350415 }
351416 style, ok := s.CursorStyle()
352417 if !ok {
353- style = 5
418+ style = 1
354419 }
355- return cursorOut{row: s.CursorRow() + 1, col: s.CursorCol(), style: style, hasStyle: true}
420+ return cursorOut{row: content.Y + s.CursorRow() + 1, col: content.X + s.CursorCol(), style: style, hasStyle: true}
356421 }
357422
358423 func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte {
@@ -421,17 +486,35 @@ func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, c
421486 }
422487
423488 func composeClientFrameWithState(bars barState, tb *tab, full bool, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
489+ return composeClientFrameWithLayout(bars, tb, full, solveTabLayoutLocked(tb), caches...)
490+}
491+
492+func composeClientFrameWithLayout(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
424493 var cache *barCache
425494 if len(caches) > 0 {
426495 cache = caches[0]
427496 }
497+ p := tb.focusedPane()
428498 styles := newThemeStyles(bars.theme)
429- width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height
499+ if p == nil {
500+ width, screenRows := tb.size.Cols, tb.size.Rows
501+ if width <= 0 || screenRows <= 0 {
502+ return renderer.NewFrame(0, 0), nil
503+ }
504+ return renderer.NewFrame(width, screenRows+2), nil
505+ }
506+ p.mu.Lock()
507+ width, screenRows := p.screen.Frame.Width, p.screen.Frame.Height
508+ p.mu.Unlock()
509+ if tb.size.Valid() {
510+ width, screenRows = tb.size.Cols, tb.size.Rows
511+ }
430512 frame := renderer.NewFrame(width, screenRows+2)
431513 topBar := frame.Row(0)
432514 drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, styles)
515+ contentFrame, contentDamage := composeTabFrameWithLayout(tb, domain.Rect{Width: width, Height: screenRows}, bars.theme, layoutSnap)
433516 for y := range screenRows {
434- copy(frame.Row(y+1), tb.screen.Frame.Row(y))
517+ copy(frame.Row(y+1), contentFrame.Row(y))
435518 }
436519 bottomBar := frame.Row(screenRows + 1)
437520 drawStatusBarState(bottomBar, bars, styles)
@@ -441,7 +524,7 @@ func composeClientFrameWithState(bars barState, tb *tab, full bool, caches ...*b
441524 }
442525 return frame, []renderer.Damage{renderer.FullRedraw()}
443526 }
444- damage := offsetDamage(tb.screen.Damage())
527+ damage := translateDamage(contentDamage, 0, 1)
445528 if cache == nil || !sameCells(cache.top, topBar) {
446529 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})
447530 }
@@ -454,6 +537,135 @@ func composeClientFrameWithState(bars barState, tb *tab, full bool, caches ...*b
454537 return frame, damage
455538 }
456539
540+func composeTabFrame(tb *tab, area domain.Rect, theme themeui.Theme) (renderer.Frame, []renderer.Damage) {
541+ return composeTabFrameWithLayout(tb, area, theme, tabLayoutSnapshot{})
542+}
543+
544+func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot) (renderer.Frame, []renderer.Damage) {
545+ frame := renderer.NewFrame(area.Width, area.Height)
546+ root := tb.tree.Root
547+ placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == area
548+ if !ok {
549+ placements, ok = layout.Solve(root, area)
550+ }
551+ var fallback *pane
552+ if !ok {
553+ fallback = tb.focusedPane()
554+ if fallback == nil {
555+ return frame, nil
556+ }
557+ placements = []layout.Placement{{ID: fallback.id, Content: area}}
558+ }
559+ if ok {
560+ drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme))
561+ }
562+ var damage []renderer.Damage
563+ for _, pl := range placements {
564+ p := tb.panes[pl.ID]
565+ if p == nil && fallback != nil && pl.ID == fallback.id {
566+ p = fallback
567+ }
568+ if p == nil {
569+ continue
570+ }
571+ focused := tb.tree.Focus == pl.ID
572+ if pl.TitleBar.Height > 0 {
573+ drawPaneTitleBar(frame, pl, p, focused, theme, "")
574+ }
575+ if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {
576+ continue
577+ }
578+ p.mu.Lock()
579+ blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme)
580+ for _, d := range p.screen.Damage() {
581+ damage = append(damage, translatePaneDamage(d, pl.Content, area)...)
582+ }
583+ p.mu.Unlock()
584+ }
585+ return frame, damage
586+}
587+
588+func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, theme themeui.Theme) {
589+ rows := min(r.Height, src.Height)
590+ cols := min(r.Width, src.Width)
591+ for y := range rows {
592+ for x := range cols {
593+ cell := src.At(x, y)
594+ if dim {
595+ cell.Style = themeui.DimStyle(cell.Style, theme)
596+ }
597+ dst.Set(r.X+x, r.Y+y, cell)
598+ }
599+ }
600+}
601+
602+func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focused bool, theme themeui.Theme, fallback string) {
603+ styles := newThemeStyles(theme)
604+ style := styles.border
605+ if focused {
606+ style = styles.statusBar
607+ } else {
608+ style = themeui.DimStyle(style, theme)
609+ }
610+ for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {
611+ frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
612+ }
613+ p.mu.Lock()
614+ title := p.title
615+ p.mu.Unlock()
616+ if title == "" {
617+ title = fallback
618+ }
619+ if title == "" {
620+ title = string(p.id)
621+ }
622+ ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style)
623+}
624+
625+func drawDividers(frame renderer.Frame, n *layout.Node, r domain.Rect, style renderer.Style) {
626+ if n == nil || n.Kind != layout.Split || len(n.Children) <= 1 {
627+ return
628+ }
629+ count := len(n.Children)
630+ if n.Dir == layout.Horizontal {
631+ usable := r.Width - (count - 1)
632+ base, rem := usable/count, usable%count
633+ x := r.X
634+ for i, child := range n.Children {
635+ w := base
636+ if i < rem {
637+ w++
638+ }
639+ drawDividers(frame, child, domain.Rect{X: x, Y: r.Y, Width: w, Height: r.Height}, style)
640+ x += w
641+ if i < count-1 {
642+ for y := r.Y; y < r.Y+r.Height; y++ {
643+ frame.Set(x, y, renderer.Cell{Rune: '│', Style: style})
644+ }
645+ x++
646+ }
647+ }
648+ return
649+ }
650+ usable := r.Height - (count - 1)
651+ base, rem := usable/count, usable%count
652+ y := r.Y
653+ for i, child := range n.Children {
654+ h := base
655+ if i < rem {
656+ h++
657+ }
658+ drawDividers(frame, child, domain.Rect{X: r.X, Y: y, Width: r.Width, Height: h}, style)
659+ y += h
660+ if i < count-1 {
661+ for x := r.X; x < r.X+r.Width; x++ {
662+ frame.Set(x, y, renderer.Cell{Rune: '─', Style: style})
663+ }
664+ y++
665+ }
666+ }
667+}
668+
457669 func (c *barCache) capture(top, bottom []renderer.Cell) {
458670 c.top = cloneCells(c.top, top)
459671 c.bottom = cloneCells(c.bottom, bottom)
@@ -482,12 +694,29 @@ func sameCells(a, b []renderer.Cell) bool {
482694 }
483695
484696 func offsetDamage(in []renderer.Damage) []renderer.Damage {
697+ return translateDamage(in, 0, 1)
698+}
699+
700+func translateDamage(in []renderer.Damage, dx, dy int) []renderer.Damage {
485701 out := make([]renderer.Damage, len(in))
486702 for i, d := range in {
487703 out[i] = d
488704 if d.Kind != renderer.DamageFullRedraw {
489- out[i].Y++
705+ out[i].X += dx
706+ out[i].Y += dy
490707 }
491708 }
492709 return out
493710 }
711+
712+func translatePaneDamage(d renderer.Damage, content domain.Rect, area domain.Rect) []renderer.Damage {
713+ if d.Kind == renderer.DamageFullRedraw {
714+ return []renderer.Damage{d}
715+ }
716+ if d.Kind == renderer.DamageScrollUp && (content.X != 0 || content.Width != area.Width) {
717+ return []renderer.Damage{{Kind: renderer.DamageText, X: content.X, Y: content.Y, Width: content.Width, Height: content.Height}}
718+ }
719+ d.X += content.X
720+ d.Y += content.Y
721+ return []renderer.Damage{d}
722+}
M internal/usecase/daemon/render_test.go +246 -77
@@ -6,6 +6,7 @@ import (
66 "errors"
77 "io"
88 "log/slog"
9+ "strconv"
910 "strings"
1011 "sync/atomic"
1112 "testing"
@@ -17,8 +18,9 @@ import (
1718 "github.com/bnema/vev/internal/domain"
1819 "github.com/bnema/vev/internal/ports"
1920 portsmocks "github.com/bnema/vev/internal/ports/mocks"
21+ "github.com/bnema/vev/internal/usecase/layout"
22+ themeui "github.com/bnema/vev/internal/usecase/theme"
2023 "github.com/bnema/vev/pkg/renderer"
21- "github.com/bnema/vev/pkg/vt"
2224 )
2325
2426 // --- test doubles -----------------------------------------------------------
@@ -32,7 +34,7 @@ func TestPTYWriteErrorIsLogged(t *testing.T) {
3234 errBoom := errors.New("boom")
3335 p := portsmocks.NewMockPTY(t)
3436 p.EXPECT().Write([]byte("input")).Return(0, errBoom).Once()
35- win := &tab{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}}
37+ win := newTab(p, domain.Size{Cols: 80, Rows: 23})
3638 sess := &session{id: "manual", name: "work", tabs: []*tab{win}}
3739 ac := &attachedClient{}
3840 ac.setSession(sess)
@@ -55,8 +57,8 @@ func TestAltXClosesNonFinalTabScheduler(t *testing.T) {
5557 defer releasePTY2()
5658
5759 d.sessWg.Add(1)
58- go d.scheduler(sess, sess.tabs[1])
59- sess.tabs[1].dirty <- struct{}{}
60+ go d.scheduler(sess, sess.tabs[1], sess.tabs[1].focusedPane())
61+ sess.tabs[1].focusedPane().dirty <- struct{}{}
6062 <-clk.called
6163
6264 d.handleInput(sess, ac, []byte("\x1b2"))
@@ -81,9 +83,9 @@ func TestPTYEOFClosesNonFinalTabScheduler(t *testing.T) {
8183 defer releasePTY2()
8284
8385 d.sessWg.Add(2)
84- go d.scheduler(sess, sess.tabs[1])
85- go d.ptyReader(sess, sess.tabs[1])
86- sess.tabs[1].dirty <- struct{}{}
86+ go d.scheduler(sess, sess.tabs[1], sess.tabs[1].focusedPane())
87+ go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane())
88+ sess.tabs[1].focusedPane().dirty <- struct{}{}
8789 <-clk.called
8890 releasePTY2()
8991
@@ -115,10 +117,10 @@ func TestPTYEOFClosesActiveNonFinalTabAndRepaintsRemaining(t *testing.T) {
115117 d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2)
116118 defer releasePTY2()
117119 sess.active = 0
118- sess.tabs[1].screen.Write([]byte("remaining"))
120+ sess.tabs[1].focusedPane().screen.Write([]byte("remaining"))
119121
120122 d.sessWg.Add(1)
121- go d.ptyReader(sess, sess.tabs[0])
123+ go d.ptyReader(sess, sess.tabs[0], sess.tabs[0].focusedPane())
122124 releasePTY1()
123125
124126 require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond)
@@ -141,10 +143,10 @@ func TestPTYEOFClosesInactiveNonFinalTabAndRepaintsStatus(t *testing.T) {
141143 d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2)
142144 defer releasePTY1()
143145 sess.active = 0
144- sess.tabs[0].screen.Write([]byte("active"))
146+ sess.tabs[0].focusedPane().screen.Write([]byte("active"))
145147
146148 d.sessWg.Add(1)
147- go d.ptyReader(sess, sess.tabs[1])
149+ go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane())
148150 releasePTY2()
149151
150152 require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond)
@@ -165,7 +167,7 @@ func TestPTYEOFFinalTabKillsSessionAndDetaches(t *testing.T) {
165167 d, sess, _, sends, releases := newManualTabSession(t, 1)
166168
167169 d.sessWg.Add(1)
168- go d.ptyReader(sess, sess.tabs[0])
170+ go d.ptyReader(sess, sess.tabs[0], sess.tabs[0].focusedPane())
169171 releases[0]()
170172
171173 require.Eventually(t, func() bool { return sessionCount(d) == 0 }, 2*time.Second, 5*time.Millisecond)
@@ -211,26 +213,26 @@ func TestSchedulerDebounceCoalesces(t *testing.T) {
211213
212214 d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil)))
213215 sctx, cancel := context.WithCancel(context.Background())
214- win := &tab{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}}
216+ win := newTab(newScriptPTY(nil), domain.Size{Cols: 20, Rows: 5})
215217 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
216218 sess := &session{id: "s", name: "s", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
217219 ac.setSession(sess)
218220
219221 win.mu.Lock()
220- win.screen.Write([]byte("hi"))
222+ win.focusedPane().screen.Write([]byte("hi"))
221223 win.mu.Unlock()
222224
223225 d.sessWg.Add(1)
224- go d.scheduler(sess, win)
226+ go d.scheduler(sess, win, win.focusedPane())
225227
226228 // First dirty opens the debounce window.
227- win.dirty <- struct{}{}
229+ win.focusedPane().dirty <- struct{}{}
228230 <-newTimerCalled
229231
230232 // A burst of further dirties inside the window must be absorbed.
231233 for range 5 {
232234 select {
233- case win.dirty <- struct{}{}:
235+ case win.focusedPane().dirty <- struct{}{}:
234236 default:
235237 }
236238 }
@@ -266,9 +268,9 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
266268
267269 win := newTab(p, domain.Size{Cols: 4, Rows: 4})
268270 for y, text := range []string{"0000", "1111", "2222", "3333"} {
269- copy(win.screen.Frame.Row(y), testRow(text))
271+ copy(win.focusedPane().screen.Frame.Row(y), testRow(text))
270272 }
271- win.screen.Row = 3
273+ win.focusedPane().screen.Row = 3
272274
273275 tr := portsmocks.NewMockTransport(t)
274276 tr.EXPECT().Close().Return(nil).Maybe()
@@ -283,16 +285,16 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
283285 // layout: tabSize reserves 2 chrome rows (top + bottom bar) here, not 1,
284286 // so a client height of 4 (not 3) is what yields the same 2-row tab.
285287 d.resize(sess, ac, domain.Size{Cols: 4, Rows: 4})
286- require.Equal(t, "2222", frameRowString(win.screen.Frame, 0))
287- require.Equal(t, "3333", frameRowString(win.screen.Frame, 1))
288- require.Equal(t, 2, win.scrollback.Len())
289- require.Equal(t, "0000", cellsString(win.scrollback.Row(0)))
290- require.Equal(t, "1111", cellsString(win.scrollback.Row(1)))
288+ require.Equal(t, "2222", frameRowString(win.focusedPane().screen.Frame, 0))
289+ require.Equal(t, "3333", frameRowString(win.focusedPane().screen.Frame, 1))
290+ require.Equal(t, 2, win.focusedPane().scrollback.Len())
291+ require.Equal(t, "0000", cellsString(win.focusedPane().scrollback.Row(0)))
292+ require.Equal(t, "1111", cellsString(win.focusedPane().scrollback.Row(1)))
291293
292294 d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6})
293- require.Equal(t, "2222 ", frameRowString(win.screen.Frame, 0))
294- require.Equal(t, "3333 ", frameRowString(win.screen.Frame, 1))
295- require.Equal(t, 2, win.scrollback.Len())
295+ require.Equal(t, "2222 ", frameRowString(win.focusedPane().screen.Frame, 0))
296+ require.Equal(t, "3333 ", frameRowString(win.focusedPane().screen.Frame, 1))
297+ require.Equal(t, 2, win.focusedPane().scrollback.Len())
296298 }
297299
298300 func frameRowString(f renderer.Frame, y int) string {
@@ -323,21 +325,131 @@ func TestOffsetDamageShiftsScreenDamageBelowTopBar(t *testing.T) {
323325 }, damage)
324326 }
325327
328+func TestTranslateDamageShiftsXYAndPreservesFullRedraw(t *testing.T) {
329+ damage := translateDamage([]renderer.Damage{
330+ {Kind: renderer.DamageText, X: 2, Y: 3, Width: 4, Height: 1},
331+ renderer.FullRedraw(),
332+ }, 5, 7)
333+ require.Equal(t, []renderer.Damage{
334+ {Kind: renderer.DamageText, X: 7, Y: 10, Width: 4, Height: 1},
335+ renderer.FullRedraw(),
336+ }, damage)
337+}
338+
339+func TestTranslatePaneDamagePreservesFullWidthScrollFastPathOnly(t *testing.T) {
340+ tests := []struct {
341+ name string
342+ content domain.Rect
343+ area domain.Rect
344+ in renderer.Damage
345+ want []renderer.Damage
346+ }{
347+ {
348+ name: "half width scroll becomes pane text damage",
349+ content: domain.Rect{X: 21, Y: 0, Width: 20, Height: 4},
350+ area: domain.Rect{Width: 41, Height: 4},
351+ in: renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: 0, Width: 20, Height: 4, Count: 1},
352+ want: []renderer.Damage{{Kind: renderer.DamageText, X: 21, Y: 0, Width: 20, Height: 4}},
353+ },
354+ {
355+ name: "full width scroll keeps translated scroll damage",
356+ content: domain.Rect{X: 0, Y: 5, Width: 80, Height: 10},
357+ area: domain.Rect{Width: 80, Height: 24},
358+ in: renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: 0, Width: 80, Height: 10, Count: 1},
359+ want: []renderer.Damage{{Kind: renderer.DamageScrollUp, X: 0, Y: 5, Width: 80, Height: 10, Count: 1}},
360+ },
361+ }
362+ for _, tt := range tests {
363+ t.Run(tt.name, func(t *testing.T) {
364+ require.Equal(t, tt.want, translatePaneDamage(tt.in, tt.content, tt.area))
365+ })
366+ }
367+}
368+
369+func TestRenderClearsDamageOnEmittingPaneWhenDetached(t *testing.T) {
370+ tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
371+ focused := tb.focusedPane()
372+ emitting := newPane("pane-2", nil, domain.Size{Cols: 10, Rows: 3})
373+ tb.panes[emitting.id] = emitting
374+ tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(focused.id), layout.NewLeaf(emitting.id)}}
375+ tb.tree.Focus = focused.id
376+ sess := &session{id: "s", name: "work", tabs: []*tab{tb}, active: 0}
377+ d := newTestDaemon(t, nil, stubClock{})
378+
379+ focused.screen.Write([]byte("a"))
380+ emitting.screen.Write([]byte("b"))
381+ d.render(sess, tb, emitting)
382+
383+ require.NotEmpty(t, focused.screen.Damage(), "focused pane damage should be untouched by detached render for another pane")
384+ require.Empty(t, emitting.screen.Damage(), "emitting pane damage should be drained")
385+}
386+
387+func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *testing.T) {
388+ win := newTab(nil, domain.Size{Cols: 41, Rows: 4})
389+ left := win.focusedPane()
390+ left.screen.ClearDamage()
391+ left.screen.Write([]byte("L"))
392+ left.screen.ClearDamage()
393+
394+ right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4})
395+ right.screen.Write([]byte("R"))
396+ right.screen.ClearDamage()
397+ right.screen.Write([]byte("x"))
398+
399+ win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
400+ win.tree.Focus = right.id
401+ win.panes[right.id] = right
402+
403+ theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}}
404+ frame, damage := composeTabFrame(win, domain.Rect{Width: 41, Height: 4}, theme)
405+
406+ require.Equal(t, 'L', frame.At(0, 0).Rune)
407+ require.Equal(t, '│', frame.At(20, 0).Rune)
408+ require.Equal(t, 'R', frame.At(21, 0).Rune)
409+ require.True(t, frame.At(0, 0).Style.HasForegroundRGB, "unfocused left pane should be dimmed during blit")
410+ require.False(t, left.screen.Frame.At(0, 0).Style.HasForegroundRGB, "dimming must not mutate vt.Screen")
411+ require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 22, Y: 0, Width: 1, Height: 1, Count: 1})
412+}
413+
414+func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {
415+ win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
416+ p1 := win.focusedPane()
417+ p1.title = "one"
418+ p1.screen.ClearDamage()
419+ p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
420+ p2.title = "two"
421+ p2.screen.Write([]byte("T"))
422+ p2.screen.ClearDamage()
423+
424+ win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p2.id}
425+ win.tree.Focus = p2.id
426+ win.panes[p2.id] = p2
427+
428+ theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}}
429+ frame, _ := composeTabFrame(win, domain.Rect{Width: 20, Height: 5}, theme)
430+
431+ require.Equal(t, "one", rowText(frame.Row(0))[:3])
432+ require.Equal(t, "two", rowText(frame.Row(1))[:3])
433+ require.Equal(t, 'T', frame.At(0, 2).Rune)
434+ require.True(t, frame.At(0, 0).Style.HasForegroundRGB, "collapsed title bar should use dimmed chrome")
435+ require.True(t, frame.At(0, 1).Style.Inverse || frame.At(0, 1).Style.HasBackgroundRGB, "focused title bar should use accent chrome")
436+}
437+
326438 func TestComposeClientFrameBarCacheSkipsUnchangedBars(t *testing.T) {
327439 sess, win := newBarCacheTestSession()
328440 var cache barCache
329441
330442 _, damage := composeClientFrame(sess, win, true, "", &cache)
331443 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
332- win.screen.ClearDamage()
333- win.screen.Write([]byte("x"))
444+ win.focusedPane().screen.ClearDamage()
445+ win.focusedPane().screen.Write([]byte("x"))
334446
335447 _, damage = composeClientFrame(sess, win, false, "", &cache)
336448
337449 require.NotEmpty(t, damage)
338450 for _, d := range damage {
339451 require.NotEqual(t, 0, d.Y, "unchanged top bar should not be damaged")
340- require.NotEqual(t, win.screen.Frame.Height+1, d.Y, "unchanged bottom bar should not be damaged")
452+ require.NotEqual(t, win.focusedPane().screen.Frame.Height+1, d.Y, "unchanged bottom bar should not be damaged")
341453 }
342454 }
343455
@@ -345,12 +457,12 @@ func TestComposeClientFrameBarCacheDamagesChangedBottomOnly(t *testing.T) {
345457 sess, win := newBarCacheTestSession()
346458 var cache barCache
347459 composeClientFrame(sess, win, true, "", &cache)
348- win.screen.ClearDamage()
460+ win.focusedPane().screen.ClearDamage()
349461
350462 sess.name = "renamed"
351463 _, damage := composeClientFrame(sess, win, false, "", &cache)
352464
353- require.Equal(t, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: win.screen.Frame.Height + 1, Width: win.screen.Frame.Width, Height: 1}}, damage)
465+ require.Equal(t, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: win.focusedPane().screen.Frame.Height + 1, Width: win.focusedPane().screen.Frame.Width, Height: 1}}, damage)
354466 }
355467
356468 func TestComposeClientFrameFullRedrawPrimesBarCache(t *testing.T) {
@@ -361,14 +473,14 @@ func TestComposeClientFrameFullRedrawPrimesBarCache(t *testing.T) {
361473
362474 _, damage := composeClientFrame(sess, win, true, "", &cache)
363475 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
364- win.screen.ClearDamage()
476+ win.focusedPane().screen.ClearDamage()
365477
366478 _, damage = composeClientFrame(sess, win, false, "", &cache)
367479 require.Empty(t, damage)
368480 }
369481
370482 func newBarCacheTestSession() (*session, *tab) {
371- win := &tab{screen: vt.NewScreen(20, 3), size: domain.Size{Cols: 20, Rows: 3}}
483+ win := newTab(newScriptPTY(nil), domain.Size{Cols: 20, Rows: 3})
372484 sess := &session{id: "s", name: "work", tabs: []*tab{win}}
373485 return sess, win
374486 }
@@ -378,13 +490,13 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
378490
379491 p := portsmocks.NewMockPTY(t)
380492 var screenWidthAtResize int
381- win := &tab{screen: vt.NewScreen(80, 24), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 24}}
493+ win := newTab(newScriptPTY(nil), domain.Size{Cols: 80, Rows: 24})
382494 p.EXPECT().Resize(domain.Size{Cols: 100, Rows: 28}).RunAndReturn(func(sz domain.Size) error {
383495 // The screen must not yet be resized when the PTY is: proves order.
384- screenWidthAtResize = win.screen.Frame.Width
496+ screenWidthAtResize = win.focusedPane().screen.Frame.Width
385497 return nil
386498 }).Once()
387- win.pty = p
499+ win.focusedPane().pty = p
388500
389501 var gotOutput atomic.Bool
390502 tr := portsmocks.NewMockTransport(t)
@@ -404,8 +516,8 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
404516 d.resize(sess, ac, newSize)
405517
406518 require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize")
407- require.Equal(t, 100, win.screen.Frame.Width, "screen resized after pty")
408- require.Equal(t, 28, win.screen.Frame.Height, "screen reserves top and bottom chrome rows")
519+ require.Equal(t, 100, win.focusedPane().screen.Frame.Width, "screen resized after pty")
520+ require.Equal(t, 28, win.focusedPane().screen.Frame.Height, "screen reserves top and bottom chrome rows")
409521 require.True(t, gotOutput.Load(), "resize forces a full redraw output")
410522 }
411523
@@ -420,7 +532,7 @@ func TestSendErrorKillsEphemeral(t *testing.T) {
420532 tr.EXPECT().Close().Return(nil).Maybe()
421533
422534 d := newTestDaemon(t, nil, stubClock{})
423- win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}}
535+ win := newTab(p, domain.Size{Cols: 20, Rows: 5})
424536 sctx, cancel := context.WithCancel(context.Background())
425537 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
426538 sess := &session{id: "e", name: "0", ephemeral: true, tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
@@ -428,7 +540,7 @@ func TestSendErrorKillsEphemeral(t *testing.T) {
428540 d.sessions[sess.id] = sess
429541
430542 win.mu.Lock()
431- win.screen.Write([]byte("x"))
543+ win.focusedPane().screen.Write([]byte("x"))
432544 win.mu.Unlock()
433545
434546 d.paint(sess, ac, true) // send fails -> detach -> ephemeral killed
@@ -463,22 +575,25 @@ func TestSchedulerDefersPendingDirtyTimerDuringSynchronizedUpdate(t *testing.T)
463575
464576 d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil)))
465577 sctx, cancel := context.WithCancel(context.Background())
466- win := &tab{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
578+ win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
579+ win.ctx, win.cancel = sctx, cancel
580+ p := win.focusedPane()
581+ p.ctx, p.cancel = sctx, cancel
467582 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
468583 sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
469584 ac.setSession(sess)
470585
471- win.mu.Lock()
472- win.screen.Write([]byte("before"))
473- win.mu.Unlock()
586+ p.mu.Lock()
587+ p.screen.Write([]byte("before"))
588+ p.mu.Unlock()
474589
475590 d.sessWg.Add(1)
476- go d.scheduler(sess, win)
477- win.dirty <- struct{}{}
591+ go d.scheduler(sess, win, p)
592+ p.dirty <- struct{}{}
478593
479- win.mu.Lock()
480- win.screen.Write([]byte("\x1b[?2026hafter"))
481- win.mu.Unlock()
594+ p.mu.Lock()
595+ p.screen.Write([]byte("\x1b[?2026hafter"))
596+ p.mu.Unlock()
482597 timerCh <- time.Now()
483598
484599 select {
@@ -488,10 +603,10 @@ func TestSchedulerDefersPendingDirtyTimerDuringSynchronizedUpdate(t *testing.T)
488603 }
489604 require.Equal(t, int32(0), outputs.Load())
490605
491- win.mu.Lock()
492- win.screen.Write([]byte(" done\x1b[?2026l"))
493- win.mu.Unlock()
494- win.flush <- struct{}{}
606+ p.mu.Lock()
607+ p.screen.Write([]byte(" done\x1b[?2026l"))
608+ p.mu.Unlock()
609+ p.flush <- struct{}{}
495610 select {
496611 case <-gotOutput:
497612 case <-time.After(time.Second):
@@ -523,13 +638,13 @@ func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
523638
524639 tr, sends := newCapturingTransport(t)
525640 sctx, cancel := context.WithCancel(context.Background())
526- win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
641+ win := newTestTabWithContext(p, sctx, cancel)
527642 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
528643 sess := &session{id: "query", name: "query", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
529644 ac.setSession(sess)
530645 d.sessions[sess.id] = sess
531646 d.sessWg.Add(1)
532- d.ptyReader(sess, win)
647+ d.ptyReader(sess, win, win.focusedPane())
533648
534649 select {
535650 case got := <-writes:
@@ -559,19 +674,19 @@ func TestPTYReaderSameReadSynchronizedUpdateStartEndFlushesImmediately(t *testin
559674 p.EXPECT().Close().Return(nil).Maybe()
560675
561676 sctx, cancel := context.WithCancel(context.Background())
562- win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
677+ win := newTestTabWithContext(p, sctx, cancel)
563678 sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel}
564679 d.sessions[sess.id] = sess
565680 d.sessWg.Add(1)
566- d.ptyReader(sess, win)
681+ d.ptyReader(sess, win, win.focusedPane())
567682
568683 select {
569- case <-win.dirty:
684+ case <-win.focusedPane().dirty:
570685 t.Fatal("same-read synchronized update end should request flush, not dirty")
571686 default:
572687 }
573688 select {
574- case <-win.flush:
689+ case <-win.focusedPane().flush:
575690 case <-time.After(time.Second):
576691 t.Fatal("same-read synchronized update end did not request immediate flush")
577692 }
@@ -592,19 +707,19 @@ func TestPTYReaderDefersDirtyDuringSynchronizedUpdateAndFlushesAtEnd(t *testing.
592707 p.EXPECT().Close().Return(nil).Maybe()
593708
594709 sctx, cancel := context.WithCancel(context.Background())
595- win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
710+ win := newTestTabWithContext(p, sctx, cancel)
596711 sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel}
597712 d.sessions[sess.id] = sess
598713 d.sessWg.Add(1)
599- d.ptyReader(sess, win)
714+ d.ptyReader(sess, win, win.focusedPane())
600715
601716 select {
602- case <-win.dirty:
717+ case <-win.focusedPane().dirty:
603718 t.Fatal("dirty signaled while synchronized update was active")
604719 default:
605720 }
606721 select {
607- case <-win.flush:
722+ case <-win.focusedPane().flush:
608723 case <-time.After(time.Second):
609724 t.Fatal("sync end did not request immediate flush")
610725 }
@@ -628,22 +743,22 @@ func TestSyncUpdateWatchdogAbandonedSyncRenders(t *testing.T) {
628743
629744 tr, sends := newCapturingTransport(t)
630745 sctx, cancel := context.WithCancel(context.Background())
631- win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
746+ win := newTestTabWithContext(p, sctx, cancel)
632747 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
633748 sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
634749 ac.setSession(sess)
635750 d.sessions[sess.id] = sess
636751 d.sessWg.Add(2)
637- go d.scheduler(sess, win)
638- go d.ptyReader(sess, win)
752+ go d.scheduler(sess, win, win.focusedPane())
753+ go d.ptyReader(sess, win, win.focusedPane())
639754
640755 timer := <-clk.timers
641756 timer.ch <- time.Now()
642757 awaitFrame(t, sends, ports.MsgOutput)
643758
644759 win.mu.Lock()
645- active := win.screen.SyncUpdateActive()
646- got := screenLineText(win.screen, 0)
760+ active := win.focusedPane().screen.SyncUpdateActive()
761+ got := screenLineText(win.focusedPane().screen, 0)
647762 win.mu.Unlock()
648763 require.False(t, active)
649764 require.Contains(t, got, "abandoned")
@@ -672,14 +787,14 @@ func TestSyncUpdateWatchdogStaleGenerationNoopAfterEnd(t *testing.T) {
672787
673788 tr, sends := newCapturingTransport(t)
674789 sctx, cancel := context.WithCancel(context.Background())
675- win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
790+ win := newTestTabWithContext(p, sctx, cancel)
676791 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
677792 sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
678793 ac.setSession(sess)
679794 d.sessions[sess.id] = sess
680795 d.sessWg.Add(2)
681- go d.scheduler(sess, win)
682- go d.ptyReader(sess, win)
796+ go d.scheduler(sess, win, win.focusedPane())
797+ go d.ptyReader(sess, win, win.focusedPane())
683798
684799 timer := <-clk.timers
685800 awaitFrame(t, sends, ports.MsgOutput)
@@ -693,7 +808,7 @@ func TestSyncUpdateWatchdogStaleGenerationNoopAfterEnd(t *testing.T) {
693808 case <-time.After(50 * time.Millisecond):
694809 }
695810 win.mu.Lock()
696- active := win.screen.SyncUpdateActive()
811+ active := win.focusedPane().screen.SyncUpdateActive()
697812 win.mu.Unlock()
698813 require.False(t, active)
699814
@@ -706,21 +821,75 @@ func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {
706821 p, _ := newBlockingPTY(t)
707822 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
708823 win := sess.tabs[0]
709- win.screen.Write([]byte("A"))
824+ win.focusedPane().screen.Write([]byte("A"))
710825
711826 d.paint(sess, ac, true)
712827 data := mustOutputData(t, sends)
713- require.Contains(t, string(data), "\x1b[5 q")
828+ require.Contains(t, string(data), "\x1b[1 q")
714829 require.Contains(t, string(data), "\x1b[?25h")
715830
716- win.screen.Write([]byte("\x1b[2;3H"))
831+ win.focusedPane().screen.Write([]byte("\x1b[2;3H"))
717832 d.paint(sess, ac, false)
718833 data = mustOutputData(t, sends)
719834 require.Contains(t, string(data), "\x1b[3;3H")
720835 require.Contains(t, string(data), "\x1b[?25h")
721836
722- win.screen.Write([]byte("\x1b[?25l"))
837+ win.focusedPane().screen.Write([]byte("\x1b[?25l"))
723838 d.paint(sess, ac, false)
724839 data = mustOutputData(t, sends)
725840 require.Contains(t, string(data), "\x1b[?25l")
726841 }
842+
843+func TestCursorTailUsesFocusedPanePlacement(t *testing.T) {
844+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
845+ win := sess.tabs[0]
846+ win.size = domain.Size{Cols: 80, Rows: 23}
847+ left := win.focusedPane()
848+ right := newPane("pane-2", nil, domain.Size{Cols: 39, Rows: 23})
849+ win.panes[right.id] = right
850+ win.tree = layout.NewTree(left.id)
851+ require.NoError(t, win.tree.Split(left.id, layout.Right, true, right.id, domain.Rect{Width: 80, Height: 23}))
852+ win.tree.Focus = right.id
853+ right.screen.Write([]byte("\x1b[2;3H"))
854+ placements, ok := layout.Solve(win.tree.Root, domain.Rect{Width: 80, Height: 23})
855+ require.True(t, ok)
856+ rightContent := placementContent(placements, right.id)
857+
858+ d.paint(sess, ac, true)
859+ data := mustOutputData(t, sends)
860+ want := cursorCSI(rightContent.Y+right.screen.CursorRow()+2, rightContent.X+right.screen.CursorCol()+1)
861+ require.Contains(t, string(data), want)
862+}
863+
864+func TestCursorTailUsesExpandedStackContentPlacement(t *testing.T) {
865+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
866+ win := sess.tabs[0]
867+ win.size = domain.Size{Cols: 80, Rows: 23}
868+ one := win.focusedPane()
869+ two := newPane("pane-2", nil, domain.Size{Cols: 80, Rows: 20})
870+ three := newPane("pane-3", nil, domain.Size{Cols: 80, Rows: 20})
871+ win.panes[two.id] = two
872+ win.panes[three.id] = three
873+ win.tree = &layout.Tree{
874+ Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{
875+ layout.NewLeaf(one.id),
876+ layout.NewLeaf(two.id),
877+ layout.NewLeaf(three.id),
878+ }, Expanded: two.id},
879+ Focus: two.id,
880+ }
881+ two.screen.Write([]byte("\x1b[2;3H"))
882+ placements, ok := layout.Solve(win.tree.Root, domain.Rect{Width: 80, Height: 23})
883+ require.True(t, ok)
884+ twoContent := placementContent(placements, two.id)
885+ require.Greater(t, twoContent.Y, 0, "stack title bars should offset content")
886+
887+ d.paint(sess, ac, true)
888+ data := mustOutputData(t, sends)
889+ want := cursorCSI(twoContent.Y+two.screen.CursorRow()+2, twoContent.X+two.screen.CursorCol()+1)
890+ require.Contains(t, string(data), want)
891+}
892+
893+func cursorCSI(row, col int) string {
894+ return "\x1b[" + strconv.Itoa(row) + ";" + strconv.Itoa(col) + "H"
895+}
M internal/usecase/daemon/scrollback_test.go +6 -6
@@ -24,19 +24,19 @@ func TestNewTabInitializesScrollback(t *testing.T) {
2424 for _, tt := range tests {
2525 t.Run(tt.name, func(t *testing.T) {
2626 win := newTab(nil, domain.Size{Cols: 4, Rows: 3})
27- if win.scrollback == nil {
27+ if win.focusedPane().scrollback == nil {
2828 t.Fatal("scrollback is nil")
2929 }
30- if win.scrollback.Cap() != defaultScrollbackRows {
31- t.Fatalf("scrollback cap = %d, want %d", win.scrollback.Cap(), defaultScrollbackRows)
30+ if win.focusedPane().scrollback.Cap() != defaultScrollbackRows {
31+ t.Fatalf("scrollback cap = %d, want %d", win.focusedPane().scrollback.Cap(), defaultScrollbackRows)
3232 }
3333
34- win.screen.Write(tt.write)
34+ win.focusedPane().screen.Write(tt.write)
3535
36- if got := win.scrollback.Len(); got != tt.wantLen {
36+ if got := win.focusedPane().scrollback.Len(); got != tt.wantLen {
3737 t.Fatalf("scrollback len = %d, want %d", got, tt.wantLen)
3838 }
39- row := win.scrollback.Row(0)
39+ row := win.focusedPane().scrollback.Row(0)
4040 runes := make([]rune, len(row))
4141 for i, c := range row {
4242 runes[i] = c.Rune
M internal/usecase/daemon/session.go +84 -34
@@ -17,11 +17,11 @@
1717 // when the parent context is cancelled (graceful shutdown notifies any
1818 // attached clients with ReasonServerShutdown).
1919 //
20-// Locking: a session's screen and per-client renderer shadow are both guarded
21-// by tab.mu; the attached-client pointer by session.mu; the registry by
22-// Daemon.mu. When more than one is held the order is always
23-// sendMu > Daemon.mu > session.mu > tab.mu — the PTY reader only ever takes
24-// tab.mu, so it never blocks on a slow client.
20+// Locking: a pane's screen/scrollback and per-client renderer shadow are
21+// guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
22+// session.mu; the registry by Daemon.mu. When more than one is held the order
23+// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
24+// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
2525 package daemon
2626
2727 import (
@@ -36,8 +36,7 @@ import (
3636 "github.com/bnema/vev/internal/domain"
3737 "github.com/bnema/vev/internal/persist"
3838 "github.com/bnema/vev/internal/ports"
39- scopy "github.com/bnema/vev/internal/usecase/copy"
40- "github.com/bnema/vev/pkg/vt"
39+ "github.com/bnema/vev/internal/usecase/layout"
4140 )
4241
4342 type session struct {
@@ -56,26 +55,23 @@ type session struct {
5655 createdAt int64
5756 }
5857
59-// tab is one PTY-backed screen. dirty is a cap-1 channel so bursty output
60-// applies back-pressure by collapsing (a full buffer means "a render is
61-// already pending"), never by blocking the reader.
58+// tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.
6259 type tab struct {
63- pty ports.PTY
64- mu sync.Mutex // guards screen, syncGen, and every attached renderer's shadow
65- screen *vt.Screen
66- scrollback *scopy.Scrollback
67- dirty chan struct{}
68- flush chan struct{}
69- syncGen uint64
60+ mu sync.Mutex // guards tree, panes, nextPaneID, size, previewClient, and pane map membership
61+
62+ tree *layout.Tree
63+ panes map[layout.PaneID]*pane
64+ nextPaneID int
65+ size domain.Size
66+ ctx context.Context
67+ cancel context.CancelFunc
68+
7069 // previewClient tracks the one client currently previewing this tab in the picker.
7170 // v1 is last-writer-wins: multiple clients previewing the same tab are not supported.
7271 previewClient *attachedClient
7372 // attention and attentionAt are guarded by the owning session.mu.
7473 attention bool
7574 attentionAt time.Time
76- size domain.Size
77- ctx context.Context
78- cancel context.CancelFunc
7975 }
8076
8177 // attachedClient is a client currently attached to a session's tab. rend is
@@ -183,7 +179,14 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
183179 tb := newTab(pty, tbSize)
184180 if client != nil {
185181 t := client.getTheme()
186- tb.screen.SetDefaultColors(t.Foreground, t.Background, t.HasFG && t.HasBG)
182+ tb.mu.Lock()
183+ p := tb.focusedPane()
184+ tb.mu.Unlock()
185+ if p != nil {
186+ p.mu.Lock()
187+ p.screen.SetDefaultColors(t.Foreground, t.Background, t.HasFG && t.HasBG)
188+ p.mu.Unlock()
189+ }
187190 }
188191 d.mu.Lock()
189192 if d.closing || d.sessions[sess.id] != sess || sess.ctx.Err() != nil {
@@ -192,6 +195,9 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
192195 return errors.New("daemon: session closed")
193196 }
194197 tb.ctx, tb.cancel = context.WithCancel(sess.ctx)
198+ for _, p := range tb.panes {
199+ p.ctx, p.cancel = context.WithCancel(tb.ctx)
200+ }
195201 sess.mu.Lock()
196202 sess.tabs = append(sess.tabs, tb)
197203 sess.active = len(sess.tabs) - 1
@@ -202,19 +208,48 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
202208 }
203209
204210 func newTab(pty ports.PTY, sz domain.Size) *tab {
205- sb := scopy.NewScrollback(defaultScrollbackRows)
206- screen := vt.NewScreen(sz.Cols, sz.Rows)
207- screen.OnLineEvicted = sb.Append
211+ id := layout.PaneID("pane-1")
212+ p := newPane(id, pty, sz)
208213 return &tab{
209- pty: pty,
210- screen: screen,
211- scrollback: sb,
212- dirty: make(chan struct{}, 1),
213- flush: make(chan struct{}, 1),
214+ tree: layout.NewTree(id),
215+ panes: map[layout.PaneID]*pane{id: p},
216+ nextPaneID: 2,
214217 size: sz,
215218 }
216219 }
217220
221+func (tb *tab) focusedPane() *pane {
222+ if tb == nil || tb.tree == nil || tb.panes == nil {
223+ return nil
224+ }
225+ return tb.panes[tb.tree.Focus]
226+}
227+
228+func (tb *tab) panesSnapshot() []*pane {
229+ if tb == nil {
230+ return nil
231+ }
232+ out := make([]*pane, 0, len(tb.panes))
233+ for _, p := range tb.panes {
234+ out = append(out, p)
235+ }
236+ return out
237+}
238+
239+func (tb *tab) closeAllPanes() {
240+ tb.mu.Lock()
241+ panes := tb.panesSnapshot()
242+ tb.mu.Unlock()
243+ for _, p := range panes {
244+ if p.cancel != nil {
245+ p.cancel()
246+ }
247+ if p.pty != nil {
248+ _ = p.pty.Close()
249+ }
250+ }
251+}
252+
218253 func tabSize(clientSize domain.Size) domain.Size {
219254 if !clientSize.Valid() {
220255 clientSize = defaultSize
@@ -224,9 +259,18 @@ func tabSize(clientSize domain.Size) domain.Size {
224259 }
225260
226261 func (d *Daemon) startTabGoroutines(sess *session, tb *tab) {
262+ tb.mu.Lock()
263+ panes := tb.panesSnapshot()
264+ tb.mu.Unlock()
265+ for _, p := range panes {
266+ d.startPaneGoroutines(sess, tb, p)
267+ }
268+}
269+
270+func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {
227271 d.sessWg.Add(2)
228- go d.ptyReader(sess, tb)
229- go d.scheduler(sess, tb)
272+ go d.ptyReader(sess, tb, p)
273+ go d.scheduler(sess, tb, p)
230274 }
231275
232276 // attachClient makes ac the session's current client, displacing any prior one
@@ -345,7 +389,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
345389 if tb.cancel != nil {
346390 tb.cancel()
347391 }
348- _ = tb.pty.Close()
392+ tb.closeAllPanes()
349393 if repaint && ac != nil {
350394 d.paint(sess, ac, true)
351395 }
@@ -425,7 +469,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
425469 sess.mu.Unlock()
426470 for _, tb := range tabs {
427471 d.clearDestroyedTabPreview(tb)
428- _ = tb.pty.Close()
472+ tb.closeAllPanes()
429473 }
430474 if empty {
431475 d.doneOnce.Do(func() { close(d.done) })
@@ -509,7 +553,13 @@ func (d *Daemon) refreshSessionCwd(sess *session) {
509553 if tb == nil {
510554 return
511555 }
512- cwd, err := d.procCwd(tb.pty.Pid())
556+ tb.mu.Lock()
557+ p := tb.focusedPane()
558+ tb.mu.Unlock()
559+ if p == nil {
560+ return
561+ }
562+ cwd, err := d.procCwd(p.pty.Pid())
513563 if err != nil || cwd == "" {
514564 return
515565 }
M internal/usecase/daemon/status.go +5 -6
@@ -17,12 +17,11 @@
1717 // when the parent context is cancelled (graceful shutdown notifies any
1818 // attached clients with ReasonServerShutdown).
1919 //
20-// Locking: a session's screen and per-client renderer shadow are both guarded
21-// by tab.mu; the attached-client pointer by session.mu; the registry by
22-// Daemon.mu. When more than one is held the order is always
23-// Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >
24-// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on
25-// a slow client.
20+// Locking: a pane's screen/scrollback and per-client renderer shadow are
21+// guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
22+// session.mu; the registry by Daemon.mu. When more than one is held the order
23+// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
24+// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
2625 package daemon
2726
2827 import (
M internal/usecase/daemon/status_test.go +20 -21
@@ -12,7 +12,6 @@ import (
1212 "github.com/bnema/vev/internal/ports"
1313 themeui "github.com/bnema/vev/internal/usecase/theme"
1414 "github.com/bnema/vev/pkg/renderer"
15- "github.com/bnema/vev/pkg/vt"
1615 )
1716
1817 // --- test doubles -----------------------------------------------------------
@@ -30,9 +29,9 @@ func TestStatusCompositionGolden(t *testing.T) {
3029 sess.name = "work"
3130
3231 win := sess.activeTab()
33- win.screen = vt.NewScreen(12, 2)
32+ win.focusedPane().screen.Resize(12, 2)
3433 win.size = domain.Size{Cols: 12, Rows: 2}
35- win.screen.Write([]byte("hello"))
34+ win.focusedPane().screen.Write([]byte("hello"))
3635
3736 frame, damage := composeClientFrame(sess, win, true, "")
3837
@@ -54,7 +53,7 @@ func TestStatusCompositionUsesTruecolorTheme(t *testing.T) {
5453 _, sess, ac, _ := newManualSessionWithPTYs(t, p)
5554 defer release()
5655 win := sess.activeTab()
57- win.screen = vt.NewScreen(12, 2)
56+ win.focusedPane().screen.Resize(12, 2)
5857 win.size = domain.Size{Cols: 12, Rows: 2}
5958 ac.setTheme(themeui.Theme{
6059 Foreground: renderer.RGB{R: 220, G: 220, B: 220},
@@ -86,8 +85,8 @@ func TestStatusApplyThemeStoresClientAndPropagatesScreens(t *testing.T) {
8685 require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, ac.getTheme().Foreground)
8786 for _, tb := range sess.tabs {
8887 var got []byte
89- tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }
90- tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
88+ tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
89+ tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
9190 require.True(t, strings.Contains(string(got), "rgb:0101/0202/0303"), string(got))
9291 require.True(t, strings.Contains(string(got), "rgb:0404/0505/0606"), string(got))
9392 }
@@ -107,8 +106,8 @@ func TestAttachClientClearsStaleScreenDefaultColors(t *testing.T) {
107106
108107 var got []byte
109108 tb := sess.activeTab()
110- tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }
111- tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
109+ tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
110+ tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
112111 require.Empty(t, got)
113112 }
114113
@@ -124,15 +123,15 @@ func TestClientGoneResetsScreenDefaultColors(t *testing.T) {
124123 // Sanity: the tab answers OSC 10/11 while ac is attached.
125124 tb := sess.activeTab()
126125 var before []byte
127- tb.screen.OnResponse = func(b []byte) { before = append(before, b...) }
128- tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
126+ tb.focusedPane().screen.OnResponse = func(b []byte) { before = append(before, b...) }
127+ tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
129128 require.NotEmpty(t, before)
130129
131130 d.clientGone(sess, ac, true)
132131
133132 var got []byte
134- tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }
135- tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
133+ tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
134+ tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
136135 require.Empty(t, got, "OSC 10/11 queries must be swallowed once the client that reported these colors is gone")
137136 }
138137
@@ -164,8 +163,8 @@ func TestClientGoneResetDoesNotClobberNewlyAttachedClient(t *testing.T) {
164163
165164 tb := sess.activeTab()
166165 var got []byte
167- tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }
168- tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
166+ tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
167+ tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
169168 require.NotEmpty(t, got, "a newer client's screen default colors must survive a stale detach's reset")
170169 }
171170
@@ -182,8 +181,8 @@ func TestDetachOnSendErrorResetsScreenDefaultColors(t *testing.T) {
182181
183182 tb := sess.activeTab()
184183 var got []byte
185- tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }
186- tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
184+ tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
185+ tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
187186 require.Empty(t, got, "OSC 10/11 queries must be swallowed once the client that reported these colors is gone")
188187 }
189188
@@ -201,8 +200,8 @@ func TestApplyThemeIgnoresReplacedClient(t *testing.T) {
201200
202201 var got []byte
203202 tb := sess.activeTab()
204- tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }
205- tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
203+ tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
204+ tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
206205 require.Empty(t, got)
207206 }
208207
@@ -213,7 +212,7 @@ func TestStatusMarksEphemeralSession(t *testing.T) {
213212 sess.name = "0"
214213 sess.ephemeral = true
215214 win := sess.activeTab()
216- win.screen = vt.NewScreen(12, 2)
215+ win.focusedPane().screen.Resize(12, 2)
217216 win.size = domain.Size{Cols: 12, Rows: 2}
218217
219218 frame, _ := composeClientFrame(sess, win, true, "")
@@ -228,7 +227,7 @@ func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
228227 defer releasePTY()
229228 sess.name = "work"
230229 win := sess.activeTab()
231- win.screen = vt.NewScreen(30, 2)
230+ win.focusedPane().screen.Resize(30, 2)
232231 win.size = domain.Size{Cols: 30, Rows: 2}
233232
234233 frame, _ := composeClientFrame(sess, win, true, "ok")
@@ -265,7 +264,7 @@ func TestTopBarRendersAttentionBell(t *testing.T) {
265264 defer releasePTY1()
266265 defer releasePTY2()
267266 win := sess.activeTab()
268- win.screen = vt.NewScreen(18, 2)
267+ win.focusedPane().screen.Resize(18, 2)
269268 win.size = domain.Size{Cols: 18, Rows: 2}
270269 sess.mu.Lock()
271270 sess.tabs[1].attention = true
M internal/usecase/daemon/wire.go +5 -6
@@ -17,12 +17,11 @@
1717 // when the parent context is cancelled (graceful shutdown notifies any
1818 // attached clients with ReasonServerShutdown).
1919 //
20-// Locking: a session's screen and per-client renderer shadow are both guarded
21-// by tab.mu; the attached-client pointer by session.mu; the registry by
22-// Daemon.mu. When more than one is held the order is always
23-// Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >
24-// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on
25-// a slow client.
20+// Locking: a pane's screen/scrollback and per-client renderer shadow are
21+// guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
22+// session.mu; the registry by Daemon.mu. When more than one is held the order
23+// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
24+// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
2625 package daemon
2726
2827 import (
M internal/usecase/keys/router.go +81 -11
@@ -3,6 +3,7 @@
33 package keys
44
55 import (
6+ "slices"
67 "sync"
78 "time"
89 "unicode/utf8"
@@ -30,6 +31,10 @@ const (
3031 ActionSwitchTab8
3132 ActionSwitchTab9
3233 ActionJumpAttention
34+ ActionFocusPaneLeft
35+ ActionFocusPaneRight
36+ ActionFocusPaneUp
37+ ActionFocusPaneDown
3338 )
3439
3540 // Handler receives router outputs. Forward is called only for bytes that should
@@ -98,13 +103,23 @@ func (r *Router) route(data []byte) {
98103 r.retainESC()
99104 return
100105 }
106+ remaining := data[i+1:]
107+ if action, size, ok, partial := altArrowCSI(remaining); ok {
108+ flush()
109+ r.h.Action(action)
110+ i += 1 + size
111+ continue
112+ } else if partial {
113+ flush()
114+ r.retainESC(remaining)
115+ return
116+ }
101117 next := data[i+1]
102118 if passThroughPrefix(next) {
103119 buf = append(buf, ESC, next)
104120 i += 2
105121 continue
106122 }
107- remaining := data[i+1:]
108123 if action, size, ok := binding(remaining); ok {
109124 flush()
110125 r.h.Action(action)
@@ -123,11 +138,14 @@ func (r *Router) route(data []byte) {
123138 }
124139
125140 func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int) int {
126- next := data[0]
127- if passThroughPrefix(next) {
128- r.forward([]byte{ESC, next})
129- return 1
141+ if action, size, ok, partial := altArrowCSI(data); ok {
142+ r.h.Action(action)
143+ return size
144+ } else if partial {
145+ r.retainESC(data)
146+ return len(data)
130147 }
148+ next := data[0]
131149 if action, size, ok := binding(data); ok {
132150 r.h.Action(action)
133151 return size
@@ -136,15 +154,19 @@ func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int) int {
136154 r.retainESC(data)
137155 return len(data)
138156 }
139- key, size := utf8.DecodeRune(data)
157+ _, size := utf8.DecodeRune(data)
140158 if size > 1 {
141159 r.forward(append([]byte{ESC}, data[:size]...))
142160 return size
143161 }
144- if key == utf8.RuneError && pendingAltLen > 0 {
162+ if pendingAltLen > 0 {
145163 r.forward(append([]byte{ESC}, data[:pendingAltLen]...))
146164 return pendingAltLen
147165 }
166+ if passThroughPrefix(next) {
167+ r.forward([]byte{ESC, next})
168+ return 1
169+ }
148170 r.forward([]byte{ESC})
149171 return 0
150172 }
@@ -200,6 +222,48 @@ func (r *Router) forward(data []byte) {
200222
201223 func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' }
202224
225+func altArrowCSI(data []byte) (Action, int, bool, bool) {
226+ const seqLen = len("[1;3A")
227+ if len(data) < seqLen {
228+ return 0, 0, false, hasAltArrowCSIPrefix(data)
229+ }
230+ seq := data[:seqLen]
231+ if seq[0] != '[' || seq[1] != '1' || seq[2] != ';' || (seq[3] != '3' && seq[3] != '9') {
232+ return 0, 0, false, false
233+ }
234+ switch seq[4] {
235+ case 'A':
236+ return ActionFocusPaneUp, seqLen, true, false
237+ case 'B':
238+ return ActionFocusPaneDown, seqLen, true, false
239+ case 'C':
240+ return ActionFocusPaneRight, seqLen, true, false
241+ case 'D':
242+ return ActionFocusPaneLeft, seqLen, true, false
243+ default:
244+ return 0, 0, false, false
245+ }
246+}
247+
248+func hasAltArrowCSIPrefix(data []byte) bool {
249+ if len(data) == 0 || len(data) >= len("[1;3A") {
250+ return false
251+ }
252+ return matchesPrefix(data, "[1;3") || matchesPrefix(data, "[1;9")
253+}
254+
255+func matchesPrefix(data []byte, want string) bool {
256+ if len(data) > len(want) {
257+ return false
258+ }
259+ for i := range data {
260+ if data[i] != want[i] {
261+ return false
262+ }
263+ }
264+ return true
265+}
266+
203267 func binding(data []byte) (Action, int, bool) {
204268 if len(data) == 0 {
205269 return 0, 0, false
@@ -209,6 +273,14 @@ func binding(data []byte) (Action, int, bool) {
209273 return ActionOpenPalette, 1, true
210274 case 'a':
211275 return ActionJumpAttention, 1, true
276+ case 'h':
277+ return ActionFocusPaneLeft, 1, true
278+ case 'j':
279+ return ActionFocusPaneDown, 1, true
280+ case 'k':
281+ return ActionFocusPaneUp, 1, true
282+ case 'l':
283+ return ActionFocusPaneRight, 1, true
212284 }
213285
214286 key, size := utf8.DecodeRune(data)
@@ -238,10 +310,8 @@ var topRowDigitAliases = [][]rune{
238310 // Ctrl+digit bindings can share the same layout support.
239311 func topRowDigitIndex(key rune) (int, bool) {
240312 for idx, aliases := range topRowDigitAliases {
241- for _, alias := range aliases {
242- if key == alias {
243- return idx, true
244- }
313+ if slices.Contains(aliases, key) {
314+ return idx, true
245315 }
246316 }
247317 return 0, false
M internal/usecase/keys/router_test.go +142 -0
@@ -212,6 +212,29 @@ func TestRouterInterceptsAltAForJumpAttention(t *testing.T) {
212212 require.Empty(t, clk.timers)
213213 }
214214
215+func TestRouterInterceptsAltHJKLForPaneFocus(t *testing.T) {
216+ cases := []struct {
217+ name string
218+ key byte
219+ want Action
220+ }{
221+ {name: "left", key: 'h', want: ActionFocusPaneLeft},
222+ {name: "down", key: 'j', want: ActionFocusPaneDown},
223+ {name: "up", key: 'k', want: ActionFocusPaneUp},
224+ {name: "right", key: 'l', want: ActionFocusPaneRight},
225+ }
226+ for _, tc := range cases {
227+ t.Run(tc.name, func(t *testing.T) {
228+ clk := &fakeClock{}
229+ h := &captureHandler{}
230+ NewRouter(clk, h).Route([]byte{ESC, tc.key})
231+ require.Equal(t, []Action{tc.want}, h.actions)
232+ require.Empty(t, h.forwards)
233+ require.Empty(t, clk.timers)
234+ })
235+ }
236+}
237+
215238 func TestRouterForwardsRemovedAltLetterBindings(t *testing.T) {
216239 for _, b := range []byte("cnpdxurt") {
217240 t.Run(string(b), func(t *testing.T) {
@@ -244,6 +267,125 @@ func TestRouterPassesThroughTerminalEscapePrefixes(t *testing.T) {
244267 }
245268 }
246269
270+func TestRouterInterceptsAltArrowCSIForPaneFocus(t *testing.T) {
271+ cases := []struct {
272+ name string
273+ in []byte
274+ want Action
275+ }{
276+ {name: "alt up", in: []byte{ESC, '[', '1', ';', '3', 'A'}, want: ActionFocusPaneUp},
277+ {name: "alt down", in: []byte{ESC, '[', '1', ';', '3', 'B'}, want: ActionFocusPaneDown},
278+ {name: "alt right", in: []byte{ESC, '[', '1', ';', '3', 'C'}, want: ActionFocusPaneRight},
279+ {name: "alt left", in: []byte{ESC, '[', '1', ';', '3', 'D'}, want: ActionFocusPaneLeft},
280+ {name: "meta up", in: []byte{ESC, '[', '1', ';', '9', 'A'}, want: ActionFocusPaneUp},
281+ {name: "meta down", in: []byte{ESC, '[', '1', ';', '9', 'B'}, want: ActionFocusPaneDown},
282+ {name: "meta right", in: []byte{ESC, '[', '1', ';', '9', 'C'}, want: ActionFocusPaneRight},
283+ {name: "meta left", in: []byte{ESC, '[', '1', ';', '9', 'D'}, want: ActionFocusPaneLeft},
284+ }
285+
286+ for _, tc := range cases {
287+ t.Run(tc.name, func(t *testing.T) {
288+ clk := &fakeClock{}
289+ h := &captureHandler{}
290+ NewRouter(clk, h).Route(tc.in)
291+ require.Equal(t, []Action{tc.want}, h.actions)
292+ require.Empty(t, h.forwards)
293+ require.Empty(t, clk.timers)
294+ })
295+ }
296+}
297+
298+func TestRouterAltArrowCSIPartialsAcrossReads(t *testing.T) {
299+ cases := []struct {
300+ name string
301+ reads [][]byte
302+ want Action
303+ timers int
304+ }{
305+ {name: "esc then rest", reads: [][]byte{{ESC}, []byte("[1;3D")}, want: ActionFocusPaneLeft, timers: 1},
306+ {name: "csi prefix then rest", reads: [][]byte{{ESC, '['}, []byte("1;3C")}, want: ActionFocusPaneRight, timers: 1},
307+ {name: "modifier prefix then final", reads: [][]byte{{ESC, '[', '1', ';', '3'}, []byte("A")}, want: ActionFocusPaneUp, timers: 1},
308+ {name: "meta modifier prefix then final", reads: [][]byte{{ESC, '[', '1', ';', '9'}, []byte("B")}, want: ActionFocusPaneDown, timers: 1},
309+ }
310+
311+ for _, tc := range cases {
312+ t.Run(tc.name, func(t *testing.T) {
313+ clk := &fakeClock{}
314+ h := &captureHandler{}
315+ r := NewRouter(clk, h)
316+ for _, read := range tc.reads {
317+ r.Route(read)
318+ }
319+ require.Equal(t, []Action{tc.want}, h.actions)
320+ require.Empty(t, h.forwards)
321+ require.Len(t, clk.timers, tc.timers)
322+ assertRouterPendingCleared(t, r)
323+ })
324+ }
325+}
326+
327+func TestRouterAltArrowCSIPrefixRouting(t *testing.T) {
328+ cases := []struct {
329+ name string
330+ reads [][]byte
331+ wantActions []Action
332+ wantForwards [][]byte
333+ }{
334+ {
335+ name: "two alt arrows in one read",
336+ reads: [][]byte{{ESC, '[', '1', ';', '3', 'A', ESC, '[', '1', ';', '3', 'A'}},
337+ wantActions: []Action{ActionFocusPaneUp, ActionFocusPaneUp},
338+ },
339+ {
340+ name: "alt arrow followed by normal byte",
341+ reads: [][]byte{{ESC, '[', '1', ';', '3', 'A', 'x'}},
342+ wantActions: []Action{ActionFocusPaneUp},
343+ wantForwards: [][]byte{[]byte("x")},
344+ },
345+ {
346+ name: "bare arrow passthrough",
347+ reads: [][]byte{{ESC, '[', 'A'}},
348+ wantForwards: [][]byte{{ESC, '[', 'A'}},
349+ },
350+ {
351+ name: "retained escape alt arrow across reads",
352+ reads: [][]byte{{ESC}, []byte("[1;3A")},
353+ wantActions: []Action{ActionFocusPaneUp},
354+ },
355+ }
356+
357+ for _, tc := range cases {
358+ t.Run(tc.name, func(t *testing.T) {
359+ clk := &fakeClock{}
360+ h := &captureHandler{}
361+ r := NewRouter(clk, h)
362+ for _, read := range tc.reads {
363+ r.Route(read)
364+ }
365+ require.Equal(t, tc.wantActions, h.actions)
366+ require.Equal(t, tc.wantForwards, h.forwards)
367+ })
368+ }
369+}
370+
371+func TestRouterPassesThroughBareArrows(t *testing.T) {
372+ for _, in := range [][]byte{
373+ {ESC, '[', 'A'},
374+ {ESC, '[', 'B'},
375+ {ESC, '[', 'C'},
376+ {ESC, '[', 'D'},
377+ } {
378+ t.Run(string(in), func(t *testing.T) {
379+ clk := &fakeClock{}
380+ h := &captureHandler{}
381+ NewRouter(clk, h).Route(in)
382+ require.Empty(t, h.actions)
383+ require.Equal(t, [][]byte{in}, h.forwards)
384+ require.Empty(t, clk.timers)
385+ })
386+ }
387+}
388+
247389 func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) {
248390 clk := &fakeClock{}
249391 h := &captureHandler{}
A internal/usecase/layout/geometry.go +118 -0
@@ -0,0 +1,118 @@
1+package layout
2+
3+import "github.com/bnema/vev/internal/domain"
4+
5+// Solve returns deterministic pane placements for root inside area.
6+func Solve(root *Node, area domain.Rect) ([]Placement, bool) {
7+ if root == nil || area.Width <= 0 || area.Height <= 0 {
8+ return nil, false
9+ }
10+ var out []Placement
11+ if !solve(root, area, &out) {
12+ return nil, false
13+ }
14+ return out, true
15+}
16+
17+func solve(n *Node, r domain.Rect, out *[]Placement) bool {
18+ switch n.Kind {
19+ case Leaf:
20+ if r.Width < MinPaneCols || r.Height < MinPaneRows {
21+ return false
22+ }
23+ *out = append(*out, Placement{ID: n.Leaf, Content: r})
24+ return true
25+ case Split:
26+ return solveSplit(n, r, out)
27+ case Stack:
28+ return solveStack(n, r, out)
29+ default:
30+ return false
31+ }
32+}
33+
34+func solveSplit(n *Node, r domain.Rect, out *[]Placement) bool {
35+ count := len(n.Children)
36+ if count == 0 {
37+ return false
38+ }
39+ if count == 1 {
40+ return solve(n.Children[0], r, out)
41+ }
42+ if n.Dir == Horizontal {
43+ usable := r.Width - (count - 1)
44+ if usable < count*MinPaneCols || r.Height < MinPaneRows {
45+ return false
46+ }
47+ base, rem := usable/count, usable%count
48+ x := r.X
49+ for i, child := range n.Children {
50+ w := base
51+ if i < rem {
52+ w++
53+ }
54+ if !solve(child, domain.Rect{X: x, Y: r.Y, Width: w, Height: r.Height}, out) {
55+ return false
56+ }
57+ x += w + 1
58+ }
59+ return true
60+ }
61+
62+ usable := r.Height - (count - 1)
63+ if usable < count*MinPaneRows || r.Width < MinPaneCols {
64+ return false
65+ }
66+ base, rem := usable/count, usable%count
67+ y := r.Y
68+ for i, child := range n.Children {
69+ h := base
70+ if i < rem {
71+ h++
72+ }
73+ if !solve(child, domain.Rect{X: r.X, Y: y, Width: r.Width, Height: h}, out) {
74+ return false
75+ }
76+ y += h + 1
77+ }
78+ return true
79+}
80+
81+func solveStack(n *Node, r domain.Rect, out *[]Placement) bool {
82+ count := len(n.Children)
83+ if count == 0 || r.Width < MinPaneCols || r.Height < count+1 {
84+ return false
85+ }
86+ idx := -1
87+ for i, child := range n.Children {
88+ if child.Kind != Leaf {
89+ return false
90+ }
91+ if child.Leaf == n.Expanded {
92+ idx = i
93+ }
94+ }
95+ if idx < 0 {
96+ return false
97+ }
98+ contentRows := r.Height - count
99+ for i, child := range n.Children {
100+ if i < idx {
101+ *out = append(*out, Placement{ID: child.Leaf, TitleBar: domain.Rect{X: r.X, Y: r.Y + i, Width: r.Width, Height: 1}, Collapsed: true, InStack: true})
102+ continue
103+ }
104+ if i == idx {
105+ titleY := r.Y + i
106+ *out = append(*out, Placement{
107+ ID: child.Leaf,
108+ TitleBar: domain.Rect{X: r.X, Y: titleY, Width: r.Width, Height: 1},
109+ Content: domain.Rect{X: r.X, Y: titleY + 1, Width: r.Width, Height: contentRows},
110+ InStack: true,
111+ })
112+ continue
113+ }
114+ titleY := r.Y + idx + 1 + contentRows + (i - idx - 1)
115+ *out = append(*out, Placement{ID: child.Leaf, TitleBar: domain.Rect{X: r.X, Y: titleY, Width: r.Width, Height: 1}, Collapsed: true, InStack: true})
116+ }
117+ return true
118+}
A internal/usecase/layout/layout_test.go +429 -0
@@ -0,0 +1,429 @@
1+package layout
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestSolveSplitGeometry(t *testing.T) {
12+ t.Parallel()
13+
14+ tests := []struct {
15+ name string
16+ root *Node
17+ area domain.Rect
18+ want map[PaneID]domain.Rect
19+ }{
20+ {
21+ name: "horizontal remainder to first children",
22+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{NewLeaf("a"), NewLeaf("b"), NewLeaf("c")}},
23+ area: domain.Rect{Width: 64, Height: 5},
24+ want: map[PaneID]domain.Rect{"a": {Width: 21, Height: 5}, "b": {X: 22, Width: 21, Height: 5}, "c": {X: 44, Width: 20, Height: 5}},
25+ },
26+ {
27+ name: "vertical remainder to first children",
28+ root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("a"), NewLeaf("b")}},
29+ area: domain.Rect{Width: 20, Height: 7},
30+ want: map[PaneID]domain.Rect{"a": {Width: 20, Height: 3}, "b": {Y: 4, Width: 20, Height: 3}},
31+ },
32+ }
33+
34+ for _, tt := range tests {
35+ t.Run(tt.name, func(t *testing.T) {
36+ got, ok := Solve(tt.root, tt.area)
37+ require.True(t, ok)
38+ require.Len(t, got, len(tt.want))
39+ for _, p := range got {
40+ require.Equal(t, tt.want[p.ID], p.Content)
41+ }
42+ })
43+ }
44+}
45+
46+func TestSolveNestedTrees(t *testing.T) {
47+ t.Parallel()
48+ root := &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
49+ NewLeaf("left"),
50+ {Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("top"), NewLeaf("bottom")}},
51+ }}
52+ got, ok := Solve(root, domain.Rect{Width: 61, Height: 5})
53+ require.True(t, ok)
54+ require.Equal(t, map[PaneID]domain.Rect{
55+ "left": {Width: 30, Height: 5},
56+ "top": {X: 31, Width: 30, Height: 2},
57+ "bottom": {X: 31, Y: 3, Width: 30, Height: 2},
58+ }, contents(got))
59+}
60+
61+func TestSolveStackFitAndOverflow(t *testing.T) {
62+ t.Parallel()
63+ stack := &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b"), NewLeaf("c")}, Expanded: "b"}
64+ got, ok := Solve(stack, domain.Rect{Width: 20, Height: 4})
65+ require.True(t, ok)
66+ require.Equal(t, []Placement{
67+ {ID: "a", TitleBar: domain.Rect{Width: 20, Height: 1}, Collapsed: true, InStack: true},
68+ {ID: "b", Content: domain.Rect{Y: 2, Width: 20, Height: 1}, TitleBar: domain.Rect{Y: 1, Width: 20, Height: 1}, InStack: true},
69+ {ID: "c", TitleBar: domain.Rect{Y: 3, Width: 20, Height: 1}, Collapsed: true, InStack: true},
70+ }, got)
71+
72+ _, ok = Solve(stack, domain.Rect{Width: 20, Height: 3})
73+ require.False(t, ok)
74+}
75+
76+func TestSplitStackedLeafSplitsWholeStack(t *testing.T) {
77+ t.Parallel()
78+ tr := &Tree{
79+ Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("one"), NewLeaf("two")}, Expanded: "two"},
80+ Focus: "two",
81+ }
82+ area := domain.Rect{Width: 41, Height: 4}
83+
84+ require.NoError(t, tr.Split("two", Right, true, "three", area))
85+ placements, ok := Solve(tr.Root, area)
86+ require.True(t, ok)
87+ require.Equal(t, PaneID("three"), tr.Focus)
88+ require.Equal(t, domain.Rect{Y: 2, Width: 20, Height: 2}, contents(placements)["two"], "existing stack should remain visible on the left")
89+ require.Equal(t, domain.Rect{X: 21, Width: 20, Height: 4}, contents(placements)["three"], "new split pane should appear to the right of the stack")
90+}
91+
92+func TestStackNewEmptyTreeReturnsError(t *testing.T) {
93+ t.Parallel()
94+ tr := &Tree{}
95+ require.ErrorIs(t, tr.StackNew("missing", "new", domain.Rect{Width: 20, Height: 2}), ErrNotFound)
96+ require.Nil(t, tr.Root)
97+ require.Empty(t, tr.Focus)
98+}
99+
100+func TestSplitEmptyTreeReturnsError(t *testing.T) {
101+ t.Parallel()
102+ tr := &Tree{}
103+ require.ErrorIs(t, tr.Split("missing", Right, true, "new", domain.Rect{Width: 41, Height: 2}), ErrNotFound)
104+ require.Nil(t, tr.Root)
105+ require.Empty(t, tr.Focus)
106+}
107+
108+func TestNilTreeCloneIsSafe(t *testing.T) {
109+ t.Parallel()
110+ var tr *Tree
111+ require.Nil(t, tr.clone())
112+}
113+
114+func TestTooSmallRefusalsDoNotMutate(t *testing.T) {
115+ t.Parallel()
116+ tests := []struct {
117+ name string
118+ op func(*Tree) error
119+ }{
120+ {
121+ name: "split refuses below min width",
122+ op: func(tr *Tree) error {
123+ return tr.Split("a", Right, true, "b", domain.Rect{Width: 40, Height: 2})
124+ },
125+ },
126+ {
127+ name: "stack refuses overflow",
128+ op: func(tr *Tree) error {
129+ require.NoError(t, tr.StackNew("a", "b", domain.Rect{Width: 20, Height: 3}))
130+ return tr.StackNew("b", "c", domain.Rect{Width: 20, Height: 3})
131+ },
132+ },
133+ }
134+
135+ for _, tt := range tests {
136+ t.Run(tt.name, func(t *testing.T) {
137+ tr := NewTree("a")
138+ before := tr.clone()
139+ err := tt.op(tr)
140+ require.ErrorIs(t, err, ErrTooSmall)
141+ if tt.name == "stack refuses overflow" {
142+ require.Equal(t, PaneID("b"), tr.Focus)
143+ require.Equal(t, []PaneID{"a", "b"}, leafIDs(tr.Root))
144+ return
145+ }
146+ require.Equal(t, before, tr)
147+ })
148+ }
149+}
150+
151+func TestTreeSurgeryShapes(t *testing.T) {
152+ t.Parallel()
153+
154+ t.Run("split preserves and dissolves containers", func(t *testing.T) {
155+ tr := NewTree("a")
156+ require.NoError(t, tr.Split("a", Right, true, "b", domain.Rect{Width: 41, Height: 2}))
157+ require.Equal(t, Split, tr.Root.Kind)
158+ require.Equal(t, Horizontal, tr.Root.Dir)
159+ require.Equal(t, []PaneID{"a", "b"}, leafIDs(tr.Root))
160+ require.NoError(t, tr.Close("a"))
161+ require.Equal(t, Leaf, tr.Root.Kind)
162+ require.Equal(t, PaneID("b"), tr.Root.Leaf)
163+ })
164+
165+ t.Run("nested split inserts on matching axis", func(t *testing.T) {
166+ tr := NewTree("a")
167+ require.NoError(t, tr.Split("a", Right, true, "b", domain.Rect{Width: 41, Height: 4}))
168+ require.NoError(t, tr.Split("b", Right, true, "c", domain.Rect{Width: 62, Height: 4}))
169+ require.Len(t, tr.Root.Children, 3)
170+ require.Equal(t, []PaneID{"a", "b", "c"}, leafIDs(tr.Root))
171+ require.NoError(t, tr.Split("b", Down, true, "d", domain.Rect{Width: 62, Height: 5}))
172+ require.Equal(t, Vertical, tr.Root.Children[1].Dir)
173+ require.Equal(t, []PaneID{"a", "b", "d", "c"}, leafIDs(tr.Root))
174+ })
175+}
176+
177+func TestToggleStackNestedShapes(t *testing.T) {
178+ t.Parallel()
179+ area := domain.Rect{Width: 62, Height: 5}
180+ tests := []struct {
181+ name string
182+ root *Node
183+ target PaneID
184+ wantKind Kind
185+ wantDir SplitDir
186+ wantErr error
187+ wantLeaves []PaneID
188+ }{
189+ {
190+ name: "nested stack toggles directly containing stack",
191+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
192+ NewLeaf("a"),
193+ {Kind: Stack, Children: []*Node{NewLeaf("b"), NewLeaf("c")}, Expanded: "b"},
194+ }},
195+ target: "b",
196+ wantKind: Split,
197+ wantDir: Vertical,
198+ wantLeaves: []PaneID{"b", "c"},
199+ },
200+ {
201+ name: "nested split toggles directly containing two-leaf split",
202+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
203+ NewLeaf("a"),
204+ {Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("b"), NewLeaf("c")}},
205+ }},
206+ target: "b",
207+ wantKind: Stack,
208+ wantLeaves: []PaneID{"b", "c"},
209+ },
210+ {
211+ name: "nested non toggleable split reports not toggleable",
212+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
213+ NewLeaf("a"),
214+ {Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("b"), NewLeaf("c"), NewLeaf("d")}},
215+ }},
216+ target: "b",
217+ wantErr: ErrNotToggleable,
218+ },
219+ {
220+ name: "three leaf split reports not toggleable not too small",
221+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
222+ NewLeaf("a"), NewLeaf("b"), NewLeaf("c"),
223+ }},
224+ target: "b",
225+ wantErr: ErrNotToggleable,
226+ },
227+ }
228+ for _, tt := range tests {
229+ t.Run(tt.name, func(t *testing.T) {
230+ tr := &Tree{Root: tt.root, Focus: tt.target}
231+ err := tr.ToggleStack(tt.target, area)
232+ if tt.wantErr != nil {
233+ require.ErrorIs(t, err, tt.wantErr)
234+ require.NotErrorIs(t, err, ErrTooSmall)
235+ return
236+ }
237+ require.NoError(t, err)
238+ container := tr.Root.Children[1]
239+ require.Equal(t, tt.wantKind, container.Kind)
240+ if tt.wantKind == Split {
241+ require.Equal(t, tt.wantDir, container.Dir)
242+ }
243+ require.Equal(t, tt.wantLeaves, LeafIDs(container))
244+ })
245+ }
246+}
247+
248+func TestCloseRefocusPrefersSibling(t *testing.T) {
249+ t.Parallel()
250+ tr := &Tree{Focus: "c", Root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
251+ NewLeaf("a"),
252+ {Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("b"), NewLeaf("c")}},
253+ NewLeaf("d"),
254+ }}}
255+ require.NoError(t, tr.Close("c"))
256+ require.Equal(t, PaneID("b"), tr.Focus)
257+}
258+
259+func TestCloseNotFoundDoesNotMutate(t *testing.T) {
260+ t.Parallel()
261+ tr := &Tree{Focus: "b", Root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
262+ NewLeaf("a"),
263+ {Kind: Stack, Children: []*Node{NewLeaf("b"), NewLeaf("c")}, Expanded: "b"},
264+ }}}
265+ before := tr.clone()
266+ require.ErrorIs(t, tr.Close("missing"), ErrNotFound)
267+ require.Equal(t, before, tr)
268+}
269+
270+func TestSolveRejectsStackWithMissingExpanded(t *testing.T) {
271+ t.Parallel()
272+ _, ok := Solve(&Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b")}, Expanded: "missing"}, domain.Rect{Width: 20, Height: 4})
273+ require.False(t, ok)
274+}
275+
276+func TestExportedLeafHelpers(t *testing.T) {
277+ t.Parallel()
278+ root := &Node{Kind: Split, Dir: Horizontal, Children: []*Node{NewLeaf("a"), NewLeaf("b")}}
279+ require.True(t, ContainsLeaf(root, "b"))
280+ require.False(t, ContainsLeaf(root, "c"))
281+ require.Equal(t, []PaneID{"a", "b"}, LeafIDs(root))
282+}
283+
284+func TestFocusDir(t *testing.T) {
285+ t.Parallel()
286+ area := domain.Rect{Width: 62, Height: 5}
287+ tests := []struct {
288+ name string
289+ focus PaneID
290+ dir Direction
291+ want PaneID
292+ }{
293+ {"right chooses overlapping neighbor", "a", Right, "b"},
294+ {"left chooses overlapping neighbor", "b", Left, "a"},
295+ {"down chooses vertical neighbor", "b", Down, "c"},
296+ {"up chooses vertical neighbor", "c", Up, "b"},
297+ }
298+ for _, tt := range tests {
299+ t.Run(tt.name, func(t *testing.T) {
300+ tr := &Tree{Focus: tt.focus, Root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
301+ NewLeaf("a"),
302+ {Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("b"), NewLeaf("c")}},
303+ }}}
304+ require.NoError(t, tr.FocusDir(tt.dir, area))
305+ require.Equal(t, tt.want, tr.Focus)
306+ })
307+ }
308+
309+ t.Run("stack local vertical focus expands member", func(t *testing.T) {
310+ tr := &Tree{Focus: "b", Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b"), NewLeaf("c")}, Expanded: "b"}}
311+ require.NoError(t, tr.FocusDir(Down, domain.Rect{Width: 20, Height: 4}))
312+ require.Equal(t, PaneID("c"), tr.Focus)
313+ require.Equal(t, PaneID("c"), tr.Root.Expanded)
314+ require.NoError(t, tr.FocusDir(Up, domain.Rect{Width: 20, Height: 4}))
315+ require.Equal(t, PaneID("b"), tr.Focus)
316+ require.Equal(t, PaneID("b"), tr.Root.Expanded)
317+ })
318+
319+ t.Run("no pane in direction", func(t *testing.T) {
320+ tr := NewTree("a")
321+ err := tr.FocusDir(Left, domain.Rect{Width: 20, Height: 2})
322+ require.True(t, errors.Is(err, ErrNoPane))
323+ })
324+}
325+
326+func TestFocusDirEnteringStackExpandsMember(t *testing.T) {
327+ t.Parallel()
328+ tests := []struct {
329+ name string
330+ root *Node
331+ area domain.Rect
332+ focus PaneID
333+ dir Direction
334+ want PaneID
335+ }{
336+ {
337+ name: "from left",
338+ area: domain.Rect{Width: 62, Height: 8},
339+ focus: "a",
340+ dir: Right,
341+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
342+ {Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("x"), NewLeaf("a")}},
343+ {Kind: Stack, Children: []*Node{NewLeaf("b"), NewLeaf("c"), NewLeaf("d"), NewLeaf("e")}, Expanded: "b"},
344+ }},
345+ want: "c",
346+ },
347+ {
348+ name: "from right",
349+ area: domain.Rect{Width: 62, Height: 8},
350+ focus: "a",
351+ dir: Left,
352+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
353+ {Kind: Stack, Children: []*Node{NewLeaf("b"), NewLeaf("c"), NewLeaf("d"), NewLeaf("e")}, Expanded: "b"},
354+ {Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("x"), NewLeaf("a")}},
355+ }},
356+ want: "c",
357+ },
358+ {
359+ name: "from above",
360+ area: domain.Rect{Width: 41, Height: 7},
361+ focus: "a",
362+ dir: Down,
363+ root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{
364+ NewLeaf("a"),
365+ {Kind: Stack, Children: []*Node{NewLeaf("b"), NewLeaf("c")}, Expanded: "c"},
366+ }},
367+ want: "b",
368+ },
369+ {
370+ name: "from below",
371+ area: domain.Rect{Width: 41, Height: 7},
372+ focus: "a",
373+ dir: Up,
374+ root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{
375+ {Kind: Stack, Children: []*Node{NewLeaf("b"), NewLeaf("c")}, Expanded: "b"},
376+ NewLeaf("a"),
377+ }},
378+ want: "c",
379+ },
380+ }
381+ for _, tt := range tests {
382+ t.Run(tt.name, func(t *testing.T) {
383+ tr := &Tree{Root: tt.root, Focus: tt.focus}
384+ var stack *Node
385+ findStack(tr.Root, &stack)
386+ require.NotEqual(t, tt.want, stack.Expanded)
387+ require.NoError(t, tr.FocusDir(tt.dir, tt.area))
388+ require.Equal(t, tt.want, tr.Focus)
389+ require.Equal(t, tt.want, stack.Expanded)
390+ })
391+ }
392+}
393+
394+func TestFocusDirStackLocalSkipsNonLeafChildren(t *testing.T) {
395+ t.Parallel()
396+ tr := &Tree{
397+ Root: &Node{Kind: Stack, Children: []*Node{
398+ NewLeaf("a"),
399+ {Kind: Split, Dir: Horizontal, Children: []*Node{NewLeaf("nested")}},
400+ NewLeaf("b"),
401+ }, Expanded: "a"},
402+ Focus: "a",
403+ }
404+
405+ require.NoError(t, tr.FocusDir(Down, domain.Rect{Width: 80, Height: 6}))
406+ require.Equal(t, PaneID("b"), tr.Focus)
407+ require.Equal(t, PaneID("b"), tr.Root.Expanded)
408+}
409+
410+func contents(ps []Placement) map[PaneID]domain.Rect {
411+ out := make(map[PaneID]domain.Rect, len(ps))
412+ for _, p := range ps {
413+ out[p.ID] = p.Content
414+ }
415+ return out
416+}
417+
418+func findStack(n *Node, out **Node) {
419+ if n == nil || *out != nil {
420+ return
421+ }
422+ if n.Kind == Stack {
423+ *out = n
424+ return
425+ }
426+ for _, child := range n.Children {
427+ findStack(child, out)
428+ }
429+}
A internal/usecase/layout/ops.go +517 -0
@@ -0,0 +1,517 @@
1+package layout
2+
3+import (
4+ "errors"
5+ "sort"
6+
7+ "github.com/bnema/vev/internal/domain"
8+)
9+
10+var (
11+ ErrNotFound = errors.New("pane not found")
12+ ErrTooSmall = errors.New("layout too small")
13+ ErrNoPane = errors.New("no pane in direction")
14+ ErrNotToggleable = errors.New("layout node is not toggleable")
15+)
16+
17+// Split adds newID next to target. The after flag controls whether the new pane
18+// is placed after target on the chosen axis.
19+func (t *Tree) Split(target PaneID, dir Direction, after bool, newID PaneID, area domain.Rect) error {
20+ candidate := t.clone()
21+ if candidate == nil || candidate.Root == nil || !insertSplit(candidate.Root, target, axisFor(dir), after, newID) {
22+ return ErrNotFound
23+ }
24+ candidate.Focus = newID
25+ if _, ok := Solve(candidate.Root, area); !ok {
26+ return ErrTooSmall
27+ }
28+ *t = *candidate
29+ return nil
30+}
31+
32+func insertSplit(n *Node, target PaneID, axis SplitDir, after bool, newID PaneID) bool {
33+ if n == nil {
34+ return false
35+ }
36+ for i, child := range n.Children {
37+ if child.Kind == Leaf && child.Leaf == target {
38+ newLeaf := NewLeaf(newID)
39+ if n.Kind == Split && n.Dir == axis {
40+ at := i
41+ if after {
42+ at++
43+ }
44+ n.Children = append(n.Children, nil)
45+ copy(n.Children[at+1:], n.Children[at:])
46+ n.Children[at] = newLeaf
47+ return true
48+ }
49+ if n.Kind == Stack {
50+ stack := n.clone()
51+ children := []*Node{stack, newLeaf}
52+ if !after {
53+ children = []*Node{newLeaf, stack}
54+ }
55+ *n = Node{Kind: Split, Dir: axis, Children: children}
56+ return true
57+ }
58+ children := []*Node{child, newLeaf}
59+ if !after {
60+ children = []*Node{newLeaf, child}
61+ }
62+ n.Children[i] = &Node{Kind: Split, Dir: axis, Children: children}
63+ return true
64+ }
65+ if insertSplit(child, target, axis, after, newID) {
66+ return true
67+ }
68+ }
69+ if n.Kind == Leaf && n.Leaf == target {
70+ children := []*Node{n.clone(), NewLeaf(newID)}
71+ if !after {
72+ children = []*Node{NewLeaf(newID), n.clone()}
73+ }
74+ *n = Node{Kind: Split, Dir: axis, Children: children}
75+ return true
76+ }
77+ return false
78+}
79+
80+// StackNew puts newID in target's stack, creating one if target is not stacked.
81+func (t *Tree) StackNew(target, newID PaneID, area domain.Rect) error {
82+ candidate := t.clone()
83+ if candidate == nil || !stackNew(candidate.Root, target, newID) {
84+ return ErrNotFound
85+ }
86+ candidate.Focus = newID
87+ if _, ok := Solve(candidate.Root, area); !ok {
88+ return ErrTooSmall
89+ }
90+ *t = *candidate
91+ return nil
92+}
93+
94+func stackNew(n *Node, target, newID PaneID) bool {
95+ if n == nil {
96+ return false
97+ }
98+ if n.Kind == Stack {
99+ for _, child := range n.Children {
100+ if child.Kind == Leaf && child.Leaf == target {
101+ n.Children = append(n.Children, NewLeaf(newID))
102+ n.Expanded = newID
103+ return true
104+ }
105+ }
106+ }
107+ for i, child := range n.Children {
108+ if child.Kind == Leaf && child.Leaf == target {
109+ n.Children[i] = &Node{Kind: Stack, Children: []*Node{child, NewLeaf(newID)}, Expanded: newID}
110+ return true
111+ }
112+ if stackNew(child, target, newID) {
113+ return true
114+ }
115+ }
116+ if n.Kind == Leaf && n.Leaf == target {
117+ *n = Node{Kind: Stack, Children: []*Node{NewLeaf(target), NewLeaf(newID)}, Expanded: newID}
118+ return true
119+ }
120+ return false
121+}
122+
123+// ToggleStack converts a stack back to a vertical split. For an unstacked leaf in
124+// a two-child split, it converts that split to a stack.
125+func (t *Tree) ToggleStack(target PaneID, area domain.Rect) error {
126+ candidate := t.clone()
127+ result := toggleStack(candidate.Root, target)
128+ switch result {
129+ case toggleNotFound:
130+ return ErrNotFound
131+ case toggleNotToggleable:
132+ return ErrNotToggleable
133+ }
134+ if _, ok := Solve(candidate.Root, area); !ok {
135+ return ErrTooSmall
136+ }
137+ *t = *candidate
138+ return nil
139+}
140+
141+type toggleResult int
142+
143+const (
144+ toggleNotFound toggleResult = iota
145+ toggleToggled
146+ toggleNotToggleable
147+)
148+
149+func toggleStack(n *Node, target PaneID) toggleResult {
150+ if n == nil {
151+ return toggleNotFound
152+ }
153+ for _, child := range n.Children {
154+ if child.Kind == Leaf || !containsLeaf(child, target) {
155+ continue
156+ }
157+ result := toggleStack(child, target)
158+ if result != toggleNotFound {
159+ return result
160+ }
161+ }
162+ if !containsLeaf(n, target) {
163+ return toggleNotFound
164+ }
165+ if n.Kind == Stack {
166+ if stackDirectLeafIndex(n, target) < 0 {
167+ return toggleNotToggleable
168+ }
169+ n.Kind = Split
170+ n.Dir = Vertical
171+ n.Expanded = ""
172+ return toggleToggled
173+ }
174+ if n.Kind == Split && len(n.Children) == 2 && splitDirectLeavesContain(n, target) {
175+ n.Kind = Stack
176+ n.Expanded = target
177+ return toggleToggled
178+ }
179+ return toggleNotToggleable
180+}
181+
182+func stackDirectLeafIndex(n *Node, target PaneID) int {
183+ for i, child := range n.Children {
184+ if child.Kind == Leaf && child.Leaf == target {
185+ return i
186+ }
187+ }
188+ return -1
189+}
190+
191+func splitDirectLeavesContain(n *Node, target PaneID) bool {
192+ for _, child := range n.Children {
193+ if child.Kind != Leaf {
194+ return false
195+ }
196+ }
197+ return n.Children[0].Leaf == target || n.Children[1].Leaf == target
198+}
199+
200+// Close removes target and dissolves single-child containers.
201+func (t *Tree) Close(target PaneID) error {
202+ candidate := t.clone()
203+ if candidate == nil || candidate.Root == nil || !containsLeaf(candidate.Root, target) {
204+ return ErrNotFound
205+ }
206+ if candidate.Root.Kind == Leaf && candidate.Root.Leaf == target {
207+ candidate.Root = nil
208+ candidate.Focus = ""
209+ *t = *candidate
210+ return nil
211+ }
212+ refocus := closeRefocusCandidate(candidate.Root, target)
213+ root, removed := closeNode(candidate.Root, target)
214+ if !removed {
215+ return ErrNotFound
216+ }
217+ candidate.Root = root
218+ if candidate.Focus == target {
219+ if refocus != "" && containsLeaf(candidate.Root, refocus) {
220+ candidate.Focus = refocus
221+ } else {
222+ ids := leafIDs(candidate.Root)
223+ if len(ids) > 0 {
224+ candidate.Focus = ids[0]
225+ } else {
226+ candidate.Focus = ""
227+ }
228+ }
229+ setExpanded(candidate.Root, candidate.Focus)
230+ }
231+ *t = *candidate
232+ return nil
233+}
234+
235+func closeNode(n *Node, target PaneID) (*Node, bool) {
236+ if n.Kind == Leaf {
237+ if n.Leaf == target {
238+ return nil, true
239+ }
240+ return n, false
241+ }
242+ removed := false
243+ children := n.Children[:0]
244+ for _, child := range n.Children {
245+ newChild, didRemove := closeNode(child, target)
246+ removed = removed || didRemove
247+ if newChild != nil {
248+ children = append(children, newChild)
249+ }
250+ }
251+ n.Children = children
252+ if !removed {
253+ return n, false
254+ }
255+ if n.Kind == Stack {
256+ if n.Expanded == target && len(n.Children) > 0 {
257+ n.Expanded = firstLeaf(n.Children[0])
258+ }
259+ if len(n.Children) == 1 {
260+ return n.Children[0], true
261+ }
262+ }
263+ if n.Kind == Split && len(n.Children) == 1 {
264+ return n.Children[0], true
265+ }
266+ return n, true
267+}
268+
269+func closeRefocusCandidate(n *Node, target PaneID) PaneID {
270+ if n == nil || n.Kind == Leaf {
271+ return ""
272+ }
273+ for i, child := range n.Children {
274+ if !containsLeaf(child, target) {
275+ continue
276+ }
277+ if child.Kind == Leaf && child.Leaf == target {
278+ if i > 0 {
279+ return lastLeaf(n.Children[i-1])
280+ }
281+ if i+1 < len(n.Children) {
282+ return firstLeaf(n.Children[i+1])
283+ }
284+ return ""
285+ }
286+ if id := closeRefocusCandidate(child, target); id != "" {
287+ return id
288+ }
289+ if i > 0 {
290+ return lastLeaf(n.Children[i-1])
291+ }
292+ if i+1 < len(n.Children) {
293+ return firstLeaf(n.Children[i+1])
294+ }
295+ return ""
296+ }
297+ return ""
298+}
299+
300+// ContainsLeaf reports whether n contains a leaf with id.
301+func ContainsLeaf(n *Node, id PaneID) bool { return containsLeaf(n, id) }
302+
303+func containsLeaf(n *Node, id PaneID) bool {
304+ if n == nil {
305+ return false
306+ }
307+ if n.Kind == Leaf {
308+ return n.Leaf == id
309+ }
310+ for _, child := range n.Children {
311+ if containsLeaf(child, id) {
312+ return true
313+ }
314+ }
315+ return false
316+}
317+
318+func firstLeaf(n *Node) PaneID {
319+ if n.Kind == Leaf {
320+ return n.Leaf
321+ }
322+ for _, child := range n.Children {
323+ if id := firstLeaf(child); id != "" {
324+ return id
325+ }
326+ }
327+ return ""
328+}
329+
330+func lastLeaf(n *Node) PaneID {
331+ if n.Kind == Leaf {
332+ return n.Leaf
333+ }
334+ for i := len(n.Children) - 1; i >= 0; i-- {
335+ if id := lastLeaf(n.Children[i]); id != "" {
336+ return id
337+ }
338+ }
339+ return ""
340+}
341+
342+func setExpanded(n *Node, id PaneID) bool {
343+ if n == nil {
344+ return false
345+ }
346+ if n.Kind == Stack && stackDirectLeafIndex(n, id) >= 0 {
347+ n.Expanded = id
348+ return true
349+ }
350+ for _, child := range n.Children {
351+ if setExpanded(child, id) {
352+ return true
353+ }
354+ }
355+ return false
356+}
357+
358+// LeafIDs returns the leaf ids under n in layout order.
359+func LeafIDs(n *Node) []PaneID { return leafIDs(n) }
360+
361+func leafIDs(n *Node) []PaneID {
362+ if n == nil {
363+ return nil
364+ }
365+ if n.Kind == Leaf {
366+ return []PaneID{n.Leaf}
367+ }
368+ var ids []PaneID
369+ for _, child := range n.Children {
370+ ids = append(ids, leafIDs(child)...)
371+ }
372+ return ids
373+}
374+
375+// FocusDir moves focus in dir. Vertical movement within a stack walks stack
376+// members before falling back to geometric focus.
377+func (t *Tree) FocusDir(dir Direction, area domain.Rect) error {
378+ if t.Root == nil {
379+ return ErrNotFound
380+ }
381+ if (dir == Up || dir == Down) && focusStackLocal(t.Root, t.Focus, dir, &t.Focus) {
382+ return nil
383+ }
384+ placements, ok := Solve(t.Root, area)
385+ if !ok {
386+ return ErrTooSmall
387+ }
388+ currentIdx := -1
389+ for i, p := range placements {
390+ if p.ID == t.Focus {
391+ currentIdx = i
392+ break
393+ }
394+ }
395+ if currentIdx < 0 {
396+ return ErrNotFound
397+ }
398+ current := focusRect(placements[currentIdx])
399+ type candidate struct {
400+ id PaneID
401+ overlap int
402+ gap int
403+ }
404+ var candidates []candidate
405+ for _, p := range placements {
406+ if p.ID == t.Focus {
407+ continue
408+ }
409+ r := focusRect(p)
410+ if ok, gap, overlap := sideGapOverlap(current, r, dir); ok {
411+ candidates = append(candidates, candidate{id: p.ID, gap: gap, overlap: overlap})
412+ }
413+ }
414+ if len(candidates) == 0 {
415+ return ErrNoPane
416+ }
417+ sort.Slice(candidates, func(i, j int) bool {
418+ if candidates[i].overlap != candidates[j].overlap {
419+ return candidates[i].overlap > candidates[j].overlap
420+ }
421+ if candidates[i].gap != candidates[j].gap {
422+ return candidates[i].gap < candidates[j].gap
423+ }
424+ return candidates[i].id < candidates[j].id
425+ })
426+ t.Focus = candidates[0].id
427+ setExpanded(t.Root, t.Focus)
428+ return nil
429+}
430+
431+func focusStackLocal(n *Node, id PaneID, dir Direction, focus *PaneID) bool {
432+ if n.Kind == Stack {
433+ idx := stackDirectLeafIndex(n, id)
434+ if idx >= 0 {
435+ if next := adjacentStackLeaf(n, idx, dir); next != "" {
436+ n.Expanded = next
437+ *focus = next
438+ return true
439+ }
440+ return false
441+ }
442+ }
443+ for _, child := range n.Children {
444+ if focusStackLocal(child, id, dir, focus) {
445+ return true
446+ }
447+ }
448+ return false
449+}
450+
451+func adjacentStackLeaf(n *Node, idx int, dir Direction) PaneID {
452+ step := 0
453+ switch dir {
454+ case Up:
455+ step = -1
456+ case Down:
457+ step = 1
458+ default:
459+ return ""
460+ }
461+ for i := idx + step; i >= 0 && i < len(n.Children); i += step {
462+ if n.Children[i].Kind == Leaf {
463+ return n.Children[i].Leaf
464+ }
465+ }
466+ return ""
467+}
468+
469+func focusRect(p Placement) domain.Rect {
470+ if p.TitleBar.Height == 0 {
471+ return p.Content
472+ }
473+ if p.Content.Height == 0 {
474+ return p.TitleBar
475+ }
476+ y := p.TitleBar.Y
477+ height := p.Content.Y + p.Content.Height - y
478+ return domain.Rect{X: p.TitleBar.X, Y: y, Width: p.TitleBar.Width, Height: height}
479+}
480+
481+func sideGapOverlap(a, b domain.Rect, dir Direction) (bool, int, int) {
482+ ar, ab := a.X+a.Width, a.Y+a.Height
483+ br, bb := b.X+b.Width, b.Y+b.Height
484+ switch dir {
485+ case Left:
486+ if br > a.X {
487+ return false, 0, 0
488+ }
489+ return true, a.X - br, overlap(a.Y, ab, b.Y, bb)
490+ case Right:
491+ if b.X < ar {
492+ return false, 0, 0
493+ }
494+ return true, b.X - ar, overlap(a.Y, ab, b.Y, bb)
495+ case Up:
496+ if bb > a.Y {
497+ return false, 0, 0
498+ }
499+ return true, a.Y - bb, overlap(a.X, ar, b.X, br)
500+ case Down:
501+ if b.Y < ab {
502+ return false, 0, 0
503+ }
504+ return true, b.Y - ab, overlap(a.X, ar, b.X, br)
505+ default:
506+ return false, 0, 0
507+ }
508+}
509+
510+func overlap(a0, a1, b0, b1 int) int {
511+ lo := max(a0, b0)
512+ hi := min(a1, b1)
513+ if hi <= lo {
514+ return 0
515+ }
516+ return hi - lo
517+}
A internal/usecase/layout/tree.go +96 -0
@@ -0,0 +1,96 @@
1+package layout
2+
3+import "github.com/bnema/vev/internal/domain"
4+
5+// PaneID is an opaque pane identifier owned by higher layers.
6+type PaneID string
7+
8+// Kind identifies the topology role of a Node.
9+type Kind int
10+
11+const (
12+ Leaf Kind = iota
13+ Split
14+ Stack
15+)
16+
17+// Direction is used for split and focus operations.
18+type Direction int
19+
20+const (
21+ Left Direction = iota
22+ Right
23+ Up
24+ Down
25+)
26+
27+// SplitDir is the axis used by a split container.
28+type SplitDir int
29+
30+const (
31+ Horizontal SplitDir = iota // children are laid out left-to-right
32+ Vertical // children are laid out top-to-bottom
33+)
34+
35+const (
36+ MinPaneCols = 20
37+ MinPaneRows = 2
38+)
39+
40+// Node is a recursive pane layout node. Leaf is meaningful for Leaf nodes; Dir
41+// and Children are meaningful for Split nodes; Children and Expanded are
42+// meaningful for Stack nodes.
43+type Node struct {
44+ Kind Kind
45+ Dir SplitDir
46+ Children []*Node
47+ Leaf PaneID
48+ Expanded PaneID
49+}
50+
51+// Tree is a pane layout plus its focused pane.
52+type Tree struct {
53+ Root *Node
54+ Focus PaneID
55+}
56+
57+// Placement is the solved geometry for a pane.
58+type Placement struct {
59+ ID PaneID
60+ Content domain.Rect
61+ TitleBar domain.Rect
62+ Collapsed bool
63+ InStack bool
64+}
65+
66+func NewLeaf(id PaneID) *Node { return &Node{Kind: Leaf, Leaf: id} }
67+
68+func NewTree(id PaneID) *Tree { return &Tree{Root: NewLeaf(id), Focus: id} }
69+
70+func axisFor(dir Direction) SplitDir {
71+ if dir == Left || dir == Right {
72+ return Horizontal
73+ }
74+ return Vertical
75+}
76+
77+func (n *Node) clone() *Node {
78+ if n == nil {
79+ return nil
80+ }
81+ out := *n
82+ if len(n.Children) > 0 {
83+ out.Children = make([]*Node, len(n.Children))
84+ for i, child := range n.Children {
85+ out.Children[i] = child.clone()
86+ }
87+ }
88+ return &out
89+}
90+
91+func (t *Tree) clone() *Tree {
92+ if t == nil {
93+ return nil
94+ }
95+ return &Tree{Root: t.Root.clone(), Focus: t.Focus}
96+}
M internal/usecase/theme/theme.go +53 -1
@@ -184,7 +184,7 @@ func colorOSCPrefix(data []byte) (complete bool, possible bool) {
184184 }
185185
186186 func findTerminator(data []byte) (start int, end int) {
187- for i := 0; i < len(data); i++ {
187+ for i := range data {
188188 switch data[i] {
189189 case '\x07':
190190 return i, i + 1
@@ -250,6 +250,58 @@ func BorderStyle(t Theme) renderer.Style {
250250 return style
251251 }
252252
253+func DimStyle(style renderer.Style, t Theme) renderer.Style {
254+ if !usable(t) {
255+ return style
256+ }
257+ out := style
258+ out.HasForegroundRGB = true
259+ if style.HasForegroundRGB {
260+ out.ForegroundRGB = Blend(style.ForegroundRGB, t.Background, 0.35)
261+ } else if style.Foreground >= 0 {
262+ out.ForegroundRGB = Blend(xterm256Color(style.Foreground), t.Background, 0.35)
263+ } else {
264+ out.ForegroundRGB = Blend(t.Foreground, t.Background, 0.35)
265+ }
266+ out.HasBackgroundRGB = true
267+ if style.HasBackgroundRGB {
268+ out.BackgroundRGB = Blend(style.BackgroundRGB, t.Background, 0.35)
269+ } else if style.Background >= 0 {
270+ out.BackgroundRGB = Blend(xterm256Color(style.Background), t.Background, 0.35)
271+ } else {
272+ out.BackgroundRGB = Blend(t.Background, t.Background, 0.35)
273+ }
274+ return out
275+}
276+
277+var (
278+ xterm256Base = [...]renderer.RGB{
279+ {R: 0x00, G: 0x00, B: 0x00}, {R: 0x80, G: 0x00, B: 0x00}, {R: 0x00, G: 0x80, B: 0x00}, {R: 0x80, G: 0x80, B: 0x00},
280+ {R: 0x00, G: 0x00, B: 0x80}, {R: 0x80, G: 0x00, B: 0x80}, {R: 0x00, G: 0x80, B: 0x80}, {R: 0xc0, G: 0xc0, B: 0xc0},
281+ {R: 0x80, G: 0x80, B: 0x80}, {R: 0xff, G: 0x00, B: 0x00}, {R: 0x00, G: 0xff, B: 0x00}, {R: 0xff, G: 0xff, B: 0x00},
282+ {R: 0x00, G: 0x00, B: 0xff}, {R: 0xff, G: 0x00, B: 0xff}, {R: 0x00, G: 0xff, B: 0xff}, {R: 0xff, G: 0xff, B: 0xff},
283+ }
284+ xterm256Levels = [...]uint8{0, 95, 135, 175, 215, 255}
285+)
286+
287+func xterm256Color(index int) renderer.RGB {
288+ if index < 0 {
289+ return renderer.RGB{}
290+ }
291+ if index < len(xterm256Base) {
292+ return xterm256Base[index]
293+ }
294+ if index < 232 {
295+ index -= 16
296+ return renderer.RGB{R: xterm256Levels[index/36], G: xterm256Levels[(index/6)%6], B: xterm256Levels[index%6]}
297+ }
298+ if index < 256 {
299+ level := uint8(8 + (index-232)*10)
300+ return renderer.RGB{R: level, G: level, B: level}
301+ }
302+ return xterm256Base[index%len(xterm256Base)]
303+}
304+
253305 func SelectionStyle(t Theme) renderer.Style {
254306 if !usable(t) {
255307 return inverseStyle()
M internal/usecase/theme/theme_test.go +86 -0
@@ -197,6 +197,92 @@ func TestScannerFlushesOverflowingPartialQueue(t *testing.T) {
197197 }
198198 }
199199
200+func TestDimStyle(t *testing.T) {
201+ theme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true}
202+ tests := []struct {
203+ name string
204+ in renderer.Style
205+ want renderer.Style
206+ }{
207+ {
208+ name: "default foreground and background dim to theme colors",
209+ in: renderer.DefaultStyle(),
210+ want: renderer.Style{
211+ Foreground: -1,
212+ Background: -1,
213+ HasForegroundRGB: true,
214+ ForegroundRGB: Blend(theme.Foreground, theme.Background, 0.35),
215+ HasBackgroundRGB: true,
216+ BackgroundRGB: theme.Background,
217+ },
218+ },
219+ {
220+ name: "indexed foreground is mapped before dimming",
221+ in: renderer.Style{Foreground: 196, Background: -1},
222+ want: renderer.Style{
223+ Foreground: 196,
224+ Background: -1,
225+ HasForegroundRGB: true,
226+ ForegroundRGB: Blend(renderer.RGB{R: 255, G: 0, B: 0}, theme.Background, 0.35),
227+ HasBackgroundRGB: true,
228+ BackgroundRGB: theme.Background,
229+ },
230+ },
231+ {
232+ name: "indexed foregrounds remain distinct",
233+ in: renderer.Style{Foreground: 46, Background: -1},
234+ want: renderer.Style{
235+ Foreground: 46,
236+ Background: -1,
237+ HasForegroundRGB: true,
238+ ForegroundRGB: Blend(renderer.RGB{R: 0, G: 255, B: 0}, theme.Background, 0.35),
239+ HasBackgroundRGB: true,
240+ BackgroundRGB: theme.Background,
241+ },
242+ },
243+ {
244+ name: "indexed background is mapped before dimming",
245+ in: renderer.Style{Foreground: -1, Background: 21},
246+ want: renderer.Style{
247+ Foreground: -1,
248+ Background: 21,
249+ HasForegroundRGB: true,
250+ ForegroundRGB: Blend(theme.Foreground, theme.Background, 0.35),
251+ HasBackgroundRGB: true,
252+ BackgroundRGB: Blend(renderer.RGB{R: 0, G: 0, B: 255}, theme.Background, 0.35),
253+ },
254+ },
255+ {
256+ name: "rgb foreground and background are dimmed",
257+ in: renderer.Style{
258+ Foreground: -1,
259+ Background: -1,
260+ HasForegroundRGB: true,
261+ ForegroundRGB: renderer.RGB{R: 100, G: 50, B: 25},
262+ HasBackgroundRGB: true,
263+ BackgroundRGB: renderer.RGB{R: 20, G: 100, B: 180},
264+ },
265+ want: renderer.Style{
266+ Foreground: -1,
267+ Background: -1,
268+ HasForegroundRGB: true,
269+ ForegroundRGB: Blend(renderer.RGB{R: 100, G: 50, B: 25}, theme.Background, 0.35),
270+ HasBackgroundRGB: true,
271+ BackgroundRGB: Blend(renderer.RGB{R: 20, G: 100, B: 180}, theme.Background, 0.35),
272+ },
273+ },
274+ }
275+
276+ for _, tt := range tests {
277+ t.Run(tt.name, func(t *testing.T) {
278+ got := DimStyle(tt.in, theme)
279+ if !got.Equal(tt.want) {
280+ t.Fatalf("DimStyle()=%+v want %+v", got, tt.want)
281+ }
282+ })
283+ }
284+}
285+
200286 func TestStyleHelpersFallbackAndThemed(t *testing.T) {
201287 unknown := Theme{}
202288 if got := StatusBarStyle(unknown); !got.Equal(renderer.DefaultStyle()) {
M pkg/renderer/renderer.go +19 -3
@@ -36,11 +36,18 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
3636 return nil, err
3737 }
3838
39- // Unchanged frame no-op: same dimensions, shadow populated, no damage, no dirty lines.
40- if r.width == frame.Width && r.height == frame.Height && len(r.shadow) == len(frame.Cells) {
39+ knownSameDimensions := r.width == frame.Width && r.height == frame.Height && len(r.shadow) == len(frame.Cells)
40+
41+ // Unchanged frame no-op: same dimensions, shadow populated, and either no
42+ // damage or a structural redraw request that can be satisfied from the
43+ // known terminal shadow.
44+ if knownSameDimensions {
4145 if len(damage) == 0 && !r.hasDirtyLines(frame) {
4246 return nil, nil
4347 }
48+ if needsFull(damage) && !r.hasDirtyLines(frame) {
49+ return nil, nil
50+ }
4451 }
4552
4653 buf := bufferPool.Get().(*bytes.Buffer)
@@ -55,7 +62,7 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
5562 // tracking and the SGR pen persist across all rects within one Draw.
5663 st := newDrawState()
5764
58- if r.width != frame.Width || r.height != frame.Height || len(r.shadow) != len(frame.Cells) || needsFull(damage) {
65+ if !knownSameDimensions {
5966 r.writeFull(buf, frame, &st)
6067 r.replaceShadow(frame)
6168 if r.caps.SynchronizedOutput {
@@ -64,6 +71,15 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
6471 return copyBytes(buf), nil
6572 }
6673
74+ if needsFull(damage) {
75+ r.writeDamage(buf, frame, nil, nil, &st)
76+ r.replaceShadow(frame)
77+ if r.caps.SynchronizedOutput {
78+ buf.WriteString(SyncEndCSI)
79+ }
80+ return copyBytes(buf), nil
81+ }
82+
6783 if scroll, ok := findSafeScroll(frame, damage); ok && r.canApplyScroll(frame, scroll, damage) {
6884 emitScrollUp(buf, scroll)
6985 // emitScrollUp resets the SGR pen to default (matching st's initial
M pkg/renderer/renderer_test.go +71 -22
@@ -552,32 +552,81 @@ func TestReset(t *testing.T) {
552552 func TestFullRedraw(t *testing.T) {
553553 tests := []struct {
554554 name string
555+ run func(t *testing.T)
555556 }{
556- {name: "forces full output even with an unchanged frame"},
557- }
557+ {
558+ name: "known same-size full redraw diffs against shadow",
559+ run: func(t *testing.T) {
560+ r := New(Capabilities{})
561+ frame := NewFrame(3, 2)
562+ markFrame(&frame)
558563
559- for _, tt := range tests {
560- t.Run(tt.name, func(t *testing.T) {
561- r := New(Capabilities{})
562- frame := NewFrame(3, 2)
563- markFrame(&frame)
564+ if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
565+ t.Fatal(err)
566+ }
564567
565- // Populate.
566- _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}})
567- if err != nil {
568- t.Fatal(err)
569- }
568+ changed := frame
569+ changed.Set(0, 1, Cell{Rune: 'Z', Style: DefaultStyle()})
570+ out, err := r.Draw(changed, []Damage{FullRedraw()})
571+ if err != nil {
572+ t.Fatal(err)
573+ }
574+ got := string(out)
575+ if strings.Contains(got, "ABC") {
576+ t.Fatalf("unchanged row was re-emitted on dimension-stable FullRedraw: %q", got)
577+ }
578+ if !strings.Contains(got, "ZEF") {
579+ t.Fatalf("changed row was not emitted on dimension-stable FullRedraw: %q", got)
580+ }
581+ outputEndsWith(t, out, "\x1b[0m")
582+ },
583+ },
584+ {
585+ name: "unchanged known same-size full redraw is a no-op",
586+ run: func(t *testing.T) {
587+ r := New(Capabilities{})
588+ frame := NewFrame(3, 2)
589+ markFrame(&frame)
570590
571- // FullRedraw forces full output even with same frame.
572- out, err := r.Draw(frame, []Damage{FullRedraw()})
573- if err != nil {
574- t.Fatal(err)
575- }
576- if len(out) == 0 {
577- t.Fatal("expected output for FullRedraw")
578- }
579- outputEndsWith(t, out, "\x1b[0m")
580- })
591+ if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
592+ t.Fatal(err)
593+ }
594+ out, err := r.Draw(frame, []Damage{FullRedraw()})
595+ if err != nil {
596+ t.Fatal(err)
597+ }
598+ if len(out) != 0 {
599+ t.Fatalf("unchanged dimension-stable FullRedraw emitted output: %q", string(out))
600+ }
601+ },
602+ },
603+ {
604+ name: "reset full redraw emits complete frame",
605+ run: func(t *testing.T) {
606+ r := New(Capabilities{})
607+ frame := NewFrame(3, 2)
608+ markFrame(&frame)
609+
610+ if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
611+ t.Fatal(err)
612+ }
613+ r.Reset()
614+
615+ out, err := r.Draw(frame, []Damage{FullRedraw()})
616+ if err != nil {
617+ t.Fatal(err)
618+ }
619+ got := string(out)
620+ if !strings.Contains(got, "ABC") || !strings.Contains(got, "DEF") {
621+ t.Fatalf("reset FullRedraw did not emit complete frame: %q", got)
622+ }
623+ outputEndsWith(t, out, "\x1b[0m")
624+ },
625+ },
626+ }
627+
628+ for _, tt := range tests {
629+ t.Run(tt.name, tt.run)
581630 }
582631 }
583632

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