detached 0933959

refactor(geometry): carry terminal geometry end to end (#219)

* feat(term): retain terminal pixel geometry

* feat(pty): apply pixel geometry to winsize

* test(pty): preserve pixel winsize geometry

* feat(protocol): propagate terminal pixel geometry

* fix(daemon): preserve pixel geometry through attachment lifecycle

* fix(pty): apply initial pixel geometry

* refactor(geometry): remove size-only seams

* test(pty): report geometry cleanup errors

* fix(daemon): preserve control session geometry
commit
0933959e27f571916593a2fbe00483e00d219ec5
author
bnema · · 2026-08-26 20:15 UTC
committer
GitHub · · 2026-08-26 20:15 UTC
parent
bf4f2c5
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
76 files · +1146 -521
M go.mod +1 -1
@@ -3,7 +3,7 @@ module github.com/bnema/vev
33 go 1.26
44
55 require (
6- github.com/bnema/vev-vt v0.3.0
6+ github.com/bnema/vev-vt v0.4.1
77 github.com/stretchr/testify v1.11.1
88 )
99
@@ -3,7 +3,7 @@ module github.com/bnema/vev
3 go 1.263 go 1.26
4 4
5 require (5 require (
6- github.com/bnema/vev-vt v0.3.06+ github.com/bnema/vev-vt v0.4.1
7 github.com/stretchr/testify v1.11.17 github.com/stretchr/testify v1.11.1
8 )8 )
9 9
M go.sum +2 -2 generated file: collapsed
A internal/adapters/pty/geometry_linux_test.go +47 -0
@@ -0,0 +1,47 @@
1+//go:build linux
2+
3+package pty
4+
5+import (
6+ "os"
7+ "syscall"
8+ "testing"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/pkg/rawterm"
12+)
13+
14+func TestSetGeometryPreservesPixelDimensions(t *testing.T) {
15+ masterFD, err := syscall.Open("/dev/ptmx", syscall.O_RDWR|syscall.O_NOCTTY|syscall.O_CLOEXEC, 0)
16+ if err != nil {
17+ t.Skipf("open /dev/ptmx: %v", err)
18+ }
19+ master := os.NewFile(uintptr(masterFD), "pty-master")
20+ defer func() {
21+ if err := master.Close(); err != nil {
22+ t.Errorf("close master: %v", err)
23+ }
24+ }()
25+ slave, err := rawterm.PreparePty(masterFD)
26+ if err != nil {
27+ t.Fatalf("PreparePty: %v", err)
28+ }
29+ defer func() {
30+ if err := slave.Close(); err != nil {
31+ t.Errorf("close slave: %v", err)
32+ }
33+ }()
34+
35+ geometry := domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 640, PixelHeight: 384}
36+ if err := setGeometry(masterFD, geometry); err != nil {
37+ t.Fatalf("setGeometry: %v", err)
38+ }
39+ got, err := rawterm.GetWinsize(int(slave.Fd()))
40+ if err != nil {
41+ t.Fatalf("GetWinsize: %v", err)
42+ }
43+ want := rawterm.Winsize{Col: 80, Row: 24, Xpixel: 640, Ypixel: 384}
44+ if got != want {
45+ t.Fatalf("winsize = %+v, want %+v", got, want)
46+ }
47+}
@@ -0,0 +1,47 @@
1+//go:build linux
2+
3+package pty
4+
5+import (
6+ "os"
7+ "syscall"
8+ "testing"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/pkg/rawterm"
12+)
13+
14+func TestSetGeometryPreservesPixelDimensions(t *testing.T) {
15+ masterFD, err := syscall.Open("/dev/ptmx", syscall.O_RDWR|syscall.O_NOCTTY|syscall.O_CLOEXEC, 0)
16+ if err != nil {
17+ t.Skipf("open /dev/ptmx: %v", err)
18+ }
19+ master := os.NewFile(uintptr(masterFD), "pty-master")
20+ defer func() {
21+ if err := master.Close(); err != nil {
22+ t.Errorf("close master: %v", err)
23+ }
24+ }()
25+ slave, err := rawterm.PreparePty(masterFD)
26+ if err != nil {
27+ t.Fatalf("PreparePty: %v", err)
28+ }
29+ defer func() {
30+ if err := slave.Close(); err != nil {
31+ t.Errorf("close slave: %v", err)
32+ }
33+ }()
34+
35+ geometry := domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 640, PixelHeight: 384}
36+ if err := setGeometry(masterFD, geometry); err != nil {
37+ t.Fatalf("setGeometry: %v", err)
38+ }
39+ got, err := rawterm.GetWinsize(int(slave.Fd()))
40+ if err != nil {
41+ t.Fatalf("GetWinsize: %v", err)
42+ }
43+ want := rawterm.Winsize{Col: 80, Row: 24, Xpixel: 640, Ypixel: 384}
44+ if got != want {
45+ t.Fatalf("winsize = %+v, want %+v", got, want)
46+ }
47+}
M internal/adapters/pty/pty_darwin_test.go +5 -5
@@ -46,12 +46,12 @@ func TestOpen_DarwinEchoAndOutputEOF(t *testing.T) {
4646 }
4747
4848 factory := pty.NewFactory()
49- outputPTY, err := factory.Open(context.Background(), "sh", []string{"-c", "printf hello"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
49+ outputPTY, err := factory.Open(context.Background(), "sh", []string{"-c", "printf hello"}, os.Environ(), "", domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
5050 require.NoError(t, err)
5151 t.Cleanup(func() { _ = outputPTY.Close() })
5252 require.Equal(t, "hello", string(readAllDarwin(t, outputPTY, 5*time.Second)))
5353
54- echoPTY, err := factory.Open(context.Background(), "cat", nil, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
54+ echoPTY, err := factory.Open(context.Background(), "cat", nil, os.Environ(), "", domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
5555 require.NoError(t, err)
5656 t.Cleanup(func() { _ = echoPTY.Close() })
5757 _, err = io.WriteString(echoPTY, "roundtrip\n")
@@ -98,7 +98,7 @@ func TestOpen_DarwinResizeAndForegroundPgid(t *testing.T) {
9898 }
9999
100100 const ready = "__vev_ready__"
101- p, err := pty.NewFactory().Open(context.Background(), "sh", []string{"-c", "stty -echo; printf '__vev_ready__\\n'; IFS= read -r release; stty size"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
101+ p, err := pty.NewFactory().Open(context.Background(), "sh", []string{"-c", "stty -echo; printf '__vev_ready__\\n'; IFS= read -r release; stty size"}, os.Environ(), "", domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
102102 require.NoError(t, err)
103103 t.Cleanup(func() { _ = p.Close() })
104104
@@ -106,7 +106,7 @@ func TestOpen_DarwinResizeAndForegroundPgid(t *testing.T) {
106106 pgid, err := p.ForegroundPgid()
107107 require.NoError(t, err)
108108 require.Greater(t, pgid, 0)
109- require.NoError(t, p.Resize(domain.Size{Cols: 120, Rows: 40}))
109+ require.NoError(t, p.Resize(domain.Geometry{Size: domain.Size{Cols: 120, Rows: 40}}))
110110 _, err = io.WriteString(p, "release\n")
111111 require.NoError(t, err)
112112 require.Equal(t, "40 120", strings.TrimSpace(string(readAllDarwin(t, p, 5*time.Second))))
@@ -119,7 +119,7 @@ func TestOpen_DarwinContextCancellationEndsStream(t *testing.T) {
119119
120120 ctx, cancel := context.WithCancel(context.Background())
121121 defer cancel()
122- p, err := pty.NewFactory().Open(ctx, "sh", []string{"-c", "exec sleep 60"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
122+ p, err := pty.NewFactory().Open(ctx, "sh", []string{"-c", "exec sleep 60"}, os.Environ(), "", domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
123123 require.NoError(t, err)
124124 t.Cleanup(func() { _ = p.Close() })
125125
@@ -46,12 +46,12 @@ func TestOpen_DarwinEchoAndOutputEOF(t *testing.T) {
46 }46 }
47 47
48 factory := pty.NewFactory()48 factory := pty.NewFactory()
49- outputPTY, err := factory.Open(context.Background(), "sh", []string{"-c", "printf hello"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})49+ outputPTY, err := factory.Open(context.Background(), "sh", []string{"-c", "printf hello"}, os.Environ(), "", domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
50 require.NoError(t, err)50 require.NoError(t, err)
51 t.Cleanup(func() { _ = outputPTY.Close() })51 t.Cleanup(func() { _ = outputPTY.Close() })
52 require.Equal(t, "hello", string(readAllDarwin(t, outputPTY, 5*time.Second)))52 require.Equal(t, "hello", string(readAllDarwin(t, outputPTY, 5*time.Second)))
53 53
54- echoPTY, err := factory.Open(context.Background(), "cat", nil, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})54+ echoPTY, err := factory.Open(context.Background(), "cat", nil, os.Environ(), "", domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
55 require.NoError(t, err)55 require.NoError(t, err)
56 t.Cleanup(func() { _ = echoPTY.Close() })56 t.Cleanup(func() { _ = echoPTY.Close() })
57 _, err = io.WriteString(echoPTY, "roundtrip\n")57 _, err = io.WriteString(echoPTY, "roundtrip\n")
@@ -98,7 +98,7 @@ func TestOpen_DarwinResizeAndForegroundPgid(t *testing.T) {
98 }98 }
99 99
100 const ready = "__vev_ready__"100 const ready = "__vev_ready__"
101- p, err := pty.NewFactory().Open(context.Background(), "sh", []string{"-c", "stty -echo; printf '__vev_ready__\\n'; IFS= read -r release; stty size"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})101+ p, err := pty.NewFactory().Open(context.Background(), "sh", []string{"-c", "stty -echo; printf '__vev_ready__\\n'; IFS= read -r release; stty size"}, os.Environ(), "", domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
102 require.NoError(t, err)102 require.NoError(t, err)
103 t.Cleanup(func() { _ = p.Close() })103 t.Cleanup(func() { _ = p.Close() })
104 104
@@ -106,7 +106,7 @@ func TestOpen_DarwinResizeAndForegroundPgid(t *testing.T) {
106 pgid, err := p.ForegroundPgid()106 pgid, err := p.ForegroundPgid()
107 require.NoError(t, err)107 require.NoError(t, err)
108 require.Greater(t, pgid, 0)108 require.Greater(t, pgid, 0)
109- require.NoError(t, p.Resize(domain.Size{Cols: 120, Rows: 40}))109+ require.NoError(t, p.Resize(domain.Geometry{Size: domain.Size{Cols: 120, Rows: 40}}))
110 _, err = io.WriteString(p, "release\n")110 _, err = io.WriteString(p, "release\n")
111 require.NoError(t, err)111 require.NoError(t, err)
112 require.Equal(t, "40 120", strings.TrimSpace(string(readAllDarwin(t, p, 5*time.Second))))112 require.Equal(t, "40 120", strings.TrimSpace(string(readAllDarwin(t, p, 5*time.Second))))
@@ -119,7 +119,7 @@ func TestOpen_DarwinContextCancellationEndsStream(t *testing.T) {
119 119
120 ctx, cancel := context.WithCancel(context.Background())120 ctx, cancel := context.WithCancel(context.Background())
121 defer cancel()121 defer cancel()
122- p, err := pty.NewFactory().Open(ctx, "sh", []string{"-c", "exec sleep 60"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})122+ p, err := pty.NewFactory().Open(ctx, "sh", []string{"-c", "exec sleep 60"}, os.Environ(), "", domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
123 require.NoError(t, err)123 require.NoError(t, err)
124 t.Cleanup(func() { _ = p.Close() })124 t.Cleanup(func() { _ = p.Close() })
125 125
M internal/adapters/pty/pty_linux_test.go +10 -10
@@ -54,7 +54,7 @@ func TestOpen_ChildOutputToEOF(t *testing.T) {
5454 t.Skip("skipping pty integration test in -short mode")
5555 }
5656 f := newFactory()
57- p, err := f.Open(context.Background(), "sh", []string{"-c", "printf hello"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
57+ p, err := f.Open(context.Background(), "sh", []string{"-c", "printf hello"}, os.Environ(), "", domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
5858 require.NoError(t, err)
5959 t.Cleanup(func() { _ = p.Close() })
6060
@@ -71,7 +71,7 @@ func TestOpen_EchoRoundtrip(t *testing.T) {
7171 t.Skip("skipping pty integration test in -short mode")
7272 }
7373 f := newFactory()
74- p, err := f.Open(context.Background(), "cat", nil, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
74+ p, err := f.Open(context.Background(), "cat", nil, os.Environ(), "", domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
7575 require.NoError(t, err)
7676 t.Cleanup(func() { _ = p.Close() })
7777
@@ -104,7 +104,7 @@ func TestForegroundPgid(t *testing.T) {
104104 t.Skip("skipping pty integration test in -short mode")
105105 }
106106 f := newFactory()
107- p, err := f.Open(context.Background(), "sh", []string{"-c", "sleep 2"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
107+ p, err := f.Open(context.Background(), "sh", []string{"-c", "sleep 2"}, os.Environ(), "", domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
108108 require.NoError(t, err)
109109 t.Cleanup(func() { _ = p.Close() })
110110
@@ -142,12 +142,12 @@ func TestResize_SttySize(t *testing.T) {
142142 for _, tt := range tests {
143143 t.Run(tt.name, func(t *testing.T) {
144144 f := newFactory()
145- p, err := f.Open(context.Background(), "sh", []string{"-c", tt.script}, os.Environ(), "", tt.initial)
145+ p, err := f.Open(context.Background(), "sh", []string{"-c", tt.script}, os.Environ(), "", domain.Geometry{Size: tt.initial})
146146 require.NoError(t, err)
147147 t.Cleanup(func() { _ = p.Close() })
148148
149149 if tt.resize != nil {
150- require.NoError(t, p.Resize(*tt.resize))
150+ require.NoError(t, p.Resize(domain.Geometry{Size: *tt.resize}))
151151 }
152152
153153 out := readAll(t, p, 5*time.Second)
@@ -161,7 +161,7 @@ func TestClose_ReapsChildNoZombie(t *testing.T) {
161161 t.Skip("skipping pty integration test in -short mode")
162162 }
163163 f := newFactory()
164- p, err := f.Open(context.Background(), "sh", []string{"-c", "sleep 30"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
164+ p, err := f.Open(context.Background(), "sh", []string{"-c", "sleep 30"}, os.Environ(), "", domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
165165 require.NoError(t, err)
166166
167167 pid := p.Pid()
@@ -192,7 +192,7 @@ func TestClose_TerminatesLongRunningChild(t *testing.T) {
192192 t.Skip("skipping pty integration test in -short mode")
193193 }
194194 f := newFactory()
195- p, err := f.Open(context.Background(), "sh", []string{"-c", "sleep 60"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
195+ p, err := f.Open(context.Background(), "sh", []string{"-c", "sleep 60"}, os.Environ(), "", domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
196196 require.NoError(t, err)
197197 pid := p.Pid()
198198
@@ -214,7 +214,7 @@ func TestOpen_ContextCancellationTerminatesProcessGroup(t *testing.T) {
214214
215215 // The background sleep ignores SIGHUP so killing only the session leader
216216 // cannot accidentally pass via the terminal's hangup behavior.
217- p, err := newFactory().Open(ctx, "sh", []string{"-c", `(trap '' HUP; exec sleep 60) & child=$!; printf '%s\n' "$child"; wait "$child"`}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
217+ p, err := newFactory().Open(ctx, "sh", []string{"-c", `(trap '' HUP; exec sleep 60) & child=$!; printf '%s\n' "$child"; wait "$child"`}, os.Environ(), "", domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
218218 require.NoError(t, err)
219219 t.Cleanup(func() { _ = p.Close() })
220220
@@ -249,7 +249,7 @@ func TestResize_DeliversSIGWINCH(t *testing.T) {
249249 // deliver SIGWINCH to the foreground process group.
250250 script := `trap 'echo GOTWINCH; exit 0' WINCH; echo READY; i=0; while [ $i -lt 200 ]; do sleep 0.05; i=$((i+1)); done`
251251 f := newFactory()
252- p, err := f.Open(context.Background(), "sh", []string{"-c", script}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
252+ p, err := f.Open(context.Background(), "sh", []string{"-c", script}, os.Environ(), "", domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
253253 require.NoError(t, err)
254254 t.Cleanup(func() { _ = p.Close() })
255255
@@ -268,7 +268,7 @@ func TestResize_DeliversSIGWINCH(t *testing.T) {
268268 }
269269 }
270270
271- require.NoError(t, p.Resize(domain.Size{Cols: 100, Rows: 30}))
271+ require.NoError(t, p.Resize(domain.Geometry{Size: domain.Size{Cols: 100, Rows: 30}}))
272272
273273 for !strings.Contains(acc, "GOTWINCH") {
274274 if time.Now().After(deadline) {
@@ -54,7 +54,7 @@ func TestOpen_ChildOutputToEOF(t *testing.T) {
54 t.Skip("skipping pty integration test in -short mode")54 t.Skip("skipping pty integration test in -short mode")
55 }55 }
56 f := newFactory()56 f := newFactory()
57- p, err := f.Open(context.Background(), "sh", []string{"-c", "printf hello"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})57+ p, err := f.Open(context.Background(), "sh", []string{"-c", "printf hello"}, os.Environ(), "", domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
58 require.NoError(t, err)58 require.NoError(t, err)
59 t.Cleanup(func() { _ = p.Close() })59 t.Cleanup(func() { _ = p.Close() })
60 60
@@ -71,7 +71,7 @@ func TestOpen_EchoRoundtrip(t *testing.T) {
71 t.Skip("skipping pty integration test in -short mode")71 t.Skip("skipping pty integration test in -short mode")
72 }72 }
73 f := newFactory()73 f := newFactory()
74- p, err := f.Open(context.Background(), "cat", nil, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})74+ p, err := f.Open(context.Background(), "cat", nil, os.Environ(), "", domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
75 require.NoError(t, err)75 require.NoError(t, err)
76 t.Cleanup(func() { _ = p.Close() })76 t.Cleanup(func() { _ = p.Close() })
77 77
@@ -104,7 +104,7 @@ func TestForegroundPgid(t *testing.T) {
104 t.Skip("skipping pty integration test in -short mode")104 t.Skip("skipping pty integration test in -short mode")
105 }105 }
106 f := newFactory()106 f := newFactory()
107- p, err := f.Open(context.Background(), "sh", []string{"-c", "sleep 2"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})107+ p, err := f.Open(context.Background(), "sh", []string{"-c", "sleep 2"}, os.Environ(), "", domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
108 require.NoError(t, err)108 require.NoError(t, err)
109 t.Cleanup(func() { _ = p.Close() })109 t.Cleanup(func() { _ = p.Close() })
110 110
@@ -142,12 +142,12 @@ func TestResize_SttySize(t *testing.T) {
142 for _, tt := range tests {142 for _, tt := range tests {
143 t.Run(tt.name, func(t *testing.T) {143 t.Run(tt.name, func(t *testing.T) {
144 f := newFactory()144 f := newFactory()
145- p, err := f.Open(context.Background(), "sh", []string{"-c", tt.script}, os.Environ(), "", tt.initial)145+ p, err := f.Open(context.Background(), "sh", []string{"-c", tt.script}, os.Environ(), "", domain.Geometry{Size: tt.initial})
146 require.NoError(t, err)146 require.NoError(t, err)
147 t.Cleanup(func() { _ = p.Close() })147 t.Cleanup(func() { _ = p.Close() })
148 148
149 if tt.resize != nil {149 if tt.resize != nil {
150- require.NoError(t, p.Resize(*tt.resize))150+ require.NoError(t, p.Resize(domain.Geometry{Size: *tt.resize}))
151 }151 }
152 152
153 out := readAll(t, p, 5*time.Second)153 out := readAll(t, p, 5*time.Second)
@@ -161,7 +161,7 @@ func TestClose_ReapsChildNoZombie(t *testing.T) {
161 t.Skip("skipping pty integration test in -short mode")161 t.Skip("skipping pty integration test in -short mode")
162 }162 }
163 f := newFactory()163 f := newFactory()
164- p, err := f.Open(context.Background(), "sh", []string{"-c", "sleep 30"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})164+ p, err := f.Open(context.Background(), "sh", []string{"-c", "sleep 30"}, os.Environ(), "", domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
165 require.NoError(t, err)165 require.NoError(t, err)
166 166
167 pid := p.Pid()167 pid := p.Pid()
@@ -192,7 +192,7 @@ func TestClose_TerminatesLongRunningChild(t *testing.T) {
192 t.Skip("skipping pty integration test in -short mode")192 t.Skip("skipping pty integration test in -short mode")
193 }193 }
194 f := newFactory()194 f := newFactory()
195- p, err := f.Open(context.Background(), "sh", []string{"-c", "sleep 60"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})195+ p, err := f.Open(context.Background(), "sh", []string{"-c", "sleep 60"}, os.Environ(), "", domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
196 require.NoError(t, err)196 require.NoError(t, err)
197 pid := p.Pid()197 pid := p.Pid()
198 198
@@ -214,7 +214,7 @@ func TestOpen_ContextCancellationTerminatesProcessGroup(t *testing.T) {
214 214
215 // The background sleep ignores SIGHUP so killing only the session leader215 // The background sleep ignores SIGHUP so killing only the session leader
216 // cannot accidentally pass via the terminal's hangup behavior.216 // cannot accidentally pass via the terminal's hangup behavior.
217- p, err := newFactory().Open(ctx, "sh", []string{"-c", `(trap '' HUP; exec sleep 60) & child=$!; printf '%s\n' "$child"; wait "$child"`}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})217+ p, err := newFactory().Open(ctx, "sh", []string{"-c", `(trap '' HUP; exec sleep 60) & child=$!; printf '%s\n' "$child"; wait "$child"`}, os.Environ(), "", domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
218 require.NoError(t, err)218 require.NoError(t, err)
219 t.Cleanup(func() { _ = p.Close() })219 t.Cleanup(func() { _ = p.Close() })
220 220
@@ -249,7 +249,7 @@ func TestResize_DeliversSIGWINCH(t *testing.T) {
249 // deliver SIGWINCH to the foreground process group.249 // deliver SIGWINCH to the foreground process group.
250 script := `trap 'echo GOTWINCH; exit 0' WINCH; echo READY; i=0; while [ $i -lt 200 ]; do sleep 0.05; i=$((i+1)); done`250 script := `trap 'echo GOTWINCH; exit 0' WINCH; echo READY; i=0; while [ $i -lt 200 ]; do sleep 0.05; i=$((i+1)); done`
251 f := newFactory()251 f := newFactory()
252- p, err := f.Open(context.Background(), "sh", []string{"-c", script}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})252+ p, err := f.Open(context.Background(), "sh", []string{"-c", script}, os.Environ(), "", domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
253 require.NoError(t, err)253 require.NoError(t, err)
254 t.Cleanup(func() { _ = p.Close() })254 t.Cleanup(func() { _ = p.Close() })
255 255
@@ -268,7 +268,7 @@ func TestResize_DeliversSIGWINCH(t *testing.T) {
268 }268 }
269 }269 }
270 270
271- require.NoError(t, p.Resize(domain.Size{Cols: 100, Rows: 30}))271+ require.NoError(t, p.Resize(domain.Geometry{Size: domain.Size{Cols: 100, Rows: 30}}))
272 272
273 for !strings.Contains(acc, "GOTWINCH") {273 for !strings.Contains(acc, "GOTWINCH") {
274 if time.Now().After(deadline) {274 if time.Now().After(deadline) {
M internal/adapters/pty/pty_unix.go +25 -10
@@ -36,9 +36,10 @@ func NewFactory() *Factory { return &Factory{} }
3636 // Open spawns command with args attached to a freshly allocated pseudo-terminal
3737 // and returns the master side as a ports.PTY. env is passed to the child
3838 // verbatim (nil means inherit the current process environment); the caller
39-// decides TERM and friends. sz sets the terminal window size before the child
40-// starts, so the child observes the correct dimensions on its first query.
41-func (Factory) Open(ctx context.Context, command string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
39+// decides TERM and friends. geometry sets the terminal window geometry before
40+// the child starts, so the child observes the correct dimensions on its first
41+// query.
42+func (Factory) Open(ctx context.Context, command string, args []string, env []string, dir string, geometry domain.Geometry) (ports.PTY, error) {
4243 if err := ctx.Err(); err != nil {
4344 return nil, err
4445 }
@@ -63,10 +64,11 @@ func (Factory) Open(ctx context.Context, command string, args []string, env []st
6364 // parent always drops the slave once Start has run (or on any error).
6465 defer func() { _ = slave.Close() }()
6566
66- // Set the initial window size on the master before Start so the child's very
67- // first size query already reflects sz.
68- if sz.Valid() {
69- if err := setWinsize(masterFd, sz); err != nil {
67+ // Set the initial window geometry on the master before Start so the child's
68+ // very first size query already reflects the authoritative geometry.
69+ geometry = geometry.NormalizePixels()
70+ if geometry.Valid() {
71+ if err := setGeometry(masterFd, geometry); err != nil {
7072 return nil, fmt.Errorf("pty: initial TIOCSWINSZ: %w", err)
7173 }
7274 }
@@ -148,14 +150,15 @@ func (p *unixPTY) Write(b []byte) (int, error) {
148150 // every subsequent Read into a thread-parking syscall and breaking the
149151 // Close-unblocks-Read behavior. Control also pins the fd for the duration and
150152 // fails cleanly (os.ErrClosed) after Close.
151-func (p *unixPTY) Resize(sz domain.Size) error {
153+func (p *unixPTY) Resize(geometry domain.Geometry) error {
154+ geometry = geometry.NormalizePixels()
152155 rc, err := p.master.SyscallConn()
153156 if err != nil {
154157 return fmt.Errorf("pty: resize: %w", err)
155158 }
156159 var ioctlErr error
157160 if err := rc.Control(func(fd uintptr) {
158- ioctlErr = setWinsize(int(fd), sz)
161+ ioctlErr = setGeometry(int(fd), geometry)
159162 }); err != nil {
160163 return fmt.Errorf("pty: resize: %w", err)
161164 }
@@ -228,5 +231,17 @@ func signalProcessGroup(pid int, signal syscall.Signal) error {
228231
229232 // setWinsize applies sz to the terminal referenced by fd via TIOCSWINSZ.
230233 func setWinsize(fd int, sz domain.Size) error {
231- return rawterm.SetWinsize(fd, uint16(sz.Cols), uint16(sz.Rows))
234+ return setGeometry(fd, domain.Geometry{Size: sz})
235+}
236+
237+// setGeometry applies cell dimensions and any pixel dimensions reported by the
238+// geometry authority. Zero pixel dimensions deliberately preserve the kernel's
239+// explicit unknown value instead of fabricating a cell-derived size.
240+func setGeometry(fd int, geometry domain.Geometry) error {
241+ return rawterm.SetWinsizeFull(fd, rawterm.Winsize{
242+ Col: uint16(geometry.Cols),
243+ Row: uint16(geometry.Rows),
244+ Xpixel: uint16(geometry.PixelWidth),
245+ Ypixel: uint16(geometry.PixelHeight),
246+ })
232247 }
@@ -36,9 +36,10 @@ func NewFactory() *Factory { return &Factory{} }
36 // Open spawns command with args attached to a freshly allocated pseudo-terminal36 // Open spawns command with args attached to a freshly allocated pseudo-terminal
37 // and returns the master side as a ports.PTY. env is passed to the child37 // and returns the master side as a ports.PTY. env is passed to the child
38 // verbatim (nil means inherit the current process environment); the caller38 // verbatim (nil means inherit the current process environment); the caller
39-// decides TERM and friends. sz sets the terminal window size before the child39+// decides TERM and friends. geometry sets the terminal window geometry before
40-// starts, so the child observes the correct dimensions on its first query.40+// the child starts, so the child observes the correct dimensions on its first
41-func (Factory) Open(ctx context.Context, command string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {41+// query.
42+func (Factory) Open(ctx context.Context, command string, args []string, env []string, dir string, geometry domain.Geometry) (ports.PTY, error) {
42 if err := ctx.Err(); err != nil {43 if err := ctx.Err(); err != nil {
43 return nil, err44 return nil, err
44 }45 }
@@ -63,10 +64,11 @@ func (Factory) Open(ctx context.Context, command string, args []string, env []st
63 // parent always drops the slave once Start has run (or on any error).64 // parent always drops the slave once Start has run (or on any error).
64 defer func() { _ = slave.Close() }()65 defer func() { _ = slave.Close() }()
65 66
66- // Set the initial window size on the master before Start so the child's very67+ // Set the initial window geometry on the master before Start so the child's
67- // first size query already reflects sz.68+ // very first size query already reflects the authoritative geometry.
68- if sz.Valid() {69+ geometry = geometry.NormalizePixels()
69- if err := setWinsize(masterFd, sz); err != nil {70+ if geometry.Valid() {
71+ if err := setGeometry(masterFd, geometry); err != nil {
70 return nil, fmt.Errorf("pty: initial TIOCSWINSZ: %w", err)72 return nil, fmt.Errorf("pty: initial TIOCSWINSZ: %w", err)
71 }73 }
72 }74 }
@@ -148,14 +150,15 @@ func (p *unixPTY) Write(b []byte) (int, error) {
148 // every subsequent Read into a thread-parking syscall and breaking the150 // every subsequent Read into a thread-parking syscall and breaking the
149 // Close-unblocks-Read behavior. Control also pins the fd for the duration and151 // Close-unblocks-Read behavior. Control also pins the fd for the duration and
150 // fails cleanly (os.ErrClosed) after Close.152 // fails cleanly (os.ErrClosed) after Close.
151-func (p *unixPTY) Resize(sz domain.Size) error {153+func (p *unixPTY) Resize(geometry domain.Geometry) error {
154+ geometry = geometry.NormalizePixels()
152 rc, err := p.master.SyscallConn()155 rc, err := p.master.SyscallConn()
153 if err != nil {156 if err != nil {
154 return fmt.Errorf("pty: resize: %w", err)157 return fmt.Errorf("pty: resize: %w", err)
155 }158 }
156 var ioctlErr error159 var ioctlErr error
157 if err := rc.Control(func(fd uintptr) {160 if err := rc.Control(func(fd uintptr) {
158- ioctlErr = setWinsize(int(fd), sz)161+ ioctlErr = setGeometry(int(fd), geometry)
159 }); err != nil {162 }); err != nil {
160 return fmt.Errorf("pty: resize: %w", err)163 return fmt.Errorf("pty: resize: %w", err)
161 }164 }
@@ -228,5 +231,17 @@ func signalProcessGroup(pid int, signal syscall.Signal) error {
228 231
229 // setWinsize applies sz to the terminal referenced by fd via TIOCSWINSZ.232 // setWinsize applies sz to the terminal referenced by fd via TIOCSWINSZ.
230 func setWinsize(fd int, sz domain.Size) error {233 func setWinsize(fd int, sz domain.Size) error {
231- return rawterm.SetWinsize(fd, uint16(sz.Cols), uint16(sz.Rows))234+ return setGeometry(fd, domain.Geometry{Size: sz})
235+}
236+
237+// setGeometry applies cell dimensions and any pixel dimensions reported by the
238+// geometry authority. Zero pixel dimensions deliberately preserve the kernel's
239+// explicit unknown value instead of fabricating a cell-derived size.
240+func setGeometry(fd int, geometry domain.Geometry) error {
241+ return rawterm.SetWinsizeFull(fd, rawterm.Winsize{
242+ Col: uint16(geometry.Cols),
243+ Row: uint16(geometry.Rows),
244+ Xpixel: uint16(geometry.PixelWidth),
245+ Ypixel: uint16(geometry.PixelHeight),
246+ })
232 }247 }
M internal/adapters/term/pty_integration_test.go +8 -5
@@ -41,7 +41,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) {
4141 defer func() { _ = master.Close() }()
4242 defer func() { _ = slave.Close() }()
4343
44- if err := rawterm.SetWinsize(int(slave.Fd()), 80, 24); err != nil {
44+ if err := rawterm.SetWinsizeFull(int(slave.Fd()), rawterm.Winsize{Col: 80, Row: 24, Xpixel: 640, Ypixel: 384}); err != nil {
4545 t.Fatalf("set winsize: %v", err)
4646 }
4747
@@ -55,12 +55,15 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) {
5555
5656 tm := NewWithFiles(slave, slave)
5757
58- sz, err := tm.Size()
58+ geometry, err := tm.Geometry()
5959 if err != nil {
60- t.Fatalf("Size: %v", err)
60+ t.Fatalf("Geometry: %v", err)
6161 }
62- if sz.Cols != 80 || sz.Rows != 24 {
63- t.Fatalf("Size = %+v, want {Cols:80 Rows:24}", sz)
62+ if geometry.Cols != 80 || geometry.Rows != 24 {
63+ t.Fatalf("Geometry = %+v, want {Cols:80 Rows:24}", geometry)
64+ }
65+ if geometry.PixelWidth != 640 || geometry.PixelHeight != 384 {
66+ t.Fatalf("Geometry = %+v, want 640x384 pixels", geometry)
6467 }
6568
6669 restore, err := tm.EnterRaw()
@@ -41,7 +41,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) {
41 defer func() { _ = master.Close() }()41 defer func() { _ = master.Close() }()
42 defer func() { _ = slave.Close() }()42 defer func() { _ = slave.Close() }()
43 43
44- if err := rawterm.SetWinsize(int(slave.Fd()), 80, 24); err != nil {44+ if err := rawterm.SetWinsizeFull(int(slave.Fd()), rawterm.Winsize{Col: 80, Row: 24, Xpixel: 640, Ypixel: 384}); err != nil {
45 t.Fatalf("set winsize: %v", err)45 t.Fatalf("set winsize: %v", err)
46 }46 }
47 47
@@ -55,12 +55,15 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) {
55 55
56 tm := NewWithFiles(slave, slave)56 tm := NewWithFiles(slave, slave)
57 57
58- sz, err := tm.Size()58+ geometry, err := tm.Geometry()
59 if err != nil {59 if err != nil {
60- t.Fatalf("Size: %v", err)60+ t.Fatalf("Geometry: %v", err)
61 }61 }
62- if sz.Cols != 80 || sz.Rows != 24 {62+ if geometry.Cols != 80 || geometry.Rows != 24 {
63- t.Fatalf("Size = %+v, want {Cols:80 Rows:24}", sz)63+ t.Fatalf("Geometry = %+v, want {Cols:80 Rows:24}", geometry)
64+ }
65+ if geometry.PixelWidth != 640 || geometry.PixelHeight != 384 {
66+ t.Fatalf("Geometry = %+v, want 640x384 pixels", geometry)
64 }67 }
65 68
66 restore, err := tm.EnterRaw()69 restore, err := tm.EnterRaw()
M internal/adapters/term/resize.go +7 -7
@@ -7,14 +7,14 @@ import (
77 )
88
99 // resizeLoop watches sig for signal notifications (SIGWINCH in
10-// production) and emits the coalesced terminal size on out: a burst of
11-// signals collapses to a single emitted size, since any signals still
10+// production) and emits the coalesced terminal geometry on out: a burst of
11+// signals collapses to a single emitted value, since any signals still
1212 // queued once the first is observed are drained before querying the
13-// size. getSize is called to resolve the size to emit; a getSize error
14-// is treated as "no event this round" and the loop continues waiting.
13+// geometry. getGeometry is called to resolve the value to emit; an error is
14+// treated as "no event this round" and the loop continues waiting.
1515 //
1616 // The loop exits and closes out when quit is closed.
17-func resizeLoop(sig <-chan os.Signal, out chan<- domain.Size, quit <-chan struct{}, getSize func() (domain.Size, error)) {
17+func resizeLoop(sig <-chan os.Signal, out chan<- domain.Geometry, quit <-chan struct{}, getGeometry func() (domain.Geometry, error)) {
1818 defer close(out)
1919 for {
2020 select {
@@ -22,12 +22,12 @@ func resizeLoop(sig <-chan os.Signal, out chan<- domain.Size, quit <-chan struct
2222 return
2323 case <-sig:
2424 drainSignals(sig)
25- sz, err := getSize()
25+ geometry, err := getGeometry()
2626 if err != nil {
2727 continue
2828 }
2929 select {
30- case out <- sz:
30+ case out <- geometry:
3131 case <-quit:
3232 return
3333 }
@@ -7,14 +7,14 @@ import (
7 )7 )
8 8
9 // resizeLoop watches sig for signal notifications (SIGWINCH in9 // resizeLoop watches sig for signal notifications (SIGWINCH in
10-// production) and emits the coalesced terminal size on out: a burst of10+// production) and emits the coalesced terminal geometry on out: a burst of
11-// signals collapses to a single emitted size, since any signals still11+// signals collapses to a single emitted value, since any signals still
12 // queued once the first is observed are drained before querying the12 // queued once the first is observed are drained before querying the
13-// size. getSize is called to resolve the size to emit; a getSize error13+// geometry. getGeometry is called to resolve the value to emit; an error is
14-// is treated as "no event this round" and the loop continues waiting.14+// treated as "no event this round" and the loop continues waiting.
15 //15 //
16 // The loop exits and closes out when quit is closed.16 // The loop exits and closes out when quit is closed.
17-func resizeLoop(sig <-chan os.Signal, out chan<- domain.Size, quit <-chan struct{}, getSize func() (domain.Size, error)) {17+func resizeLoop(sig <-chan os.Signal, out chan<- domain.Geometry, quit <-chan struct{}, getGeometry func() (domain.Geometry, error)) {
18 defer close(out)18 defer close(out)
19 for {19 for {
20 select {20 select {
@@ -22,12 +22,12 @@ func resizeLoop(sig <-chan os.Signal, out chan<- domain.Size, quit <-chan struct
22 return22 return
23 case <-sig:23 case <-sig:
24 drainSignals(sig)24 drainSignals(sig)
25- sz, err := getSize()25+ geometry, err := getGeometry()
26 if err != nil {26 if err != nil {
27 continue27 continue
28 }28 }
29 select {29 select {
30- case out <- sz:30+ case out <- geometry:
31 case <-quit:31 case <-quit:
32 return32 return
33 }33 }
M internal/adapters/term/resize_test.go +16 -16
@@ -19,19 +19,19 @@ func TestResizeLoop_CoalescesBurstToOneEmission(t *testing.T) {
1919 sig <- syscall.SIGWINCH
2020 }
2121
22- out := make(chan domain.Size)
22+ out := make(chan domain.Geometry)
2323 quit := make(chan struct{})
2424
2525 var calls int
26- getSize := func() (domain.Size, error) {
26+ getGeometry := func() (domain.Geometry, error) {
2727 calls++
28- return domain.Size{Cols: 80, Rows: 24}, nil
28+ return domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 800, PixelHeight: 480}, nil
2929 }
3030
31- go resizeLoop(sig, out, quit, getSize)
31+ go resizeLoop(sig, out, quit, getGeometry)
3232
3333 got := <-out
34- want := domain.Size{Cols: 80, Rows: 24}
34+ want := domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 800, PixelHeight: 480}
3535 if got != want {
3636 t.Fatalf("emitted size = %+v, want %+v", got, want)
3737 }
@@ -54,28 +54,28 @@ func TestResizeLoop_CoalescesBurstToOneEmission(t *testing.T) {
5454
5555 func TestResizeLoop_GetSizeErrorSkipsEmission(t *testing.T) {
5656 sig := make(chan os.Signal) // unbuffered: sends rendezvous with the loop's receive
57- out := make(chan domain.Size)
57+ out := make(chan domain.Geometry)
5858 quit := make(chan struct{})
5959
6060 var calls int
6161 firstGetSize := make(chan struct{})
62- getSize := func() (domain.Size, error) {
62+ getGeometry := func() (domain.Geometry, error) {
6363 calls++
6464 if calls == 1 {
6565 close(firstGetSize)
66- return domain.Size{}, errors.New("boom")
66+ return domain.Geometry{}, errors.New("boom")
6767 }
68- return domain.Size{Cols: 10, Rows: 5}, nil
68+ return domain.Geometry{Size: domain.Size{Cols: 10, Rows: 5}}, nil
6969 }
7070
71- go resizeLoop(sig, out, quit, getSize)
71+ go resizeLoop(sig, out, quit, getGeometry)
7272
7373 sig <- syscall.SIGWINCH // errors; no emission
7474 <-firstGetSize // wait until the failed signal has been handled
7575 sig <- syscall.SIGWINCH // succeeds
7676
7777 got := <-out
78- want := domain.Size{Cols: 10, Rows: 5}
78+ want := domain.Geometry{Size: domain.Size{Cols: 10, Rows: 5}}
7979 if got != want {
8080 t.Fatalf("emitted size = %+v, want %+v", got, want)
8181 }
@@ -91,15 +91,15 @@ func TestResizeLoop_GetSizeErrorSkipsEmission(t *testing.T) {
9191
9292 func TestResizeLoop_QuitClosesOutImmediately(t *testing.T) {
9393 sig := make(chan os.Signal)
94- out := make(chan domain.Size)
94+ out := make(chan domain.Geometry)
9595 quit := make(chan struct{})
9696
97- getSize := func() (domain.Size, error) {
98- t.Fatalf("getSize should not be called")
99- return domain.Size{}, nil
97+ getGeometry := func() (domain.Geometry, error) {
98+ t.Fatalf("getGeometry should not be called")
99+ return domain.Geometry{}, nil
100100 }
101101
102- go resizeLoop(sig, out, quit, getSize)
102+ go resizeLoop(sig, out, quit, getGeometry)
103103 close(quit)
104104
105105 select {
@@ -19,19 +19,19 @@ func TestResizeLoop_CoalescesBurstToOneEmission(t *testing.T) {
19 sig <- syscall.SIGWINCH19 sig <- syscall.SIGWINCH
20 }20 }
21 21
22- out := make(chan domain.Size)22+ out := make(chan domain.Geometry)
23 quit := make(chan struct{})23 quit := make(chan struct{})
24 24
25 var calls int25 var calls int
26- getSize := func() (domain.Size, error) {26+ getGeometry := func() (domain.Geometry, error) {
27 calls++27 calls++
28- return domain.Size{Cols: 80, Rows: 24}, nil28+ return domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 800, PixelHeight: 480}, nil
29 }29 }
30 30
31- go resizeLoop(sig, out, quit, getSize)31+ go resizeLoop(sig, out, quit, getGeometry)
32 32
33 got := <-out33 got := <-out
34- want := domain.Size{Cols: 80, Rows: 24}34+ want := domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 800, PixelHeight: 480}
35 if got != want {35 if got != want {
36 t.Fatalf("emitted size = %+v, want %+v", got, want)36 t.Fatalf("emitted size = %+v, want %+v", got, want)
37 }37 }
@@ -54,28 +54,28 @@ func TestResizeLoop_CoalescesBurstToOneEmission(t *testing.T) {
54 54
55 func TestResizeLoop_GetSizeErrorSkipsEmission(t *testing.T) {55 func TestResizeLoop_GetSizeErrorSkipsEmission(t *testing.T) {
56 sig := make(chan os.Signal) // unbuffered: sends rendezvous with the loop's receive56 sig := make(chan os.Signal) // unbuffered: sends rendezvous with the loop's receive
57- out := make(chan domain.Size)57+ out := make(chan domain.Geometry)
58 quit := make(chan struct{})58 quit := make(chan struct{})
59 59
60 var calls int60 var calls int
61 firstGetSize := make(chan struct{})61 firstGetSize := make(chan struct{})
62- getSize := func() (domain.Size, error) {62+ getGeometry := func() (domain.Geometry, error) {
63 calls++63 calls++
64 if calls == 1 {64 if calls == 1 {
65 close(firstGetSize)65 close(firstGetSize)
66- return domain.Size{}, errors.New("boom")66+ return domain.Geometry{}, errors.New("boom")
67 }67 }
68- return domain.Size{Cols: 10, Rows: 5}, nil68+ return domain.Geometry{Size: domain.Size{Cols: 10, Rows: 5}}, nil
69 }69 }
70 70
71- go resizeLoop(sig, out, quit, getSize)71+ go resizeLoop(sig, out, quit, getGeometry)
72 72
73 sig <- syscall.SIGWINCH // errors; no emission73 sig <- syscall.SIGWINCH // errors; no emission
74 <-firstGetSize // wait until the failed signal has been handled74 <-firstGetSize // wait until the failed signal has been handled
75 sig <- syscall.SIGWINCH // succeeds75 sig <- syscall.SIGWINCH // succeeds
76 76
77 got := <-out77 got := <-out
78- want := domain.Size{Cols: 10, Rows: 5}78+ want := domain.Geometry{Size: domain.Size{Cols: 10, Rows: 5}}
79 if got != want {79 if got != want {
80 t.Fatalf("emitted size = %+v, want %+v", got, want)80 t.Fatalf("emitted size = %+v, want %+v", got, want)
81 }81 }
@@ -91,15 +91,15 @@ func TestResizeLoop_GetSizeErrorSkipsEmission(t *testing.T) {
91 91
92 func TestResizeLoop_QuitClosesOutImmediately(t *testing.T) {92 func TestResizeLoop_QuitClosesOutImmediately(t *testing.T) {
93 sig := make(chan os.Signal)93 sig := make(chan os.Signal)
94- out := make(chan domain.Size)94+ out := make(chan domain.Geometry)
95 quit := make(chan struct{})95 quit := make(chan struct{})
96 96
97- getSize := func() (domain.Size, error) {97+ getGeometry := func() (domain.Geometry, error) {
98- t.Fatalf("getSize should not be called")98+ t.Fatalf("getGeometry should not be called")
99- return domain.Size{}, nil99+ return domain.Geometry{}, nil
100 }100 }
101 101
102- go resizeLoop(sig, out, quit, getSize)102+ go resizeLoop(sig, out, quit, getGeometry)
103 close(quit)103 close(quit)
104 104
105 select {105 select {
M internal/adapters/term/terminal.go +14 -9
@@ -73,7 +73,7 @@ type Terminal struct {
7373 // (ResizeEvents) and watcher stop (restore) are strictly ordered:
7474 // a restore that runs before the first ResizeEvents call must
7575 // prevent any watcher (and its signal.Notify) from ever starting.
76- resizeCh chan domain.Size
76+ resizeCh chan domain.Geometry
7777 resizeQuit chan struct{}
7878 sigCh chan os.Signal
7979 resizeDone bool // set by stopResizeLocked; no watcher may start afterwards
@@ -206,13 +206,18 @@ func (t *Terminal) restoreRawLocked() error {
206206 return err
207207 }
208208
209-// Size returns the current terminal dimensions.
210-func (t *Terminal) Size() (domain.Size, error) {
211- cols, rows, err := rawterm.GetSize(t.fd)
209+// Geometry returns the current terminal cell dimensions and optional pixel
210+// dimensions reported by the controlling terminal.
211+func (t *Terminal) Geometry() (domain.Geometry, error) {
212+ ws, err := rawterm.GetWinsize(t.fd)
212213 if err != nil {
213- return domain.Size{}, fmt.Errorf("term: get size: %w", err)
214+ return domain.Geometry{}, fmt.Errorf("term: get size: %w", err)
214215 }
215- return domain.Size{Cols: cols, Rows: rows}, nil
216+ return (domain.Geometry{
217+ Size: domain.Size{Cols: int(ws.Col), Rows: int(ws.Row)},
218+ PixelWidth: int(ws.Xpixel),
219+ PixelHeight: int(ws.Ypixel),
220+ }).NormalizePixels(), nil
216221 }
217222
218223 // ResizeEvents returns a channel of coalesced terminal sizes, one per
@@ -221,14 +226,14 @@ func (t *Terminal) Size() (domain.Size, error) {
221226 // restore (returned by EnterRaw) runs. If restore has already run when
222227 // ResizeEvents is first called, no watcher is started and the returned
223228 // channel is already closed.
224-func (t *Terminal) ResizeEvents() <-chan domain.Size {
229+func (t *Terminal) ResizeEvents() <-chan domain.Geometry {
225230 t.mu.Lock()
226231 defer t.mu.Unlock()
227232
228233 if t.resizeCh != nil {
229234 return t.resizeCh
230235 }
231- t.resizeCh = make(chan domain.Size)
236+ t.resizeCh = make(chan domain.Geometry)
232237
233238 if t.resizeDone {
234239 // restore ran before the first ResizeEvents call: never start a
@@ -244,7 +249,7 @@ func (t *Terminal) ResizeEvents() <-chan domain.Size {
244249
245250 t.resizeWG.Go(func() {
246251 defer signal.Stop(t.sigCh)
247- resizeLoop(t.sigCh, t.resizeCh, t.resizeQuit, t.Size)
252+ resizeLoop(t.sigCh, t.resizeCh, t.resizeQuit, t.Geometry)
248253 })
249254
250255 return t.resizeCh
@@ -73,7 +73,7 @@ type Terminal struct {
73 // (ResizeEvents) and watcher stop (restore) are strictly ordered:73 // (ResizeEvents) and watcher stop (restore) are strictly ordered:
74 // a restore that runs before the first ResizeEvents call must74 // a restore that runs before the first ResizeEvents call must
75 // prevent any watcher (and its signal.Notify) from ever starting.75 // prevent any watcher (and its signal.Notify) from ever starting.
76- resizeCh chan domain.Size76+ resizeCh chan domain.Geometry
77 resizeQuit chan struct{}77 resizeQuit chan struct{}
78 sigCh chan os.Signal78 sigCh chan os.Signal
79 resizeDone bool // set by stopResizeLocked; no watcher may start afterwards79 resizeDone bool // set by stopResizeLocked; no watcher may start afterwards
@@ -206,13 +206,18 @@ func (t *Terminal) restoreRawLocked() error {
206 return err206 return err
207 }207 }
208 208
209-// Size returns the current terminal dimensions.209+// Geometry returns the current terminal cell dimensions and optional pixel
210-func (t *Terminal) Size() (domain.Size, error) {210+// dimensions reported by the controlling terminal.
211- cols, rows, err := rawterm.GetSize(t.fd)211+func (t *Terminal) Geometry() (domain.Geometry, error) {
212+ ws, err := rawterm.GetWinsize(t.fd)
212 if err != nil {213 if err != nil {
213- return domain.Size{}, fmt.Errorf("term: get size: %w", err)214+ return domain.Geometry{}, fmt.Errorf("term: get size: %w", err)
214 }215 }
215- return domain.Size{Cols: cols, Rows: rows}, nil216+ return (domain.Geometry{
217+ Size: domain.Size{Cols: int(ws.Col), Rows: int(ws.Row)},
218+ PixelWidth: int(ws.Xpixel),
219+ PixelHeight: int(ws.Ypixel),
220+ }).NormalizePixels(), nil
216 }221 }
217 222
218 // ResizeEvents returns a channel of coalesced terminal sizes, one per223 // ResizeEvents returns a channel of coalesced terminal sizes, one per
@@ -221,14 +226,14 @@ func (t *Terminal) Size() (domain.Size, error) {
221 // restore (returned by EnterRaw) runs. If restore has already run when226 // restore (returned by EnterRaw) runs. If restore has already run when
222 // ResizeEvents is first called, no watcher is started and the returned227 // ResizeEvents is first called, no watcher is started and the returned
223 // channel is already closed.228 // channel is already closed.
224-func (t *Terminal) ResizeEvents() <-chan domain.Size {229+func (t *Terminal) ResizeEvents() <-chan domain.Geometry {
225 t.mu.Lock()230 t.mu.Lock()
226 defer t.mu.Unlock()231 defer t.mu.Unlock()
227 232
228 if t.resizeCh != nil {233 if t.resizeCh != nil {
229 return t.resizeCh234 return t.resizeCh
230 }235 }
231- t.resizeCh = make(chan domain.Size)236+ t.resizeCh = make(chan domain.Geometry)
232 237
233 if t.resizeDone {238 if t.resizeDone {
234 // restore ran before the first ResizeEvents call: never start a239 // restore ran before the first ResizeEvents call: never start a
@@ -244,7 +249,7 @@ func (t *Terminal) ResizeEvents() <-chan domain.Size {
244 249
245 t.resizeWG.Go(func() {250 t.resizeWG.Go(func() {
246 defer signal.Stop(t.sigCh)251 defer signal.Stop(t.sigCh)
247- resizeLoop(t.sigCh, t.resizeCh, t.resizeQuit, t.Size)252+ resizeLoop(t.sigCh, t.resizeCh, t.resizeQuit, t.Geometry)
248 })253 })
249 254
250 return t.resizeCh255 return t.resizeCh
M internal/adapters/term/terminal_test.go +3 -3
@@ -433,7 +433,7 @@ func TestTerminal_ResizeEvents_ConcurrentWithRestore(t *testing.T) {
433433 }
434434
435435 start := make(chan struct{})
436- chch := make(chan (<-chan domain.Size), 1)
436+ chch := make(chan (<-chan domain.Geometry), 1)
437437 go func() {
438438 <-start
439439 chch <- tm.ResizeEvents()
@@ -486,7 +486,7 @@ func TestTerminal_Size_NonTTY_ReturnsError(t *testing.T) {
486486
487487 tm := NewWithFiles(inR, outW)
488488
489- if _, err := tm.Size(); err == nil {
490- t.Fatalf("expected Size() to error on a non-tty fd")
489+ if _, err := tm.Geometry(); err == nil {
490+ t.Fatalf("expected Geometry() to error on a non-tty fd")
491491 }
492492 }
@@ -433,7 +433,7 @@ func TestTerminal_ResizeEvents_ConcurrentWithRestore(t *testing.T) {
433 }433 }
434 434
435 start := make(chan struct{})435 start := make(chan struct{})
436- chch := make(chan (<-chan domain.Size), 1)436+ chch := make(chan (<-chan domain.Geometry), 1)
437 go func() {437 go func() {
438 <-start438 <-start
439 chch <- tm.ResizeEvents()439 chch <- tm.ResizeEvents()
@@ -486,7 +486,7 @@ func TestTerminal_Size_NonTTY_ReturnsError(t *testing.T) {
486 486
487 tm := NewWithFiles(inR, outW)487 tm := NewWithFiles(inR, outW)
488 488
489- if _, err := tm.Size(); err == nil {489+ if _, err := tm.Geometry(); err == nil {
490- t.Fatalf("expected Size() to error on a non-tty fd")490+ t.Fatalf("expected Geometry() to error on a non-tty fd")
491 }491 }
492 }492 }
M internal/app/remote_final_acceptance_test.go +6 -6
@@ -104,13 +104,13 @@ type acceptanceRemoteTerminal struct {
104104 func (t *acceptanceRemoteTerminal) EnterRaw() (func() error, error) {
105105 return func() error { return nil }, nil
106106 }
107-func (*acceptanceRemoteTerminal) Size() (domain.Size, error) {
108- return domain.Size{Cols: 80, Rows: 24}, nil
107+func (*acceptanceRemoteTerminal) Geometry() (domain.Geometry, error) {
108+ return domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil
109109 }
110-func (*acceptanceRemoteTerminal) ResizeEvents() <-chan domain.Size { return nil }
111-func (t *acceptanceRemoteTerminal) In() io.Reader { return t.in }
112-func (t *acceptanceRemoteTerminal) Out() io.Writer { return &t.out }
113-func (*acceptanceRemoteTerminal) Flush() error { return nil }
110+func (*acceptanceRemoteTerminal) ResizeEvents() <-chan domain.Geometry { return nil }
111+func (t *acceptanceRemoteTerminal) In() io.Reader { return t.in }
112+func (t *acceptanceRemoteTerminal) Out() io.Writer { return &t.out }
113+func (*acceptanceRemoteTerminal) Flush() error { return nil }
114114
115115 func TestAcceptanceRemoteDirectAndPickerUseOnlyRemoteTransports(t *testing.T) {
116116 outputs := make(chan string, 4)
@@ -104,13 +104,13 @@ type acceptanceRemoteTerminal struct {
104 func (t *acceptanceRemoteTerminal) EnterRaw() (func() error, error) {104 func (t *acceptanceRemoteTerminal) EnterRaw() (func() error, error) {
105 return func() error { return nil }, nil105 return func() error { return nil }, nil
106 }106 }
107-func (*acceptanceRemoteTerminal) Size() (domain.Size, error) {107+func (*acceptanceRemoteTerminal) Geometry() (domain.Geometry, error) {
108- return domain.Size{Cols: 80, Rows: 24}, nil108+ return domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil
109 }109 }
110-func (*acceptanceRemoteTerminal) ResizeEvents() <-chan domain.Size { return nil }110+func (*acceptanceRemoteTerminal) ResizeEvents() <-chan domain.Geometry { return nil }
111-func (t *acceptanceRemoteTerminal) In() io.Reader { return t.in }111+func (t *acceptanceRemoteTerminal) In() io.Reader { return t.in }
112-func (t *acceptanceRemoteTerminal) Out() io.Writer { return &t.out }112+func (t *acceptanceRemoteTerminal) Out() io.Writer { return &t.out }
113-func (*acceptanceRemoteTerminal) Flush() error { return nil }113+func (*acceptanceRemoteTerminal) Flush() error { return nil }
114 114
115 func TestAcceptanceRemoteDirectAndPickerUseOnlyRemoteTransports(t *testing.T) {115 func TestAcceptanceRemoteDirectAndPickerUseOnlyRemoteTransports(t *testing.T) {
116 outputs := make(chan string, 4)116 outputs := make(chan string, 4)
M internal/domain/types.go +29 -0
@@ -84,6 +84,35 @@ func (sz Size) Valid() bool {
8484 return sz.Cols > 0 && sz.Rows > 0
8585 }
8686
87+// Geometry combines a terminal's required cell dimensions with optional pixel
88+// dimensions reported by the controlling terminal. Zero pixel dimensions mean
89+// the terminal did not report them; they must not be inferred from cells.
90+type Geometry struct {
91+ Size
92+ PixelWidth, PixelHeight int
93+}
94+
95+// Valid reports whether the cell dimensions can describe a terminal screen.
96+func (g Geometry) Valid() bool {
97+ return g.Size.Valid()
98+}
99+
100+// PixelsKnown reports whether both pixel dimensions are available.
101+func (g Geometry) PixelsKnown() bool {
102+ return g.PixelWidth > 0 && g.PixelHeight > 0
103+}
104+
105+// NormalizePixels clears partial or negative pixel dimensions. Pixel geometry
106+// is optional as a pair; retaining only one axis would give downstream PTYs and
107+// terminal queries a geometry the controlling terminal never reported.
108+func (g Geometry) NormalizePixels() Geometry {
109+ if !g.PixelsKnown() {
110+ g.PixelWidth = 0
111+ g.PixelHeight = 0
112+ }
113+ return g
114+}
115+
87116 // Rect is an axis-aligned rectangle in cell coordinates.
88117 type Rect struct {
89118 X, Y, Width, Height int
@@ -84,6 +84,35 @@ func (sz Size) Valid() bool {
84 return sz.Cols > 0 && sz.Rows > 084 return sz.Cols > 0 && sz.Rows > 0
85 }85 }
86 86
87+// Geometry combines a terminal's required cell dimensions with optional pixel
88+// dimensions reported by the controlling terminal. Zero pixel dimensions mean
89+// the terminal did not report them; they must not be inferred from cells.
90+type Geometry struct {
91+ Size
92+ PixelWidth, PixelHeight int
93+}
94+
95+// Valid reports whether the cell dimensions can describe a terminal screen.
96+func (g Geometry) Valid() bool {
97+ return g.Size.Valid()
98+}
99+
100+// PixelsKnown reports whether both pixel dimensions are available.
101+func (g Geometry) PixelsKnown() bool {
102+ return g.PixelWidth > 0 && g.PixelHeight > 0
103+}
104+
105+// NormalizePixels clears partial or negative pixel dimensions. Pixel geometry
106+// is optional as a pair; retaining only one axis would give downstream PTYs and
107+// terminal queries a geometry the controlling terminal never reported.
108+func (g Geometry) NormalizePixels() Geometry {
109+ if !g.PixelsKnown() {
110+ g.PixelWidth = 0
111+ g.PixelHeight = 0
112+ }
113+ return g
114+}
115+
87 // Rect is an axis-aligned rectangle in cell coordinates.116 // Rect is an axis-aligned rectangle in cell coordinates.
88 type Rect struct {117 type Rect struct {
89 X, Y, Width, Height int118 X, Y, Width, Height int
M internal/ports/frame.go +1 -1
@@ -12,7 +12,7 @@ import (
1212 const HandshakeTimeout = 15 * time.Second
1313
1414 // ProtocolVersion is the current vev IPC wire protocol version.
15-const ProtocolVersion uint16 = 34
15+const ProtocolVersion uint16 = 35
1616
1717 // MaxFrameLen is the largest permitted frame length, including the type byte
1818 // and excluding the four-byte length prefix.
@@ -12,7 +12,7 @@ import (
12 const HandshakeTimeout = 15 * time.Second12 const HandshakeTimeout = 15 * time.Second
13 13
14 // ProtocolVersion is the current vev IPC wire protocol version.14 // ProtocolVersion is the current vev IPC wire protocol version.
15-const ProtocolVersion uint16 = 3415+const ProtocolVersion uint16 = 35
16 16
17 // MaxFrameLen is the largest permitted frame length, including the type byte17 // MaxFrameLen is the largest permitted frame length, including the type byte
18 // and excluding the four-byte length prefix.18 // and excluding the four-byte length prefix.
M internal/ports/frame_test.go +2 -2
@@ -3,8 +3,8 @@ package ports
33 import "testing"
44
55 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 34 {
7- t.Fatalf("ProtocolVersion = %d, want 34", ProtocolVersion)
6+ if ProtocolVersion != 35 {
7+ t.Fatalf("ProtocolVersion = %d, want 35", ProtocolVersion)
88 }
99 }
1010
@@ -3,8 +3,8 @@ package ports
3 import "testing"3 import "testing"
4 4
5 func TestProtocolVersion(t *testing.T) {5 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 34 {6+ if ProtocolVersion != 35 {
7- t.Fatalf("ProtocolVersion = %d, want 34", ProtocolVersion)7+ t.Fatalf("ProtocolVersion = %d, want 35", ProtocolVersion)
8 }8 }
9 }9 }
10 10
M internal/ports/mocks/pty_mock.go +26 -26
@@ -241,16 +241,16 @@ func (_c *MockPTY_Read_Call) RunAndReturn(run func(p []byte) (int, error)) *Mock
241241 }
242242
243243 // Resize provides a mock function for the type MockPTY
244-func (_mock *MockPTY) Resize(sz domain.Size) error {
245- ret := _mock.Called(sz)
244+func (_mock *MockPTY) Resize(geometry domain.Geometry) error {
245+ ret := _mock.Called(geometry)
246246
247247 if len(ret) == 0 {
248248 panic("no return value specified for Resize")
249249 }
250250
251251 var r0 error
252- if returnFunc, ok := ret.Get(0).(func(domain.Size) error); ok {
253- r0 = returnFunc(sz)
252+ if returnFunc, ok := ret.Get(0).(func(domain.Geometry) error); ok {
253+ r0 = returnFunc(geometry)
254254 } else {
255255 r0 = ret.Error(0)
256256 }
@@ -263,16 +263,16 @@ type MockPTY_Resize_Call struct {
263263 }
264264
265265 // Resize is a helper method to define mock.On call
266-// - sz domain.Size
267-func (_e *MockPTY_Expecter) Resize(sz any) *MockPTY_Resize_Call {
268- return &MockPTY_Resize_Call{Call: _e.mock.On("Resize", sz)}
266+// - geometry domain.Geometry
267+func (_e *MockPTY_Expecter) Resize(geometry any) *MockPTY_Resize_Call {
268+ return &MockPTY_Resize_Call{Call: _e.mock.On("Resize", geometry)}
269269 }
270270
271-func (_c *MockPTY_Resize_Call) Run(run func(sz domain.Size)) *MockPTY_Resize_Call {
271+func (_c *MockPTY_Resize_Call) Run(run func(geometry domain.Geometry)) *MockPTY_Resize_Call {
272272 _c.Call.Run(func(args mock.Arguments) {
273- var arg0 domain.Size
273+ var arg0 domain.Geometry
274274 if args[0] != nil {
275- arg0 = args[0].(domain.Size)
275+ arg0 = args[0].(domain.Geometry)
276276 }
277277 run(
278278 arg0,
@@ -286,7 +286,7 @@ func (_c *MockPTY_Resize_Call) Return(err error) *MockPTY_Resize_Call {
286286 return _c
287287 }
288288
289-func (_c *MockPTY_Resize_Call) RunAndReturn(run func(sz domain.Size) error) *MockPTY_Resize_Call {
289+func (_c *MockPTY_Resize_Call) RunAndReturn(run func(geometry domain.Geometry) error) *MockPTY_Resize_Call {
290290 _c.Call.Return(run)
291291 return _c
292292 }
@@ -379,8 +379,8 @@ func (_m *MockPTYFactory) EXPECT() *MockPTYFactory_Expecter {
379379 }
380380
381381 // Open provides a mock function for the type MockPTYFactory
382-func (_mock *MockPTYFactory) Open(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
383- ret := _mock.Called(ctx, cmd, args, env, dir, sz)
382+func (_mock *MockPTYFactory) Open(ctx context.Context, cmd string, args []string, env []string, dir string, geometry domain.Geometry) (ports.PTY, error) {
383+ ret := _mock.Called(ctx, cmd, args, env, dir, geometry)
384384
385385 if len(ret) == 0 {
386386 panic("no return value specified for Open")
@@ -388,18 +388,18 @@ func (_mock *MockPTYFactory) Open(ctx context.Context, cmd string, args []string
388388
389389 var r0 ports.PTY
390390 var r1 error
391- if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, []string, string, domain.Size) (ports.PTY, error)); ok {
392- return returnFunc(ctx, cmd, args, env, dir, sz)
391+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, []string, string, domain.Geometry) (ports.PTY, error)); ok {
392+ return returnFunc(ctx, cmd, args, env, dir, geometry)
393393 }
394- if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, []string, string, domain.Size) ports.PTY); ok {
395- r0 = returnFunc(ctx, cmd, args, env, dir, sz)
394+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, []string, string, domain.Geometry) ports.PTY); ok {
395+ r0 = returnFunc(ctx, cmd, args, env, dir, geometry)
396396 } else {
397397 if ret.Get(0) != nil {
398398 r0 = ret.Get(0).(ports.PTY)
399399 }
400400 }
401- if returnFunc, ok := ret.Get(1).(func(context.Context, string, []string, []string, string, domain.Size) error); ok {
402- r1 = returnFunc(ctx, cmd, args, env, dir, sz)
401+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, []string, []string, string, domain.Geometry) error); ok {
402+ r1 = returnFunc(ctx, cmd, args, env, dir, geometry)
403403 } else {
404404 r1 = ret.Error(1)
405405 }
@@ -417,12 +417,12 @@ type MockPTYFactory_Open_Call struct {
417417 // - args []string
418418 // - env []string
419419 // - dir string
420-// - sz domain.Size
421-func (_e *MockPTYFactory_Expecter) Open(ctx any, cmd any, args any, env any, dir any, sz any) *MockPTYFactory_Open_Call {
422- return &MockPTYFactory_Open_Call{Call: _e.mock.On("Open", ctx, cmd, args, env, dir, sz)}
420+// - geometry domain.Geometry
421+func (_e *MockPTYFactory_Expecter) Open(ctx any, cmd any, args any, env any, dir any, geometry any) *MockPTYFactory_Open_Call {
422+ return &MockPTYFactory_Open_Call{Call: _e.mock.On("Open", ctx, cmd, args, env, dir, geometry)}
423423 }
424424
425-func (_c *MockPTYFactory_Open_Call) Run(run func(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size)) *MockPTYFactory_Open_Call {
425+func (_c *MockPTYFactory_Open_Call) Run(run func(ctx context.Context, cmd string, args []string, env []string, dir string, geometry domain.Geometry)) *MockPTYFactory_Open_Call {
426426 _c.Call.Run(func(args mock.Arguments) {
427427 var arg0 context.Context
428428 if args[0] != nil {
@@ -444,9 +444,9 @@ func (_c *MockPTYFactory_Open_Call) Run(run func(ctx context.Context, cmd string
444444 if args[4] != nil {
445445 arg4 = args[4].(string)
446446 }
447- var arg5 domain.Size
447+ var arg5 domain.Geometry
448448 if args[5] != nil {
449- arg5 = args[5].(domain.Size)
449+ arg5 = args[5].(domain.Geometry)
450450 }
451451 run(
452452 arg0,
@@ -465,7 +465,7 @@ func (_c *MockPTYFactory_Open_Call) Return(pTY ports.PTY, err error) *MockPTYFac
465465 return _c
466466 }
467467
468-func (_c *MockPTYFactory_Open_Call) RunAndReturn(run func(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error)) *MockPTYFactory_Open_Call {
468+func (_c *MockPTYFactory_Open_Call) RunAndReturn(run func(ctx context.Context, cmd string, args []string, env []string, dir string, geometry domain.Geometry) (ports.PTY, error)) *MockPTYFactory_Open_Call {
469469 _c.Call.Return(run)
470470 return _c
471471 }
@@ -241,16 +241,16 @@ func (_c *MockPTY_Read_Call) RunAndReturn(run func(p []byte) (int, error)) *Mock
241 }241 }
242 242
243 // Resize provides a mock function for the type MockPTY243 // Resize provides a mock function for the type MockPTY
244-func (_mock *MockPTY) Resize(sz domain.Size) error {244+func (_mock *MockPTY) Resize(geometry domain.Geometry) error {
245- ret := _mock.Called(sz)245+ ret := _mock.Called(geometry)
246 246
247 if len(ret) == 0 {247 if len(ret) == 0 {
248 panic("no return value specified for Resize")248 panic("no return value specified for Resize")
249 }249 }
250 250
251 var r0 error251 var r0 error
252- if returnFunc, ok := ret.Get(0).(func(domain.Size) error); ok {252+ if returnFunc, ok := ret.Get(0).(func(domain.Geometry) error); ok {
253- r0 = returnFunc(sz)253+ r0 = returnFunc(geometry)
254 } else {254 } else {
255 r0 = ret.Error(0)255 r0 = ret.Error(0)
256 }256 }
@@ -263,16 +263,16 @@ type MockPTY_Resize_Call struct {
263 }263 }
264 264
265 // Resize is a helper method to define mock.On call265 // Resize is a helper method to define mock.On call
266-// - sz domain.Size266+// - geometry domain.Geometry
267-func (_e *MockPTY_Expecter) Resize(sz any) *MockPTY_Resize_Call {267+func (_e *MockPTY_Expecter) Resize(geometry any) *MockPTY_Resize_Call {
268- return &MockPTY_Resize_Call{Call: _e.mock.On("Resize", sz)}268+ return &MockPTY_Resize_Call{Call: _e.mock.On("Resize", geometry)}
269 }269 }
270 270
271-func (_c *MockPTY_Resize_Call) Run(run func(sz domain.Size)) *MockPTY_Resize_Call {271+func (_c *MockPTY_Resize_Call) Run(run func(geometry domain.Geometry)) *MockPTY_Resize_Call {
272 _c.Call.Run(func(args mock.Arguments) {272 _c.Call.Run(func(args mock.Arguments) {
273- var arg0 domain.Size273+ var arg0 domain.Geometry
274 if args[0] != nil {274 if args[0] != nil {
275- arg0 = args[0].(domain.Size)275+ arg0 = args[0].(domain.Geometry)
276 }276 }
277 run(277 run(
278 arg0,278 arg0,
@@ -286,7 +286,7 @@ func (_c *MockPTY_Resize_Call) Return(err error) *MockPTY_Resize_Call {
286 return _c286 return _c
287 }287 }
288 288
289-func (_c *MockPTY_Resize_Call) RunAndReturn(run func(sz domain.Size) error) *MockPTY_Resize_Call {289+func (_c *MockPTY_Resize_Call) RunAndReturn(run func(geometry domain.Geometry) error) *MockPTY_Resize_Call {
290 _c.Call.Return(run)290 _c.Call.Return(run)
291 return _c291 return _c
292 }292 }
@@ -379,8 +379,8 @@ func (_m *MockPTYFactory) EXPECT() *MockPTYFactory_Expecter {
379 }379 }
380 380
381 // Open provides a mock function for the type MockPTYFactory381 // Open provides a mock function for the type MockPTYFactory
382-func (_mock *MockPTYFactory) Open(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {382+func (_mock *MockPTYFactory) Open(ctx context.Context, cmd string, args []string, env []string, dir string, geometry domain.Geometry) (ports.PTY, error) {
383- ret := _mock.Called(ctx, cmd, args, env, dir, sz)383+ ret := _mock.Called(ctx, cmd, args, env, dir, geometry)
384 384
385 if len(ret) == 0 {385 if len(ret) == 0 {
386 panic("no return value specified for Open")386 panic("no return value specified for Open")
@@ -388,18 +388,18 @@ func (_mock *MockPTYFactory) Open(ctx context.Context, cmd string, args []string
388 388
389 var r0 ports.PTY389 var r0 ports.PTY
390 var r1 error390 var r1 error
391- if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, []string, string, domain.Size) (ports.PTY, error)); ok {391+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, []string, string, domain.Geometry) (ports.PTY, error)); ok {
392- return returnFunc(ctx, cmd, args, env, dir, sz)392+ return returnFunc(ctx, cmd, args, env, dir, geometry)
393 }393 }
394- if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, []string, string, domain.Size) ports.PTY); ok {394+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, []string, string, domain.Geometry) ports.PTY); ok {
395- r0 = returnFunc(ctx, cmd, args, env, dir, sz)395+ r0 = returnFunc(ctx, cmd, args, env, dir, geometry)
396 } else {396 } else {
397 if ret.Get(0) != nil {397 if ret.Get(0) != nil {
398 r0 = ret.Get(0).(ports.PTY)398 r0 = ret.Get(0).(ports.PTY)
399 }399 }
400 }400 }
401- if returnFunc, ok := ret.Get(1).(func(context.Context, string, []string, []string, string, domain.Size) error); ok {401+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, []string, []string, string, domain.Geometry) error); ok {
402- r1 = returnFunc(ctx, cmd, args, env, dir, sz)402+ r1 = returnFunc(ctx, cmd, args, env, dir, geometry)
403 } else {403 } else {
404 r1 = ret.Error(1)404 r1 = ret.Error(1)
405 }405 }
@@ -417,12 +417,12 @@ type MockPTYFactory_Open_Call struct {
417 // - args []string417 // - args []string
418 // - env []string418 // - env []string
419 // - dir string419 // - dir string
420-// - sz domain.Size420+// - geometry domain.Geometry
421-func (_e *MockPTYFactory_Expecter) Open(ctx any, cmd any, args any, env any, dir any, sz any) *MockPTYFactory_Open_Call {421+func (_e *MockPTYFactory_Expecter) Open(ctx any, cmd any, args any, env any, dir any, geometry any) *MockPTYFactory_Open_Call {
422- return &MockPTYFactory_Open_Call{Call: _e.mock.On("Open", ctx, cmd, args, env, dir, sz)}422+ return &MockPTYFactory_Open_Call{Call: _e.mock.On("Open", ctx, cmd, args, env, dir, geometry)}
423 }423 }
424 424
425-func (_c *MockPTYFactory_Open_Call) Run(run func(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size)) *MockPTYFactory_Open_Call {425+func (_c *MockPTYFactory_Open_Call) Run(run func(ctx context.Context, cmd string, args []string, env []string, dir string, geometry domain.Geometry)) *MockPTYFactory_Open_Call {
426 _c.Call.Run(func(args mock.Arguments) {426 _c.Call.Run(func(args mock.Arguments) {
427 var arg0 context.Context427 var arg0 context.Context
428 if args[0] != nil {428 if args[0] != nil {
@@ -444,9 +444,9 @@ func (_c *MockPTYFactory_Open_Call) Run(run func(ctx context.Context, cmd string
444 if args[4] != nil {444 if args[4] != nil {
445 arg4 = args[4].(string)445 arg4 = args[4].(string)
446 }446 }
447- var arg5 domain.Size447+ var arg5 domain.Geometry
448 if args[5] != nil {448 if args[5] != nil {
449- arg5 = args[5].(domain.Size)449+ arg5 = args[5].(domain.Geometry)
450 }450 }
451 run(451 run(
452 arg0,452 arg0,
@@ -465,7 +465,7 @@ func (_c *MockPTYFactory_Open_Call) Return(pTY ports.PTY, err error) *MockPTYFac
465 return _c465 return _c
466 }466 }
467 467
468-func (_c *MockPTYFactory_Open_Call) RunAndReturn(run func(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error)) *MockPTYFactory_Open_Call {468+func (_c *MockPTYFactory_Open_Call) RunAndReturn(run func(ctx context.Context, cmd string, args []string, env []string, dir string, geometry domain.Geometry) (ports.PTY, error)) *MockPTYFactory_Open_Call {
469 _c.Call.Return(run)469 _c.Call.Return(run)
470 return _c470 return _c
471 }471 }
M internal/ports/mocks/terminal_mock.go +60 -60
@@ -137,6 +137,59 @@ func (_c *MockTerminal_Flush_Call) RunAndReturn(run func() error) *MockTerminal_
137137 return _c
138138 }
139139
140+// Geometry provides a mock function for the type MockTerminal
141+func (_mock *MockTerminal) Geometry() (domain.Geometry, error) {
142+ ret := _mock.Called()
143+
144+ if len(ret) == 0 {
145+ panic("no return value specified for Geometry")
146+ }
147+
148+ var r0 domain.Geometry
149+ var r1 error
150+ if returnFunc, ok := ret.Get(0).(func() (domain.Geometry, error)); ok {
151+ return returnFunc()
152+ }
153+ if returnFunc, ok := ret.Get(0).(func() domain.Geometry); ok {
154+ r0 = returnFunc()
155+ } else {
156+ r0 = ret.Get(0).(domain.Geometry)
157+ }
158+ if returnFunc, ok := ret.Get(1).(func() error); ok {
159+ r1 = returnFunc()
160+ } else {
161+ r1 = ret.Error(1)
162+ }
163+ return r0, r1
164+}
165+
166+// MockTerminal_Geometry_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Geometry'
167+type MockTerminal_Geometry_Call struct {
168+ *mock.Call
169+}
170+
171+// Geometry is a helper method to define mock.On call
172+func (_e *MockTerminal_Expecter) Geometry() *MockTerminal_Geometry_Call {
173+ return &MockTerminal_Geometry_Call{Call: _e.mock.On("Geometry")}
174+}
175+
176+func (_c *MockTerminal_Geometry_Call) Run(run func()) *MockTerminal_Geometry_Call {
177+ _c.Call.Run(func(args mock.Arguments) {
178+ run()
179+ })
180+ return _c
181+}
182+
183+func (_c *MockTerminal_Geometry_Call) Return(geometry domain.Geometry, err error) *MockTerminal_Geometry_Call {
184+ _c.Call.Return(geometry, err)
185+ return _c
186+}
187+
188+func (_c *MockTerminal_Geometry_Call) RunAndReturn(run func() (domain.Geometry, error)) *MockTerminal_Geometry_Call {
189+ _c.Call.Return(run)
190+ return _c
191+}
192+
140193 // In provides a mock function for the type MockTerminal
141194 func (_mock *MockTerminal) In() io.Reader {
142195 ret := _mock.Called()
@@ -230,19 +283,19 @@ func (_c *MockTerminal_Out_Call) RunAndReturn(run func() io.Writer) *MockTermina
230283 }
231284
232285 // ResizeEvents provides a mock function for the type MockTerminal
233-func (_mock *MockTerminal) ResizeEvents() <-chan domain.Size {
286+func (_mock *MockTerminal) ResizeEvents() <-chan domain.Geometry {
234287 ret := _mock.Called()
235288
236289 if len(ret) == 0 {
237290 panic("no return value specified for ResizeEvents")
238291 }
239292
240- var r0 <-chan domain.Size
241- if returnFunc, ok := ret.Get(0).(func() <-chan domain.Size); ok {
293+ var r0 <-chan domain.Geometry
294+ if returnFunc, ok := ret.Get(0).(func() <-chan domain.Geometry); ok {
242295 r0 = returnFunc()
243296 } else {
244297 if ret.Get(0) != nil {
245- r0 = ret.Get(0).(<-chan domain.Size)
298+ r0 = ret.Get(0).(<-chan domain.Geometry)
246299 }
247300 }
248301 return r0
@@ -265,65 +318,12 @@ func (_c *MockTerminal_ResizeEvents_Call) Run(run func()) *MockTerminal_ResizeEv
265318 return _c
266319 }
267320
268-func (_c *MockTerminal_ResizeEvents_Call) Return(sizeCh <-chan domain.Size) *MockTerminal_ResizeEvents_Call {
269- _c.Call.Return(sizeCh)
270- return _c
271-}
272-
273-func (_c *MockTerminal_ResizeEvents_Call) RunAndReturn(run func() <-chan domain.Size) *MockTerminal_ResizeEvents_Call {
274- _c.Call.Return(run)
275- return _c
276-}
277-
278-// Size provides a mock function for the type MockTerminal
279-func (_mock *MockTerminal) Size() (domain.Size, error) {
280- ret := _mock.Called()
281-
282- if len(ret) == 0 {
283- panic("no return value specified for Size")
284- }
285-
286- var r0 domain.Size
287- var r1 error
288- if returnFunc, ok := ret.Get(0).(func() (domain.Size, error)); ok {
289- return returnFunc()
290- }
291- if returnFunc, ok := ret.Get(0).(func() domain.Size); ok {
292- r0 = returnFunc()
293- } else {
294- r0 = ret.Get(0).(domain.Size)
295- }
296- if returnFunc, ok := ret.Get(1).(func() error); ok {
297- r1 = returnFunc()
298- } else {
299- r1 = ret.Error(1)
300- }
301- return r0, r1
302-}
303-
304-// MockTerminal_Size_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Size'
305-type MockTerminal_Size_Call struct {
306- *mock.Call
307-}
308-
309-// Size is a helper method to define mock.On call
310-func (_e *MockTerminal_Expecter) Size() *MockTerminal_Size_Call {
311- return &MockTerminal_Size_Call{Call: _e.mock.On("Size")}
312-}
313-
314-func (_c *MockTerminal_Size_Call) Run(run func()) *MockTerminal_Size_Call {
315- _c.Call.Run(func(args mock.Arguments) {
316- run()
317- })
318- return _c
319-}
320-
321-func (_c *MockTerminal_Size_Call) Return(size domain.Size, err error) *MockTerminal_Size_Call {
322- _c.Call.Return(size, err)
321+func (_c *MockTerminal_ResizeEvents_Call) Return(geometryCh <-chan domain.Geometry) *MockTerminal_ResizeEvents_Call {
322+ _c.Call.Return(geometryCh)
323323 return _c
324324 }
325325
326-func (_c *MockTerminal_Size_Call) RunAndReturn(run func() (domain.Size, error)) *MockTerminal_Size_Call {
326+func (_c *MockTerminal_ResizeEvents_Call) RunAndReturn(run func() <-chan domain.Geometry) *MockTerminal_ResizeEvents_Call {
327327 _c.Call.Return(run)
328328 return _c
329329 }
@@ -137,6 +137,59 @@ func (_c *MockTerminal_Flush_Call) RunAndReturn(run func() error) *MockTerminal_
137 return _c137 return _c
138 }138 }
139 139
140+// Geometry provides a mock function for the type MockTerminal
141+func (_mock *MockTerminal) Geometry() (domain.Geometry, error) {
142+ ret := _mock.Called()
143+
144+ if len(ret) == 0 {
145+ panic("no return value specified for Geometry")
146+ }
147+
148+ var r0 domain.Geometry
149+ var r1 error
150+ if returnFunc, ok := ret.Get(0).(func() (domain.Geometry, error)); ok {
151+ return returnFunc()
152+ }
153+ if returnFunc, ok := ret.Get(0).(func() domain.Geometry); ok {
154+ r0 = returnFunc()
155+ } else {
156+ r0 = ret.Get(0).(domain.Geometry)
157+ }
158+ if returnFunc, ok := ret.Get(1).(func() error); ok {
159+ r1 = returnFunc()
160+ } else {
161+ r1 = ret.Error(1)
162+ }
163+ return r0, r1
164+}
165+
166+// MockTerminal_Geometry_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Geometry'
167+type MockTerminal_Geometry_Call struct {
168+ *mock.Call
169+}
170+
171+// Geometry is a helper method to define mock.On call
172+func (_e *MockTerminal_Expecter) Geometry() *MockTerminal_Geometry_Call {
173+ return &MockTerminal_Geometry_Call{Call: _e.mock.On("Geometry")}
174+}
175+
176+func (_c *MockTerminal_Geometry_Call) Run(run func()) *MockTerminal_Geometry_Call {
177+ _c.Call.Run(func(args mock.Arguments) {
178+ run()
179+ })
180+ return _c
181+}
182+
183+func (_c *MockTerminal_Geometry_Call) Return(geometry domain.Geometry, err error) *MockTerminal_Geometry_Call {
184+ _c.Call.Return(geometry, err)
185+ return _c
186+}
187+
188+func (_c *MockTerminal_Geometry_Call) RunAndReturn(run func() (domain.Geometry, error)) *MockTerminal_Geometry_Call {
189+ _c.Call.Return(run)
190+ return _c
191+}
192+
140 // In provides a mock function for the type MockTerminal193 // In provides a mock function for the type MockTerminal
141 func (_mock *MockTerminal) In() io.Reader {194 func (_mock *MockTerminal) In() io.Reader {
142 ret := _mock.Called()195 ret := _mock.Called()
@@ -230,19 +283,19 @@ func (_c *MockTerminal_Out_Call) RunAndReturn(run func() io.Writer) *MockTermina
230 }283 }
231 284
232 // ResizeEvents provides a mock function for the type MockTerminal285 // ResizeEvents provides a mock function for the type MockTerminal
233-func (_mock *MockTerminal) ResizeEvents() <-chan domain.Size {286+func (_mock *MockTerminal) ResizeEvents() <-chan domain.Geometry {
234 ret := _mock.Called()287 ret := _mock.Called()
235 288
236 if len(ret) == 0 {289 if len(ret) == 0 {
237 panic("no return value specified for ResizeEvents")290 panic("no return value specified for ResizeEvents")
238 }291 }
239 292
240- var r0 <-chan domain.Size293+ var r0 <-chan domain.Geometry
241- if returnFunc, ok := ret.Get(0).(func() <-chan domain.Size); ok {294+ if returnFunc, ok := ret.Get(0).(func() <-chan domain.Geometry); ok {
242 r0 = returnFunc()295 r0 = returnFunc()
243 } else {296 } else {
244 if ret.Get(0) != nil {297 if ret.Get(0) != nil {
245- r0 = ret.Get(0).(<-chan domain.Size)298+ r0 = ret.Get(0).(<-chan domain.Geometry)
246 }299 }
247 }300 }
248 return r0301 return r0
@@ -265,65 +318,12 @@ func (_c *MockTerminal_ResizeEvents_Call) Run(run func()) *MockTerminal_ResizeEv
265 return _c318 return _c
266 }319 }
267 320
268-func (_c *MockTerminal_ResizeEvents_Call) Return(sizeCh <-chan domain.Size) *MockTerminal_ResizeEvents_Call {321+func (_c *MockTerminal_ResizeEvents_Call) Return(geometryCh <-chan domain.Geometry) *MockTerminal_ResizeEvents_Call {
269- _c.Call.Return(sizeCh)322+ _c.Call.Return(geometryCh)
270- return _c
271-}
272-
273-func (_c *MockTerminal_ResizeEvents_Call) RunAndReturn(run func() <-chan domain.Size) *MockTerminal_ResizeEvents_Call {
274- _c.Call.Return(run)
275- return _c
276-}
277-
278-// Size provides a mock function for the type MockTerminal
279-func (_mock *MockTerminal) Size() (domain.Size, error) {
280- ret := _mock.Called()
281-
282- if len(ret) == 0 {
283- panic("no return value specified for Size")
284- }
285-
286- var r0 domain.Size
287- var r1 error
288- if returnFunc, ok := ret.Get(0).(func() (domain.Size, error)); ok {
289- return returnFunc()
290- }
291- if returnFunc, ok := ret.Get(0).(func() domain.Size); ok {
292- r0 = returnFunc()
293- } else {
294- r0 = ret.Get(0).(domain.Size)
295- }
296- if returnFunc, ok := ret.Get(1).(func() error); ok {
297- r1 = returnFunc()
298- } else {
299- r1 = ret.Error(1)
300- }
301- return r0, r1
302-}
303-
304-// MockTerminal_Size_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Size'
305-type MockTerminal_Size_Call struct {
306- *mock.Call
307-}
308-
309-// Size is a helper method to define mock.On call
310-func (_e *MockTerminal_Expecter) Size() *MockTerminal_Size_Call {
311- return &MockTerminal_Size_Call{Call: _e.mock.On("Size")}
312-}
313-
314-func (_c *MockTerminal_Size_Call) Run(run func()) *MockTerminal_Size_Call {
315- _c.Call.Run(func(args mock.Arguments) {
316- run()
317- })
318- return _c
319-}
320-
321-func (_c *MockTerminal_Size_Call) Return(size domain.Size, err error) *MockTerminal_Size_Call {
322- _c.Call.Return(size, err)
323 return _c323 return _c
324 }324 }
325 325
326-func (_c *MockTerminal_Size_Call) RunAndReturn(run func() (domain.Size, error)) *MockTerminal_Size_Call {326+func (_c *MockTerminal_ResizeEvents_Call) RunAndReturn(run func() <-chan domain.Geometry) *MockTerminal_ResizeEvents_Call {
327 _c.Call.Return(run)327 _c.Call.Return(run)
328 return _c328 return _c
329 }329 }
M internal/ports/navigation_test.go +1 -1
@@ -152,7 +152,7 @@ func TestHelloNavigationValidationTable(t *testing.T) {
152152 {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false},
153153 {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
154154 {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
155- {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "002202" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0201"},
155+ {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "002302" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0201"},
156156 {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false},
157157 {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},
158158 {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false},
@@ -152,7 +152,7 @@ func TestHelloNavigationValidationTable(t *testing.T) {
152 {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false},152 {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false},
153 {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},153 {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
154 {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},154 {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
155- {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "002202" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0201"},155+ {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "002302" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0201"},
156 {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false},156 {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false},
157 {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},157 {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},
158 {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false},158 {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false},
M internal/ports/pty.go +4 -2
@@ -10,7 +10,7 @@ import (
1010 // PTY is a running pseudo-terminal-backed child process.
1111 type PTY interface {
1212 io.ReadWriteCloser // Read = child output, Write = child input
13- Resize(sz domain.Size) error
13+ Resize(geometry domain.Geometry) error
1414 Pid() int
1515 ForegroundPgid() (int, error)
1616 }
@@ -19,5 +19,7 @@ type PTY interface {
1919 type PTYFactory interface {
2020 // Open creates a PTY while honoring ctx cancellation. Implementations must
2121 // return promptly once ctx is cancelled and must not leave a child running.
22- Open(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size) (PTY, error)
22+ // geometry is applied before the child starts, including optional pixel
23+ // dimensions when they are known.
24+ Open(ctx context.Context, cmd string, args []string, env []string, dir string, geometry domain.Geometry) (PTY, error)
2325 }
@@ -10,7 +10,7 @@ import (
10 // PTY is a running pseudo-terminal-backed child process.10 // PTY is a running pseudo-terminal-backed child process.
11 type PTY interface {11 type PTY interface {
12 io.ReadWriteCloser // Read = child output, Write = child input12 io.ReadWriteCloser // Read = child output, Write = child input
13- Resize(sz domain.Size) error13+ Resize(geometry domain.Geometry) error
14 Pid() int14 Pid() int
15 ForegroundPgid() (int, error)15 ForegroundPgid() (int, error)
16 }16 }
@@ -19,5 +19,7 @@ type PTY interface {
19 type PTYFactory interface {19 type PTYFactory interface {
20 // Open creates a PTY while honoring ctx cancellation. Implementations must20 // Open creates a PTY while honoring ctx cancellation. Implementations must
21 // return promptly once ctx is cancelled and must not leave a child running.21 // return promptly once ctx is cancelled and must not leave a child running.
22- Open(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size) (PTY, error)22+ // geometry is applied before the child starts, including optional pixel
23+ // dimensions when they are known.
24+ Open(ctx context.Context, cmd string, args []string, env []string, dir string, geometry domain.Geometry) (PTY, error)
23 }25 }
M internal/ports/terminal.go +2 -2
@@ -10,8 +10,8 @@ import (
1010 // Terminal is the CLIENT-side controlling terminal.
1111 type Terminal interface {
1212 EnterRaw() (restore func() error, err error)
13- Size() (domain.Size, error)
14- ResizeEvents() <-chan domain.Size
13+ Geometry() (domain.Geometry, error)
14+ ResizeEvents() <-chan domain.Geometry
1515 In() io.Reader
1616 Out() io.Writer
1717 Flush() error
@@ -10,8 +10,8 @@ import (
10 // Terminal is the CLIENT-side controlling terminal.10 // Terminal is the CLIENT-side controlling terminal.
11 type Terminal interface {11 type Terminal interface {
12 EnterRaw() (restore func() error, err error)12 EnterRaw() (restore func() error, err error)
13- Size() (domain.Size, error)13+ Geometry() (domain.Geometry, error)
14- ResizeEvents() <-chan domain.Size14+ ResizeEvents() <-chan domain.Geometry
15 In() io.Reader15 In() io.Reader
16 Out() io.Writer16 Out() io.Writer
17 Flush() error17 Flush() error
M internal/ports/wire.go +55 -7
@@ -169,6 +169,8 @@ type Hello struct {
169169 ResumeToken uint64
170170 Name string
171171 Size domain.Size
172+ PixelWidth int
173+ PixelHeight int
172174 TermEnv string
173175 Cwd string
174176 TrueColor bool
@@ -200,9 +202,23 @@ type Input struct {
200202 Data []byte
201203 }
202204
203-// Resize notifies the daemon of a client-side terminal size change.
205+// Geometry returns the complete controlling-terminal geometry carried by the
206+// handshake. Pixel dimensions are optional as a pair.
207+func (h Hello) Geometry() domain.Geometry {
208+ return domain.Geometry{Size: h.Size, PixelWidth: h.PixelWidth, PixelHeight: h.PixelHeight}.NormalizePixels()
209+}
210+
211+// Resize notifies the daemon of a client-side terminal geometry change.
204212 type Resize struct {
205- Size domain.Size
213+ Size domain.Size
214+ PixelWidth int
215+ PixelHeight int
216+}
217+
218+// Geometry returns the complete controlling-terminal geometry carried by the
219+// resize message. Pixel dimensions are optional as a pair.
220+func (m Resize) Geometry() domain.Geometry {
221+ return domain.Geometry{Size: m.Size, PixelWidth: m.PixelWidth, PixelHeight: m.PixelHeight}.NormalizePixels()
206222 }
207223
208224 // Theme reports the client's terminal foreground/background colors, ANSI
@@ -732,8 +748,8 @@ func ValidateHello(h Hello) error {
732748 if h.Intent != IntentEphemeral && h.Intent != IntentNew && h.Intent != IntentAttach && h.Intent != IntentResume {
733749 return ErrInvalidHello
734750 }
735- if err := ValidateSize(h.Size); err != nil {
736- return fmt.Errorf("%w: size", ErrInvalidHello)
751+ if err := ValidateGeometry(domain.Geometry{Size: h.Size, PixelWidth: h.PixelWidth, PixelHeight: h.PixelHeight}); err != nil {
752+ return fmt.Errorf("%w: geometry", ErrInvalidHello)
737753 }
738754 if !validEnvironmentPolicy(h.EnvironmentPolicy) {
739755 return ErrInvalidHello
@@ -881,6 +897,8 @@ func MarshalHello(h Hello) []byte {
881897 w.putString(h.Name)
882898 w.putUint16(uint16(h.Size.Cols))
883899 w.putUint16(uint16(h.Size.Rows))
900+ w.putUint16(uint16(h.PixelWidth))
901+ w.putUint16(uint16(h.PixelHeight))
884902 w.putString(h.TermEnv)
885903 w.putString(h.Cwd)
886904 w.putBool(h.TrueColor)
@@ -925,6 +943,12 @@ func preflightHello(b []byte) error {
925943 if _, err := r.getUint16(); err != nil {
926944 return err
927945 }
946+ if _, err := r.getUint16(); err != nil {
947+ return err
948+ }
949+ if _, err := r.getUint16(); err != nil {
950+ return err
951+ }
928952 if err := r.skipString(); err != nil {
929953 return err
930954 }
@@ -1004,7 +1028,17 @@ func UnmarshalHello(b []byte) (Hello, error) {
10041028 if err != nil {
10051029 return Hello{}, err
10061030 }
1031+ pixelWidth, err := r.getUint16()
1032+ if err != nil {
1033+ return Hello{}, err
1034+ }
1035+ pixelHeight, err := r.getUint16()
1036+ if err != nil {
1037+ return Hello{}, err
1038+ }
10071039 h.Size = domain.Size{Cols: int(cols), Rows: int(rows)}
1040+ h.PixelWidth = int(pixelWidth)
1041+ h.PixelHeight = int(pixelHeight)
10081042 if h.TermEnv, err = r.getString(); err != nil {
10091043 return Hello{}, err
10101044 }
@@ -1134,12 +1168,14 @@ func UnmarshalClientNotice(b []byte) (ClientNotice, error) {
11341168
11351169 // MarshalResize encodes m into a Resize message payload.
11361170 func MarshalResize(m Resize) ([]byte, error) {
1137- if err := ValidateSize(m.Size); err != nil {
1171+ if err := ValidateGeometry(domain.Geometry{Size: m.Size, PixelWidth: m.PixelWidth, PixelHeight: m.PixelHeight}); err != nil {
11381172 return nil, err
11391173 }
11401174 w := payloadWriter{}
11411175 w.putUint16(uint16(m.Size.Cols))
11421176 w.putUint16(uint16(m.Size.Rows))
1177+ w.putUint16(uint16(m.PixelWidth))
1178+ w.putUint16(uint16(m.PixelHeight))
11431179 return w.b, nil
11441180 }
11451181
@@ -1256,11 +1292,23 @@ func UnmarshalResize(b []byte) (Resize, error) {
12561292 if err != nil {
12571293 return Resize{}, err
12581294 }
1295+ pixelWidth, err := r.getUint16()
1296+ if err != nil {
1297+ return Resize{}, err
1298+ }
1299+ pixelHeight, err := r.getUint16()
1300+ if err != nil {
1301+ return Resize{}, err
1302+ }
12591303 if err := r.done(); err != nil {
12601304 return Resize{}, err
12611305 }
1262- m := Resize{Size: domain.Size{Cols: int(cols), Rows: int(rows)}}
1263- if err := ValidateSize(m.Size); err != nil {
1306+ m := Resize{
1307+ Size: domain.Size{Cols: int(cols), Rows: int(rows)},
1308+ PixelWidth: int(pixelWidth),
1309+ PixelHeight: int(pixelHeight),
1310+ }
1311+ if err := ValidateGeometry(domain.Geometry{Size: m.Size, PixelWidth: m.PixelWidth, PixelHeight: m.PixelHeight}); err != nil {
12641312 return Resize{}, err
12651313 }
12661314 return m, nil
@@ -169,6 +169,8 @@ type Hello struct {
169 ResumeToken uint64169 ResumeToken uint64
170 Name string170 Name string
171 Size domain.Size171 Size domain.Size
172+ PixelWidth int
173+ PixelHeight int
172 TermEnv string174 TermEnv string
173 Cwd string175 Cwd string
174 TrueColor bool176 TrueColor bool
@@ -200,9 +202,23 @@ type Input struct {
200 Data []byte202 Data []byte
201 }203 }
202 204
203-// Resize notifies the daemon of a client-side terminal size change.205+// Geometry returns the complete controlling-terminal geometry carried by the
206+// handshake. Pixel dimensions are optional as a pair.
207+func (h Hello) Geometry() domain.Geometry {
208+ return domain.Geometry{Size: h.Size, PixelWidth: h.PixelWidth, PixelHeight: h.PixelHeight}.NormalizePixels()
209+}
210+
211+// Resize notifies the daemon of a client-side terminal geometry change.
204 type Resize struct {212 type Resize struct {
205- Size domain.Size213+ Size domain.Size
214+ PixelWidth int
215+ PixelHeight int
216+}
217+
218+// Geometry returns the complete controlling-terminal geometry carried by the
219+// resize message. Pixel dimensions are optional as a pair.
220+func (m Resize) Geometry() domain.Geometry {
221+ return domain.Geometry{Size: m.Size, PixelWidth: m.PixelWidth, PixelHeight: m.PixelHeight}.NormalizePixels()
206 }222 }
207 223
208 // Theme reports the client's terminal foreground/background colors, ANSI224 // Theme reports the client's terminal foreground/background colors, ANSI
@@ -732,8 +748,8 @@ func ValidateHello(h Hello) error {
732 if h.Intent != IntentEphemeral && h.Intent != IntentNew && h.Intent != IntentAttach && h.Intent != IntentResume {748 if h.Intent != IntentEphemeral && h.Intent != IntentNew && h.Intent != IntentAttach && h.Intent != IntentResume {
733 return ErrInvalidHello749 return ErrInvalidHello
734 }750 }
735- if err := ValidateSize(h.Size); err != nil {751+ if err := ValidateGeometry(domain.Geometry{Size: h.Size, PixelWidth: h.PixelWidth, PixelHeight: h.PixelHeight}); err != nil {
736- return fmt.Errorf("%w: size", ErrInvalidHello)752+ return fmt.Errorf("%w: geometry", ErrInvalidHello)
737 }753 }
738 if !validEnvironmentPolicy(h.EnvironmentPolicy) {754 if !validEnvironmentPolicy(h.EnvironmentPolicy) {
739 return ErrInvalidHello755 return ErrInvalidHello
@@ -881,6 +897,8 @@ func MarshalHello(h Hello) []byte {
881 w.putString(h.Name)897 w.putString(h.Name)
882 w.putUint16(uint16(h.Size.Cols))898 w.putUint16(uint16(h.Size.Cols))
883 w.putUint16(uint16(h.Size.Rows))899 w.putUint16(uint16(h.Size.Rows))
900+ w.putUint16(uint16(h.PixelWidth))
901+ w.putUint16(uint16(h.PixelHeight))
884 w.putString(h.TermEnv)902 w.putString(h.TermEnv)
885 w.putString(h.Cwd)903 w.putString(h.Cwd)
886 w.putBool(h.TrueColor)904 w.putBool(h.TrueColor)
@@ -925,6 +943,12 @@ func preflightHello(b []byte) error {
925 if _, err := r.getUint16(); err != nil {943 if _, err := r.getUint16(); err != nil {
926 return err944 return err
927 }945 }
946+ if _, err := r.getUint16(); err != nil {
947+ return err
948+ }
949+ if _, err := r.getUint16(); err != nil {
950+ return err
951+ }
928 if err := r.skipString(); err != nil {952 if err := r.skipString(); err != nil {
929 return err953 return err
930 }954 }
@@ -1004,7 +1028,17 @@ func UnmarshalHello(b []byte) (Hello, error) {
1004 if err != nil {1028 if err != nil {
1005 return Hello{}, err1029 return Hello{}, err
1006 }1030 }
1031+ pixelWidth, err := r.getUint16()
1032+ if err != nil {
1033+ return Hello{}, err
1034+ }
1035+ pixelHeight, err := r.getUint16()
1036+ if err != nil {
1037+ return Hello{}, err
1038+ }
1007 h.Size = domain.Size{Cols: int(cols), Rows: int(rows)}1039 h.Size = domain.Size{Cols: int(cols), Rows: int(rows)}
1040+ h.PixelWidth = int(pixelWidth)
1041+ h.PixelHeight = int(pixelHeight)
1008 if h.TermEnv, err = r.getString(); err != nil {1042 if h.TermEnv, err = r.getString(); err != nil {
1009 return Hello{}, err1043 return Hello{}, err
1010 }1044 }
@@ -1134,12 +1168,14 @@ func UnmarshalClientNotice(b []byte) (ClientNotice, error) {
1134 1168
1135 // MarshalResize encodes m into a Resize message payload.1169 // MarshalResize encodes m into a Resize message payload.
1136 func MarshalResize(m Resize) ([]byte, error) {1170 func MarshalResize(m Resize) ([]byte, error) {
1137- if err := ValidateSize(m.Size); err != nil {1171+ if err := ValidateGeometry(domain.Geometry{Size: m.Size, PixelWidth: m.PixelWidth, PixelHeight: m.PixelHeight}); err != nil {
1138 return nil, err1172 return nil, err
1139 }1173 }
1140 w := payloadWriter{}1174 w := payloadWriter{}
1141 w.putUint16(uint16(m.Size.Cols))1175 w.putUint16(uint16(m.Size.Cols))
1142 w.putUint16(uint16(m.Size.Rows))1176 w.putUint16(uint16(m.Size.Rows))
1177+ w.putUint16(uint16(m.PixelWidth))
1178+ w.putUint16(uint16(m.PixelHeight))
1143 return w.b, nil1179 return w.b, nil
1144 }1180 }
1145 1181
@@ -1256,11 +1292,23 @@ func UnmarshalResize(b []byte) (Resize, error) {
1256 if err != nil {1292 if err != nil {
1257 return Resize{}, err1293 return Resize{}, err
1258 }1294 }
1295+ pixelWidth, err := r.getUint16()
1296+ if err != nil {
1297+ return Resize{}, err
1298+ }
1299+ pixelHeight, err := r.getUint16()
1300+ if err != nil {
1301+ return Resize{}, err
1302+ }
1259 if err := r.done(); err != nil {1303 if err := r.done(); err != nil {
1260 return Resize{}, err1304 return Resize{}, err
1261 }1305 }
1262- m := Resize{Size: domain.Size{Cols: int(cols), Rows: int(rows)}}1306+ m := Resize{
1263- if err := ValidateSize(m.Size); err != nil {1307+ Size: domain.Size{Cols: int(cols), Rows: int(rows)},
1308+ PixelWidth: int(pixelWidth),
1309+ PixelHeight: int(pixelHeight),
1310+ }
1311+ if err := ValidateGeometry(domain.Geometry{Size: m.Size, PixelWidth: m.PixelWidth, PixelHeight: m.PixelHeight}); err != nil {
1264 return Resize{}, err1312 return Resize{}, err
1265 }1313 }
1266 return m, nil1314 return m, nil
M internal/ports/wire_final_test.go +5 -5
@@ -11,8 +11,8 @@ import (
1111 )
1212
1313 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
14- if ProtocolVersion != 34 {
15- t.Fatalf("ProtocolVersion = %d, want 34", ProtocolVersion)
14+ if ProtocolVersion != 35 {
15+ t.Fatalf("ProtocolVersion = %d, want 35", ProtocolVersion)
1616 }
1717 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
1818 if len(payload) < 2 {
@@ -26,8 +26,8 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
2626
2727 func TestFinalHelloGoldenStrict(t *testing.T) {
2828 msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
29- want := append([]byte{0, 34, 2}, make([]byte, 16+8)...)
30- want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
29+ want := append([]byte{0, 35, 2}, make([]byte, 16+8)...)
30+ want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
3131 got := MarshalHello(msg)
3232 if !bytes.Equal(got, want) {
3333 t.Fatalf("Hello bytes = %x, want %x", got, want)
@@ -66,7 +66,7 @@ func TestFinalHelloSemanticValidation(t *testing.T) {
6666
6767 func TestFinalResizeGoldenStrict(t *testing.T) {
6868 msg := Resize{Size: domain.Size{Cols: 80, Rows: 24}}
69- want := []byte{0, 80, 0, 24}
69+ want := []byte{0, 80, 0, 24, 0, 0, 0, 0}
7070 got, err := MarshalResize(msg)
7171 if err != nil {
7272 t.Fatalf("MarshalResize() error = %v", err)
@@ -11,8 +11,8 @@ import (
11 )11 )
12 12
13 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {13 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
14- if ProtocolVersion != 34 {14+ if ProtocolVersion != 35 {
15- t.Fatalf("ProtocolVersion = %d, want 34", ProtocolVersion)15+ t.Fatalf("ProtocolVersion = %d, want 35", ProtocolVersion)
16 }16 }
17 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})17 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
18 if len(payload) < 2 {18 if len(payload) < 2 {
@@ -26,8 +26,8 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
26 26
27 func TestFinalHelloGoldenStrict(t *testing.T) {27 func TestFinalHelloGoldenStrict(t *testing.T) {
28 msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}28 msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
29- want := append([]byte{0, 34, 2}, make([]byte, 16+8)...)29+ want := append([]byte{0, 35, 2}, make([]byte, 16+8)...)
30- want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)30+ want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
31 got := MarshalHello(msg)31 got := MarshalHello(msg)
32 if !bytes.Equal(got, want) {32 if !bytes.Equal(got, want) {
33 t.Fatalf("Hello bytes = %x, want %x", got, want)33 t.Fatalf("Hello bytes = %x, want %x", got, want)
@@ -66,7 +66,7 @@ func TestFinalHelloSemanticValidation(t *testing.T) {
66 66
67 func TestFinalResizeGoldenStrict(t *testing.T) {67 func TestFinalResizeGoldenStrict(t *testing.T) {
68 msg := Resize{Size: domain.Size{Cols: 80, Rows: 24}}68 msg := Resize{Size: domain.Size{Cols: 80, Rows: 24}}
69- want := []byte{0, 80, 0, 24}69+ want := []byte{0, 80, 0, 24, 0, 0, 0, 0}
70 got, err := MarshalResize(msg)70 got, err := MarshalResize(msg)
71 if err != nil {71 if err != nil {
72 t.Fatalf("MarshalResize() error = %v", err)72 t.Fatalf("MarshalResize() error = %v", err)
M internal/ports/wire_size.go +14 -0
@@ -21,3 +21,17 @@ func ValidateSize(size domain.Size) error {
2121 }
2222 return nil
2323 }
24+
25+// ValidateGeometry validates cell geometry and its optional uint16 pixel pair.
26+func ValidateGeometry(geometry domain.Geometry) error {
27+ if err := ValidateSize(geometry.Size); err != nil {
28+ return err
29+ }
30+ if geometry.PixelWidth == 0 && geometry.PixelHeight == 0 {
31+ return nil
32+ }
33+ if geometry.PixelWidth <= 0 || geometry.PixelHeight <= 0 || geometry.PixelWidth > math.MaxUint16 || geometry.PixelHeight > math.MaxUint16 {
34+ return ErrInvalidSize
35+ }
36+ return nil
37+}
@@ -21,3 +21,17 @@ func ValidateSize(size domain.Size) error {
21 }21 }
22 return nil22 return nil
23 }23 }
24+
25+// ValidateGeometry validates cell geometry and its optional uint16 pixel pair.
26+func ValidateGeometry(geometry domain.Geometry) error {
27+ if err := ValidateSize(geometry.Size); err != nil {
28+ return err
29+ }
30+ if geometry.PixelWidth == 0 && geometry.PixelHeight == 0 {
31+ return nil
32+ }
33+ if geometry.PixelWidth <= 0 || geometry.PixelHeight <= 0 || geometry.PixelWidth > math.MaxUint16 || geometry.PixelHeight > math.MaxUint16 {
34+ return ErrInvalidSize
35+ }
36+ return nil
37+}
M internal/ports/wire_test.go +15 -10
@@ -79,7 +79,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
7979 TrueColor: true,
8080 MaxOutputInFlight: 8,
8181 },
82- want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
82+ want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
8383 },
8484 {
8585 name: "empty strings",
@@ -92,7 +92,11 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
9292 Cwd: "",
9393 TrueColor: false,
9494 },
95- want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
95+ want: func() []byte {
96+ out := append([]byte{0x00, 0x01, 0x00}, make([]byte, 26)...)
97+ out = append(out, 0x00, 0x01, 0x00, 0x01) // cell size
98+ return append(out, make([]byte, 16)...) // pixels and remaining fields
99+ }(),
96100 },
97101 }
98102
@@ -131,12 +135,13 @@ func TestHelloEnvironmentCodec(t *testing.T) {
131135 }
132136 got := MarshalHello(msg)
133137 want := []byte{
134- 0x00, 0x22, 0x00, // version, intent, first client ID byte
138+ 0x00, 0x23, 0x00, // version, intent, first client ID byte
135139 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
136140 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
137141 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
138142 0x00, 0x00, // name
139143 0x00, 0x01, 0x00, 0x01, // size
144+ 0x00, 0x00, 0x00, 0x00, // optional pixel size
140145 0x00, 0x00, // TERM
141146 0x00, 0x00, // cwd
142147 0x00, 0x00, // true color, max output in flight
@@ -361,12 +366,12 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
361366 }
362367 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
363368 require.Equal(t, want, MarshalTheme(cleared))
364- require.Equal(t, uint16(34), ProtocolVersion)
369+ require.Equal(t, uint16(35), ProtocolVersion)
365370 }
366371
367372 func TestResizeGoldenAndRoundTrip(t *testing.T) {
368373 msg := Resize{Size: domain.Size{Cols: 100, Rows: 40}}
369- want := []byte{0x00, 0x64, 0x00, 0x28}
374+ want := []byte{0x00, 0x64, 0x00, 0x28, 0x00, 0x00, 0x00, 0x00}
370375
371376 got, err := MarshalResize(msg)
372377 if err != nil {
@@ -522,7 +527,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
522527 name: "minimal",
523528 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
524529 want: []byte{
525- 0x00, 0x22,
530+ 0x00, 0x23,
526531 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
527532 0x00, // attached
528533 0x00, // self
@@ -545,7 +550,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
545550 JSON: true,
546551 },
547552 want: []byte{
548- 0x00, 0x22,
553+ 0x00, 0x23,
549554 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
550555 0x00, // attached
551556 0x01, // self
@@ -943,7 +948,7 @@ func TestHelloOutputWindowByteExactValues(t *testing.T) {
943948 t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) {
944949 hello := Hello{Version: 14, Size: domain.Size{Cols: 1, Rows: 1}, MaxOutputInFlight: window}
945950 // The empty Hello has a fixed 1x1 size before the negotiated output window.
946- want := append(make([]byte, 38), window, 0, 0, 0, 0, 0, 0)
951+ want := append(make([]byte, 42), window, 0, 0, 0, 0, 0, 0)
947952 want = appendNoNavigationTail(want)
948953 want[1] = 14
949954 want[30], want[32] = 1, 1
@@ -974,7 +979,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
974979 name: "attached",
975980 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
976981 want: []byte{
977- 0x00, 0x22, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
982+ 0x00, 0x23, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
978983 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
979984 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
980985 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -984,7 +989,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
984989 name: "control",
985990 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
986991 want: []byte{
987- 0x00, 0x22, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
992+ 0x00, 0x23, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
988993 0x00, 0x02, 'l', 's', 0x00, 0x00,
989994 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
990995 },
@@ -79,7 +79,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
79 TrueColor: true,79 TrueColor: true,
80 MaxOutputInFlight: 8,80 MaxOutputInFlight: 8,
81 },81 },
82- want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},82+ want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
83 },83 },
84 {84 {
85 name: "empty strings",85 name: "empty strings",
@@ -92,7 +92,11 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
92 Cwd: "",92 Cwd: "",
93 TrueColor: false,93 TrueColor: false,
94 },94 },
95- want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},95+ want: func() []byte {
96+ out := append([]byte{0x00, 0x01, 0x00}, make([]byte, 26)...)
97+ out = append(out, 0x00, 0x01, 0x00, 0x01) // cell size
98+ return append(out, make([]byte, 16)...) // pixels and remaining fields
99+ }(),
96 },100 },
97 }101 }
98 102
@@ -131,12 +135,13 @@ func TestHelloEnvironmentCodec(t *testing.T) {
131 }135 }
132 got := MarshalHello(msg)136 got := MarshalHello(msg)
133 want := []byte{137 want := []byte{
134- 0x00, 0x22, 0x00, // version, intent, first client ID byte138+ 0x00, 0x23, 0x00, // version, intent, first client ID byte
135 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,139 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
136 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID140 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
137 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token141 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
138 0x00, 0x00, // name142 0x00, 0x00, // name
139 0x00, 0x01, 0x00, 0x01, // size143 0x00, 0x01, 0x00, 0x01, // size
144+ 0x00, 0x00, 0x00, 0x00, // optional pixel size
140 0x00, 0x00, // TERM145 0x00, 0x00, // TERM
141 0x00, 0x00, // cwd146 0x00, 0x00, // cwd
142 0x00, 0x00, // true color, max output in flight147 0x00, 0x00, // true color, max output in flight
@@ -361,12 +366,12 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
361 }366 }
362 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)367 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
363 require.Equal(t, want, MarshalTheme(cleared))368 require.Equal(t, want, MarshalTheme(cleared))
364- require.Equal(t, uint16(34), ProtocolVersion)369+ require.Equal(t, uint16(35), ProtocolVersion)
365 }370 }
366 371
367 func TestResizeGoldenAndRoundTrip(t *testing.T) {372 func TestResizeGoldenAndRoundTrip(t *testing.T) {
368 msg := Resize{Size: domain.Size{Cols: 100, Rows: 40}}373 msg := Resize{Size: domain.Size{Cols: 100, Rows: 40}}
369- want := []byte{0x00, 0x64, 0x00, 0x28}374+ want := []byte{0x00, 0x64, 0x00, 0x28, 0x00, 0x00, 0x00, 0x00}
370 375
371 got, err := MarshalResize(msg)376 got, err := MarshalResize(msg)
372 if err != nil {377 if err != nil {
@@ -522,7 +527,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
522 name: "minimal",527 name: "minimal",
523 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},528 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
524 want: []byte{529 want: []byte{
525- 0x00, 0x22,530+ 0x00, 0x23,
526 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID531 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
527 0x00, // attached532 0x00, // attached
528 0x00, // self533 0x00, // self
@@ -545,7 +550,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
545 JSON: true,550 JSON: true,
546 },551 },
547 want: []byte{552 want: []byte{
548- 0x00, 0x22,553+ 0x00, 0x23,
549 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID554 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
550 0x00, // attached555 0x00, // attached
551 0x01, // self556 0x01, // self
@@ -943,7 +948,7 @@ func TestHelloOutputWindowByteExactValues(t *testing.T) {
943 t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) {948 t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) {
944 hello := Hello{Version: 14, Size: domain.Size{Cols: 1, Rows: 1}, MaxOutputInFlight: window}949 hello := Hello{Version: 14, Size: domain.Size{Cols: 1, Rows: 1}, MaxOutputInFlight: window}
945 // The empty Hello has a fixed 1x1 size before the negotiated output window.950 // The empty Hello has a fixed 1x1 size before the negotiated output window.
946- want := append(make([]byte, 38), window, 0, 0, 0, 0, 0, 0)951+ want := append(make([]byte, 42), window, 0, 0, 0, 0, 0, 0)
947 want = appendNoNavigationTail(want)952 want = appendNoNavigationTail(want)
948 want[1] = 14953 want[1] = 14
949 want[30], want[32] = 1, 1954 want[30], want[32] = 1, 1
@@ -974,7 +979,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
974 name: "attached",979 name: "attached",
975 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},980 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
976 want: []byte{981 want: []byte{
977- 0x00, 0x22, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,982+ 0x00, 0x23, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
978 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',983 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
979 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',984 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
980 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,985 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -984,7 +989,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
984 name: "control",989 name: "control",
985 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},990 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
986 want: []byte{991 want: []byte{
987- 0x00, 0x22, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,992+ 0x00, 0x23, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
988 0x00, 0x02, 'l', 's', 0x00, 0x00,993 0x00, 0x02, 'l', 's', 0x00, 0x00,
989 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,994 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
990 },995 },
M internal/usecase/client/client.go +32 -25
@@ -863,11 +863,11 @@ func (u *reconnectUI) redraw(size domain.Size) error {
863863 func (u *reconnectUI) drawStage(stage reconnectStage) error {
864864 u.stage = stage
865865 if u.showing && u.remote {
866- size, err := u.term.Size()
866+ geometry, err := u.term.Geometry()
867867 if err != nil {
868- return fmt.Errorf("reading terminal size for reconnect toast: %w", err)
868+ return fmt.Errorf("reading terminal geometry for reconnect toast: %w", err)
869869 }
870- return u.redraw(size)
870+ return u.redraw(geometry.Size)
871871 }
872872 return u.draw()
873873 }
@@ -877,11 +877,11 @@ func (u *reconnectUI) draw() error {
877877 return nil
878878 }
879879 if u.remote {
880- size, err := u.term.Size()
880+ geometry, err := u.term.Geometry()
881881 if err != nil {
882- return fmt.Errorf("reading terminal size for reconnect toast: %w", err)
882+ return fmt.Errorf("reading terminal geometry for reconnect toast: %w", err)
883883 }
884- return u.redraw(size)
884+ return u.redraw(geometry.Size)
885885 }
886886 if _, err := u.term.Out().Write([]byte(statusReconnect)); err != nil {
887887 return fmt.Errorf("writing reconnect status: %w", err)
@@ -927,7 +927,7 @@ func sleepReconnect(ctx context.Context, clk ports.Clock, d time.Duration) bool
927927 }
928928 }
929929
930-func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time.Duration, resizeEvents <-chan domain.Size, onResize func(domain.Size) error) (bool, error) {
930+func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time.Duration, resizeEvents <-chan domain.Geometry, onResize func(domain.Size) error) (bool, error) {
931931 t := clk.NewTimer(d)
932932 defer t.Stop()
933933 for {
@@ -936,13 +936,13 @@ func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time
936936 return true, nil
937937 case <-ctx.Done():
938938 return false, nil
939- case size, ok := <-resizeEvents:
939+ case geometry, ok := <-resizeEvents:
940940 if !ok {
941941 resizeEvents = nil
942942 continue
943943 }
944944 if onResize != nil {
945- if err := onResize(size); err != nil {
945+ if err := onResize(geometry.Size); err != nil {
946946 return false, err
947947 }
948948 }
@@ -1083,11 +1083,13 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
10831083 }
10841084 return nil
10851085 }
1086- // 1. Handshake: send Hello with our size and TERM.
1087- size, err := term.Size()
1086+ // 1. Handshake: send Hello with our geometry and TERM.
1087+ geometry, err := term.Geometry()
10881088 if err != nil {
1089- return attachResult{err: fmt.Errorf("vev: reading terminal size: %w", err)}
1089+ return attachResult{err: fmt.Errorf("vev: reading terminal geometry: %w", err)}
10901090 }
1091+ geometry = geometry.NormalizePixels()
1092+ size := geometry.Size
10911093 cwd, err := os.Getwd()
10921094 if err != nil {
10931095 cwd = ""
@@ -1110,6 +1112,8 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
11101112 ResumeToken: resumeToken,
11111113 Name: name,
11121114 Size: size,
1115+ PixelWidth: geometry.PixelWidth,
1116+ PixelHeight: geometry.PixelHeight,
11131117 TermEnv: termEnv,
11141118 Cwd: cwd,
11151119 TrueColor: trueColor,
@@ -1383,7 +1387,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
13831387 // Before the asynchronous sender starts, preserve transport ordering by
13841388 // requesting the reset synchronously after the initial Theme publication.
13851389 resetErr := sendHandshake(func() error {
1386- payload, err := ports.MarshalResize(ports.Resize{Size: size})
1390+ payload, err := ports.MarshalResize(ports.Resize{Size: geometry.Size, PixelWidth: geometry.PixelWidth, PixelHeight: geometry.PixelHeight})
13871391 if err != nil {
13881392 return err
13891393 }
@@ -1504,11 +1508,12 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
15041508 defer clearParkedPending()
15051509
15061510 sendParkedRouteSize := func() error {
1507- current, err := term.Size()
1511+ current, err := term.Geometry()
15081512 if err != nil {
1509- return fmt.Errorf("reading terminal size for parked route: %w", err)
1513+ return fmt.Errorf("reading terminal geometry for parked route: %w", err)
15101514 }
1511- payload, err := ports.MarshalResize(ports.Resize{Size: current})
1515+ current = current.NormalizePixels()
1516+ payload, err := ports.MarshalResize(ports.Resize{Size: current.Size, PixelWidth: current.PixelWidth, PixelHeight: current.PixelHeight})
15121517 if err != nil {
15131518 return fmt.Errorf("encoding terminal size for parked route: %w", err)
15141519 }
@@ -1600,11 +1605,12 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
16001605 if awaitingReconnectReset {
16011606 return nil
16021607 }
1603- size, serr := term.Size()
1608+ current, serr := term.Geometry()
16041609 if serr != nil {
1605- return fmt.Errorf("reading terminal size for reconnect reconciliation: %w", serr)
1610+ return fmt.Errorf("reading terminal geometry for reconnect reconciliation: %w", serr)
16061611 }
1607- payload, err := ports.MarshalResize(ports.Resize{Size: size})
1612+ current = current.NormalizePixels()
1613+ payload, err := ports.MarshalResize(ports.Resize{Size: current.Size, PixelWidth: current.PixelWidth, PixelHeight: current.PixelHeight})
16081614 if err != nil {
16091615 return fmt.Errorf("encoding terminal size for reconnect reconciliation: %w", err)
16101616 }
@@ -1792,11 +1798,11 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
17921798 awaitingReconnectReset = false
17931799 }
17941800 if reconnect.showing && reconnect.remote {
1795- size, serr := term.Size()
1801+ current, serr := term.Geometry()
17961802 if serr != nil {
1797- return welcomedResult(fmt.Errorf("vev: reading terminal size for reconnect redraw: %w", serr))
1803+ return welcomedResult(fmt.Errorf("vev: reading terminal geometry for reconnect redraw: %w", serr))
17981804 }
1799- if rerr := reconnect.redraw(size); rerr != nil {
1805+ if rerr := reconnect.redraw(current.Size); rerr != nil {
18001806 return welcomedResult(fmt.Errorf("vev: redrawing reconnect toast: %w", rerr))
18011807 }
18021808 } else if ferr := term.Flush(); ferr != nil {
@@ -2844,15 +2850,16 @@ func (p *stdinPump) run() {
28442850 // runResize forwards coalesced terminal resize events to the daemon. It
28452851 // tolerates an already-closed resize channel (which the terminal adapter
28462852 // hands back when restore ran before ResizeEvents was first called).
2847-func runResize(ctx context.Context, events <-chan domain.Size, out chan<- ports.Frame, sendLease *foregroundSendLease, log *slog.Logger) {
2853+func runResize(ctx context.Context, events <-chan domain.Geometry, out chan<- ports.Frame, sendLease *foregroundSendLease, log *slog.Logger) {
28482854 defer log.Debug("resize pump exited")
28492855 for {
28502856 select {
2851- case sz, ok := <-events:
2857+ case geometry, ok := <-events:
28522858 if !ok {
28532859 return
28542860 }
2855- payload, err := ports.MarshalResize(ports.Resize{Size: sz})
2861+ geometry = geometry.NormalizePixels()
2862+ payload, err := ports.MarshalResize(ports.Resize{Size: geometry.Size, PixelWidth: geometry.PixelWidth, PixelHeight: geometry.PixelHeight})
28562863 if err != nil {
28572864 log.Error("encoding terminal resize", "error", err)
28582865 continue
@@ -863,11 +863,11 @@ func (u *reconnectUI) redraw(size domain.Size) error {
863 func (u *reconnectUI) drawStage(stage reconnectStage) error {863 func (u *reconnectUI) drawStage(stage reconnectStage) error {
864 u.stage = stage864 u.stage = stage
865 if u.showing && u.remote {865 if u.showing && u.remote {
866- size, err := u.term.Size()866+ geometry, err := u.term.Geometry()
867 if err != nil {867 if err != nil {
868- return fmt.Errorf("reading terminal size for reconnect toast: %w", err)868+ return fmt.Errorf("reading terminal geometry for reconnect toast: %w", err)
869 }869 }
870- return u.redraw(size)870+ return u.redraw(geometry.Size)
871 }871 }
872 return u.draw()872 return u.draw()
873 }873 }
@@ -877,11 +877,11 @@ func (u *reconnectUI) draw() error {
877 return nil877 return nil
878 }878 }
879 if u.remote {879 if u.remote {
880- size, err := u.term.Size()880+ geometry, err := u.term.Geometry()
881 if err != nil {881 if err != nil {
882- return fmt.Errorf("reading terminal size for reconnect toast: %w", err)882+ return fmt.Errorf("reading terminal geometry for reconnect toast: %w", err)
883 }883 }
884- return u.redraw(size)884+ return u.redraw(geometry.Size)
885 }885 }
886 if _, err := u.term.Out().Write([]byte(statusReconnect)); err != nil {886 if _, err := u.term.Out().Write([]byte(statusReconnect)); err != nil {
887 return fmt.Errorf("writing reconnect status: %w", err)887 return fmt.Errorf("writing reconnect status: %w", err)
@@ -927,7 +927,7 @@ func sleepReconnect(ctx context.Context, clk ports.Clock, d time.Duration) bool
927 }927 }
928 }928 }
929 929
930-func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time.Duration, resizeEvents <-chan domain.Size, onResize func(domain.Size) error) (bool, error) {930+func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time.Duration, resizeEvents <-chan domain.Geometry, onResize func(domain.Size) error) (bool, error) {
931 t := clk.NewTimer(d)931 t := clk.NewTimer(d)
932 defer t.Stop()932 defer t.Stop()
933 for {933 for {
@@ -936,13 +936,13 @@ func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time
936 return true, nil936 return true, nil
937 case <-ctx.Done():937 case <-ctx.Done():
938 return false, nil938 return false, nil
939- case size, ok := <-resizeEvents:939+ case geometry, ok := <-resizeEvents:
940 if !ok {940 if !ok {
941 resizeEvents = nil941 resizeEvents = nil
942 continue942 continue
943 }943 }
944 if onResize != nil {944 if onResize != nil {
945- if err := onResize(size); err != nil {945+ if err := onResize(geometry.Size); err != nil {
946 return false, err946 return false, err
947 }947 }
948 }948 }
@@ -1083,11 +1083,13 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1083 }1083 }
1084 return nil1084 return nil
1085 }1085 }
1086- // 1. Handshake: send Hello with our size and TERM.1086+ // 1. Handshake: send Hello with our geometry and TERM.
1087- size, err := term.Size()1087+ geometry, err := term.Geometry()
1088 if err != nil {1088 if err != nil {
1089- return attachResult{err: fmt.Errorf("vev: reading terminal size: %w", err)}1089+ return attachResult{err: fmt.Errorf("vev: reading terminal geometry: %w", err)}
1090 }1090 }
1091+ geometry = geometry.NormalizePixels()
1092+ size := geometry.Size
1091 cwd, err := os.Getwd()1093 cwd, err := os.Getwd()
1092 if err != nil {1094 if err != nil {
1093 cwd = ""1095 cwd = ""
@@ -1110,6 +1112,8 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1110 ResumeToken: resumeToken,1112 ResumeToken: resumeToken,
1111 Name: name,1113 Name: name,
1112 Size: size,1114 Size: size,
1115+ PixelWidth: geometry.PixelWidth,
1116+ PixelHeight: geometry.PixelHeight,
1113 TermEnv: termEnv,1117 TermEnv: termEnv,
1114 Cwd: cwd,1118 Cwd: cwd,
1115 TrueColor: trueColor,1119 TrueColor: trueColor,
@@ -1383,7 +1387,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1383 // Before the asynchronous sender starts, preserve transport ordering by1387 // Before the asynchronous sender starts, preserve transport ordering by
1384 // requesting the reset synchronously after the initial Theme publication.1388 // requesting the reset synchronously after the initial Theme publication.
1385 resetErr := sendHandshake(func() error {1389 resetErr := sendHandshake(func() error {
1386- payload, err := ports.MarshalResize(ports.Resize{Size: size})1390+ payload, err := ports.MarshalResize(ports.Resize{Size: geometry.Size, PixelWidth: geometry.PixelWidth, PixelHeight: geometry.PixelHeight})
1387 if err != nil {1391 if err != nil {
1388 return err1392 return err
1389 }1393 }
@@ -1504,11 +1508,12 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1504 defer clearParkedPending()1508 defer clearParkedPending()
1505 1509
1506 sendParkedRouteSize := func() error {1510 sendParkedRouteSize := func() error {
1507- current, err := term.Size()1511+ current, err := term.Geometry()
1508 if err != nil {1512 if err != nil {
1509- return fmt.Errorf("reading terminal size for parked route: %w", err)1513+ return fmt.Errorf("reading terminal geometry for parked route: %w", err)
1510 }1514 }
1511- payload, err := ports.MarshalResize(ports.Resize{Size: current})1515+ current = current.NormalizePixels()
1516+ payload, err := ports.MarshalResize(ports.Resize{Size: current.Size, PixelWidth: current.PixelWidth, PixelHeight: current.PixelHeight})
1512 if err != nil {1517 if err != nil {
1513 return fmt.Errorf("encoding terminal size for parked route: %w", err)1518 return fmt.Errorf("encoding terminal size for parked route: %w", err)
1514 }1519 }
@@ -1600,11 +1605,12 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1600 if awaitingReconnectReset {1605 if awaitingReconnectReset {
1601 return nil1606 return nil
1602 }1607 }
1603- size, serr := term.Size()1608+ current, serr := term.Geometry()
1604 if serr != nil {1609 if serr != nil {
1605- return fmt.Errorf("reading terminal size for reconnect reconciliation: %w", serr)1610+ return fmt.Errorf("reading terminal geometry for reconnect reconciliation: %w", serr)
1606 }1611 }
1607- payload, err := ports.MarshalResize(ports.Resize{Size: size})1612+ current = current.NormalizePixels()
1613+ payload, err := ports.MarshalResize(ports.Resize{Size: current.Size, PixelWidth: current.PixelWidth, PixelHeight: current.PixelHeight})
1608 if err != nil {1614 if err != nil {
1609 return fmt.Errorf("encoding terminal size for reconnect reconciliation: %w", err)1615 return fmt.Errorf("encoding terminal size for reconnect reconciliation: %w", err)
1610 }1616 }
@@ -1792,11 +1798,11 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1792 awaitingReconnectReset = false1798 awaitingReconnectReset = false
1793 }1799 }
1794 if reconnect.showing && reconnect.remote {1800 if reconnect.showing && reconnect.remote {
1795- size, serr := term.Size()1801+ current, serr := term.Geometry()
1796 if serr != nil {1802 if serr != nil {
1797- return welcomedResult(fmt.Errorf("vev: reading terminal size for reconnect redraw: %w", serr))1803+ return welcomedResult(fmt.Errorf("vev: reading terminal geometry for reconnect redraw: %w", serr))
1798 }1804 }
1799- if rerr := reconnect.redraw(size); rerr != nil {1805+ if rerr := reconnect.redraw(current.Size); rerr != nil {
1800 return welcomedResult(fmt.Errorf("vev: redrawing reconnect toast: %w", rerr))1806 return welcomedResult(fmt.Errorf("vev: redrawing reconnect toast: %w", rerr))
1801 }1807 }
1802 } else if ferr := term.Flush(); ferr != nil {1808 } else if ferr := term.Flush(); ferr != nil {
@@ -2844,15 +2850,16 @@ func (p *stdinPump) run() {
2844 // runResize forwards coalesced terminal resize events to the daemon. It2850 // runResize forwards coalesced terminal resize events to the daemon. It
2845 // tolerates an already-closed resize channel (which the terminal adapter2851 // tolerates an already-closed resize channel (which the terminal adapter
2846 // hands back when restore ran before ResizeEvents was first called).2852 // hands back when restore ran before ResizeEvents was first called).
2847-func runResize(ctx context.Context, events <-chan domain.Size, out chan<- ports.Frame, sendLease *foregroundSendLease, log *slog.Logger) {2853+func runResize(ctx context.Context, events <-chan domain.Geometry, out chan<- ports.Frame, sendLease *foregroundSendLease, log *slog.Logger) {
2848 defer log.Debug("resize pump exited")2854 defer log.Debug("resize pump exited")
2849 for {2855 for {
2850 select {2856 select {
2851- case sz, ok := <-events:2857+ case geometry, ok := <-events:
2852 if !ok {2858 if !ok {
2853 return2859 return
2854 }2860 }
2855- payload, err := ports.MarshalResize(ports.Resize{Size: sz})2861+ geometry = geometry.NormalizePixels()
2862+ payload, err := ports.MarshalResize(ports.Resize{Size: geometry.Size, PixelWidth: geometry.PixelWidth, PixelHeight: geometry.PixelHeight})
2856 if err != nil {2863 if err != nil {
2857 log.Error("encoding terminal resize", "error", err)2864 log.Error("encoding terminal resize", "error", err)
2858 continue2865 continue
M internal/usecase/client/client_test.go +44 -43
@@ -218,9 +218,9 @@ func (r *chunkedBlockingReader) Read(p []byte) (int, error) {
218218
219219 func (r *chunkedBlockingReader) unblock() { close(r.done) }
220220
221-func newHappyTerminal(t *testing.T, out *bytes.Buffer, restoreCount *atomic.Int32, resizeCh chan domain.Size) (*portsmocks.MockTerminal, *blockingReader) {
221+func newHappyTerminal(t *testing.T, out *bytes.Buffer, restoreCount *atomic.Int32, resizeCh chan domain.Geometry) (*portsmocks.MockTerminal, *blockingReader) {
222222 tm := portsmocks.NewMockTerminal(t)
223- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
223+ tm.EXPECT().Geometry().Return(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil).Once()
224224 tm.EXPECT().EnterRaw().Return(func() error {
225225 restoreCount.Add(1)
226226 return nil
@@ -320,7 +320,7 @@ func TestRunBoundsPreWelcomeOperations(t *testing.T) {
320320 clk, createdTimers := newHandshakeClock(t, 1)
321321
322322 term := portsmocks.NewMockTerminal(t)
323- term.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
323+ term.EXPECT().Geometry().Return(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil).Once()
324324 // No EnterRaw expectation: it must not run before Welcome.
325325
326326 started := make(chan struct{})
@@ -393,7 +393,7 @@ func TestAttachHelloIncludesTrueColor(t *testing.T) {
393393
394394 var out bytes.Buffer
395395 var restoreCount atomic.Int32
396- resizeCh := make(chan domain.Size)
396+ resizeCh := make(chan domain.Geometry)
397397 tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)
398398 defer in.unblock()
399399
@@ -1123,7 +1123,7 @@ func mustMarshalRoutePosition(position ports.RoutePosition) []byte {
11231123 func TestAttachTargetHandoffReturnsValidatedTargetAndClosesTransport(t *testing.T) {
11241124 var out bytes.Buffer
11251125 var restoreCount atomic.Int32
1126- resizeCh := make(chan domain.Size)
1126+ resizeCh := make(chan domain.Geometry)
11271127 tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)
11281128 defer in.unblock()
11291129
@@ -1150,7 +1150,7 @@ func TestAttachHelloIncludesCompleteLocalEnvironment(t *testing.T) {
11501150
11511151 var out bytes.Buffer
11521152 var restoreCount atomic.Int32
1153- resizeCh := make(chan domain.Size)
1153+ resizeCh := make(chan domain.Geometry)
11541154 tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)
11551155 defer in.unblock()
11561156
@@ -1180,7 +1180,7 @@ func TestAttachHelloIncludesCompleteLocalEnvironment(t *testing.T) {
11801180 func TestAttachHelloRequestsSingleOutputForDatagramTransport(t *testing.T) {
11811181 var out bytes.Buffer
11821182 var restoreCount atomic.Int32
1183- resizeCh := make(chan domain.Size)
1183+ resizeCh := make(chan domain.Geometry)
11841184 tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)
11851185 defer in.unblock()
11861186
@@ -1206,7 +1206,7 @@ func TestAttachHelloRequestsSingleOutputForDatagramTransport(t *testing.T) {
12061206 func TestAttachHappyPath(t *testing.T) {
12071207 var out bytes.Buffer
12081208 var restoreCount atomic.Int32
1209- resizeCh := make(chan domain.Size)
1209+ resizeCh := make(chan domain.Geometry)
12101210 tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)
12111211 defer in.unblock()
12121212
@@ -1230,7 +1230,7 @@ func TestAttachHappyPath(t *testing.T) {
12301230
12311231 func TestAttachVersionMismatch(t *testing.T) {
12321232 tm := portsmocks.NewMockTerminal(t)
1233- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
1233+ tm.EXPECT().Geometry().Return(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil).Once()
12341234 // EnterRaw must NOT be called on the error-before-welcome path.
12351235
12361236 tr := portsmocks.NewMockTransport(t)
@@ -1252,7 +1252,7 @@ func TestAttachVersionMismatch(t *testing.T) {
12521252 func TestAttachRestoredOnRecvErrorMidStream(t *testing.T) {
12531253 var out bytes.Buffer
12541254 var restoreCount atomic.Int32
1255- resizeCh := make(chan domain.Size)
1255+ resizeCh := make(chan domain.Geometry)
12561256 tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)
12571257 defer in.unblock()
12581258
@@ -1276,7 +1276,7 @@ func TestAttachRestoredOnRecvErrorMidStream(t *testing.T) {
12761276 func TestAttachDaemonVanishedOnEOF(t *testing.T) {
12771277 var out bytes.Buffer
12781278 var restoreCount atomic.Int32
1279- resizeCh := make(chan domain.Size)
1279+ resizeCh := make(chan domain.Geometry)
12801280 tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)
12811281 defer in.unblock()
12821282
@@ -1300,12 +1300,12 @@ func TestAttachDaemonVanishedOnEOF(t *testing.T) {
13001300 func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) {
13011301 var out bytes.Buffer
13021302 var restoreCount atomic.Int32
1303- resizeCh := make(chan domain.Size)
1303+ resizeCh := make(chan domain.Geometry)
13041304 input := newOneShotBlockingReader([]byte("\x1b[<0;1;1M"))
13051305 defer input.unblock()
13061306
13071307 tm := portsmocks.NewMockTerminal(t)
1308- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
1308+ tm.EXPECT().Geometry().Return(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil).Once()
13091309 tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
13101310 tm.EXPECT().In().Return(input).Maybe()
13111311 tm.EXPECT().Out().Return(&out).Maybe()
@@ -1361,13 +1361,13 @@ func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) {
13611361 func TestAttachStdinCoalescesSplitBracketedPaste(t *testing.T) {
13621362 var out bytes.Buffer
13631363 var restoreCount atomic.Int32
1364- resizeCh := make(chan domain.Size)
1364+ resizeCh := make(chan domain.Geometry)
13651365 paste := []byte("\x1b[200~hello\nworld\x1b[201~")
13661366 input := newChunkedBlockingReader([]byte("\x1b[200~hello\n"), []byte("world\x1b[201~"))
13671367 defer input.unblock()
13681368
13691369 tm := portsmocks.NewMockTerminal(t)
1370- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
1370+ tm.EXPECT().Geometry().Return(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil).Once()
13711371 tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
13721372 tm.EXPECT().In().Return(input).Maybe()
13731373 tm.EXPECT().Out().Return(&out).Maybe()
@@ -1423,7 +1423,7 @@ func TestAttachStdinCoalescesSplitBracketedPaste(t *testing.T) {
14231423 func TestAttachForwardsResize(t *testing.T) {
14241424 var out bytes.Buffer
14251425 var restoreCount atomic.Int32
1426- resizeCh := make(chan domain.Size)
1426+ resizeCh := make(chan domain.Geometry)
14271427 tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)
14281428 defer in.unblock()
14291429
@@ -1472,7 +1472,7 @@ func TestAttachForwardsResize(t *testing.T) {
14721472 // Push a resize event once attach begins receiving daemon frames.
14731473 go func() {
14741474 <-firstRecv
1475- resizeCh <- domain.Size{Cols: 120, Rows: 40}
1475+ resizeCh <- domain.Geometry{Size: domain.Size{Cols: 120, Rows: 40}}
14761476 }()
14771477
14781478 err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
@@ -1488,7 +1488,7 @@ func TestAttachForwardsResize(t *testing.T) {
14881488 func TestRunRestoresRawModeAfterAttachError(t *testing.T) {
14891489 var out bytes.Buffer
14901490 var restoreCount atomic.Int32
1491- resizeCh := make(chan domain.Size)
1491+ resizeCh := make(chan domain.Geometry)
14921492 tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)
14931493 defer in.unblock()
14941494
@@ -1513,7 +1513,7 @@ func TestRunRestoresRawModeAfterAttachError(t *testing.T) {
15131513
15141514 func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) {
15151515 tm := portsmocks.NewMockTerminal(t)
1516- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
1516+ tm.EXPECT().Geometry().Return(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil).Once()
15171517 // EnterRaw must NOT be called when the daemon rejects Hello before Welcome.
15181518
15191519 tr := portsmocks.NewMockTransport(t)
@@ -1615,15 +1615,16 @@ type runTerminal struct {
16151615 out bytes.Buffer
16161616 rawCount atomic.Int32
16171617 restoreCount atomic.Int32
1618- resizeCh chan domain.Size
1619- sizeMu sync.Mutex
1620- size domain.Size
1618+ resizeCh chan domain.Geometry
1619+ geometryMu sync.Mutex
1620+ geometry domain.Geometry
16211621 }
16221622
16231623 func newRunTerminal() *runTerminal {
16241624 return &runTerminal{
1625- in: newBlockingReader(), resizeCh: make(chan domain.Size),
1626- size: domain.Size{Cols: 80, Rows: 24},
1625+ in: newBlockingReader(),
1626+ resizeCh: make(chan domain.Geometry),
1627+ geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}},
16271628 }
16281629 }
16291630
@@ -1631,20 +1632,20 @@ func (t *runTerminal) EnterRaw() (func() error, error) {
16311632 t.rawCount.Add(1)
16321633 return func() error { t.restoreCount.Add(1); return nil }, nil
16331634 }
1634-func (t *runTerminal) Size() (domain.Size, error) {
1635- t.sizeMu.Lock()
1636- defer t.sizeMu.Unlock()
1637- return t.size, nil
1635+func (t *runTerminal) Geometry() (domain.Geometry, error) {
1636+ t.geometryMu.Lock()
1637+ defer t.geometryMu.Unlock()
1638+ return t.geometry, nil
16381639 }
16391640 func (t *runTerminal) setSize(size domain.Size) {
1640- t.sizeMu.Lock()
1641- t.size = size
1642- t.sizeMu.Unlock()
1641+ t.geometryMu.Lock()
1642+ t.geometry.Size = size
1643+ t.geometryMu.Unlock()
16431644 }
1644-func (t *runTerminal) ResizeEvents() <-chan domain.Size { return t.resizeCh }
1645-func (t *runTerminal) In() io.Reader { return t.in }
1646-func (t *runTerminal) Out() io.Writer { return &t.out }
1647-func (t *runTerminal) Flush() error { return nil }
1645+func (t *runTerminal) ResizeEvents() <-chan domain.Geometry { return t.resizeCh }
1646+func (t *runTerminal) In() io.Reader { return t.in }
1647+func (t *runTerminal) Out() io.Writer { return &t.out }
1648+func (t *runTerminal) Flush() error { return nil }
16481649
16491650 func helloFromSend(t *testing.T, tr *recordingTransport) ports.Hello {
16501651 t.Helper()
@@ -1871,12 +1872,12 @@ func TestAttachRememberRemoteHost(t *testing.T) {
18711872 var unblockIn func()
18721873 if tt.preWelcomeReject {
18731874 tm = portsmocks.NewMockTerminal(t)
1874- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
1875+ tm.EXPECT().Geometry().Return(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil).Once()
18751876 // EnterRaw must NOT be called when the daemon rejects Hello before Welcome.
18761877 } else {
18771878 var out bytes.Buffer
18781879 var restoreCount atomic.Int32
1879- resizeCh := make(chan domain.Size)
1880+ resizeCh := make(chan domain.Geometry)
18801881 var in *blockingReader
18811882 tm, in = newHappyTerminal(t, &out, &restoreCount, resizeCh)
18821883 unblockIn = in.unblock
@@ -1971,9 +1972,9 @@ func TestRunRememberRemoteHostAtMostOnceAcrossReconnects(t *testing.T) {
19711972 func TestAttachRememberRemoteHostDoesNotBlockAfterWelcome(t *testing.T) {
19721973 var out bytes.Buffer
19731974 var restoreCount atomic.Int32
1974- resizeCh := make(chan domain.Size)
1975+ resizeCh := make(chan domain.Geometry)
19751976 tm := portsmocks.NewMockTerminal(t)
1976- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
1977+ tm.EXPECT().Geometry().Return(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil).Once()
19771978 enteredRaw := make(chan struct{})
19781979 tm.EXPECT().EnterRaw().Run(func() { close(enteredRaw) }).Return(func() error {
19791980 restoreCount.Add(1)
@@ -2052,9 +2053,9 @@ func TestRunRestoresTerminalBeforeWaitingForLearner(t *testing.T) {
20522053 var out bytes.Buffer
20532054 var restoreCount atomic.Int32
20542055 restored := make(chan struct{})
2055- resizeCh := make(chan domain.Size)
2056+ resizeCh := make(chan domain.Geometry)
20562057 tm := portsmocks.NewMockTerminal(t)
2057- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
2058+ tm.EXPECT().Geometry().Return(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil).Once()
20582059 tm.EXPECT().EnterRaw().Return(func() error {
20592060 restoreCount.Add(1)
20602061 close(restored)
@@ -2134,9 +2135,9 @@ func TestRunReturnsWhenRemoteHostLearnerStalls(t *testing.T) {
21342135 var out bytes.Buffer
21352136 var restoreCount atomic.Int32
21362137 restored := make(chan struct{})
2137- resizeCh := make(chan domain.Size)
2138+ resizeCh := make(chan domain.Geometry)
21382139 tm := portsmocks.NewMockTerminal(t)
2139- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
2140+ tm.EXPECT().Geometry().Return(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil).Once()
21402141 tm.EXPECT().EnterRaw().Return(func() error {
21412142 restoreCount.Add(1)
21422143 close(restored)
@@ -218,9 +218,9 @@ func (r *chunkedBlockingReader) Read(p []byte) (int, error) {
218 218
219 func (r *chunkedBlockingReader) unblock() { close(r.done) }219 func (r *chunkedBlockingReader) unblock() { close(r.done) }
220 220
221-func newHappyTerminal(t *testing.T, out *bytes.Buffer, restoreCount *atomic.Int32, resizeCh chan domain.Size) (*portsmocks.MockTerminal, *blockingReader) {221+func newHappyTerminal(t *testing.T, out *bytes.Buffer, restoreCount *atomic.Int32, resizeCh chan domain.Geometry) (*portsmocks.MockTerminal, *blockingReader) {
222 tm := portsmocks.NewMockTerminal(t)222 tm := portsmocks.NewMockTerminal(t)
223- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()223+ tm.EXPECT().Geometry().Return(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil).Once()
224 tm.EXPECT().EnterRaw().Return(func() error {224 tm.EXPECT().EnterRaw().Return(func() error {
225 restoreCount.Add(1)225 restoreCount.Add(1)
226 return nil226 return nil
@@ -320,7 +320,7 @@ func TestRunBoundsPreWelcomeOperations(t *testing.T) {
320 clk, createdTimers := newHandshakeClock(t, 1)320 clk, createdTimers := newHandshakeClock(t, 1)
321 321
322 term := portsmocks.NewMockTerminal(t)322 term := portsmocks.NewMockTerminal(t)
323- term.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()323+ term.EXPECT().Geometry().Return(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil).Once()
324 // No EnterRaw expectation: it must not run before Welcome.324 // No EnterRaw expectation: it must not run before Welcome.
325 325
326 started := make(chan struct{})326 started := make(chan struct{})
@@ -393,7 +393,7 @@ func TestAttachHelloIncludesTrueColor(t *testing.T) {
393 393
394 var out bytes.Buffer394 var out bytes.Buffer
395 var restoreCount atomic.Int32395 var restoreCount atomic.Int32
396- resizeCh := make(chan domain.Size)396+ resizeCh := make(chan domain.Geometry)
397 tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)397 tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)
398 defer in.unblock()398 defer in.unblock()
399 399
@@ -1123,7 +1123,7 @@ func mustMarshalRoutePosition(position ports.RoutePosition) []byte {
1123 func TestAttachTargetHandoffReturnsValidatedTargetAndClosesTransport(t *testing.T) {1123 func TestAttachTargetHandoffReturnsValidatedTargetAndClosesTransport(t *testing.T) {
1124 var out bytes.Buffer1124 var out bytes.Buffer
1125 var restoreCount atomic.Int321125 var restoreCount atomic.Int32
1126- resizeCh := make(chan domain.Size)1126+ resizeCh := make(chan domain.Geometry)
1127 tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)1127 tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)
1128 defer in.unblock()1128 defer in.unblock()
1129 1129
@@ -1150,7 +1150,7 @@ func TestAttachHelloIncludesCompleteLocalEnvironment(t *testing.T) {
1150 1150
1151 var out bytes.Buffer1151 var out bytes.Buffer
1152 var restoreCount atomic.Int321152 var restoreCount atomic.Int32
1153- resizeCh := make(chan domain.Size)1153+ resizeCh := make(chan domain.Geometry)
1154 tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)1154 tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)
1155 defer in.unblock()1155 defer in.unblock()
1156 1156
@@ -1180,7 +1180,7 @@ func TestAttachHelloIncludesCompleteLocalEnvironment(t *testing.T) {
1180 func TestAttachHelloRequestsSingleOutputForDatagramTransport(t *testing.T) {1180 func TestAttachHelloRequestsSingleOutputForDatagramTransport(t *testing.T) {
1181 var out bytes.Buffer1181 var out bytes.Buffer
1182 var restoreCount atomic.Int321182 var restoreCount atomic.Int32
1183- resizeCh := make(chan domain.Size)1183+ resizeCh := make(chan domain.Geometry)
1184 tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)1184 tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)
1185 defer in.unblock()1185 defer in.unblock()
1186 1186
@@ -1206,7 +1206,7 @@ func TestAttachHelloRequestsSingleOutputForDatagramTransport(t *testing.T) {
1206 func TestAttachHappyPath(t *testing.T) {1206 func TestAttachHappyPath(t *testing.T) {
1207 var out bytes.Buffer1207 var out bytes.Buffer
1208 var restoreCount atomic.Int321208 var restoreCount atomic.Int32
1209- resizeCh := make(chan domain.Size)1209+ resizeCh := make(chan domain.Geometry)
1210 tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)1210 tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)
1211 defer in.unblock()1211 defer in.unblock()
1212 1212
@@ -1230,7 +1230,7 @@ func TestAttachHappyPath(t *testing.T) {
1230 1230
1231 func TestAttachVersionMismatch(t *testing.T) {1231 func TestAttachVersionMismatch(t *testing.T) {
1232 tm := portsmocks.NewMockTerminal(t)1232 tm := portsmocks.NewMockTerminal(t)
1233- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()1233+ tm.EXPECT().Geometry().Return(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil).Once()
1234 // EnterRaw must NOT be called on the error-before-welcome path.1234 // EnterRaw must NOT be called on the error-before-welcome path.
1235 1235
1236 tr := portsmocks.NewMockTransport(t)1236 tr := portsmocks.NewMockTransport(t)
@@ -1252,7 +1252,7 @@ func TestAttachVersionMismatch(t *testing.T) {
1252 func TestAttachRestoredOnRecvErrorMidStream(t *testing.T) {1252 func TestAttachRestoredOnRecvErrorMidStream(t *testing.T) {
1253 var out bytes.Buffer1253 var out bytes.Buffer
1254 var restoreCount atomic.Int321254 var restoreCount atomic.Int32
1255- resizeCh := make(chan domain.Size)1255+ resizeCh := make(chan domain.Geometry)
1256 tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)1256 tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)
1257 defer in.unblock()1257 defer in.unblock()
1258 1258
@@ -1276,7 +1276,7 @@ func TestAttachRestoredOnRecvErrorMidStream(t *testing.T) {
1276 func TestAttachDaemonVanishedOnEOF(t *testing.T) {1276 func TestAttachDaemonVanishedOnEOF(t *testing.T) {
1277 var out bytes.Buffer1277 var out bytes.Buffer
1278 var restoreCount atomic.Int321278 var restoreCount atomic.Int32
1279- resizeCh := make(chan domain.Size)1279+ resizeCh := make(chan domain.Geometry)
1280 tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)1280 tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)
1281 defer in.unblock()1281 defer in.unblock()
1282 1282
@@ -1300,12 +1300,12 @@ func TestAttachDaemonVanishedOnEOF(t *testing.T) {
1300 func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) {1300 func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) {
1301 var out bytes.Buffer1301 var out bytes.Buffer
1302 var restoreCount atomic.Int321302 var restoreCount atomic.Int32
1303- resizeCh := make(chan domain.Size)1303+ resizeCh := make(chan domain.Geometry)
1304 input := newOneShotBlockingReader([]byte("\x1b[<0;1;1M"))1304 input := newOneShotBlockingReader([]byte("\x1b[<0;1;1M"))
1305 defer input.unblock()1305 defer input.unblock()
1306 1306
1307 tm := portsmocks.NewMockTerminal(t)1307 tm := portsmocks.NewMockTerminal(t)
1308- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()1308+ tm.EXPECT().Geometry().Return(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil).Once()
1309 tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()1309 tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
1310 tm.EXPECT().In().Return(input).Maybe()1310 tm.EXPECT().In().Return(input).Maybe()
1311 tm.EXPECT().Out().Return(&out).Maybe()1311 tm.EXPECT().Out().Return(&out).Maybe()
@@ -1361,13 +1361,13 @@ func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) {
1361 func TestAttachStdinCoalescesSplitBracketedPaste(t *testing.T) {1361 func TestAttachStdinCoalescesSplitBracketedPaste(t *testing.T) {
1362 var out bytes.Buffer1362 var out bytes.Buffer
1363 var restoreCount atomic.Int321363 var restoreCount atomic.Int32
1364- resizeCh := make(chan domain.Size)1364+ resizeCh := make(chan domain.Geometry)
1365 paste := []byte("\x1b[200~hello\nworld\x1b[201~")1365 paste := []byte("\x1b[200~hello\nworld\x1b[201~")
1366 input := newChunkedBlockingReader([]byte("\x1b[200~hello\n"), []byte("world\x1b[201~"))1366 input := newChunkedBlockingReader([]byte("\x1b[200~hello\n"), []byte("world\x1b[201~"))
1367 defer input.unblock()1367 defer input.unblock()
1368 1368
1369 tm := portsmocks.NewMockTerminal(t)1369 tm := portsmocks.NewMockTerminal(t)
1370- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()1370+ tm.EXPECT().Geometry().Return(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil).Once()
1371 tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()1371 tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
1372 tm.EXPECT().In().Return(input).Maybe()1372 tm.EXPECT().In().Return(input).Maybe()
1373 tm.EXPECT().Out().Return(&out).Maybe()1373 tm.EXPECT().Out().Return(&out).Maybe()
@@ -1423,7 +1423,7 @@ func TestAttachStdinCoalescesSplitBracketedPaste(t *testing.T) {
1423 func TestAttachForwardsResize(t *testing.T) {1423 func TestAttachForwardsResize(t *testing.T) {
1424 var out bytes.Buffer1424 var out bytes.Buffer
1425 var restoreCount atomic.Int321425 var restoreCount atomic.Int32
1426- resizeCh := make(chan domain.Size)1426+ resizeCh := make(chan domain.Geometry)
1427 tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)1427 tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)
1428 defer in.unblock()1428 defer in.unblock()
1429 1429
@@ -1472,7 +1472,7 @@ func TestAttachForwardsResize(t *testing.T) {
1472 // Push a resize event once attach begins receiving daemon frames.1472 // Push a resize event once attach begins receiving daemon frames.
1473 go func() {1473 go func() {
1474 <-firstRecv1474 <-firstRecv
1475- resizeCh <- domain.Size{Cols: 120, Rows: 40}1475+ resizeCh <- domain.Geometry{Size: domain.Size{Cols: 120, Rows: 40}}
1476 }()1476 }()
1477 1477
1478 err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})1478 err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
@@ -1488,7 +1488,7 @@ func TestAttachForwardsResize(t *testing.T) {
1488 func TestRunRestoresRawModeAfterAttachError(t *testing.T) {1488 func TestRunRestoresRawModeAfterAttachError(t *testing.T) {
1489 var out bytes.Buffer1489 var out bytes.Buffer
1490 var restoreCount atomic.Int321490 var restoreCount atomic.Int32
1491- resizeCh := make(chan domain.Size)1491+ resizeCh := make(chan domain.Geometry)
1492 tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)1492 tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)
1493 defer in.unblock()1493 defer in.unblock()
1494 1494
@@ -1513,7 +1513,7 @@ func TestRunRestoresRawModeAfterAttachError(t *testing.T) {
1513 1513
1514 func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) {1514 func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) {
1515 tm := portsmocks.NewMockTerminal(t)1515 tm := portsmocks.NewMockTerminal(t)
1516- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()1516+ tm.EXPECT().Geometry().Return(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil).Once()
1517 // EnterRaw must NOT be called when the daemon rejects Hello before Welcome.1517 // EnterRaw must NOT be called when the daemon rejects Hello before Welcome.
1518 1518
1519 tr := portsmocks.NewMockTransport(t)1519 tr := portsmocks.NewMockTransport(t)
@@ -1615,15 +1615,16 @@ type runTerminal struct {
1615 out bytes.Buffer1615 out bytes.Buffer
1616 rawCount atomic.Int321616 rawCount atomic.Int32
1617 restoreCount atomic.Int321617 restoreCount atomic.Int32
1618- resizeCh chan domain.Size1618+ resizeCh chan domain.Geometry
1619- sizeMu sync.Mutex1619+ geometryMu sync.Mutex
1620- size domain.Size1620+ geometry domain.Geometry
1621 }1621 }
1622 1622
1623 func newRunTerminal() *runTerminal {1623 func newRunTerminal() *runTerminal {
1624 return &runTerminal{1624 return &runTerminal{
1625- in: newBlockingReader(), resizeCh: make(chan domain.Size),1625+ in: newBlockingReader(),
1626- size: domain.Size{Cols: 80, Rows: 24},1626+ resizeCh: make(chan domain.Geometry),
1627+ geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}},
1627 }1628 }
1628 }1629 }
1629 1630
@@ -1631,20 +1632,20 @@ func (t *runTerminal) EnterRaw() (func() error, error) {
1631 t.rawCount.Add(1)1632 t.rawCount.Add(1)
1632 return func() error { t.restoreCount.Add(1); return nil }, nil1633 return func() error { t.restoreCount.Add(1); return nil }, nil
1633 }1634 }
1634-func (t *runTerminal) Size() (domain.Size, error) {1635+func (t *runTerminal) Geometry() (domain.Geometry, error) {
1635- t.sizeMu.Lock()1636+ t.geometryMu.Lock()
1636- defer t.sizeMu.Unlock()1637+ defer t.geometryMu.Unlock()
1637- return t.size, nil1638+ return t.geometry, nil
1638 }1639 }
1639 func (t *runTerminal) setSize(size domain.Size) {1640 func (t *runTerminal) setSize(size domain.Size) {
1640- t.sizeMu.Lock()1641+ t.geometryMu.Lock()
1641- t.size = size1642+ t.geometry.Size = size
1642- t.sizeMu.Unlock()1643+ t.geometryMu.Unlock()
1643 }1644 }
1644-func (t *runTerminal) ResizeEvents() <-chan domain.Size { return t.resizeCh }1645+func (t *runTerminal) ResizeEvents() <-chan domain.Geometry { return t.resizeCh }
1645-func (t *runTerminal) In() io.Reader { return t.in }1646+func (t *runTerminal) In() io.Reader { return t.in }
1646-func (t *runTerminal) Out() io.Writer { return &t.out }1647+func (t *runTerminal) Out() io.Writer { return &t.out }
1647-func (t *runTerminal) Flush() error { return nil }1648+func (t *runTerminal) Flush() error { return nil }
1648 1649
1649 func helloFromSend(t *testing.T, tr *recordingTransport) ports.Hello {1650 func helloFromSend(t *testing.T, tr *recordingTransport) ports.Hello {
1650 t.Helper()1651 t.Helper()
@@ -1871,12 +1872,12 @@ func TestAttachRememberRemoteHost(t *testing.T) {
1871 var unblockIn func()1872 var unblockIn func()
1872 if tt.preWelcomeReject {1873 if tt.preWelcomeReject {
1873 tm = portsmocks.NewMockTerminal(t)1874 tm = portsmocks.NewMockTerminal(t)
1874- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()1875+ tm.EXPECT().Geometry().Return(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil).Once()
1875 // EnterRaw must NOT be called when the daemon rejects Hello before Welcome.1876 // EnterRaw must NOT be called when the daemon rejects Hello before Welcome.
1876 } else {1877 } else {
1877 var out bytes.Buffer1878 var out bytes.Buffer
1878 var restoreCount atomic.Int321879 var restoreCount atomic.Int32
1879- resizeCh := make(chan domain.Size)1880+ resizeCh := make(chan domain.Geometry)
1880 var in *blockingReader1881 var in *blockingReader
1881 tm, in = newHappyTerminal(t, &out, &restoreCount, resizeCh)1882 tm, in = newHappyTerminal(t, &out, &restoreCount, resizeCh)
1882 unblockIn = in.unblock1883 unblockIn = in.unblock
@@ -1971,9 +1972,9 @@ func TestRunRememberRemoteHostAtMostOnceAcrossReconnects(t *testing.T) {
1971 func TestAttachRememberRemoteHostDoesNotBlockAfterWelcome(t *testing.T) {1972 func TestAttachRememberRemoteHostDoesNotBlockAfterWelcome(t *testing.T) {
1972 var out bytes.Buffer1973 var out bytes.Buffer
1973 var restoreCount atomic.Int321974 var restoreCount atomic.Int32
1974- resizeCh := make(chan domain.Size)1975+ resizeCh := make(chan domain.Geometry)
1975 tm := portsmocks.NewMockTerminal(t)1976 tm := portsmocks.NewMockTerminal(t)
1976- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()1977+ tm.EXPECT().Geometry().Return(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil).Once()
1977 enteredRaw := make(chan struct{})1978 enteredRaw := make(chan struct{})
1978 tm.EXPECT().EnterRaw().Run(func() { close(enteredRaw) }).Return(func() error {1979 tm.EXPECT().EnterRaw().Run(func() { close(enteredRaw) }).Return(func() error {
1979 restoreCount.Add(1)1980 restoreCount.Add(1)
@@ -2052,9 +2053,9 @@ func TestRunRestoresTerminalBeforeWaitingForLearner(t *testing.T) {
2052 var out bytes.Buffer2053 var out bytes.Buffer
2053 var restoreCount atomic.Int322054 var restoreCount atomic.Int32
2054 restored := make(chan struct{})2055 restored := make(chan struct{})
2055- resizeCh := make(chan domain.Size)2056+ resizeCh := make(chan domain.Geometry)
2056 tm := portsmocks.NewMockTerminal(t)2057 tm := portsmocks.NewMockTerminal(t)
2057- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()2058+ tm.EXPECT().Geometry().Return(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil).Once()
2058 tm.EXPECT().EnterRaw().Return(func() error {2059 tm.EXPECT().EnterRaw().Return(func() error {
2059 restoreCount.Add(1)2060 restoreCount.Add(1)
2060 close(restored)2061 close(restored)
@@ -2134,9 +2135,9 @@ func TestRunReturnsWhenRemoteHostLearnerStalls(t *testing.T) {
2134 var out bytes.Buffer2135 var out bytes.Buffer
2135 var restoreCount atomic.Int322136 var restoreCount atomic.Int32
2136 restored := make(chan struct{})2137 restored := make(chan struct{})
2137- resizeCh := make(chan domain.Size)2138+ resizeCh := make(chan domain.Geometry)
2138 tm := portsmocks.NewMockTerminal(t)2139 tm := portsmocks.NewMockTerminal(t)
2139- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()2140+ tm.EXPECT().Geometry().Return(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil).Once()
2140 tm.EXPECT().EnterRaw().Return(func() error {2141 tm.EXPECT().EnterRaw().Return(func() error {
2141 restoreCount.Add(1)2142 restoreCount.Add(1)
2142 close(restored)2143 close(restored)
M internal/usecase/client/clipboard_intercept_test.go +5 -5
@@ -26,12 +26,12 @@ func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReade
2626 t.Helper()
2727 var out bytes.Buffer
2828 var restoreCount int32
29- resizeCh := make(chan domain.Size)
29+ resizeCh := make(chan domain.Geometry)
3030 input := newOneShotBlockingReader(stdin)
3131 defer input.unblock()
3232
3333 tm := portsmocks.NewMockTerminal(t)
34- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Maybe()
34+ tm.EXPECT().Geometry().Return(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil).Maybe()
3535 tm.EXPECT().EnterRaw().Return(func() error { restoreCount++; return nil }, nil).Once()
3636 tm.EXPECT().In().Return(input).Maybe()
3737 tm.EXPECT().Out().Return(&out).Maybe()
@@ -161,16 +161,16 @@ func (d clipboardToastLifecycleDialer) Dial(context.Context) (ports.Transport, e
161161
162162 func TestRunRemoteClipboardFailureNotifiesDaemonAndWritesOutputVerbatim(t *testing.T) {
163163 var out bytes.Buffer
164- resizeEvents := make(chan domain.Size)
164+ resizeEvents := make(chan domain.Geometry)
165165 input := newOneShotBlockingReader([]byte{0x16})
166166 defer input.unblock()
167167 term := portsmocks.NewMockTerminal(t)
168- term.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Maybe()
168+ term.EXPECT().Geometry().Return(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil).Maybe()
169169 term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Once()
170170 term.EXPECT().In().Return(input).Maybe()
171171 term.EXPECT().Out().Return(&out).Maybe()
172172 term.EXPECT().Flush().Return(nil).Maybe()
173- term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(resizeEvents)).Maybe()
173+ term.EXPECT().ResizeEvents().Return((<-chan domain.Geometry)(resizeEvents)).Maybe()
174174
175175 clipboard := portsmocks.NewMockClipboardReader(t)
176176 clipboard.EXPECT().ReadImage(mock.Anything).Return("", nil, errors.New("read failed")).Once()
@@ -26,12 +26,12 @@ func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReade
26 t.Helper()26 t.Helper()
27 var out bytes.Buffer27 var out bytes.Buffer
28 var restoreCount int3228 var restoreCount int32
29- resizeCh := make(chan domain.Size)29+ resizeCh := make(chan domain.Geometry)
30 input := newOneShotBlockingReader(stdin)30 input := newOneShotBlockingReader(stdin)
31 defer input.unblock()31 defer input.unblock()
32 32
33 tm := portsmocks.NewMockTerminal(t)33 tm := portsmocks.NewMockTerminal(t)
34- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Maybe()34+ tm.EXPECT().Geometry().Return(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil).Maybe()
35 tm.EXPECT().EnterRaw().Return(func() error { restoreCount++; return nil }, nil).Once()35 tm.EXPECT().EnterRaw().Return(func() error { restoreCount++; return nil }, nil).Once()
36 tm.EXPECT().In().Return(input).Maybe()36 tm.EXPECT().In().Return(input).Maybe()
37 tm.EXPECT().Out().Return(&out).Maybe()37 tm.EXPECT().Out().Return(&out).Maybe()
@@ -161,16 +161,16 @@ func (d clipboardToastLifecycleDialer) Dial(context.Context) (ports.Transport, e
161 161
162 func TestRunRemoteClipboardFailureNotifiesDaemonAndWritesOutputVerbatim(t *testing.T) {162 func TestRunRemoteClipboardFailureNotifiesDaemonAndWritesOutputVerbatim(t *testing.T) {
163 var out bytes.Buffer163 var out bytes.Buffer
164- resizeEvents := make(chan domain.Size)164+ resizeEvents := make(chan domain.Geometry)
165 input := newOneShotBlockingReader([]byte{0x16})165 input := newOneShotBlockingReader([]byte{0x16})
166 defer input.unblock()166 defer input.unblock()
167 term := portsmocks.NewMockTerminal(t)167 term := portsmocks.NewMockTerminal(t)
168- term.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Maybe()168+ term.EXPECT().Geometry().Return(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil).Maybe()
169 term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Once()169 term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Once()
170 term.EXPECT().In().Return(input).Maybe()170 term.EXPECT().In().Return(input).Maybe()
171 term.EXPECT().Out().Return(&out).Maybe()171 term.EXPECT().Out().Return(&out).Maybe()
172 term.EXPECT().Flush().Return(nil).Maybe()172 term.EXPECT().Flush().Return(nil).Maybe()
173- term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(resizeEvents)).Maybe()173+ term.EXPECT().ResizeEvents().Return((<-chan domain.Geometry)(resizeEvents)).Maybe()
174 174
175 clipboard := portsmocks.NewMockClipboardReader(t)175 clipboard := portsmocks.NewMockClipboardReader(t)
176 clipboard.EXPECT().ReadImage(mock.Anything).Return("", nil, errors.New("read failed")).Once()176 clipboard.EXPECT().ReadImage(mock.Anything).Return("", nil, errors.New("read failed")).Once()
M internal/usecase/client/palette_attach_generation_test.go +8 -8
@@ -98,19 +98,19 @@ func (w *attachPaletteWriter) Write(data []byte) (int, error) {
9898 type attachPaletteTerminalHarness struct {
9999 in io.Reader
100100 out *attachPaletteWriter
101- resize chan domain.Size
101+ resize chan domain.Geometry
102102 }
103103
104104 func (t *attachPaletteTerminalHarness) EnterRaw() (func() error, error) {
105105 return func() error { return nil }, nil
106106 }
107-func (*attachPaletteTerminalHarness) Size() (domain.Size, error) {
108- return domain.Size{Cols: 80, Rows: 24}, nil
107+func (*attachPaletteTerminalHarness) Geometry() (domain.Geometry, error) {
108+ return domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil
109109 }
110-func (t *attachPaletteTerminalHarness) ResizeEvents() <-chan domain.Size { return t.resize }
111-func (t *attachPaletteTerminalHarness) In() io.Reader { return t.in }
112-func (t *attachPaletteTerminalHarness) Out() io.Writer { return t.out }
113-func (*attachPaletteTerminalHarness) Flush() error { return nil }
110+func (t *attachPaletteTerminalHarness) ResizeEvents() <-chan domain.Geometry { return t.resize }
111+func (t *attachPaletteTerminalHarness) In() io.Reader { return t.in }
112+func (t *attachPaletteTerminalHarness) Out() io.Writer { return t.out }
113+func (*attachPaletteTerminalHarness) Flush() error { return nil }
114114
115115 type attachPaletteTransport struct {
116116 mu sync.Mutex
@@ -200,7 +200,7 @@ func startAttachPaletteHarnessWithInputAndWriteError(state *terminalThemeState,
200200 reader := newAttachPaletteReader()
201201 writer := newAttachPaletteWriter()
202202 writer.err = writeErr
203- term := &attachPaletteTerminalHarness{in: reader, out: writer, resize: make(chan domain.Size)}
203+ term := &attachPaletteTerminalHarness{in: reader, out: writer, resize: make(chan domain.Geometry)}
204204 ms := &milestones{}
205205 runner := &Runner{term: term, clock: clock, logger: slog.New(slog.DiscardHandler)}
206206 attempt := &attachAttempt{
@@ -98,19 +98,19 @@ func (w *attachPaletteWriter) Write(data []byte) (int, error) {
98 type attachPaletteTerminalHarness struct {98 type attachPaletteTerminalHarness struct {
99 in io.Reader99 in io.Reader
100 out *attachPaletteWriter100 out *attachPaletteWriter
101- resize chan domain.Size101+ resize chan domain.Geometry
102 }102 }
103 103
104 func (t *attachPaletteTerminalHarness) EnterRaw() (func() error, error) {104 func (t *attachPaletteTerminalHarness) EnterRaw() (func() error, error) {
105 return func() error { return nil }, nil105 return func() error { return nil }, nil
106 }106 }
107-func (*attachPaletteTerminalHarness) Size() (domain.Size, error) {107+func (*attachPaletteTerminalHarness) Geometry() (domain.Geometry, error) {
108- return domain.Size{Cols: 80, Rows: 24}, nil108+ return domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil
109 }109 }
110-func (t *attachPaletteTerminalHarness) ResizeEvents() <-chan domain.Size { return t.resize }110+func (t *attachPaletteTerminalHarness) ResizeEvents() <-chan domain.Geometry { return t.resize }
111-func (t *attachPaletteTerminalHarness) In() io.Reader { return t.in }111+func (t *attachPaletteTerminalHarness) In() io.Reader { return t.in }
112-func (t *attachPaletteTerminalHarness) Out() io.Writer { return t.out }112+func (t *attachPaletteTerminalHarness) Out() io.Writer { return t.out }
113-func (*attachPaletteTerminalHarness) Flush() error { return nil }113+func (*attachPaletteTerminalHarness) Flush() error { return nil }
114 114
115 type attachPaletteTransport struct {115 type attachPaletteTransport struct {
116 mu sync.Mutex116 mu sync.Mutex
@@ -200,7 +200,7 @@ func startAttachPaletteHarnessWithInputAndWriteError(state *terminalThemeState,
200 reader := newAttachPaletteReader()200 reader := newAttachPaletteReader()
201 writer := newAttachPaletteWriter()201 writer := newAttachPaletteWriter()
202 writer.err = writeErr202 writer.err = writeErr
203- term := &attachPaletteTerminalHarness{in: reader, out: writer, resize: make(chan domain.Size)}203+ term := &attachPaletteTerminalHarness{in: reader, out: writer, resize: make(chan domain.Geometry)}
204 ms := &milestones{}204 ms := &milestones{}
205 runner := &Runner{term: term, clock: clock, logger: slog.New(slog.DiscardHandler)}205 runner := &Runner{term: term, clock: clock, logger: slog.New(slog.DiscardHandler)}
206 attempt := &attachAttempt{206 attempt := &attachAttempt{
M internal/usecase/client/palette_attach_test.go +8 -8
@@ -61,19 +61,19 @@ func (r *paletteAttachReader) close() { r.once.Do(func() { close(r.done) }) }
6161 type paletteAttachTerminal struct {
6262 in io.Reader
6363 out bytes.Buffer
64- resize chan domain.Size
64+ resize chan domain.Geometry
6565 }
6666
6767 func (t *paletteAttachTerminal) EnterRaw() (func() error, error) {
6868 return func() error { return nil }, nil
6969 }
70-func (t *paletteAttachTerminal) Size() (domain.Size, error) {
71- return domain.Size{Cols: 80, Rows: 24}, nil
70+func (t *paletteAttachTerminal) Geometry() (domain.Geometry, error) {
71+ return domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil
7272 }
73-func (t *paletteAttachTerminal) ResizeEvents() <-chan domain.Size { return t.resize }
74-func (t *paletteAttachTerminal) In() io.Reader { return t.in }
75-func (t *paletteAttachTerminal) Out() io.Writer { return &t.out }
76-func (t *paletteAttachTerminal) Flush() error { return nil }
73+func (t *paletteAttachTerminal) ResizeEvents() <-chan domain.Geometry { return t.resize }
74+func (t *paletteAttachTerminal) In() io.Reader { return t.in }
75+func (t *paletteAttachTerminal) Out() io.Writer { return &t.out }
76+func (t *paletteAttachTerminal) Flush() error { return nil }
7777
7878 type paletteAttachTransport struct {
7979 mu sync.Mutex
@@ -136,7 +136,7 @@ func TestPaletteAttachReaderPreservesShortReadRemainderAndCloses(t *testing.T) {
136136 func TestAttachPublishesOnlyClearedAndDefinitiveInitialPalette(t *testing.T) {
137137 input := newPaletteAttachReader([]byte("\x1b]10;#010203\a\x1b]11;#040506\a\x1b]4;2;#102030\a\x1b[?2031;1$y"))
138138 t.Cleanup(input.close)
139- term := &paletteAttachTerminal{in: input, resize: make(chan domain.Size)}
139+ term := &paletteAttachTerminal{in: input, resize: make(chan domain.Geometry)}
140140 transport := &paletteAttachTransport{finalSet: make(chan struct{})}
141141 ms := &milestones{}
142142 runner := &Runner{term: term, clock: paletteAttachClock{}, logger: slog.New(slog.DiscardHandler)}
@@ -61,19 +61,19 @@ func (r *paletteAttachReader) close() { r.once.Do(func() { close(r.done) }) }
61 type paletteAttachTerminal struct {61 type paletteAttachTerminal struct {
62 in io.Reader62 in io.Reader
63 out bytes.Buffer63 out bytes.Buffer
64- resize chan domain.Size64+ resize chan domain.Geometry
65 }65 }
66 66
67 func (t *paletteAttachTerminal) EnterRaw() (func() error, error) {67 func (t *paletteAttachTerminal) EnterRaw() (func() error, error) {
68 return func() error { return nil }, nil68 return func() error { return nil }, nil
69 }69 }
70-func (t *paletteAttachTerminal) Size() (domain.Size, error) {70+func (t *paletteAttachTerminal) Geometry() (domain.Geometry, error) {
71- return domain.Size{Cols: 80, Rows: 24}, nil71+ return domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil
72 }72 }
73-func (t *paletteAttachTerminal) ResizeEvents() <-chan domain.Size { return t.resize }73+func (t *paletteAttachTerminal) ResizeEvents() <-chan domain.Geometry { return t.resize }
74-func (t *paletteAttachTerminal) In() io.Reader { return t.in }74+func (t *paletteAttachTerminal) In() io.Reader { return t.in }
75-func (t *paletteAttachTerminal) Out() io.Writer { return &t.out }75+func (t *paletteAttachTerminal) Out() io.Writer { return &t.out }
76-func (t *paletteAttachTerminal) Flush() error { return nil }76+func (t *paletteAttachTerminal) Flush() error { return nil }
77 77
78 type paletteAttachTransport struct {78 type paletteAttachTransport struct {
79 mu sync.Mutex79 mu sync.Mutex
@@ -136,7 +136,7 @@ func TestPaletteAttachReaderPreservesShortReadRemainderAndCloses(t *testing.T) {
136 func TestAttachPublishesOnlyClearedAndDefinitiveInitialPalette(t *testing.T) {136 func TestAttachPublishesOnlyClearedAndDefinitiveInitialPalette(t *testing.T) {
137 input := newPaletteAttachReader([]byte("\x1b]10;#010203\a\x1b]11;#040506\a\x1b]4;2;#102030\a\x1b[?2031;1$y"))137 input := newPaletteAttachReader([]byte("\x1b]10;#010203\a\x1b]11;#040506\a\x1b]4;2;#102030\a\x1b[?2031;1$y"))
138 t.Cleanup(input.close)138 t.Cleanup(input.close)
139- term := &paletteAttachTerminal{in: input, resize: make(chan domain.Size)}139+ term := &paletteAttachTerminal{in: input, resize: make(chan domain.Geometry)}
140 transport := &paletteAttachTransport{finalSet: make(chan struct{})}140 transport := &paletteAttachTransport{finalSet: make(chan struct{})}
141 ms := &milestones{}141 ms := &milestones{}
142 runner := &Runner{term: term, clock: paletteAttachClock{}, logger: slog.New(slog.DiscardHandler)}142 runner := &Runner{term: term, clock: paletteAttachClock{}, logger: slog.New(slog.DiscardHandler)}
M internal/usecase/client/reconnect_toast_test.go +25 -25
@@ -28,7 +28,7 @@ type reconnectToastTerminalHarness struct {
2828 inWriter *io.PipeWriter
2929 out bytes.Buffer
3030 size domain.Size
31- resizeCh chan domain.Size
31+ resizeCh chan domain.Geometry
3232 sizeCalls atomic.Int32
3333 sizeCalled chan int32
3434 }
@@ -41,18 +41,18 @@ func newReconnectToastTerminalHarness(t *testing.T) *reconnectToastTerminalHarne
4141 in: in,
4242 inWriter: inWriter,
4343 size: domain.Size{Cols: 80, Rows: 24},
44- resizeCh: make(chan domain.Size, sendQueueDepth),
44+ resizeCh: make(chan domain.Geometry, sendQueueDepth),
4545 sizeCalled: make(chan int32, 16),
4646 }
4747 h.term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Maybe()
48- h.term.EXPECT().Size().Run(func() {
48+ h.term.EXPECT().Geometry().Run(func() {
4949 call := h.sizeCalls.Add(1)
5050 select {
5151 case h.sizeCalled <- call:
5252 default:
5353 }
54- }).Return(h.size, nil).Maybe()
55- h.term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(h.resizeCh)).Maybe()
54+ }).Return(domain.Geometry{Size: h.size}, nil).Maybe()
55+ h.term.EXPECT().ResizeEvents().Return((<-chan domain.Geometry)(h.resizeCh)).Maybe()
5656 h.term.EXPECT().In().Return(h.in).Maybe()
5757 h.term.EXPECT().Out().Return(&h.out).Maybe()
5858 h.term.EXPECT().Flush().Return(nil).Maybe()
@@ -72,18 +72,18 @@ func newReconnectToastTerminalHarnessWithOutputAndFlush(t *testing.T, out io.Wri
7272 in: in,
7373 inWriter: inWriter,
7474 size: domain.Size{Cols: 80, Rows: 24},
75- resizeCh: make(chan domain.Size, sendQueueDepth),
75+ resizeCh: make(chan domain.Geometry, sendQueueDepth),
7676 sizeCalled: make(chan int32, 16),
7777 }
7878 h.term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Maybe()
79- h.term.EXPECT().Size().Run(func() {
79+ h.term.EXPECT().Geometry().Run(func() {
8080 call := h.sizeCalls.Add(1)
8181 select {
8282 case h.sizeCalled <- call:
8383 default:
8484 }
85- }).Return(h.size, nil).Maybe()
86- h.term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(h.resizeCh)).Maybe()
85+ }).Return(domain.Geometry{Size: h.size}, nil).Maybe()
86+ h.term.EXPECT().ResizeEvents().Return((<-chan domain.Geometry)(h.resizeCh)).Maybe()
8787 h.term.EXPECT().In().Return(h.in).Maybe()
8888 h.term.EXPECT().Out().Return(out).Maybe()
8989 h.term.EXPECT().Flush().RunAndReturn(flush).Maybe()
@@ -1071,14 +1071,14 @@ func TestReconnectResetEnqueueCancellationExitsCleanly(t *testing.T) {
10711071 requireTerminalSizeCalls(t, term, 2)
10721072 tr.blockType = ports.MsgResize
10731073 tr.armed.Store(true)
1074- term.resizeCh <- term.size
1074+ term.resizeCh <- domain.Geometry{Size: term.size}
10751075 select {
10761076 case <-tr.entered:
10771077 case <-time.After(time.Second):
10781078 t.Fatal("timed out waiting for sender to block")
10791079 }
10801080 for range sendQueueDepth {
1081- term.resizeCh <- term.size
1081+ term.resizeCh <- domain.Geometry{Size: term.size}
10821082 }
10831083 require.Eventually(t, func() bool { return len(term.resizeCh) == 0 }, time.Second, time.Millisecond)
10841084
@@ -1111,14 +1111,14 @@ func TestReconnectResetCancellationPreservesQueuedSenderError(t *testing.T) {
11111111 requireTerminalSizeCalls(t, term, 2)
11121112 tr.blockType = ports.MsgResize
11131113 tr.armed.Store(true)
1114- term.resizeCh <- term.size
1114+ term.resizeCh <- domain.Geometry{Size: term.size}
11151115 select {
11161116 case <-tr.entered:
11171117 case <-time.After(time.Second):
11181118 t.Fatal("timed out waiting for sender to block")
11191119 }
11201120 for range sendQueueDepth {
1121- term.resizeCh <- term.size
1121+ term.resizeCh <- domain.Geometry{Size: term.size}
11221122 }
11231123 require.Eventually(t, func() bool { return len(term.resizeCh) == 0 }, time.Second, time.Millisecond)
11241124
@@ -1261,9 +1261,9 @@ func TestReconnectSleepWithResizeEventsRedrawsUntilTimerFires(t *testing.T) {
12611261 clk.EXPECT().NewTimer(time.Hour).Return(timer).Once()
12621262 timer.EXPECT().C().Return((<-chan time.Time)(timerCh)).Maybe()
12631263 timer.EXPECT().Stop().Return(true).Once()
1264- resizeCh := make(chan domain.Size, 2)
1265- resizeCh <- domain.Size{Cols: 100, Rows: 30}
1266- resizeCh <- domain.Size{Cols: 120, Rows: 40}
1264+ resizeCh := make(chan domain.Geometry, 2)
1265+ resizeCh <- domain.Geometry{Size: domain.Size{Cols: 100, Rows: 30}}
1266+ resizeCh <- domain.Geometry{Size: domain.Size{Cols: 120, Rows: 40}}
12671267 got := make(chan domain.Size, 2)
12681268 type sleepResult struct {
12691269 slept bool
@@ -1293,8 +1293,8 @@ func TestReconnectSleepWithResizeEventsReturnsRedrawError(t *testing.T) {
12931293 clk.EXPECT().NewTimer(time.Hour).Return(timer).Once()
12941294 timer.EXPECT().C().Return((<-chan time.Time)(make(chan time.Time))).Maybe()
12951295 timer.EXPECT().Stop().Return(true).Once()
1296- resizeCh := make(chan domain.Size, 1)
1297- resizeCh <- domain.Size{Cols: 100, Rows: 30}
1296+ resizeCh := make(chan domain.Geometry, 1)
1297+ resizeCh <- domain.Geometry{Size: domain.Size{Cols: 100, Rows: 30}}
12981298 redrawErr := errors.New("redraw failed")
12991299
13001300 slept, err := sleepReconnectWithResizeEvents(context.Background(), clk, time.Hour, resizeCh, func(domain.Size) error {
@@ -1349,7 +1349,7 @@ func TestReconnectRetryFailuresPreserveAttachError(t *testing.T) {
13491349 for _, tt := range tests {
13501350 t.Run(tt.name, func(t *testing.T) {
13511351 oldSleepWithResize := reconnectSleepWithResize
1352- reconnectSleepWithResize = func(_ context.Context, _ ports.Clock, _ time.Duration, _ <-chan domain.Size, redraw func(domain.Size) error) (bool, error) {
1352+ reconnectSleepWithResize = func(_ context.Context, _ ports.Clock, _ time.Duration, _ <-chan domain.Geometry, redraw func(domain.Size) error) (bool, error) {
13531353 return tt.retrySleep(redraw)
13541354 }
13551355 defer func() { reconnectSleepWithResize = oldSleepWithResize }()
@@ -1394,7 +1394,7 @@ func TestReconnectRetrySleepFailuresPreserveDialError(t *testing.T) {
13941394 t.Run(tt.name, func(t *testing.T) {
13951395 oldSleepWithResize := reconnectSleepWithResize
13961396 calls := 0
1397- reconnectSleepWithResize = func(_ context.Context, _ ports.Clock, _ time.Duration, _ <-chan domain.Size, redraw func(domain.Size) error) (bool, error) {
1397+ reconnectSleepWithResize = func(_ context.Context, _ ports.Clock, _ time.Duration, _ <-chan domain.Geometry, redraw func(domain.Size) error) (bool, error) {
13981398 calls++
13991399 if calls == 1 {
14001400 return true, nil
@@ -1431,7 +1431,7 @@ func TestRemoteReconnectToastFailedDrawDoesNotBlankBounds(t *testing.T) {
14311431 oldSleepWithResize := reconnectSleepWithResize
14321432 ctx, cancel := context.WithCancel(context.Background())
14331433 reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return false }
1434- reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size) error) (bool, error) {
1434+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Geometry, func(domain.Size) error) (bool, error) {
14351435 cancel()
14361436 return false, nil
14371437 }
@@ -1508,7 +1508,7 @@ func TestReconnectCancellationPreservesContextWhenStatusClearFails(t *testing.T)
15081508 oldSleepWithResize := reconnectSleepWithResize
15091509 ctx, cancel := context.WithCancel(context.Background())
15101510 reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return tt.sleep(cancel) }
1511- reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size) error) (bool, error) {
1511+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Geometry, func(domain.Size) error) (bool, error) {
15121512 return tt.sleep(cancel), nil
15131513 }
15141514 defer func() {
@@ -1542,7 +1542,7 @@ func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) {
15421542 oldSleep := reconnectSleep
15431543 oldSleepWithResize := reconnectSleepWithResize
15441544 reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true }
1545- reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size) error) (bool, error) {
1545+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Geometry, func(domain.Size) error) (bool, error) {
15461546 return true, nil
15471547 }
15481548 defer func() {
@@ -1586,7 +1586,7 @@ func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) {
15861586 oldSleep := reconnectSleep
15871587 oldSleepWithResize := reconnectSleepWithResize
15881588 reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true }
1589- reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size) error) (bool, error) {
1589+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Geometry, func(domain.Size) error) (bool, error) {
15901590 return true, nil
15911591 }
15921592 defer func() {
@@ -1677,7 +1677,7 @@ func TestRemoteReconnectToastLifecycle(t *testing.T) {
16771677 oldSleepWithResize := reconnectSleepWithResize
16781678 ctx, cancel := context.WithCancel(context.Background())
16791679 reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return tt.sleep(cancel) }
1680- reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size) error) (bool, error) {
1680+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Geometry, func(domain.Size) error) (bool, error) {
16811681 return tt.sleep(cancel), nil
16821682 }
16831683 defer func() {
@@ -28,7 +28,7 @@ type reconnectToastTerminalHarness struct {
28 inWriter *io.PipeWriter28 inWriter *io.PipeWriter
29 out bytes.Buffer29 out bytes.Buffer
30 size domain.Size30 size domain.Size
31- resizeCh chan domain.Size31+ resizeCh chan domain.Geometry
32 sizeCalls atomic.Int3232 sizeCalls atomic.Int32
33 sizeCalled chan int3233 sizeCalled chan int32
34 }34 }
@@ -41,18 +41,18 @@ func newReconnectToastTerminalHarness(t *testing.T) *reconnectToastTerminalHarne
41 in: in,41 in: in,
42 inWriter: inWriter,42 inWriter: inWriter,
43 size: domain.Size{Cols: 80, Rows: 24},43 size: domain.Size{Cols: 80, Rows: 24},
44- resizeCh: make(chan domain.Size, sendQueueDepth),44+ resizeCh: make(chan domain.Geometry, sendQueueDepth),
45 sizeCalled: make(chan int32, 16),45 sizeCalled: make(chan int32, 16),
46 }46 }
47 h.term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Maybe()47 h.term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Maybe()
48- h.term.EXPECT().Size().Run(func() {48+ h.term.EXPECT().Geometry().Run(func() {
49 call := h.sizeCalls.Add(1)49 call := h.sizeCalls.Add(1)
50 select {50 select {
51 case h.sizeCalled <- call:51 case h.sizeCalled <- call:
52 default:52 default:
53 }53 }
54- }).Return(h.size, nil).Maybe()54+ }).Return(domain.Geometry{Size: h.size}, nil).Maybe()
55- h.term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(h.resizeCh)).Maybe()55+ h.term.EXPECT().ResizeEvents().Return((<-chan domain.Geometry)(h.resizeCh)).Maybe()
56 h.term.EXPECT().In().Return(h.in).Maybe()56 h.term.EXPECT().In().Return(h.in).Maybe()
57 h.term.EXPECT().Out().Return(&h.out).Maybe()57 h.term.EXPECT().Out().Return(&h.out).Maybe()
58 h.term.EXPECT().Flush().Return(nil).Maybe()58 h.term.EXPECT().Flush().Return(nil).Maybe()
@@ -72,18 +72,18 @@ func newReconnectToastTerminalHarnessWithOutputAndFlush(t *testing.T, out io.Wri
72 in: in,72 in: in,
73 inWriter: inWriter,73 inWriter: inWriter,
74 size: domain.Size{Cols: 80, Rows: 24},74 size: domain.Size{Cols: 80, Rows: 24},
75- resizeCh: make(chan domain.Size, sendQueueDepth),75+ resizeCh: make(chan domain.Geometry, sendQueueDepth),
76 sizeCalled: make(chan int32, 16),76 sizeCalled: make(chan int32, 16),
77 }77 }
78 h.term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Maybe()78 h.term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Maybe()
79- h.term.EXPECT().Size().Run(func() {79+ h.term.EXPECT().Geometry().Run(func() {
80 call := h.sizeCalls.Add(1)80 call := h.sizeCalls.Add(1)
81 select {81 select {
82 case h.sizeCalled <- call:82 case h.sizeCalled <- call:
83 default:83 default:
84 }84 }
85- }).Return(h.size, nil).Maybe()85+ }).Return(domain.Geometry{Size: h.size}, nil).Maybe()
86- h.term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(h.resizeCh)).Maybe()86+ h.term.EXPECT().ResizeEvents().Return((<-chan domain.Geometry)(h.resizeCh)).Maybe()
87 h.term.EXPECT().In().Return(h.in).Maybe()87 h.term.EXPECT().In().Return(h.in).Maybe()
88 h.term.EXPECT().Out().Return(out).Maybe()88 h.term.EXPECT().Out().Return(out).Maybe()
89 h.term.EXPECT().Flush().RunAndReturn(flush).Maybe()89 h.term.EXPECT().Flush().RunAndReturn(flush).Maybe()
@@ -1071,14 +1071,14 @@ func TestReconnectResetEnqueueCancellationExitsCleanly(t *testing.T) {
1071 requireTerminalSizeCalls(t, term, 2)1071 requireTerminalSizeCalls(t, term, 2)
1072 tr.blockType = ports.MsgResize1072 tr.blockType = ports.MsgResize
1073 tr.armed.Store(true)1073 tr.armed.Store(true)
1074- term.resizeCh <- term.size1074+ term.resizeCh <- domain.Geometry{Size: term.size}
1075 select {1075 select {
1076 case <-tr.entered:1076 case <-tr.entered:
1077 case <-time.After(time.Second):1077 case <-time.After(time.Second):
1078 t.Fatal("timed out waiting for sender to block")1078 t.Fatal("timed out waiting for sender to block")
1079 }1079 }
1080 for range sendQueueDepth {1080 for range sendQueueDepth {
1081- term.resizeCh <- term.size1081+ term.resizeCh <- domain.Geometry{Size: term.size}
1082 }1082 }
1083 require.Eventually(t, func() bool { return len(term.resizeCh) == 0 }, time.Second, time.Millisecond)1083 require.Eventually(t, func() bool { return len(term.resizeCh) == 0 }, time.Second, time.Millisecond)
1084 1084
@@ -1111,14 +1111,14 @@ func TestReconnectResetCancellationPreservesQueuedSenderError(t *testing.T) {
1111 requireTerminalSizeCalls(t, term, 2)1111 requireTerminalSizeCalls(t, term, 2)
1112 tr.blockType = ports.MsgResize1112 tr.blockType = ports.MsgResize
1113 tr.armed.Store(true)1113 tr.armed.Store(true)
1114- term.resizeCh <- term.size1114+ term.resizeCh <- domain.Geometry{Size: term.size}
1115 select {1115 select {
1116 case <-tr.entered:1116 case <-tr.entered:
1117 case <-time.After(time.Second):1117 case <-time.After(time.Second):
1118 t.Fatal("timed out waiting for sender to block")1118 t.Fatal("timed out waiting for sender to block")
1119 }1119 }
1120 for range sendQueueDepth {1120 for range sendQueueDepth {
1121- term.resizeCh <- term.size1121+ term.resizeCh <- domain.Geometry{Size: term.size}
1122 }1122 }
1123 require.Eventually(t, func() bool { return len(term.resizeCh) == 0 }, time.Second, time.Millisecond)1123 require.Eventually(t, func() bool { return len(term.resizeCh) == 0 }, time.Second, time.Millisecond)
1124 1124
@@ -1261,9 +1261,9 @@ func TestReconnectSleepWithResizeEventsRedrawsUntilTimerFires(t *testing.T) {
1261 clk.EXPECT().NewTimer(time.Hour).Return(timer).Once()1261 clk.EXPECT().NewTimer(time.Hour).Return(timer).Once()
1262 timer.EXPECT().C().Return((<-chan time.Time)(timerCh)).Maybe()1262 timer.EXPECT().C().Return((<-chan time.Time)(timerCh)).Maybe()
1263 timer.EXPECT().Stop().Return(true).Once()1263 timer.EXPECT().Stop().Return(true).Once()
1264- resizeCh := make(chan domain.Size, 2)1264+ resizeCh := make(chan domain.Geometry, 2)
1265- resizeCh <- domain.Size{Cols: 100, Rows: 30}1265+ resizeCh <- domain.Geometry{Size: domain.Size{Cols: 100, Rows: 30}}
1266- resizeCh <- domain.Size{Cols: 120, Rows: 40}1266+ resizeCh <- domain.Geometry{Size: domain.Size{Cols: 120, Rows: 40}}
1267 got := make(chan domain.Size, 2)1267 got := make(chan domain.Size, 2)
1268 type sleepResult struct {1268 type sleepResult struct {
1269 slept bool1269 slept bool
@@ -1293,8 +1293,8 @@ func TestReconnectSleepWithResizeEventsReturnsRedrawError(t *testing.T) {
1293 clk.EXPECT().NewTimer(time.Hour).Return(timer).Once()1293 clk.EXPECT().NewTimer(time.Hour).Return(timer).Once()
1294 timer.EXPECT().C().Return((<-chan time.Time)(make(chan time.Time))).Maybe()1294 timer.EXPECT().C().Return((<-chan time.Time)(make(chan time.Time))).Maybe()
1295 timer.EXPECT().Stop().Return(true).Once()1295 timer.EXPECT().Stop().Return(true).Once()
1296- resizeCh := make(chan domain.Size, 1)1296+ resizeCh := make(chan domain.Geometry, 1)
1297- resizeCh <- domain.Size{Cols: 100, Rows: 30}1297+ resizeCh <- domain.Geometry{Size: domain.Size{Cols: 100, Rows: 30}}
1298 redrawErr := errors.New("redraw failed")1298 redrawErr := errors.New("redraw failed")
1299 1299
1300 slept, err := sleepReconnectWithResizeEvents(context.Background(), clk, time.Hour, resizeCh, func(domain.Size) error {1300 slept, err := sleepReconnectWithResizeEvents(context.Background(), clk, time.Hour, resizeCh, func(domain.Size) error {
@@ -1349,7 +1349,7 @@ func TestReconnectRetryFailuresPreserveAttachError(t *testing.T) {
1349 for _, tt := range tests {1349 for _, tt := range tests {
1350 t.Run(tt.name, func(t *testing.T) {1350 t.Run(tt.name, func(t *testing.T) {
1351 oldSleepWithResize := reconnectSleepWithResize1351 oldSleepWithResize := reconnectSleepWithResize
1352- reconnectSleepWithResize = func(_ context.Context, _ ports.Clock, _ time.Duration, _ <-chan domain.Size, redraw func(domain.Size) error) (bool, error) {1352+ reconnectSleepWithResize = func(_ context.Context, _ ports.Clock, _ time.Duration, _ <-chan domain.Geometry, redraw func(domain.Size) error) (bool, error) {
1353 return tt.retrySleep(redraw)1353 return tt.retrySleep(redraw)
1354 }1354 }
1355 defer func() { reconnectSleepWithResize = oldSleepWithResize }()1355 defer func() { reconnectSleepWithResize = oldSleepWithResize }()
@@ -1394,7 +1394,7 @@ func TestReconnectRetrySleepFailuresPreserveDialError(t *testing.T) {
1394 t.Run(tt.name, func(t *testing.T) {1394 t.Run(tt.name, func(t *testing.T) {
1395 oldSleepWithResize := reconnectSleepWithResize1395 oldSleepWithResize := reconnectSleepWithResize
1396 calls := 01396 calls := 0
1397- reconnectSleepWithResize = func(_ context.Context, _ ports.Clock, _ time.Duration, _ <-chan domain.Size, redraw func(domain.Size) error) (bool, error) {1397+ reconnectSleepWithResize = func(_ context.Context, _ ports.Clock, _ time.Duration, _ <-chan domain.Geometry, redraw func(domain.Size) error) (bool, error) {
1398 calls++1398 calls++
1399 if calls == 1 {1399 if calls == 1 {
1400 return true, nil1400 return true, nil
@@ -1431,7 +1431,7 @@ func TestRemoteReconnectToastFailedDrawDoesNotBlankBounds(t *testing.T) {
1431 oldSleepWithResize := reconnectSleepWithResize1431 oldSleepWithResize := reconnectSleepWithResize
1432 ctx, cancel := context.WithCancel(context.Background())1432 ctx, cancel := context.WithCancel(context.Background())
1433 reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return false }1433 reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return false }
1434- reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size) error) (bool, error) {1434+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Geometry, func(domain.Size) error) (bool, error) {
1435 cancel()1435 cancel()
1436 return false, nil1436 return false, nil
1437 }1437 }
@@ -1508,7 +1508,7 @@ func TestReconnectCancellationPreservesContextWhenStatusClearFails(t *testing.T)
1508 oldSleepWithResize := reconnectSleepWithResize1508 oldSleepWithResize := reconnectSleepWithResize
1509 ctx, cancel := context.WithCancel(context.Background())1509 ctx, cancel := context.WithCancel(context.Background())
1510 reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return tt.sleep(cancel) }1510 reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return tt.sleep(cancel) }
1511- reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size) error) (bool, error) {1511+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Geometry, func(domain.Size) error) (bool, error) {
1512 return tt.sleep(cancel), nil1512 return tt.sleep(cancel), nil
1513 }1513 }
1514 defer func() {1514 defer func() {
@@ -1542,7 +1542,7 @@ func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) {
1542 oldSleep := reconnectSleep1542 oldSleep := reconnectSleep
1543 oldSleepWithResize := reconnectSleepWithResize1543 oldSleepWithResize := reconnectSleepWithResize
1544 reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true }1544 reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true }
1545- reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size) error) (bool, error) {1545+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Geometry, func(domain.Size) error) (bool, error) {
1546 return true, nil1546 return true, nil
1547 }1547 }
1548 defer func() {1548 defer func() {
@@ -1586,7 +1586,7 @@ func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) {
1586 oldSleep := reconnectSleep1586 oldSleep := reconnectSleep
1587 oldSleepWithResize := reconnectSleepWithResize1587 oldSleepWithResize := reconnectSleepWithResize
1588 reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true }1588 reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true }
1589- reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size) error) (bool, error) {1589+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Geometry, func(domain.Size) error) (bool, error) {
1590 return true, nil1590 return true, nil
1591 }1591 }
1592 defer func() {1592 defer func() {
@@ -1677,7 +1677,7 @@ func TestRemoteReconnectToastLifecycle(t *testing.T) {
1677 oldSleepWithResize := reconnectSleepWithResize1677 oldSleepWithResize := reconnectSleepWithResize
1678 ctx, cancel := context.WithCancel(context.Background())1678 ctx, cancel := context.WithCancel(context.Background())
1679 reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return tt.sleep(cancel) }1679 reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return tt.sleep(cancel) }
1680- reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size) error) (bool, error) {1680+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Geometry, func(domain.Size) error) (bool, error) {
1681 return tt.sleep(cancel), nil1681 return tt.sleep(cancel), nil
1682 }1682 }
1683 defer func() {1683 defer func() {
M internal/usecase/client/terminal_input_pump_test.go +1 -1
@@ -152,7 +152,7 @@ func TestTerminalInputPumpStopDropsLateReadWithoutStartingAnother(t *testing.T)
152152 func TestRunnerReusesBlockedTerminalInputPumpAcrossRuns(t *testing.T) {
153153 reader := newLateStopReader()
154154 runner := &Runner{term: &attachPaletteTerminalHarness{
155- in: reader, out: newAttachPaletteWriter(), resize: make(chan domain.Size),
155+ in: reader, out: newAttachPaletteWriter(), resize: make(chan domain.Geometry),
156156 }}
157157
158158 first := runner.terminalInput()
@@ -152,7 +152,7 @@ func TestTerminalInputPumpStopDropsLateReadWithoutStartingAnother(t *testing.T)
152 func TestRunnerReusesBlockedTerminalInputPumpAcrossRuns(t *testing.T) {152 func TestRunnerReusesBlockedTerminalInputPumpAcrossRuns(t *testing.T) {
153 reader := newLateStopReader()153 reader := newLateStopReader()
154 runner := &Runner{term: &attachPaletteTerminalHarness{154 runner := &Runner{term: &attachPaletteTerminalHarness{
155- in: reader, out: newAttachPaletteWriter(), resize: make(chan domain.Size),155+ in: reader, out: newAttachPaletteWriter(), resize: make(chan domain.Geometry),
156 }}156 }}
157 157
158 first := runner.terminalInput()158 first := runner.terminalInput()
M internal/usecase/client/transition_ui.go +3 -3
@@ -79,11 +79,11 @@ func (u *transitionUI) advance() error {
7979 if u == nil || !u.active {
8080 return nil
8181 }
82- size, err := u.term.Size()
82+ geometry, err := u.term.Geometry()
8383 if err != nil {
84- return fmt.Errorf("reading terminal size for transition toast: %w", err)
84+ return fmt.Errorf("reading terminal geometry for transition toast: %w", err)
8585 }
86- if err := u.redraw(size); err != nil {
86+ if err := u.redraw(geometry.Size); err != nil {
8787 return err
8888 }
8989 u.frame = (u.frame + 1) % len(transitionSpinnerFrames)
@@ -79,11 +79,11 @@ func (u *transitionUI) advance() error {
79 if u == nil || !u.active {79 if u == nil || !u.active {
80 return nil80 return nil
81 }81 }
82- size, err := u.term.Size()82+ geometry, err := u.term.Geometry()
83 if err != nil {83 if err != nil {
84- return fmt.Errorf("reading terminal size for transition toast: %w", err)84+ return fmt.Errorf("reading terminal geometry for transition toast: %w", err)
85 }85 }
86- if err := u.redraw(size); err != nil {86+ if err := u.redraw(geometry.Size); err != nil {
87 return err87 return err
88 }88 }
89 u.frame = (u.frame + 1) % len(transitionSpinnerFrames)89 u.frame = (u.frame + 1) % len(transitionSpinnerFrames)
M internal/usecase/client/transition_ui_test.go +6 -6
@@ -72,13 +72,13 @@ type transitionTestTerminal struct {
7272 func (*transitionTestTerminal) EnterRaw() (func() error, error) {
7373 return func() error { return nil }, nil
7474 }
75-func (*transitionTestTerminal) Size() (domain.Size, error) {
76- return domain.Size{Cols: 80, Rows: 24}, nil
75+func (*transitionTestTerminal) Geometry() (domain.Geometry, error) {
76+ return domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil
7777 }
78-func (*transitionTestTerminal) ResizeEvents() <-chan domain.Size { return nil }
79-func (*transitionTestTerminal) In() io.Reader { return strings.NewReader("") }
80-func (t *transitionTestTerminal) Out() io.Writer { return &t.out }
81-func (*transitionTestTerminal) Flush() error { return nil }
78+func (*transitionTestTerminal) ResizeEvents() <-chan domain.Geometry { return nil }
79+func (*transitionTestTerminal) In() io.Reader { return strings.NewReader("") }
80+func (t *transitionTestTerminal) Out() io.Writer { return &t.out }
81+func (*transitionTestTerminal) Flush() error { return nil }
8282
8383 func TestTransitionUIStartsAfterDelayTicksResizesAndStops(t *testing.T) {
8484 for _, tt := range []struct {
@@ -72,13 +72,13 @@ type transitionTestTerminal struct {
72 func (*transitionTestTerminal) EnterRaw() (func() error, error) {72 func (*transitionTestTerminal) EnterRaw() (func() error, error) {
73 return func() error { return nil }, nil73 return func() error { return nil }, nil
74 }74 }
75-func (*transitionTestTerminal) Size() (domain.Size, error) {75+func (*transitionTestTerminal) Geometry() (domain.Geometry, error) {
76- return domain.Size{Cols: 80, Rows: 24}, nil76+ return domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil
77 }77 }
78-func (*transitionTestTerminal) ResizeEvents() <-chan domain.Size { return nil }78+func (*transitionTestTerminal) ResizeEvents() <-chan domain.Geometry { return nil }
79-func (*transitionTestTerminal) In() io.Reader { return strings.NewReader("") }79+func (*transitionTestTerminal) In() io.Reader { return strings.NewReader("") }
80-func (t *transitionTestTerminal) Out() io.Writer { return &t.out }80+func (t *transitionTestTerminal) Out() io.Writer { return &t.out }
81-func (*transitionTestTerminal) Flush() error { return nil }81+func (*transitionTestTerminal) Flush() error { return nil }
82 82
83 func TestTransitionUIStartsAfterDelayTicksResizesAndStops(t *testing.T) {83 func TestTransitionUIStartsAfterDelayTicksResizesAndStops(t *testing.T) {
84 for _, tt := range []struct {84 for _, tt := range []struct {
M internal/usecase/client/transport_observability_test.go +3 -3
@@ -28,10 +28,10 @@ func TestRuntimeObserverDependencyUsesSerializedContract(t *testing.T) {
2828 func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) {
2929 var out bytes.Buffer
3030 var restores atomic.Int32
31- resizeCh := make(chan domain.Size)
31+ resizeCh := make(chan domain.Geometry)
3232 flushed := make(chan struct{})
3333 term := portsmocks.NewMockTerminal(t)
34- term.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
34+ term.EXPECT().Geometry().Return(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil).Once()
3535 term.EXPECT().EnterRaw().Return(func() error { restores.Add(1); return nil }, nil).Once()
3636 in := newBlockingReader()
3737 defer in.unblock()
@@ -78,7 +78,7 @@ func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) {
7878 func TestTerminalFlushBoundaryTransportObservability(t *testing.T) {
7979 var out bytes.Buffer
8080 var restores atomic.Int32
81- resizeCh := make(chan domain.Size)
81+ resizeCh := make(chan domain.Geometry)
8282 term, in := newHappyTerminal(t, &out, &restores, resizeCh)
8383 defer in.unblock()
8484
@@ -28,10 +28,10 @@ func TestRuntimeObserverDependencyUsesSerializedContract(t *testing.T) {
28 func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) {28 func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) {
29 var out bytes.Buffer29 var out bytes.Buffer
30 var restores atomic.Int3230 var restores atomic.Int32
31- resizeCh := make(chan domain.Size)31+ resizeCh := make(chan domain.Geometry)
32 flushed := make(chan struct{})32 flushed := make(chan struct{})
33 term := portsmocks.NewMockTerminal(t)33 term := portsmocks.NewMockTerminal(t)
34- term.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()34+ term.EXPECT().Geometry().Return(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil).Once()
35 term.EXPECT().EnterRaw().Return(func() error { restores.Add(1); return nil }, nil).Once()35 term.EXPECT().EnterRaw().Return(func() error { restores.Add(1); return nil }, nil).Once()
36 in := newBlockingReader()36 in := newBlockingReader()
37 defer in.unblock()37 defer in.unblock()
@@ -78,7 +78,7 @@ func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) {
78 func TestTerminalFlushBoundaryTransportObservability(t *testing.T) {78 func TestTerminalFlushBoundaryTransportObservability(t *testing.T) {
79 var out bytes.Buffer79 var out bytes.Buffer
80 var restores atomic.Int3280 var restores atomic.Int32
81- resizeCh := make(chan domain.Size)81+ resizeCh := make(chan domain.Geometry)
82 term, in := newHappyTerminal(t, &out, &restores, resizeCh)82 term, in := newHappyTerminal(t, &out, &restores, resizeCh)
83 defer in.unblock()83 defer in.unblock()
84 84
M internal/usecase/daemon/attachment_registry.go +24 -9
@@ -101,7 +101,7 @@ func (c *sessionCore) registerAttachmentLocked(ac *attachedClient) bool {
101101 c.nextAttachmentID++
102102 c.attachments[ac] = struct{}{}
103103 c.attachmentOrder[ac] = c.nextAttachmentID
104- if c.claimGeometryOwnerLocked(ac, ac.sizeSnapshot()) == 0 {
104+ if c.claimGeometryOwnerLocked(ac, ac.geometrySnapshot()) == 0 {
105105 c.refreshGeometryOwnerLocked()
106106 }
107107 return true
@@ -114,7 +114,7 @@ func (c *sessionCore) latestValidGeometryOwnerLocked(exclude *attachedClient) *a
114114 var owner *attachedClient
115115 var ownerClaim, ownerOrder uint64
116116 for candidate := range c.attachments {
117- if candidate == nil || candidate == exclude || !candidate.sizeSnapshot().Valid() || !candidate.geometryClaimSizeSnapshot().Valid() {
117+ if candidate == nil || candidate == exclude || !candidate.geometrySnapshot().Valid() || !candidate.geometryClaimSnapshot().Valid() {
118118 continue
119119 }
120120 claim := candidate.geometryClaim.Load()
@@ -136,7 +136,7 @@ func (c *sessionCore) refreshGeometryOwnerLocked() {
136136 }
137137 owner := c.geometryOwner.Load()
138138 if owner != nil {
139- if _, ok := c.attachments[owner]; ok && owner.sizeSnapshot().Valid() && owner.geometryClaimSizeSnapshot().Valid() {
139+ if _, ok := c.attachments[owner]; ok && owner.geometrySnapshot().Valid() && owner.geometryClaimSnapshot().Valid() {
140140 return
141141 }
142142 }
@@ -148,15 +148,15 @@ func (c *sessionCore) refreshGeometryOwnerLocked() {
148148 // claimGeometryOwnerLocked records the latest attachment claim. Caller holds
149149 // the owning session's mu; the atomic publication lets resize transactions
150150 // validate the claim while tab locks are held.
151-func (c *sessionCore) claimGeometryOwnerLocked(ac *attachedClient, size domain.Size) uint64 {
152- if c == nil || ac == nil || !size.Valid() {
151+func (c *sessionCore) claimGeometryOwnerLocked(ac *attachedClient, geometry domain.Geometry) uint64 {
152+ if c == nil || ac == nil || !geometry.Valid() {
153153 return 0
154154 }
155155 c.geometryMu.Lock()
156156 defer c.geometryMu.Unlock()
157157 c.geometryClaimSeq++
158158 claim := c.geometryClaimSeq
159- ac.publishGeometryClaim(size, claim)
159+ ac.publishGeometryClaim(geometry, claim)
160160 c.geometryOwner.Store(ac)
161161 return claim
162162 }
@@ -189,14 +189,29 @@ func (s *session) claimGeometryOwner(ac *attachedClient) (uint64, bool) {
189189 if ac == nil {
190190 return 0, false
191191 }
192- return s.claimGeometryOwnerForSize(ac, ac.sizeSnapshot())
192+ return s.claimGeometryOwnerForGeometry(ac, ac.geometrySnapshot())
193193 }
194194
195195 // claimGeometryOwnerForSize records a requested shared geometry independently
196196 // of the attachment-local publication. Transactional resize commits use this
197197 // before the PTY phase so a stale request cannot publish after a newer claim.
198198 func (s *session) claimGeometryOwnerForSize(ac *attachedClient, size domain.Size) (uint64, bool) {
199- if s == nil || ac == nil || !size.Valid() {
199+ if ac == nil {
200+ return 0, false
201+ }
202+ // Layout and floating transactions carry only their shared cell size. Keep
203+ // the claimant's terminal pixels when those operations re-claim authority;
204+ // replacing the geometry with a cell-only value would make a later fallback
205+ // lose the terminal's pixel dimensions.
206+ geometry := ac.geometrySnapshot()
207+ geometry.Size = size
208+ return s.claimGeometryOwnerForGeometry(ac, geometry)
209+}
210+
211+// claimGeometryOwnerForGeometry records a requested shared cell and pixel
212+// geometry independently of attachment-local publication.
213+func (s *session) claimGeometryOwnerForGeometry(ac *attachedClient, geometry domain.Geometry) (uint64, bool) {
214+ if s == nil || ac == nil || !geometry.Valid() {
200215 return 0, false
201216 }
202217 s.mu.Lock()
@@ -204,7 +219,7 @@ func (s *session) claimGeometryOwnerForSize(ac *attachedClient, size domain.Size
204219 if _, ok := s.attachments[ac]; !ok {
205220 return 0, false
206221 }
207- return s.claimGeometryOwnerLocked(ac, size), true
222+ return s.claimGeometryOwnerLocked(ac, geometry.NormalizePixels()), true
208223 }
209224
210225 // releaseGeometryClaim drops a claim that never reached the coordinator's
@@ -101,7 +101,7 @@ func (c *sessionCore) registerAttachmentLocked(ac *attachedClient) bool {
101 c.nextAttachmentID++101 c.nextAttachmentID++
102 c.attachments[ac] = struct{}{}102 c.attachments[ac] = struct{}{}
103 c.attachmentOrder[ac] = c.nextAttachmentID103 c.attachmentOrder[ac] = c.nextAttachmentID
104- if c.claimGeometryOwnerLocked(ac, ac.sizeSnapshot()) == 0 {104+ if c.claimGeometryOwnerLocked(ac, ac.geometrySnapshot()) == 0 {
105 c.refreshGeometryOwnerLocked()105 c.refreshGeometryOwnerLocked()
106 }106 }
107 return true107 return true
@@ -114,7 +114,7 @@ func (c *sessionCore) latestValidGeometryOwnerLocked(exclude *attachedClient) *a
114 var owner *attachedClient114 var owner *attachedClient
115 var ownerClaim, ownerOrder uint64115 var ownerClaim, ownerOrder uint64
116 for candidate := range c.attachments {116 for candidate := range c.attachments {
117- if candidate == nil || candidate == exclude || !candidate.sizeSnapshot().Valid() || !candidate.geometryClaimSizeSnapshot().Valid() {117+ if candidate == nil || candidate == exclude || !candidate.geometrySnapshot().Valid() || !candidate.geometryClaimSnapshot().Valid() {
118 continue118 continue
119 }119 }
120 claim := candidate.geometryClaim.Load()120 claim := candidate.geometryClaim.Load()
@@ -136,7 +136,7 @@ func (c *sessionCore) refreshGeometryOwnerLocked() {
136 }136 }
137 owner := c.geometryOwner.Load()137 owner := c.geometryOwner.Load()
138 if owner != nil {138 if owner != nil {
139- if _, ok := c.attachments[owner]; ok && owner.sizeSnapshot().Valid() && owner.geometryClaimSizeSnapshot().Valid() {139+ if _, ok := c.attachments[owner]; ok && owner.geometrySnapshot().Valid() && owner.geometryClaimSnapshot().Valid() {
140 return140 return
141 }141 }
142 }142 }
@@ -148,15 +148,15 @@ func (c *sessionCore) refreshGeometryOwnerLocked() {
148 // claimGeometryOwnerLocked records the latest attachment claim. Caller holds148 // claimGeometryOwnerLocked records the latest attachment claim. Caller holds
149 // the owning session's mu; the atomic publication lets resize transactions149 // the owning session's mu; the atomic publication lets resize transactions
150 // validate the claim while tab locks are held.150 // validate the claim while tab locks are held.
151-func (c *sessionCore) claimGeometryOwnerLocked(ac *attachedClient, size domain.Size) uint64 {151+func (c *sessionCore) claimGeometryOwnerLocked(ac *attachedClient, geometry domain.Geometry) uint64 {
152- if c == nil || ac == nil || !size.Valid() {152+ if c == nil || ac == nil || !geometry.Valid() {
153 return 0153 return 0
154 }154 }
155 c.geometryMu.Lock()155 c.geometryMu.Lock()
156 defer c.geometryMu.Unlock()156 defer c.geometryMu.Unlock()
157 c.geometryClaimSeq++157 c.geometryClaimSeq++
158 claim := c.geometryClaimSeq158 claim := c.geometryClaimSeq
159- ac.publishGeometryClaim(size, claim)159+ ac.publishGeometryClaim(geometry, claim)
160 c.geometryOwner.Store(ac)160 c.geometryOwner.Store(ac)
161 return claim161 return claim
162 }162 }
@@ -189,14 +189,29 @@ func (s *session) claimGeometryOwner(ac *attachedClient) (uint64, bool) {
189 if ac == nil {189 if ac == nil {
190 return 0, false190 return 0, false
191 }191 }
192- return s.claimGeometryOwnerForSize(ac, ac.sizeSnapshot())192+ return s.claimGeometryOwnerForGeometry(ac, ac.geometrySnapshot())
193 }193 }
194 194
195 // claimGeometryOwnerForSize records a requested shared geometry independently195 // claimGeometryOwnerForSize records a requested shared geometry independently
196 // of the attachment-local publication. Transactional resize commits use this196 // of the attachment-local publication. Transactional resize commits use this
197 // before the PTY phase so a stale request cannot publish after a newer claim.197 // before the PTY phase so a stale request cannot publish after a newer claim.
198 func (s *session) claimGeometryOwnerForSize(ac *attachedClient, size domain.Size) (uint64, bool) {198 func (s *session) claimGeometryOwnerForSize(ac *attachedClient, size domain.Size) (uint64, bool) {
199- if s == nil || ac == nil || !size.Valid() {199+ if ac == nil {
200+ return 0, false
201+ }
202+ // Layout and floating transactions carry only their shared cell size. Keep
203+ // the claimant's terminal pixels when those operations re-claim authority;
204+ // replacing the geometry with a cell-only value would make a later fallback
205+ // lose the terminal's pixel dimensions.
206+ geometry := ac.geometrySnapshot()
207+ geometry.Size = size
208+ return s.claimGeometryOwnerForGeometry(ac, geometry)
209+}
210+
211+// claimGeometryOwnerForGeometry records a requested shared cell and pixel
212+// geometry independently of attachment-local publication.
213+func (s *session) claimGeometryOwnerForGeometry(ac *attachedClient, geometry domain.Geometry) (uint64, bool) {
214+ if s == nil || ac == nil || !geometry.Valid() {
200 return 0, false215 return 0, false
201 }216 }
202 s.mu.Lock()217 s.mu.Lock()
@@ -204,7 +219,7 @@ func (s *session) claimGeometryOwnerForSize(ac *attachedClient, size domain.Size
204 if _, ok := s.attachments[ac]; !ok {219 if _, ok := s.attachments[ac]; !ok {
205 return 0, false220 return 0, false
206 }221 }
207- return s.claimGeometryOwnerLocked(ac, size), true222+ return s.claimGeometryOwnerLocked(ac, geometry.NormalizePixels()), true
208 }223 }
209 224
210 // releaseGeometryClaim drops a claim that never reached the coordinator's225 // releaseGeometryClaim drops a claim that never reached the coordinator's
M internal/usecase/daemon/attachment_transition_publish.go +1 -1
@@ -194,7 +194,7 @@ func publishAttachmentOwnershipLocked(publication *attachmentPublication) {
194194 if !registered && publication.source == req.target && !req.preserveAttachment {
195195 // A same-session rebind keeps membership but still represents a fresh
196196 // attachment claim with the replacement transport's current size.
197- req.target.claimGeometryOwnerLocked(req.next, req.next.sizeSnapshot())
197+ req.target.claimGeometryOwnerLocked(req.next, req.next.geometrySnapshot())
198198 }
199199 publication.nextGeneration = req.next.connectionGeneration.Add(1)
200200 req.next.setSession(req.target)
@@ -194,7 +194,7 @@ func publishAttachmentOwnershipLocked(publication *attachmentPublication) {
194 if !registered && publication.source == req.target && !req.preserveAttachment {194 if !registered && publication.source == req.target && !req.preserveAttachment {
195 // A same-session rebind keeps membership but still represents a fresh195 // A same-session rebind keeps membership but still represents a fresh
196 // attachment claim with the replacement transport's current size.196 // attachment claim with the replacement transport's current size.
197- req.target.claimGeometryOwnerLocked(req.next, req.next.sizeSnapshot())197+ req.target.claimGeometryOwnerLocked(req.next, req.next.geometrySnapshot())
198 }198 }
199 publication.nextGeneration = req.next.connectionGeneration.Add(1)199 publication.nextGeneration = req.next.connectionGeneration.Add(1)
200 req.next.setSession(req.target)200 req.next.setSession(req.target)
M internal/usecase/daemon/attention_test.go +1 -1
@@ -227,7 +227,7 @@ func (p *scriptPTY) Close() error {
227227 p.once.Do(func() { close(p.closed) })
228228 return nil
229229 }
230-func (p *scriptPTY) Resize(domain.Size) error { return nil }
230+func (p *scriptPTY) Resize(domain.Geometry) error { return nil }
231231 func (p *scriptPTY) Pid() int { return 4242 }
232232 func (p *scriptPTY) ForegroundPgid() (int, error) { return 4242, nil }
233233
@@ -227,7 +227,7 @@ func (p *scriptPTY) Close() error {
227 p.once.Do(func() { close(p.closed) })227 p.once.Do(func() { close(p.closed) })
228 return nil228 return nil
229 }229 }
230-func (p *scriptPTY) Resize(domain.Size) error { return nil }230+func (p *scriptPTY) Resize(domain.Geometry) error { return nil }
231 func (p *scriptPTY) Pid() int { return 4242 }231 func (p *scriptPTY) Pid() int { return 4242 }
232 func (p *scriptPTY) ForegroundPgid() (int, error) { return 4242, nil }232 func (p *scriptPTY) ForegroundPgid() (int, error) { return 4242, nil }
233 233
M internal/usecase/daemon/client.go +64 -23
@@ -72,9 +72,10 @@ type attachedClient struct {
7272 // sizeMu lets shared-geometry snapshots read attachment windows without
7373 // waiting behind a blocked transport send.
7474 sizeMu sync.RWMutex
75- size domain.Size
76- geometryClaimSize domain.Size // protected by sizeMu; shared geometry claim snapshot
77- geometryClaimInitialized bool // protected by sizeMu; distinguishes legacy fixtures
75+ size domain.Size // retained for legacy fixtures; live paths publish geometry
76+ geometry domain.Geometry // controlling-terminal geometry for this attachment
77+ geometryClaimGeometry domain.Geometry // protected by sizeMu; shared geometry claim snapshot
78+ geometryClaimInitialized bool // protected by sizeMu; distinguishes legacy fixtures
7879 // geometryClaim is an attachment-local monotonic claim sequence. The
7980 // owning session publishes it whenever this attachment is attached,
8081 // resumed, or explicitly resized.
@@ -169,36 +170,57 @@ func (ac *attachedClient) sizeSnapshot() domain.Size {
169170 }
170171
171172 func (ac *attachedClient) setSize(size domain.Size) {
173+ ac.setGeometry(domain.Geometry{Size: size})
174+}
175+
176+func (ac *attachedClient) geometrySnapshot() domain.Geometry {
177+ if ac == nil {
178+ return domain.Geometry{}
179+ }
180+ ac.sizeMu.RLock()
181+ defer ac.sizeMu.RUnlock()
182+ if ac.geometry.Valid() {
183+ return ac.geometry
184+ }
185+ return domain.Geometry{Size: ac.size}
186+}
187+
188+func (ac *attachedClient) setGeometry(geometry domain.Geometry) {
172189 if ac == nil {
173190 return
174191 }
192+ geometry = geometry.NormalizePixels()
175193 ac.sizeMu.Lock()
176- ac.size = size
194+ ac.size = geometry.Size
195+ ac.geometry = geometry
177196 ac.sizeMu.Unlock()
178197 }
179198
180-func (ac *attachedClient) geometryClaimSizeSnapshot() domain.Size {
199+func (ac *attachedClient) geometryClaimSnapshot() domain.Geometry {
181200 if ac == nil {
182- return domain.Size{}
201+ return domain.Geometry{}
183202 }
184203 ac.sizeMu.RLock()
185- claimSize, localSize := ac.geometryClaimSize, ac.size
204+ claimGeometry, localGeometry, localSize := ac.geometryClaimGeometry, ac.geometry, ac.size
186205 initialized := ac.geometryClaimInitialized
187206 ac.sizeMu.RUnlock()
188207 if !initialized {
189- return localSize
208+ if localGeometry.Valid() {
209+ return localGeometry
210+ }
211+ return domain.Geometry{Size: localSize}
190212 }
191- return claimSize
213+ return claimGeometry
192214 }
193215
194-// publishGeometryClaim makes the claim size and sequence visible as one
216+// publishGeometryClaim makes the claim geometry and sequence visible as one
195217 // attachment-local publication under sizeMu.
196-func (ac *attachedClient) publishGeometryClaim(size domain.Size, claim uint64) {
218+func (ac *attachedClient) publishGeometryClaim(geometry domain.Geometry, claim uint64) {
197219 if ac == nil {
198220 return
199221 }
200222 ac.sizeMu.Lock()
201- ac.geometryClaimSize = size
223+ ac.geometryClaimGeometry = geometry.NormalizePixels()
202224 ac.geometryClaimInitialized = true
203225 ac.geometryClaim.Store(claim)
204226 ac.sizeMu.Unlock()
@@ -209,7 +231,7 @@ func (ac *attachedClient) clearGeometryClaim() {
209231 return
210232 }
211233 ac.sizeMu.Lock()
212- ac.geometryClaimSize = domain.Size{}
234+ ac.geometryClaimGeometry = domain.Geometry{}
213235 ac.geometryClaimInitialized = true
214236 ac.geometryClaim.Store(0)
215237 ac.sizeMu.Unlock()
@@ -640,7 +662,7 @@ type attachClientOptions struct {
640662
641663 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, error) {
642664 d.mu.Lock()
643- ac := d.prepareAttachedClientLocked(tr, sz, opts)
665+ ac := d.prepareAttachedClientLocked(tr, domain.Geometry{Size: sz}, opts)
644666 d.mu.Unlock()
645667 result, err := d.transitionAttachment(attachmentTransitionRequest{
646668 target: sess,
@@ -679,7 +701,7 @@ func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts at
679701 // prepareAttachedClientLocked allocates one detached attachment. Caller holds
680702 // d.mu only to allocate its resume token; attachment publication happens later after
681703 // the caller releases every architecture lock.
682-func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size, opts attachClientOptions) *attachedClient {
704+func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, geometry domain.Geometry, opts attachClientOptions) *attachedClient {
683705 resumeToken := uint64(0)
684706 if opts.resumeCapable {
685707 resumeToken = d.nextResumeTokenLocked()
@@ -688,11 +710,13 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size,
688710 opts.terminalCapabilities = ports.TerminalCapabilities{ColorMode: ports.TerminalColorTrueColor}
689711 }
690712 output := newOutputStateStreamForCapabilities(opts.terminalCapabilities, opts.maxOutputInFlight)
713+ geometry = geometry.NormalizePixels()
691714 ac := &attachedClient{
692715 tr: tr,
693716 output: output,
694- size: sz,
695- view: attachmentView{windowRows: sz.Rows, windowSet: true},
717+ size: geometry.Size,
718+ geometry: geometry,
719+ view: attachmentView{windowRows: geometry.Rows, windowSet: true},
696720 clientID: opts.clientID,
697721 terminalCapabilities: opts.terminalCapabilities,
698722 navigationCapabilities: opts.navigationCapabilities,
@@ -941,7 +965,20 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
941965 // attachment effect admission; output state is rebased before any redraw so
942966 // every subsequent frame starts a new epoch.
943967 func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size domain.Size) bool {
944- if token.ac == nil || !size.Valid() || !token.attachmentEffectCurrent() {
968+ geometry := domain.Geometry{Size: size}
969+ if token.ac != nil {
970+ // Direct size-only callers do not have a fresh pixel pair. Preserve the
971+ // attachment's last complete pair instead of downgrading its claim to
972+ // cell-only geometry.
973+ geometry = token.ac.geometrySnapshot()
974+ geometry.Size = size
975+ }
976+ return d.resizeAttachmentGeometryForLease(token, geometry)
977+}
978+
979+func (d *Daemon) resizeAttachmentGeometryForLease(token attachmentConnectionToken, geometry domain.Geometry) bool {
980+ geometry = geometry.NormalizePixels()
981+ if token.ac == nil || !geometry.Valid() || !token.attachmentEffectCurrent() {
945982 return false
946983 }
947984 ac := token.ac
@@ -950,11 +987,15 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
950987 ac.sendMu.Unlock()
951988 return false
952989 }
953- sameSize := ac.sizeSnapshot() == size
990+ previous := ac.geometrySnapshot()
991+ sameSize := previous.Size == geometry.Size
992+ sameGeometry := previous == geometry
993+ if !sameGeometry {
994+ ac.setGeometry(geometry)
995+ }
954996 if !sameSize {
955- ac.setSize(size)
956997 view := ac.viewSnapshot()
957- view.windowRows = size.Rows
998+ view.windowRows = geometry.Rows
958999 view.windowSet = true
9591000 view.windowTop = 0
9601001 view.revision++
@@ -983,7 +1024,7 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
9831024 if d.recalculateSessionGeometryAndInvalidate(sess, ac, "client.go") {
9841025 return true
9851026 }
986- if sameSize {
1027+ if sameGeometry {
9871028 return false
9881029 }
9891030 rc := sess.renderCoordinator()
@@ -992,7 +1033,7 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
9921033 }
9931034 // Rendering is attachment-scoped. Schedule it through the coordinator so
9941035 // rapid resize frames coalesce under the attachment lease and deadline.
995- return rc.scheduleResizeForLease(size, ac, token.lease, func(uint64) {
1036+ return rc.scheduleResizeForLease(geometry.Size, ac, token.lease, func(uint64) {
9961037 go d.paint(sess, ac, true, token.lease)
9971038 }) != 0
9981039 }
@@ -72,9 +72,10 @@ type attachedClient struct {
72 // sizeMu lets shared-geometry snapshots read attachment windows without72 // sizeMu lets shared-geometry snapshots read attachment windows without
73 // waiting behind a blocked transport send.73 // waiting behind a blocked transport send.
74 sizeMu sync.RWMutex74 sizeMu sync.RWMutex
75- size domain.Size75+ size domain.Size // retained for legacy fixtures; live paths publish geometry
76- geometryClaimSize domain.Size // protected by sizeMu; shared geometry claim snapshot76+ geometry domain.Geometry // controlling-terminal geometry for this attachment
77- geometryClaimInitialized bool // protected by sizeMu; distinguishes legacy fixtures77+ geometryClaimGeometry domain.Geometry // protected by sizeMu; shared geometry claim snapshot
78+ geometryClaimInitialized bool // protected by sizeMu; distinguishes legacy fixtures
78 // geometryClaim is an attachment-local monotonic claim sequence. The79 // geometryClaim is an attachment-local monotonic claim sequence. The
79 // owning session publishes it whenever this attachment is attached,80 // owning session publishes it whenever this attachment is attached,
80 // resumed, or explicitly resized.81 // resumed, or explicitly resized.
@@ -169,36 +170,57 @@ func (ac *attachedClient) sizeSnapshot() domain.Size {
169 }170 }
170 171
171 func (ac *attachedClient) setSize(size domain.Size) {172 func (ac *attachedClient) setSize(size domain.Size) {
173+ ac.setGeometry(domain.Geometry{Size: size})
174+}
175+
176+func (ac *attachedClient) geometrySnapshot() domain.Geometry {
177+ if ac == nil {
178+ return domain.Geometry{}
179+ }
180+ ac.sizeMu.RLock()
181+ defer ac.sizeMu.RUnlock()
182+ if ac.geometry.Valid() {
183+ return ac.geometry
184+ }
185+ return domain.Geometry{Size: ac.size}
186+}
187+
188+func (ac *attachedClient) setGeometry(geometry domain.Geometry) {
172 if ac == nil {189 if ac == nil {
173 return190 return
174 }191 }
192+ geometry = geometry.NormalizePixels()
175 ac.sizeMu.Lock()193 ac.sizeMu.Lock()
176- ac.size = size194+ ac.size = geometry.Size
195+ ac.geometry = geometry
177 ac.sizeMu.Unlock()196 ac.sizeMu.Unlock()
178 }197 }
179 198
180-func (ac *attachedClient) geometryClaimSizeSnapshot() domain.Size {199+func (ac *attachedClient) geometryClaimSnapshot() domain.Geometry {
181 if ac == nil {200 if ac == nil {
182- return domain.Size{}201+ return domain.Geometry{}
183 }202 }
184 ac.sizeMu.RLock()203 ac.sizeMu.RLock()
185- claimSize, localSize := ac.geometryClaimSize, ac.size204+ claimGeometry, localGeometry, localSize := ac.geometryClaimGeometry, ac.geometry, ac.size
186 initialized := ac.geometryClaimInitialized205 initialized := ac.geometryClaimInitialized
187 ac.sizeMu.RUnlock()206 ac.sizeMu.RUnlock()
188 if !initialized {207 if !initialized {
189- return localSize208+ if localGeometry.Valid() {
209+ return localGeometry
210+ }
211+ return domain.Geometry{Size: localSize}
190 }212 }
191- return claimSize213+ return claimGeometry
192 }214 }
193 215
194-// publishGeometryClaim makes the claim size and sequence visible as one216+// publishGeometryClaim makes the claim geometry and sequence visible as one
195 // attachment-local publication under sizeMu.217 // attachment-local publication under sizeMu.
196-func (ac *attachedClient) publishGeometryClaim(size domain.Size, claim uint64) {218+func (ac *attachedClient) publishGeometryClaim(geometry domain.Geometry, claim uint64) {
197 if ac == nil {219 if ac == nil {
198 return220 return
199 }221 }
200 ac.sizeMu.Lock()222 ac.sizeMu.Lock()
201- ac.geometryClaimSize = size223+ ac.geometryClaimGeometry = geometry.NormalizePixels()
202 ac.geometryClaimInitialized = true224 ac.geometryClaimInitialized = true
203 ac.geometryClaim.Store(claim)225 ac.geometryClaim.Store(claim)
204 ac.sizeMu.Unlock()226 ac.sizeMu.Unlock()
@@ -209,7 +231,7 @@ func (ac *attachedClient) clearGeometryClaim() {
209 return231 return
210 }232 }
211 ac.sizeMu.Lock()233 ac.sizeMu.Lock()
212- ac.geometryClaimSize = domain.Size{}234+ ac.geometryClaimGeometry = domain.Geometry{}
213 ac.geometryClaimInitialized = true235 ac.geometryClaimInitialized = true
214 ac.geometryClaim.Store(0)236 ac.geometryClaim.Store(0)
215 ac.sizeMu.Unlock()237 ac.sizeMu.Unlock()
@@ -640,7 +662,7 @@ type attachClientOptions struct {
640 662
641 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, error) {663 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, error) {
642 d.mu.Lock()664 d.mu.Lock()
643- ac := d.prepareAttachedClientLocked(tr, sz, opts)665+ ac := d.prepareAttachedClientLocked(tr, domain.Geometry{Size: sz}, opts)
644 d.mu.Unlock()666 d.mu.Unlock()
645 result, err := d.transitionAttachment(attachmentTransitionRequest{667 result, err := d.transitionAttachment(attachmentTransitionRequest{
646 target: sess,668 target: sess,
@@ -679,7 +701,7 @@ func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts at
679 // prepareAttachedClientLocked allocates one detached attachment. Caller holds701 // prepareAttachedClientLocked allocates one detached attachment. Caller holds
680 // d.mu only to allocate its resume token; attachment publication happens later after702 // d.mu only to allocate its resume token; attachment publication happens later after
681 // the caller releases every architecture lock.703 // the caller releases every architecture lock.
682-func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size, opts attachClientOptions) *attachedClient {704+func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, geometry domain.Geometry, opts attachClientOptions) *attachedClient {
683 resumeToken := uint64(0)705 resumeToken := uint64(0)
684 if opts.resumeCapable {706 if opts.resumeCapable {
685 resumeToken = d.nextResumeTokenLocked()707 resumeToken = d.nextResumeTokenLocked()
@@ -688,11 +710,13 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size,
688 opts.terminalCapabilities = ports.TerminalCapabilities{ColorMode: ports.TerminalColorTrueColor}710 opts.terminalCapabilities = ports.TerminalCapabilities{ColorMode: ports.TerminalColorTrueColor}
689 }711 }
690 output := newOutputStateStreamForCapabilities(opts.terminalCapabilities, opts.maxOutputInFlight)712 output := newOutputStateStreamForCapabilities(opts.terminalCapabilities, opts.maxOutputInFlight)
713+ geometry = geometry.NormalizePixels()
691 ac := &attachedClient{714 ac := &attachedClient{
692 tr: tr,715 tr: tr,
693 output: output,716 output: output,
694- size: sz,717+ size: geometry.Size,
695- view: attachmentView{windowRows: sz.Rows, windowSet: true},718+ geometry: geometry,
719+ view: attachmentView{windowRows: geometry.Rows, windowSet: true},
696 clientID: opts.clientID,720 clientID: opts.clientID,
697 terminalCapabilities: opts.terminalCapabilities,721 terminalCapabilities: opts.terminalCapabilities,
698 navigationCapabilities: opts.navigationCapabilities,722 navigationCapabilities: opts.navigationCapabilities,
@@ -941,7 +965,20 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
941 // attachment effect admission; output state is rebased before any redraw so965 // attachment effect admission; output state is rebased before any redraw so
942 // every subsequent frame starts a new epoch.966 // every subsequent frame starts a new epoch.
943 func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size domain.Size) bool {967 func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size domain.Size) bool {
944- if token.ac == nil || !size.Valid() || !token.attachmentEffectCurrent() {968+ geometry := domain.Geometry{Size: size}
969+ if token.ac != nil {
970+ // Direct size-only callers do not have a fresh pixel pair. Preserve the
971+ // attachment's last complete pair instead of downgrading its claim to
972+ // cell-only geometry.
973+ geometry = token.ac.geometrySnapshot()
974+ geometry.Size = size
975+ }
976+ return d.resizeAttachmentGeometryForLease(token, geometry)
977+}
978+
979+func (d *Daemon) resizeAttachmentGeometryForLease(token attachmentConnectionToken, geometry domain.Geometry) bool {
980+ geometry = geometry.NormalizePixels()
981+ if token.ac == nil || !geometry.Valid() || !token.attachmentEffectCurrent() {
945 return false982 return false
946 }983 }
947 ac := token.ac984 ac := token.ac
@@ -950,11 +987,15 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
950 ac.sendMu.Unlock()987 ac.sendMu.Unlock()
951 return false988 return false
952 }989 }
953- sameSize := ac.sizeSnapshot() == size990+ previous := ac.geometrySnapshot()
991+ sameSize := previous.Size == geometry.Size
992+ sameGeometry := previous == geometry
993+ if !sameGeometry {
994+ ac.setGeometry(geometry)
995+ }
954 if !sameSize {996 if !sameSize {
955- ac.setSize(size)
956 view := ac.viewSnapshot()997 view := ac.viewSnapshot()
957- view.windowRows = size.Rows998+ view.windowRows = geometry.Rows
958 view.windowSet = true999 view.windowSet = true
959 view.windowTop = 01000 view.windowTop = 0
960 view.revision++1001 view.revision++
@@ -983,7 +1024,7 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
983 if d.recalculateSessionGeometryAndInvalidate(sess, ac, "client.go") {1024 if d.recalculateSessionGeometryAndInvalidate(sess, ac, "client.go") {
984 return true1025 return true
985 }1026 }
986- if sameSize {1027+ if sameGeometry {
987 return false1028 return false
988 }1029 }
989 rc := sess.renderCoordinator()1030 rc := sess.renderCoordinator()
@@ -992,7 +1033,7 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
992 }1033 }
993 // Rendering is attachment-scoped. Schedule it through the coordinator so1034 // Rendering is attachment-scoped. Schedule it through the coordinator so
994 // rapid resize frames coalesce under the attachment lease and deadline.1035 // rapid resize frames coalesce under the attachment lease and deadline.
995- return rc.scheduleResizeForLease(size, ac, token.lease, func(uint64) {1036+ return rc.scheduleResizeForLease(geometry.Size, ac, token.lease, func(uint64) {
996 go d.paint(sess, ac, true, token.lease)1037 go d.paint(sess, ac, true, token.lease)
997 }) != 01038 }) != 0
998 }1039 }
M internal/usecase/daemon/client_frame_routing.go +1 -1
@@ -97,7 +97,7 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f
9797 }
9898 case ports.MsgResize:
9999 if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.attachmentEffectCurrent() {
100- d.resizeAttachmentForLease(token, rz.Size)
100+ d.resizeAttachmentGeometryForLease(token, rz.Geometry())
101101 }
102102 case ports.MsgTheme:
103103 if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {
@@ -97,7 +97,7 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f
97 }97 }
98 case ports.MsgResize:98 case ports.MsgResize:
99 if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.attachmentEffectCurrent() {99 if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.attachmentEffectCurrent() {
100- d.resizeAttachmentForLease(token, rz.Size)100+ d.resizeAttachmentGeometryForLease(token, rz.Geometry())
101 }101 }
102 case ports.MsgTheme:102 case ports.MsgTheme:
103 if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {103 if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {
M internal/usecase/daemon/control.go +5 -2
@@ -482,7 +482,10 @@ func (e controlExec) CreateSessionNamed(name string) error {
482482 e.sess.mu.Lock()
483483 cwd, env := e.sess.cwd, copyEnvironment(e.sess.env)
484484 e.sess.mu.Unlock()
485- size := e.sess.fullViewportSize()
485+ geometry := domain.Geometry{Size: e.sess.fullViewportSize()}
486+ if source, ok := e.sess.geometrySourceSnapshot(); ok {
487+ geometry = source.geometry
488+ }
486489 e.d.mu.Lock()
487490 defer e.d.mu.Unlock()
488491 if e.d.closing {
@@ -491,7 +494,7 @@ func (e controlExec) CreateSessionNamed(name string) error {
491494 if e.d.nameLiveOrStoppedLocked(name) {
492495 return errSessionNameInUse
493496 }
494- _, err := e.d.createSessionLocked(name, false, cwd, size, env)
497+ _, err := e.d.createSessionLocked(name, false, cwd, geometry, env)
495498 return err
496499 }
497500 func (e controlExec) CloseTab() error {
@@ -482,7 +482,10 @@ func (e controlExec) CreateSessionNamed(name string) error {
482 e.sess.mu.Lock()482 e.sess.mu.Lock()
483 cwd, env := e.sess.cwd, copyEnvironment(e.sess.env)483 cwd, env := e.sess.cwd, copyEnvironment(e.sess.env)
484 e.sess.mu.Unlock()484 e.sess.mu.Unlock()
485- size := e.sess.fullViewportSize()485+ geometry := domain.Geometry{Size: e.sess.fullViewportSize()}
486+ if source, ok := e.sess.geometrySourceSnapshot(); ok {
487+ geometry = source.geometry
488+ }
486 e.d.mu.Lock()489 e.d.mu.Lock()
487 defer e.d.mu.Unlock()490 defer e.d.mu.Unlock()
488 if e.d.closing {491 if e.d.closing {
@@ -491,7 +494,7 @@ func (e controlExec) CreateSessionNamed(name string) error {
491 if e.d.nameLiveOrStoppedLocked(name) {494 if e.d.nameLiveOrStoppedLocked(name) {
492 return errSessionNameInUse495 return errSessionNameInUse
493 }496 }
494- _, err := e.d.createSessionLocked(name, false, cwd, size, env)497+ _, err := e.d.createSessionLocked(name, false, cwd, geometry, env)
495 return err498 return err
496 }499 }
497 func (e controlExec) CloseTab() error {500 func (e controlExec) CloseTab() error {
M internal/usecase/daemon/control_test.go +31 -1
@@ -676,6 +676,36 @@ func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T
676676 require.Equal(t, ports.ErrNameTaken, taken.Code)
677677 }
678678
679+func TestHandleCommandNewSessionPreservesSourceGeometry(t *testing.T) {
680+ factory := &controlPTYFactory{}
681+ d := newTestDaemon(t, factory, stubClock{})
682+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
683+ source := addControlSession(d, "work", "t_work", "p_work")
684+ client := &attachedClient{}
685+ client.setGeometry(domain.Geometry{
686+ Size: domain.Size{Cols: 120, Rows: 40}, PixelWidth: 1200, PixelHeight: 800,
687+ })
688+ require.True(t, source.registerAttachment(client))
689+
690+ result := sendCommand(t, d, ports.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"})
691+
692+ require.True(t, result.OK, result.Text)
693+ d.mu.Lock()
694+ created := d.findByNameLocked("scripted")
695+ d.mu.Unlock()
696+ require.NotNil(t, created)
697+ created.mu.Lock()
698+ tb := created.tabs[0]
699+ created.mu.Unlock()
700+ tb.mu.Lock()
701+ pane := tb.focusedPane()
702+ require.NotNil(t, pane)
703+ require.Equal(t, domain.Geometry{
704+ Size: domain.Size{Cols: 120, Rows: 38}, PixelWidth: 1200, PixelHeight: 760,
705+ }, pane.geometry)
706+ tb.mu.Unlock()
707+}
708+
679709 func TestHandleCommandValidatesToastAndQueuesForDetachedSession(t *testing.T) {
680710 d := newTestDaemon(t, nil, stubClock{})
681711 sess := addControlSession(d, "work", "t_work", "p_work")
@@ -1327,7 +1357,7 @@ type controlPTYFactory struct {
13271357 once sync.Once
13281358 }
13291359
1330-func (f *controlPTYFactory) Open(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
1360+func (f *controlPTYFactory) Open(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (ports.PTY, error) {
13311361 f.once.Do(func() {
13321362 if f.entered != nil {
13331363 close(f.entered)
@@ -676,6 +676,36 @@ func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T
676 require.Equal(t, ports.ErrNameTaken, taken.Code)676 require.Equal(t, ports.ErrNameTaken, taken.Code)
677 }677 }
678 678
679+func TestHandleCommandNewSessionPreservesSourceGeometry(t *testing.T) {
680+ factory := &controlPTYFactory{}
681+ d := newTestDaemon(t, factory, stubClock{})
682+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
683+ source := addControlSession(d, "work", "t_work", "p_work")
684+ client := &attachedClient{}
685+ client.setGeometry(domain.Geometry{
686+ Size: domain.Size{Cols: 120, Rows: 40}, PixelWidth: 1200, PixelHeight: 800,
687+ })
688+ require.True(t, source.registerAttachment(client))
689+
690+ result := sendCommand(t, d, ports.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"})
691+
692+ require.True(t, result.OK, result.Text)
693+ d.mu.Lock()
694+ created := d.findByNameLocked("scripted")
695+ d.mu.Unlock()
696+ require.NotNil(t, created)
697+ created.mu.Lock()
698+ tb := created.tabs[0]
699+ created.mu.Unlock()
700+ tb.mu.Lock()
701+ pane := tb.focusedPane()
702+ require.NotNil(t, pane)
703+ require.Equal(t, domain.Geometry{
704+ Size: domain.Size{Cols: 120, Rows: 38}, PixelWidth: 1200, PixelHeight: 760,
705+ }, pane.geometry)
706+ tb.mu.Unlock()
707+}
708+
679 func TestHandleCommandValidatesToastAndQueuesForDetachedSession(t *testing.T) {709 func TestHandleCommandValidatesToastAndQueuesForDetachedSession(t *testing.T) {
680 d := newTestDaemon(t, nil, stubClock{})710 d := newTestDaemon(t, nil, stubClock{})
681 sess := addControlSession(d, "work", "t_work", "p_work")711 sess := addControlSession(d, "work", "t_work", "p_work")
@@ -1327,7 +1357,7 @@ type controlPTYFactory struct {
1327 once sync.Once1357 once sync.Once
1328 }1358 }
1329 1359
1330-func (f *controlPTYFactory) Open(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {1360+func (f *controlPTYFactory) Open(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (ports.PTY, error) {
1331 f.once.Do(func() {1361 f.once.Do(func() {
1332 if f.entered != nil {1362 if f.entered != nil {
1333 close(f.entered)1363 close(f.entered)
M internal/usecase/daemon/daemon.go +8 -4
@@ -1320,7 +1320,11 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
13201320 terminalCapabilities: terminalCapabilities,
13211321 capabilitiesSet: true,
13221322 }
1323- ac := d.prepareAttachedClientLocked(tr, sz, opts)
1323+ geometry := h.Geometry()
1324+ if geometry.Size != sz {
1325+ geometry = domain.Geometry{Size: sz}
1326+ }
1327+ ac := d.prepareAttachedClientLocked(tr, geometry, opts)
13241328 d.mu.Unlock()
13251329 result, err := d.transitionAttachment(attachmentTransitionRequest{
13261330 target: sess,
@@ -1535,7 +1539,7 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
15351539 switch h.Intent {
15361540 case ports.IntentEphemeral:
15371541 name := d.allocEphemeralNameLocked()
1538- sess, err := d.createSessionLockedWithMode(name, true, h.Cwd, sz, h.Env)
1542+ sess, err := d.createSessionLockedWithMode(name, true, h.Cwd, h.Geometry(), h.Env)
15391543 if err != nil {
15401544 d.mu.Unlock()
15411545 return nil, nil, err
@@ -1556,7 +1560,7 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
15561560 d.mu.Unlock()
15571561 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}
15581562 }
1559- sess, err := d.createSessionLockedWithMode(h.Name, false, h.Cwd, sz, h.Env)
1563+ sess, err := d.createSessionLockedWithMode(h.Name, false, h.Cwd, h.Geometry(), h.Env)
15601564 if err != nil {
15611565 d.mu.Unlock()
15621566 return nil, nil, err
@@ -1579,7 +1583,7 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
15791583 env = copyEnvironment(d.baseEnv)
15801584 }
15811585 var err error
1582- sess, err = d.resumeInactiveSessionLocked(h.Name, cwd, sz, env, stopped, stopped.tabNames)
1586+ sess, err = d.resumeInactiveSessionLocked(h.Name, cwd, h.Geometry(), env, stopped, stopped.tabNames)
15831587 if err != nil {
15841588 d.mu.Unlock()
15851589 return nil, nil, err
@@ -1320,7 +1320,11 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
1320 terminalCapabilities: terminalCapabilities,1320 terminalCapabilities: terminalCapabilities,
1321 capabilitiesSet: true,1321 capabilitiesSet: true,
1322 }1322 }
1323- ac := d.prepareAttachedClientLocked(tr, sz, opts)1323+ geometry := h.Geometry()
1324+ if geometry.Size != sz {
1325+ geometry = domain.Geometry{Size: sz}
1326+ }
1327+ ac := d.prepareAttachedClientLocked(tr, geometry, opts)
1324 d.mu.Unlock()1328 d.mu.Unlock()
1325 result, err := d.transitionAttachment(attachmentTransitionRequest{1329 result, err := d.transitionAttachment(attachmentTransitionRequest{
1326 target: sess,1330 target: sess,
@@ -1535,7 +1539,7 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
1535 switch h.Intent {1539 switch h.Intent {
1536 case ports.IntentEphemeral:1540 case ports.IntentEphemeral:
1537 name := d.allocEphemeralNameLocked()1541 name := d.allocEphemeralNameLocked()
1538- sess, err := d.createSessionLockedWithMode(name, true, h.Cwd, sz, h.Env)1542+ sess, err := d.createSessionLockedWithMode(name, true, h.Cwd, h.Geometry(), h.Env)
1539 if err != nil {1543 if err != nil {
1540 d.mu.Unlock()1544 d.mu.Unlock()
1541 return nil, nil, err1545 return nil, nil, err
@@ -1556,7 +1560,7 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
1556 d.mu.Unlock()1560 d.mu.Unlock()
1557 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}1561 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}
1558 }1562 }
1559- sess, err := d.createSessionLockedWithMode(h.Name, false, h.Cwd, sz, h.Env)1563+ sess, err := d.createSessionLockedWithMode(h.Name, false, h.Cwd, h.Geometry(), h.Env)
1560 if err != nil {1564 if err != nil {
1561 d.mu.Unlock()1565 d.mu.Unlock()
1562 return nil, nil, err1566 return nil, nil, err
@@ -1579,7 +1583,7 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
1579 env = copyEnvironment(d.baseEnv)1583 env = copyEnvironment(d.baseEnv)
1580 }1584 }
1581 var err error1585 var err error
1582- sess, err = d.resumeInactiveSessionLocked(h.Name, cwd, sz, env, stopped, stopped.tabNames)1586+ sess, err = d.resumeInactiveSessionLocked(h.Name, cwd, h.Geometry(), env, stopped, stopped.tabNames)
1583 if err != nil {1587 if err != nil {
1584 d.mu.Unlock()1588 d.mu.Unlock()
1585 return nil, nil, err1589 return nil, nil, err
M internal/usecase/daemon/daemon_test.go +9 -9
@@ -75,7 +75,7 @@ type terminalEnv struct {
7575 func createSessionForTest(d *Daemon, name string, ephemeral bool, cwd string, sz domain.Size, _ terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) {
7676 d.mu.Lock()
7777 defer d.mu.Unlock()
78- return d.createSessionLocked(name, ephemeral, cwd, sz, env, restoredTabNames...)
78+ return d.createSessionLocked(name, ephemeral, cwd, domain.Geometry{Size: sz}, env, restoredTabNames...)
7979 }
8080
8181 func newTestDaemonWithCleanup(t testing.TB, ptys ports.PTYFactory, clk ports.Clock, registerCleanup bool) *Daemon {
@@ -158,7 +158,7 @@ func (p *quietPTY) Close() error {
158158 }
159159 return nil
160160 }
161-func (*quietPTY) Resize(domain.Size) error { return nil }
161+func (*quietPTY) Resize(domain.Geometry) error { return nil }
162162 func (*quietPTY) Pid() int { return 0 }
163163 func (*quietPTY) ForegroundPgid() (int, error) { return 0, nil }
164164
@@ -179,7 +179,7 @@ func newBlockingOpenFactory(t *testing.T, d *Daemon) *blockingOpenFactory {
179179 return f
180180 }
181181
182-func (f *blockingOpenFactory) Open(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
182+func (f *blockingOpenFactory) Open(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (ports.PTY, error) {
183183 <-f.release
184184 return nil, io.ErrClosedPipe
185185 }
@@ -192,11 +192,11 @@ func newFactory(t *testing.T, p ports.PTY) *portsmocks.MockPTYFactory {
192192 f := portsmocks.NewMockPTYFactory(t)
193193 var normal domain.Size
194194 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
195- func(_ context.Context, _ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) {
195+ func(_ context.Context, _ string, _ []string, _ []string, _ string, geometry domain.Geometry) (ports.PTY, error) {
196196 if !normal.Valid() {
197- normal = sz
197+ normal = geometry.Size
198198 }
199- if sz == normal {
199+ if geometry.Size == normal {
200200 return p, nil
201201 }
202202 return newQuietPTY(), nil
@@ -212,13 +212,13 @@ func newFactorySeq(t *testing.T, ptys ...ports.PTY) *portsmocks.MockPTYFactory {
212212 var normal domain.Size
213213 next := 0
214214 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
215- func(_ context.Context, _ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) {
215+ func(_ context.Context, _ string, _ []string, _ []string, _ string, geometry domain.Geometry) (ports.PTY, error) {
216216 mu.Lock()
217217 defer mu.Unlock()
218218 if !normal.Valid() {
219- normal = sz
219+ normal = geometry.Size
220220 }
221- if sz != normal {
221+ if geometry.Size != normal {
222222 return newQuietPTY(), nil
223223 }
224224 if next >= len(ptys) {
@@ -75,7 +75,7 @@ type terminalEnv struct {
75 func createSessionForTest(d *Daemon, name string, ephemeral bool, cwd string, sz domain.Size, _ terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) {75 func createSessionForTest(d *Daemon, name string, ephemeral bool, cwd string, sz domain.Size, _ terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) {
76 d.mu.Lock()76 d.mu.Lock()
77 defer d.mu.Unlock()77 defer d.mu.Unlock()
78- return d.createSessionLocked(name, ephemeral, cwd, sz, env, restoredTabNames...)78+ return d.createSessionLocked(name, ephemeral, cwd, domain.Geometry{Size: sz}, env, restoredTabNames...)
79 }79 }
80 80
81 func newTestDaemonWithCleanup(t testing.TB, ptys ports.PTYFactory, clk ports.Clock, registerCleanup bool) *Daemon {81 func newTestDaemonWithCleanup(t testing.TB, ptys ports.PTYFactory, clk ports.Clock, registerCleanup bool) *Daemon {
@@ -158,7 +158,7 @@ func (p *quietPTY) Close() error {
158 }158 }
159 return nil159 return nil
160 }160 }
161-func (*quietPTY) Resize(domain.Size) error { return nil }161+func (*quietPTY) Resize(domain.Geometry) error { return nil }
162 func (*quietPTY) Pid() int { return 0 }162 func (*quietPTY) Pid() int { return 0 }
163 func (*quietPTY) ForegroundPgid() (int, error) { return 0, nil }163 func (*quietPTY) ForegroundPgid() (int, error) { return 0, nil }
164 164
@@ -179,7 +179,7 @@ func newBlockingOpenFactory(t *testing.T, d *Daemon) *blockingOpenFactory {
179 return f179 return f
180 }180 }
181 181
182-func (f *blockingOpenFactory) Open(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {182+func (f *blockingOpenFactory) Open(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (ports.PTY, error) {
183 <-f.release183 <-f.release
184 return nil, io.ErrClosedPipe184 return nil, io.ErrClosedPipe
185 }185 }
@@ -192,11 +192,11 @@ func newFactory(t *testing.T, p ports.PTY) *portsmocks.MockPTYFactory {
192 f := portsmocks.NewMockPTYFactory(t)192 f := portsmocks.NewMockPTYFactory(t)
193 var normal domain.Size193 var normal domain.Size
194 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(194 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
195- func(_ context.Context, _ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) {195+ func(_ context.Context, _ string, _ []string, _ []string, _ string, geometry domain.Geometry) (ports.PTY, error) {
196 if !normal.Valid() {196 if !normal.Valid() {
197- normal = sz197+ normal = geometry.Size
198 }198 }
199- if sz == normal {199+ if geometry.Size == normal {
200 return p, nil200 return p, nil
201 }201 }
202 return newQuietPTY(), nil202 return newQuietPTY(), nil
@@ -212,13 +212,13 @@ func newFactorySeq(t *testing.T, ptys ...ports.PTY) *portsmocks.MockPTYFactory {
212 var normal domain.Size212 var normal domain.Size
213 next := 0213 next := 0
214 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(214 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
215- func(_ context.Context, _ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) {215+ func(_ context.Context, _ string, _ []string, _ []string, _ string, geometry domain.Geometry) (ports.PTY, error) {
216 mu.Lock()216 mu.Lock()
217 defer mu.Unlock()217 defer mu.Unlock()
218 if !normal.Valid() {218 if !normal.Valid() {
219- normal = sz219+ normal = geometry.Size
220 }220 }
221- if sz != normal {221+ if geometry.Size != normal {
222 return newQuietPTY(), nil222 return newQuietPTY(), nil
223 }223 }
224 if next >= len(ptys) {224 if next >= len(ptys) {
M internal/usecase/daemon/durable_recovery_test.go +1 -1
@@ -520,7 +520,7 @@ type recoveryCountingPTYFactory struct {
520520 calls atomic.Int32
521521 }
522522
523-func (f *recoveryCountingPTYFactory) Open(context.Context, string, []string, []string, string, domain.Size) (ports.PTY, error) {
523+func (f *recoveryCountingPTYFactory) Open(context.Context, string, []string, []string, string, domain.Geometry) (ports.PTY, error) {
524524 f.calls.Add(1)
525525 return nil, errors.New("unexpected PTY open")
526526 }
@@ -520,7 +520,7 @@ type recoveryCountingPTYFactory struct {
520 calls atomic.Int32520 calls atomic.Int32
521 }521 }
522 522
523-func (f *recoveryCountingPTYFactory) Open(context.Context, string, []string, []string, string, domain.Size) (ports.PTY, error) {523+func (f *recoveryCountingPTYFactory) Open(context.Context, string, []string, []string, string, domain.Geometry) (ports.PTY, error) {
524 f.calls.Add(1)524 f.calls.Add(1)
525 return nil, errors.New("unexpected PTY open")525 return nil, errors.New("unexpected PTY open")
526 }526 }
M internal/usecase/daemon/floating.go +6 -1
@@ -316,6 +316,7 @@ type floatingLaunchSpec struct {
316316 sessionName string
317317 cwd string
318318 size domain.Size
319+ ptyGeometry domain.Geometry
319320 geometry floatingGeometry
320321 paneStableID string
321322 env []string
@@ -385,6 +386,7 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
385386 sessionName: name,
386387 cwd: cwd,
387388 size: size,
389+ ptyGeometry: sess.paneGeometry(size),
388390 geometry: geometry,
389391 paneStableID: paneStableID,
390392 env: childEnvFrom(env, name, tabStableID, paneStableID),
@@ -412,7 +414,7 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau
412414 lifetime.abort()
413415 }
414416 }()
415- pty, err := d.ptys.Open(lifetime.ctx, spec.command, spec.args, spec.env, spec.cwd, spec.size)
417+ pty, err := d.ptys.Open(lifetime.ctx, spec.command, spec.args, spec.env, spec.cwd, spec.ptyGeometry)
416418 if err != nil {
417419 // Open retains ownership of a nonnil PTY only on success. Some factory
418420 // implementations can return a partially opened PTY with an error, so
@@ -424,7 +426,10 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau
424426 d.failFloatingLaunch(sess, tb, generation, spec.userOpen, spec.sessionName, err)
425427 return
426428 }
429+ ptyGeometry := spec.ptyGeometry
427430 p := newPaneWithStableIDAndTitle(layout.PaneID("floating"), spec.paneStableID, pty, spec.size, spec.fallback)
431+ p.geometry = ptyGeometry
432+ setScreenGeometry(p.screen, ptyGeometry)
428433 p.rect = spec.geometry.ptyRect()
429434 p.popupGeometry = spec.geometry
430435 if !lifetime.publish(p) {
@@ -316,6 +316,7 @@ type floatingLaunchSpec struct {
316 sessionName string316 sessionName string
317 cwd string317 cwd string
318 size domain.Size318 size domain.Size
319+ ptyGeometry domain.Geometry
319 geometry floatingGeometry320 geometry floatingGeometry
320 paneStableID string321 paneStableID string
321 env []string322 env []string
@@ -385,6 +386,7 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
385 sessionName: name,386 sessionName: name,
386 cwd: cwd,387 cwd: cwd,
387 size: size,388 size: size,
389+ ptyGeometry: sess.paneGeometry(size),
388 geometry: geometry,390 geometry: geometry,
389 paneStableID: paneStableID,391 paneStableID: paneStableID,
390 env: childEnvFrom(env, name, tabStableID, paneStableID),392 env: childEnvFrom(env, name, tabStableID, paneStableID),
@@ -412,7 +414,7 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau
412 lifetime.abort()414 lifetime.abort()
413 }415 }
414 }()416 }()
415- pty, err := d.ptys.Open(lifetime.ctx, spec.command, spec.args, spec.env, spec.cwd, spec.size)417+ pty, err := d.ptys.Open(lifetime.ctx, spec.command, spec.args, spec.env, spec.cwd, spec.ptyGeometry)
416 if err != nil {418 if err != nil {
417 // Open retains ownership of a nonnil PTY only on success. Some factory419 // Open retains ownership of a nonnil PTY only on success. Some factory
418 // implementations can return a partially opened PTY with an error, so420 // implementations can return a partially opened PTY with an error, so
@@ -424,7 +426,10 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau
424 d.failFloatingLaunch(sess, tb, generation, spec.userOpen, spec.sessionName, err)426 d.failFloatingLaunch(sess, tb, generation, spec.userOpen, spec.sessionName, err)
425 return427 return
426 }428 }
429+ ptyGeometry := spec.ptyGeometry
427 p := newPaneWithStableIDAndTitle(layout.PaneID("floating"), spec.paneStableID, pty, spec.size, spec.fallback)430 p := newPaneWithStableIDAndTitle(layout.PaneID("floating"), spec.paneStableID, pty, spec.size, spec.fallback)
431+ p.geometry = ptyGeometry
432+ setScreenGeometry(p.screen, ptyGeometry)
428 p.rect = spec.geometry.ptyRect()433 p.rect = spec.geometry.ptyRect()
429 p.popupGeometry = spec.geometry434 p.popupGeometry = spec.geometry
430 if !lifetime.publish(p) {435 if !lifetime.publish(p) {
M internal/usecase/daemon/floating_open_test.go +10 -10
@@ -38,9 +38,9 @@ func (p *contextAwareFloatingPTY) Read([]byte) (int, error) {
3838 return 0, p.ctx.Err()
3939 }
4040
41-func (*contextAwareFloatingPTY) Write(b []byte) (int, error) { return len(b), nil }
42-func (*contextAwareFloatingPTY) Resize(domain.Size) error { return nil }
43-func (*contextAwareFloatingPTY) Pid() int { return 1 }
41+func (*contextAwareFloatingPTY) Write(b []byte) (int, error) { return len(b), nil }
42+func (*contextAwareFloatingPTY) Resize(domain.Geometry) error { return nil }
43+func (*contextAwareFloatingPTY) Pid() int { return 1 }
4444 func (*contextAwareFloatingPTY) ForegroundPgid() (int, error) {
4545 return 1, nil
4646 }
@@ -60,7 +60,7 @@ type contextAwareFloatingFactory struct {
6060 onOpen func()
6161 }
6262
63-func (f *contextAwareFloatingFactory) Open(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
63+func (f *contextAwareFloatingFactory) Open(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (ports.PTY, error) {
6464 if f.pty != nil {
6565 f.pty.ctx = ctx
6666 }
@@ -182,9 +182,9 @@ func (p *failedOpenPTY) Read([]byte) (int, error) {
182182 }
183183 }
184184
185-func (*failedOpenPTY) Write(b []byte) (int, error) { return len(b), nil }
186-func (*failedOpenPTY) Resize(domain.Size) error { return nil }
187-func (*failedOpenPTY) Pid() int { return 1 }
185+func (*failedOpenPTY) Write(b []byte) (int, error) { return len(b), nil }
186+func (*failedOpenPTY) Resize(domain.Geometry) error { return nil }
187+func (*failedOpenPTY) Pid() int { return 1 }
188188 func (*failedOpenPTY) ForegroundPgid() (int, error) {
189189 return 1, nil
190190 }
@@ -214,7 +214,7 @@ func TestFloatingOpenErrorReleasesReturnedPTYBeforePublishingFailure(t *testing.
214214 cancelCallbacks := atomic.Int32{}
215215 cancelled := make(chan struct{})
216216 factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
217- func(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
217+ func(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (ports.PTY, error) {
218218 pty.ctx = ctx
219219 context.AfterFunc(ctx, func() {
220220 cancelCallbacks.Add(1)
@@ -392,7 +392,7 @@ func TestFloatingOpenCancellationBoundsSessionTeardownAndClosesLatePTY(t *testin
392392 closed := make(chan struct{})
393393 latePTY.EXPECT().Close().RunAndReturn(func() error { close(closed); return nil }).Once()
394394 factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
395- func(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
395+ func(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (ports.PTY, error) {
396396 close(opened)
397397 <-ctx.Done()
398398 return latePTY, nil
@@ -435,7 +435,7 @@ func TestFloatingOpensFromUnrelatedSessionsAreConcurrent(t *testing.T) {
435435 release := make(chan struct{})
436436 var once sync.Once
437437 factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
438- func(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
438+ func(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (ports.PTY, error) {
439439 entered <- struct{}{}
440440 <-release
441441 return newQuietPTY(), nil
@@ -38,9 +38,9 @@ func (p *contextAwareFloatingPTY) Read([]byte) (int, error) {
38 return 0, p.ctx.Err()38 return 0, p.ctx.Err()
39 }39 }
40 40
41-func (*contextAwareFloatingPTY) Write(b []byte) (int, error) { return len(b), nil }41+func (*contextAwareFloatingPTY) Write(b []byte) (int, error) { return len(b), nil }
42-func (*contextAwareFloatingPTY) Resize(domain.Size) error { return nil }42+func (*contextAwareFloatingPTY) Resize(domain.Geometry) error { return nil }
43-func (*contextAwareFloatingPTY) Pid() int { return 1 }43+func (*contextAwareFloatingPTY) Pid() int { return 1 }
44 func (*contextAwareFloatingPTY) ForegroundPgid() (int, error) {44 func (*contextAwareFloatingPTY) ForegroundPgid() (int, error) {
45 return 1, nil45 return 1, nil
46 }46 }
@@ -60,7 +60,7 @@ type contextAwareFloatingFactory struct {
60 onOpen func()60 onOpen func()
61 }61 }
62 62
63-func (f *contextAwareFloatingFactory) Open(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {63+func (f *contextAwareFloatingFactory) Open(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (ports.PTY, error) {
64 if f.pty != nil {64 if f.pty != nil {
65 f.pty.ctx = ctx65 f.pty.ctx = ctx
66 }66 }
@@ -182,9 +182,9 @@ func (p *failedOpenPTY) Read([]byte) (int, error) {
182 }182 }
183 }183 }
184 184
185-func (*failedOpenPTY) Write(b []byte) (int, error) { return len(b), nil }185+func (*failedOpenPTY) Write(b []byte) (int, error) { return len(b), nil }
186-func (*failedOpenPTY) Resize(domain.Size) error { return nil }186+func (*failedOpenPTY) Resize(domain.Geometry) error { return nil }
187-func (*failedOpenPTY) Pid() int { return 1 }187+func (*failedOpenPTY) Pid() int { return 1 }
188 func (*failedOpenPTY) ForegroundPgid() (int, error) {188 func (*failedOpenPTY) ForegroundPgid() (int, error) {
189 return 1, nil189 return 1, nil
190 }190 }
@@ -214,7 +214,7 @@ func TestFloatingOpenErrorReleasesReturnedPTYBeforePublishingFailure(t *testing.
214 cancelCallbacks := atomic.Int32{}214 cancelCallbacks := atomic.Int32{}
215 cancelled := make(chan struct{})215 cancelled := make(chan struct{})
216 factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(216 factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
217- func(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {217+ func(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (ports.PTY, error) {
218 pty.ctx = ctx218 pty.ctx = ctx
219 context.AfterFunc(ctx, func() {219 context.AfterFunc(ctx, func() {
220 cancelCallbacks.Add(1)220 cancelCallbacks.Add(1)
@@ -392,7 +392,7 @@ func TestFloatingOpenCancellationBoundsSessionTeardownAndClosesLatePTY(t *testin
392 closed := make(chan struct{})392 closed := make(chan struct{})
393 latePTY.EXPECT().Close().RunAndReturn(func() error { close(closed); return nil }).Once()393 latePTY.EXPECT().Close().RunAndReturn(func() error { close(closed); return nil }).Once()
394 factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(394 factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
395- func(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {395+ func(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (ports.PTY, error) {
396 close(opened)396 close(opened)
397 <-ctx.Done()397 <-ctx.Done()
398 return latePTY, nil398 return latePTY, nil
@@ -435,7 +435,7 @@ func TestFloatingOpensFromUnrelatedSessionsAreConcurrent(t *testing.T) {
435 release := make(chan struct{})435 release := make(chan struct{})
436 var once sync.Once436 var once sync.Once
437 factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(437 factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
438- func(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {438+ func(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (ports.PTY, error) {
439 entered <- struct{}{}439 entered <- struct{}{}
440 <-release440 <-release
441 return newQuietPTY(), nil441 return newQuietPTY(), nil
M internal/usecase/daemon/floating_render_test.go +2 -2
@@ -1012,9 +1012,9 @@ type resizePTY struct {
10121012 maxConcurrent int
10131013 }
10141014
1015-func (p *resizePTY) Resize(sz domain.Size) error {
1015+func (p *resizePTY) Resize(geometry domain.Geometry) error {
10161016 p.mu.Lock()
1017- p.resizes = append(p.resizes, sz)
1017+ p.resizes = append(p.resizes, geometry.Size)
10181018 p.activeCalls++
10191019 p.maxConcurrent = max(p.maxConcurrent, p.activeCalls)
10201020 n := len(p.resizes)
@@ -1012,9 +1012,9 @@ type resizePTY struct {
1012 maxConcurrent int1012 maxConcurrent int
1013 }1013 }
1014 1014
1015-func (p *resizePTY) Resize(sz domain.Size) error {1015+func (p *resizePTY) Resize(geometry domain.Geometry) error {
1016 p.mu.Lock()1016 p.mu.Lock()
1017- p.resizes = append(p.resizes, sz)1017+ p.resizes = append(p.resizes, geometry.Size)
1018 p.activeCalls++1018 p.activeCalls++
1019 p.maxConcurrent = max(p.maxConcurrent, p.activeCalls)1019 p.maxConcurrent = max(p.maxConcurrent, p.activeCalls)
1020 n := len(p.resizes)1020 n := len(p.resizes)
M internal/usecase/daemon/floating_test.go +4 -4
@@ -39,8 +39,8 @@ func newGatedOpenFactory(t *testing.T, result ports.PTY, openErr error) (*portsm
3939 releaseOpen := func() { releaseOnce.Do(func() { close(release) }) }
4040 t.Cleanup(releaseOpen)
4141 factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
42- func(_ context.Context, command string, args, env []string, dir string, size domain.Size) (ports.PTY, error) {
43- opened <- gatedFloatingOpen{command: command, args: append([]string(nil), args...), env: append([]string(nil), env...), dir: dir, size: size}
42+ func(_ context.Context, command string, args, env []string, dir string, geometry domain.Geometry) (ports.PTY, error) {
43+ opened <- gatedFloatingOpen{command: command, args: append([]string(nil), args...), env: append([]string(nil), env...), dir: dir, size: geometry.Size}
4444 <-release
4545 return result, openErr
4646 }).Once()
@@ -223,7 +223,7 @@ func TestFloatingBlockedOpenReconcilesLatestResponsiveSize(t *testing.T) {
223223 <-readerRelease
224224 return 0, io.EOF
225225 }).Once()
226- floatingPTY.EXPECT().Resize(rectSize(latestGeometry.Inner)).Run(func(domain.Size) { close(resized) }).Return(nil).Once()
226+ floatingPTY.EXPECT().Resize(domain.Geometry{Size: rectSize(latestGeometry.Inner)}).Run(func(domain.Geometry) { close(resized) }).Return(nil).Once()
227227 floatingPTY.EXPECT().Close().RunAndReturn(func() error {
228228 select {
229229 case <-readerRelease:
@@ -615,7 +615,7 @@ func TestFloatingLaunchUsesLiveOrValidatedSessionCwd(t *testing.T) {
615615 factory := portsmocks.NewMockPTYFactory(t)
616616 opened := make(chan string, 1)
617617 factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
618- func(_ context.Context, _ string, _ []string, _ []string, cwd string, _ domain.Size) (ports.PTY, error) {
618+ func(_ context.Context, _ string, _ []string, _ []string, cwd string, _ domain.Geometry) (ports.PTY, error) {
619619 opened <- cwd
620620 return nil, io.ErrUnexpectedEOF
621621 }).Once()
@@ -39,8 +39,8 @@ func newGatedOpenFactory(t *testing.T, result ports.PTY, openErr error) (*portsm
39 releaseOpen := func() { releaseOnce.Do(func() { close(release) }) }39 releaseOpen := func() { releaseOnce.Do(func() { close(release) }) }
40 t.Cleanup(releaseOpen)40 t.Cleanup(releaseOpen)
41 factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(41 factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
42- func(_ context.Context, command string, args, env []string, dir string, size domain.Size) (ports.PTY, error) {42+ func(_ context.Context, command string, args, env []string, dir string, geometry domain.Geometry) (ports.PTY, error) {
43- opened <- gatedFloatingOpen{command: command, args: append([]string(nil), args...), env: append([]string(nil), env...), dir: dir, size: size}43+ opened <- gatedFloatingOpen{command: command, args: append([]string(nil), args...), env: append([]string(nil), env...), dir: dir, size: geometry.Size}
44 <-release44 <-release
45 return result, openErr45 return result, openErr
46 }).Once()46 }).Once()
@@ -223,7 +223,7 @@ func TestFloatingBlockedOpenReconcilesLatestResponsiveSize(t *testing.T) {
223 <-readerRelease223 <-readerRelease
224 return 0, io.EOF224 return 0, io.EOF
225 }).Once()225 }).Once()
226- floatingPTY.EXPECT().Resize(rectSize(latestGeometry.Inner)).Run(func(domain.Size) { close(resized) }).Return(nil).Once()226+ floatingPTY.EXPECT().Resize(domain.Geometry{Size: rectSize(latestGeometry.Inner)}).Run(func(domain.Geometry) { close(resized) }).Return(nil).Once()
227 floatingPTY.EXPECT().Close().RunAndReturn(func() error {227 floatingPTY.EXPECT().Close().RunAndReturn(func() error {
228 select {228 select {
229 case <-readerRelease:229 case <-readerRelease:
@@ -615,7 +615,7 @@ func TestFloatingLaunchUsesLiveOrValidatedSessionCwd(t *testing.T) {
615 factory := portsmocks.NewMockPTYFactory(t)615 factory := portsmocks.NewMockPTYFactory(t)
616 opened := make(chan string, 1)616 opened := make(chan string, 1)
617 factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(617 factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
618- func(_ context.Context, _ string, _ []string, _ []string, cwd string, _ domain.Size) (ports.PTY, error) {618+ func(_ context.Context, _ string, _ []string, _ []string, cwd string, _ domain.Geometry) (ports.PTY, error) {
619 opened <- cwd619 opened <- cwd
620 return nil, io.ErrUnexpectedEOF620 return nil, io.ErrUnexpectedEOF
621 }).Once()621 }).Once()
A internal/usecase/daemon/geometry_propagation_test.go +217 -0
@@ -0,0 +1,217 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "io"
6+ "log/slog"
7+ "sync"
8+ "testing"
9+
10+ vt "github.com/bnema/vev-vt"
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestSessionPaneGeometryUsesLatestAttachmentClaimAndTruncates(t *testing.T) {
17+ sess := &session{}
18+ first := &attachedClient{}
19+ first.setGeometry(domain.Geometry{
20+ Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 800, PixelHeight: 480,
21+ })
22+ second := &attachedClient{}
23+ second.setGeometry(domain.Geometry{
24+ Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 803, PixelHeight: 487,
25+ })
26+
27+ require.True(t, sess.registerAttachment(first))
28+ require.True(t, sess.registerAttachment(second))
29+ require.Equal(t, domain.Geometry{
30+ Size: domain.Size{Cols: 39, Rows: 23}, PixelWidth: 391, PixelHeight: 466,
31+ }, sess.paneGeometry(domain.Size{Cols: 39, Rows: 23}))
32+
33+ require.True(t, sess.unregisterAttachment(second))
34+ require.Equal(t, domain.Geometry{
35+ Size: domain.Size{Cols: 39, Rows: 23}, PixelWidth: 390, PixelHeight: 460,
36+ }, sess.paneGeometry(domain.Size{Cols: 39, Rows: 23}))
37+}
38+
39+func TestPreparedLayoutPropagatesGeometryToPTYAndScreenQueries(t *testing.T) {
40+ geometry := domain.Geometry{
41+ Size: domain.Size{Cols: 39, Rows: 23}, PixelWidth: 391, PixelHeight: 466,
42+ }
43+ pty := &recordingGeometryPTY{}
44+ pane := &pane{pty: pty, screen: vt.NewScreen(1, 1), rect: domain.Rect{Width: 1, Height: 1}}
45+ plan := preparedTabLayout{members: []resizeMember{{pane: pane, rect: domain.Rect{Width: 39, Height: 23}, geometry: geometry}}}
46+ daemon := &Daemon{log: slog.New(slog.NewTextHandler(io.Discard, nil))}
47+
48+ daemon.applyPreparedTabMembers(&plan)
49+ require.True(t, commitPreparedTabLayoutLocked(&plan))
50+ require.Equal(t, geometry, pty.geometry)
51+ require.Equal(t, vt.Geometry{
52+ Cols: 39, Rows: 23, PixelWidth: 391, PixelHeight: 466,
53+ }, pane.screen.Geometry())
54+
55+ var responses string
56+ pane.screen.OnResponse = func(response []byte) { responses += string(response) }
57+ pane.screen.Write([]byte("\x1b[14t\x1b[16t"))
58+ require.Equal(t, "\x1b[4;466;391t\x1b[6;20;10t", responses)
59+}
60+
61+type recordingGeometryPTY struct {
62+ geometry domain.Geometry
63+}
64+
65+func (p *recordingGeometryPTY) Read([]byte) (int, error) { return 0, io.EOF }
66+func (p *recordingGeometryPTY) Write(b []byte) (int, error) { return len(b), nil }
67+func (p *recordingGeometryPTY) Close() error { return nil }
68+func (p *recordingGeometryPTY) Resize(geometry domain.Geometry) error {
69+ p.geometry = geometry
70+ return nil
71+}
72+func (p *recordingGeometryPTY) Pid() int { return 1 }
73+func (p *recordingGeometryPTY) ForegroundPgid() (int, error) { return 1, nil }
74+
75+func TestSizeOnlyGeometryClaimsPreservePixelsAndFallback(t *testing.T) {
76+ sess := &session{}
77+ first := &attachedClient{}
78+ first.setGeometry(domain.Geometry{
79+ Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 800, PixelHeight: 480,
80+ })
81+ second := &attachedClient{}
82+ second.setGeometry(domain.Geometry{
83+ Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 900, PixelHeight: 540,
84+ })
85+ require.True(t, sess.registerAttachment(first))
86+ require.True(t, sess.registerAttachment(second))
87+
88+ _, claimed := sess.claimGeometryOwnerForSize(second, domain.Size{Cols: 80, Rows: 24})
89+ require.True(t, claimed)
90+ require.Equal(t, domain.Geometry{
91+ Size: domain.Size{Cols: 39, Rows: 23}, PixelWidth: 438, PixelHeight: 517,
92+ }, sess.paneGeometry(domain.Size{Cols: 39, Rows: 23}))
93+
94+ require.True(t, sess.unregisterAttachment(second))
95+ require.Equal(t, domain.Geometry{
96+ Size: domain.Size{Cols: 39, Rows: 23}, PixelWidth: 390, PixelHeight: 460,
97+ }, sess.paneGeometry(domain.Size{Cols: 39, Rows: 23}))
98+
99+ partial := &attachedClient{}
100+ partial.setGeometry(domain.Geometry{
101+ Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 901,
102+ })
103+ require.True(t, sess.registerAttachment(partial))
104+ require.Equal(t, domain.Geometry{Size: domain.Size{Cols: 39, Rows: 23}}, sess.paneGeometry(domain.Size{Cols: 39, Rows: 23}), "partial pixels must remain cell-only")
105+ require.True(t, sess.unregisterAttachment(partial))
106+ require.Equal(t, domain.Geometry{
107+ Size: domain.Size{Cols: 39, Rows: 23}, PixelWidth: 390, PixelHeight: 460,
108+ }, sess.paneGeometry(domain.Size{Cols: 39, Rows: 23}), "fallback must restore the remaining complete pair")
109+}
110+
111+func TestSizeOnlyLayoutAndFloatingRetainClaimingPixelGeometry(t *testing.T) {
112+ full := domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 800, PixelHeight: 480}
113+ ac := &attachedClient{}
114+ ac.setGeometry(full)
115+ sess := &session{sessionCore: sessionCore{id: "geometry"}}
116+ require.True(t, sess.registerAttachment(ac))
117+
118+ layoutPTY := &recordingGeometryPTY{}
119+ tb := newTab(layoutPTY, domain.Size{Cols: 80, Rows: 22})
120+ sess.tabs = []*tab{tb}
121+ d := newTestDaemon(t, nil, stubClock{})
122+ require.True(t, d.applyTabLayout(sess, tb))
123+ require.Equal(t, domain.Geometry{Size: domain.Size{Cols: 80, Rows: 22}, PixelWidth: 800, PixelHeight: 440}, layoutPTY.geometry)
124+
125+ floatingPTY := &recordingGeometryPTY{}
126+ cfg := domain.FloatingConfig{Width: 50, Height: 50}
127+ d.ApplyConfig(domain.Config{Floating: cfg})
128+ floatingGeometry := calculateContentFloatingGeometry(tb.size, cfg)
129+ floating := newPane("floating", floatingPTY, rectSize(floatingGeometry.Inner))
130+ floating.popupGeometry = floatingGeometry
131+ tb.floating = floatingSlot{state: floatingVisible, pane: floating, generation: 1}
132+ failed, ok := d.applyVisibleFloatingLayout(sess, tb, nil)
133+ require.True(t, ok)
134+ require.Empty(t, failed)
135+ ptyRect := floatingGeometry.ptyRect()
136+ require.Equal(t, domain.Geometry{
137+ Size: domain.Size{Cols: ptyRect.Width, Rows: ptyRect.Height},
138+ PixelWidth: 800 * ptyRect.Width / 80, PixelHeight: 480 * ptyRect.Height / 24,
139+ }, floatingPTY.geometry)
140+}
141+
142+type recordingPTYFactory struct {
143+ pty ports.PTY
144+ geometry domain.Geometry
145+}
146+
147+func (f *recordingPTYFactory) Open(_ context.Context, _ string, _ []string, _ []string, _ string, geometry domain.Geometry) (ports.PTY, error) {
148+ f.geometry = geometry
149+ return f.pty, nil
150+}
151+
152+type blockingRecordingGeometryPTY struct {
153+ mu sync.Mutex
154+ geometry domain.Geometry
155+ done chan struct{}
156+ once sync.Once
157+}
158+
159+func (p *blockingRecordingGeometryPTY) Read([]byte) (int, error) {
160+ <-p.done
161+ return 0, io.EOF
162+}
163+func (p *blockingRecordingGeometryPTY) Write(b []byte) (int, error) { return len(b), nil }
164+func (p *blockingRecordingGeometryPTY) Close() error {
165+ p.once.Do(func() { close(p.done) })
166+ return nil
167+}
168+func (p *blockingRecordingGeometryPTY) Resize(geometry domain.Geometry) error {
169+ p.mu.Lock()
170+ p.geometry = geometry
171+ p.mu.Unlock()
172+ return nil
173+}
174+func (p *blockingRecordingGeometryPTY) geometrySnapshot() domain.Geometry {
175+ p.mu.Lock()
176+ defer p.mu.Unlock()
177+ return p.geometry
178+}
179+func (*blockingRecordingGeometryPTY) Pid() int { return 1 }
180+func (*blockingRecordingGeometryPTY) ForegroundPgid() (int, error) { return 1, nil }
181+
182+func TestInitialSessionPTYReceivesClaimingPixelGeometry(t *testing.T) {
183+ pty := &recordingGeometryPTY{}
184+ factory := &recordingPTYFactory{pty: pty}
185+ d := newTestDaemon(t, factory, stubClock{})
186+ geometry := domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 800, PixelHeight: 480}
187+
188+ d.mu.Lock()
189+ sess, err := d.createSessionLockedWithMode("pixels", true, "/tmp", geometry, nil)
190+ d.mu.Unlock()
191+ require.NoError(t, err)
192+ want := domain.Geometry{Size: domain.Size{Cols: 80, Rows: 22}, PixelWidth: 800, PixelHeight: 440}
193+ require.Equal(t, want, factory.geometry)
194+ require.Equal(t, want, sess.tabs[0].focusedPane().geometry)
195+ require.Equal(t, vt.Geometry{Cols: 80, Rows: 22, PixelWidth: 800, PixelHeight: 440}, sess.tabs[0].focusedPane().screen.Geometry())
196+}
197+
198+func TestResumeReappliesClaimingPixelGeometryBeforeReturn(t *testing.T) {
199+ pty := &blockingRecordingGeometryPTY{done: make(chan struct{})}
200+ t.Cleanup(func() { _ = pty.Close() })
201+ d := newTestDaemon(t, &recordingPTYFactory{pty: pty}, stubClock{})
202+ firstHello := helloResumeCapable(ports.IntentNew, "pixels", 0)
203+ firstHello.PixelWidth, firstHello.PixelHeight = 800, 480
204+ firstTransport := &closeTrackingTransport{}
205+ sess, ac, err := d.route(firstHello, firstTransport)
206+ require.NoError(t, err)
207+ d.clientGone(sess, ac, firstTransport, false)
208+
209+ resumeHello := helloResumeCapable(ports.IntentResume, "pixels", ac.resumeToken)
210+ resumeHello.PixelWidth, resumeHello.PixelHeight = 1000, 600
211+ resumed, same, ok, err := d.resumeParked(resumeHello, &closeTrackingTransport{}, resumeHello.Size)
212+ require.NoError(t, err)
213+ require.True(t, ok)
214+ require.Same(t, sess, resumed)
215+ require.Same(t, ac, same)
216+ require.Equal(t, domain.Geometry{Size: domain.Size{Cols: 80, Rows: 22}, PixelWidth: 1000, PixelHeight: 550}, pty.geometrySnapshot())
217+}
@@ -0,0 +1,217 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "io"
6+ "log/slog"
7+ "sync"
8+ "testing"
9+
10+ vt "github.com/bnema/vev-vt"
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestSessionPaneGeometryUsesLatestAttachmentClaimAndTruncates(t *testing.T) {
17+ sess := &session{}
18+ first := &attachedClient{}
19+ first.setGeometry(domain.Geometry{
20+ Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 800, PixelHeight: 480,
21+ })
22+ second := &attachedClient{}
23+ second.setGeometry(domain.Geometry{
24+ Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 803, PixelHeight: 487,
25+ })
26+
27+ require.True(t, sess.registerAttachment(first))
28+ require.True(t, sess.registerAttachment(second))
29+ require.Equal(t, domain.Geometry{
30+ Size: domain.Size{Cols: 39, Rows: 23}, PixelWidth: 391, PixelHeight: 466,
31+ }, sess.paneGeometry(domain.Size{Cols: 39, Rows: 23}))
32+
33+ require.True(t, sess.unregisterAttachment(second))
34+ require.Equal(t, domain.Geometry{
35+ Size: domain.Size{Cols: 39, Rows: 23}, PixelWidth: 390, PixelHeight: 460,
36+ }, sess.paneGeometry(domain.Size{Cols: 39, Rows: 23}))
37+}
38+
39+func TestPreparedLayoutPropagatesGeometryToPTYAndScreenQueries(t *testing.T) {
40+ geometry := domain.Geometry{
41+ Size: domain.Size{Cols: 39, Rows: 23}, PixelWidth: 391, PixelHeight: 466,
42+ }
43+ pty := &recordingGeometryPTY{}
44+ pane := &pane{pty: pty, screen: vt.NewScreen(1, 1), rect: domain.Rect{Width: 1, Height: 1}}
45+ plan := preparedTabLayout{members: []resizeMember{{pane: pane, rect: domain.Rect{Width: 39, Height: 23}, geometry: geometry}}}
46+ daemon := &Daemon{log: slog.New(slog.NewTextHandler(io.Discard, nil))}
47+
48+ daemon.applyPreparedTabMembers(&plan)
49+ require.True(t, commitPreparedTabLayoutLocked(&plan))
50+ require.Equal(t, geometry, pty.geometry)
51+ require.Equal(t, vt.Geometry{
52+ Cols: 39, Rows: 23, PixelWidth: 391, PixelHeight: 466,
53+ }, pane.screen.Geometry())
54+
55+ var responses string
56+ pane.screen.OnResponse = func(response []byte) { responses += string(response) }
57+ pane.screen.Write([]byte("\x1b[14t\x1b[16t"))
58+ require.Equal(t, "\x1b[4;466;391t\x1b[6;20;10t", responses)
59+}
60+
61+type recordingGeometryPTY struct {
62+ geometry domain.Geometry
63+}
64+
65+func (p *recordingGeometryPTY) Read([]byte) (int, error) { return 0, io.EOF }
66+func (p *recordingGeometryPTY) Write(b []byte) (int, error) { return len(b), nil }
67+func (p *recordingGeometryPTY) Close() error { return nil }
68+func (p *recordingGeometryPTY) Resize(geometry domain.Geometry) error {
69+ p.geometry = geometry
70+ return nil
71+}
72+func (p *recordingGeometryPTY) Pid() int { return 1 }
73+func (p *recordingGeometryPTY) ForegroundPgid() (int, error) { return 1, nil }
74+
75+func TestSizeOnlyGeometryClaimsPreservePixelsAndFallback(t *testing.T) {
76+ sess := &session{}
77+ first := &attachedClient{}
78+ first.setGeometry(domain.Geometry{
79+ Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 800, PixelHeight: 480,
80+ })
81+ second := &attachedClient{}
82+ second.setGeometry(domain.Geometry{
83+ Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 900, PixelHeight: 540,
84+ })
85+ require.True(t, sess.registerAttachment(first))
86+ require.True(t, sess.registerAttachment(second))
87+
88+ _, claimed := sess.claimGeometryOwnerForSize(second, domain.Size{Cols: 80, Rows: 24})
89+ require.True(t, claimed)
90+ require.Equal(t, domain.Geometry{
91+ Size: domain.Size{Cols: 39, Rows: 23}, PixelWidth: 438, PixelHeight: 517,
92+ }, sess.paneGeometry(domain.Size{Cols: 39, Rows: 23}))
93+
94+ require.True(t, sess.unregisterAttachment(second))
95+ require.Equal(t, domain.Geometry{
96+ Size: domain.Size{Cols: 39, Rows: 23}, PixelWidth: 390, PixelHeight: 460,
97+ }, sess.paneGeometry(domain.Size{Cols: 39, Rows: 23}))
98+
99+ partial := &attachedClient{}
100+ partial.setGeometry(domain.Geometry{
101+ Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 901,
102+ })
103+ require.True(t, sess.registerAttachment(partial))
104+ require.Equal(t, domain.Geometry{Size: domain.Size{Cols: 39, Rows: 23}}, sess.paneGeometry(domain.Size{Cols: 39, Rows: 23}), "partial pixels must remain cell-only")
105+ require.True(t, sess.unregisterAttachment(partial))
106+ require.Equal(t, domain.Geometry{
107+ Size: domain.Size{Cols: 39, Rows: 23}, PixelWidth: 390, PixelHeight: 460,
108+ }, sess.paneGeometry(domain.Size{Cols: 39, Rows: 23}), "fallback must restore the remaining complete pair")
109+}
110+
111+func TestSizeOnlyLayoutAndFloatingRetainClaimingPixelGeometry(t *testing.T) {
112+ full := domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 800, PixelHeight: 480}
113+ ac := &attachedClient{}
114+ ac.setGeometry(full)
115+ sess := &session{sessionCore: sessionCore{id: "geometry"}}
116+ require.True(t, sess.registerAttachment(ac))
117+
118+ layoutPTY := &recordingGeometryPTY{}
119+ tb := newTab(layoutPTY, domain.Size{Cols: 80, Rows: 22})
120+ sess.tabs = []*tab{tb}
121+ d := newTestDaemon(t, nil, stubClock{})
122+ require.True(t, d.applyTabLayout(sess, tb))
123+ require.Equal(t, domain.Geometry{Size: domain.Size{Cols: 80, Rows: 22}, PixelWidth: 800, PixelHeight: 440}, layoutPTY.geometry)
124+
125+ floatingPTY := &recordingGeometryPTY{}
126+ cfg := domain.FloatingConfig{Width: 50, Height: 50}
127+ d.ApplyConfig(domain.Config{Floating: cfg})
128+ floatingGeometry := calculateContentFloatingGeometry(tb.size, cfg)
129+ floating := newPane("floating", floatingPTY, rectSize(floatingGeometry.Inner))
130+ floating.popupGeometry = floatingGeometry
131+ tb.floating = floatingSlot{state: floatingVisible, pane: floating, generation: 1}
132+ failed, ok := d.applyVisibleFloatingLayout(sess, tb, nil)
133+ require.True(t, ok)
134+ require.Empty(t, failed)
135+ ptyRect := floatingGeometry.ptyRect()
136+ require.Equal(t, domain.Geometry{
137+ Size: domain.Size{Cols: ptyRect.Width, Rows: ptyRect.Height},
138+ PixelWidth: 800 * ptyRect.Width / 80, PixelHeight: 480 * ptyRect.Height / 24,
139+ }, floatingPTY.geometry)
140+}
141+
142+type recordingPTYFactory struct {
143+ pty ports.PTY
144+ geometry domain.Geometry
145+}
146+
147+func (f *recordingPTYFactory) Open(_ context.Context, _ string, _ []string, _ []string, _ string, geometry domain.Geometry) (ports.PTY, error) {
148+ f.geometry = geometry
149+ return f.pty, nil
150+}
151+
152+type blockingRecordingGeometryPTY struct {
153+ mu sync.Mutex
154+ geometry domain.Geometry
155+ done chan struct{}
156+ once sync.Once
157+}
158+
159+func (p *blockingRecordingGeometryPTY) Read([]byte) (int, error) {
160+ <-p.done
161+ return 0, io.EOF
162+}
163+func (p *blockingRecordingGeometryPTY) Write(b []byte) (int, error) { return len(b), nil }
164+func (p *blockingRecordingGeometryPTY) Close() error {
165+ p.once.Do(func() { close(p.done) })
166+ return nil
167+}
168+func (p *blockingRecordingGeometryPTY) Resize(geometry domain.Geometry) error {
169+ p.mu.Lock()
170+ p.geometry = geometry
171+ p.mu.Unlock()
172+ return nil
173+}
174+func (p *blockingRecordingGeometryPTY) geometrySnapshot() domain.Geometry {
175+ p.mu.Lock()
176+ defer p.mu.Unlock()
177+ return p.geometry
178+}
179+func (*blockingRecordingGeometryPTY) Pid() int { return 1 }
180+func (*blockingRecordingGeometryPTY) ForegroundPgid() (int, error) { return 1, nil }
181+
182+func TestInitialSessionPTYReceivesClaimingPixelGeometry(t *testing.T) {
183+ pty := &recordingGeometryPTY{}
184+ factory := &recordingPTYFactory{pty: pty}
185+ d := newTestDaemon(t, factory, stubClock{})
186+ geometry := domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 800, PixelHeight: 480}
187+
188+ d.mu.Lock()
189+ sess, err := d.createSessionLockedWithMode("pixels", true, "/tmp", geometry, nil)
190+ d.mu.Unlock()
191+ require.NoError(t, err)
192+ want := domain.Geometry{Size: domain.Size{Cols: 80, Rows: 22}, PixelWidth: 800, PixelHeight: 440}
193+ require.Equal(t, want, factory.geometry)
194+ require.Equal(t, want, sess.tabs[0].focusedPane().geometry)
195+ require.Equal(t, vt.Geometry{Cols: 80, Rows: 22, PixelWidth: 800, PixelHeight: 440}, sess.tabs[0].focusedPane().screen.Geometry())
196+}
197+
198+func TestResumeReappliesClaimingPixelGeometryBeforeReturn(t *testing.T) {
199+ pty := &blockingRecordingGeometryPTY{done: make(chan struct{})}
200+ t.Cleanup(func() { _ = pty.Close() })
201+ d := newTestDaemon(t, &recordingPTYFactory{pty: pty}, stubClock{})
202+ firstHello := helloResumeCapable(ports.IntentNew, "pixels", 0)
203+ firstHello.PixelWidth, firstHello.PixelHeight = 800, 480
204+ firstTransport := &closeTrackingTransport{}
205+ sess, ac, err := d.route(firstHello, firstTransport)
206+ require.NoError(t, err)
207+ d.clientGone(sess, ac, firstTransport, false)
208+
209+ resumeHello := helloResumeCapable(ports.IntentResume, "pixels", ac.resumeToken)
210+ resumeHello.PixelWidth, resumeHello.PixelHeight = 1000, 600
211+ resumed, same, ok, err := d.resumeParked(resumeHello, &closeTrackingTransport{}, resumeHello.Size)
212+ require.NoError(t, err)
213+ require.True(t, ok)
214+ require.Same(t, sess, resumed)
215+ require.Same(t, ac, same)
216+ require.Equal(t, domain.Geometry{Size: domain.Size{Cols: 80, Rows: 22}, PixelWidth: 1000, PixelHeight: 550}, pty.geometrySnapshot())
217+}
M internal/usecase/daemon/input_test.go +10 -10
@@ -1289,9 +1289,9 @@ func TestMouseSplitReportPreservesOrder(t *testing.T) {
12891289
12901290 func TestMouseWheelOverUnfocusedPaneDoesNotFocusAndForwardsChildMouse(t *testing.T) {
12911291 p1 := portsmocks.NewMockPTY(t)
1292- p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
1292+ p1.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 5}}).Return(nil).Maybe()
12931293 p2 := portsmocks.NewMockPTY(t)
1294- p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
1294+ p2.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 5}}).Return(nil).Maybe()
12951295 p2.EXPECT().Write([]byte("\x1b[<64;1;1M")).Return(len("\x1b[<64;1;1M"), nil).Once()
12961296 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
12971297 d.procComm = nil
@@ -1495,9 +1495,9 @@ func TestMouseDividerAndTitleBarDoNotForwardBogusCoordinates(t *testing.T) {
14951495
14961496 func TestCopyModeDragOutsideSplitPaneClampsToPaneContent(t *testing.T) {
14971497 p1 := portsmocks.NewMockPTY(t)
1498- p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Maybe()
1498+ p1.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(nil).Maybe()
14991499 p2 := portsmocks.NewMockPTY(t)
1500- p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Maybe()
1500+ p2.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(nil).Maybe()
15011501 d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
15021502 d.procComm = nil
15031503 tb := testAttachmentTab(sess)
@@ -1528,9 +1528,9 @@ func TestCopyModeDragOutsideSplitPaneClampsToPaneContent(t *testing.T) {
15281528
15291529 func TestMouseHitTestFocusesPaneAndTranslatesSGRColumns(t *testing.T) {
15301530 p1 := portsmocks.NewMockPTY(t)
1531- p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
1531+ p1.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 5}}).Return(nil).Maybe()
15321532 p2 := portsmocks.NewMockPTY(t)
1533- p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
1533+ p2.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 5}}).Return(nil).Maybe()
15341534 p2.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once()
15351535 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
15361536 d.procComm = nil
@@ -1556,9 +1556,9 @@ func TestMouseHitTestFocusesPaneAndTranslatesSGRColumns(t *testing.T) {
15561556 // the modal's coordinates.
15571557 func TestMouseGatedWhileNoticesOverlayActive(t *testing.T) {
15581558 p1 := portsmocks.NewMockPTY(t)
1559- p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
1559+ p1.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 5}}).Return(nil).Maybe()
15601560 p2 := portsmocks.NewMockPTY(t)
1561- p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
1561+ p2.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 5}}).Return(nil).Maybe()
15621562 d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
15631563 d.procComm = nil
15641564 tb := testAttachmentTab(sess)
@@ -1586,9 +1586,9 @@ func TestMouseGatedWhileNoticesOverlayActive(t *testing.T) {
15861586
15871587 func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) {
15881588 p1 := portsmocks.NewMockPTY(t)
1589- p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 4}).Return(nil).Maybe()
1589+ p1.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 4}}).Return(nil).Maybe()
15901590 p2 := portsmocks.NewMockPTY(t)
1591- p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 4}).Return(nil).Maybe()
1591+ p2.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 4}}).Return(nil).Maybe()
15921592 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
15931593 d.procComm = nil
15941594 tb := testAttachmentTab(sess)
@@ -1289,9 +1289,9 @@ func TestMouseSplitReportPreservesOrder(t *testing.T) {
1289 1289
1290 func TestMouseWheelOverUnfocusedPaneDoesNotFocusAndForwardsChildMouse(t *testing.T) {1290 func TestMouseWheelOverUnfocusedPaneDoesNotFocusAndForwardsChildMouse(t *testing.T) {
1291 p1 := portsmocks.NewMockPTY(t)1291 p1 := portsmocks.NewMockPTY(t)
1292- p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()1292+ p1.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 5}}).Return(nil).Maybe()
1293 p2 := portsmocks.NewMockPTY(t)1293 p2 := portsmocks.NewMockPTY(t)
1294- p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()1294+ p2.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 5}}).Return(nil).Maybe()
1295 p2.EXPECT().Write([]byte("\x1b[<64;1;1M")).Return(len("\x1b[<64;1;1M"), nil).Once()1295 p2.EXPECT().Write([]byte("\x1b[<64;1;1M")).Return(len("\x1b[<64;1;1M"), nil).Once()
1296 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)1296 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
1297 d.procComm = nil1297 d.procComm = nil
@@ -1495,9 +1495,9 @@ func TestMouseDividerAndTitleBarDoNotForwardBogusCoordinates(t *testing.T) {
1495 1495
1496 func TestCopyModeDragOutsideSplitPaneClampsToPaneContent(t *testing.T) {1496 func TestCopyModeDragOutsideSplitPaneClampsToPaneContent(t *testing.T) {
1497 p1 := portsmocks.NewMockPTY(t)1497 p1 := portsmocks.NewMockPTY(t)
1498- p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Maybe()1498+ p1.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(nil).Maybe()
1499 p2 := portsmocks.NewMockPTY(t)1499 p2 := portsmocks.NewMockPTY(t)
1500- p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Maybe()1500+ p2.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(nil).Maybe()
1501 d, sess, ac, sends := newManualSessionWithPTYs(t, p1)1501 d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
1502 d.procComm = nil1502 d.procComm = nil
1503 tb := testAttachmentTab(sess)1503 tb := testAttachmentTab(sess)
@@ -1528,9 +1528,9 @@ func TestCopyModeDragOutsideSplitPaneClampsToPaneContent(t *testing.T) {
1528 1528
1529 func TestMouseHitTestFocusesPaneAndTranslatesSGRColumns(t *testing.T) {1529 func TestMouseHitTestFocusesPaneAndTranslatesSGRColumns(t *testing.T) {
1530 p1 := portsmocks.NewMockPTY(t)1530 p1 := portsmocks.NewMockPTY(t)
1531- p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()1531+ p1.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 5}}).Return(nil).Maybe()
1532 p2 := portsmocks.NewMockPTY(t)1532 p2 := portsmocks.NewMockPTY(t)
1533- p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()1533+ p2.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 5}}).Return(nil).Maybe()
1534 p2.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once()1534 p2.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once()
1535 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)1535 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
1536 d.procComm = nil1536 d.procComm = nil
@@ -1556,9 +1556,9 @@ func TestMouseHitTestFocusesPaneAndTranslatesSGRColumns(t *testing.T) {
1556 // the modal's coordinates.1556 // the modal's coordinates.
1557 func TestMouseGatedWhileNoticesOverlayActive(t *testing.T) {1557 func TestMouseGatedWhileNoticesOverlayActive(t *testing.T) {
1558 p1 := portsmocks.NewMockPTY(t)1558 p1 := portsmocks.NewMockPTY(t)
1559- p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()1559+ p1.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 5}}).Return(nil).Maybe()
1560 p2 := portsmocks.NewMockPTY(t)1560 p2 := portsmocks.NewMockPTY(t)
1561- p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()1561+ p2.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 5}}).Return(nil).Maybe()
1562 d, sess, ac, sends := newManualSessionWithPTYs(t, p1)1562 d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
1563 d.procComm = nil1563 d.procComm = nil
1564 tb := testAttachmentTab(sess)1564 tb := testAttachmentTab(sess)
@@ -1586,9 +1586,9 @@ func TestMouseGatedWhileNoticesOverlayActive(t *testing.T) {
1586 1586
1587 func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) {1587 func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) {
1588 p1 := portsmocks.NewMockPTY(t)1588 p1 := portsmocks.NewMockPTY(t)
1589- p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 4}).Return(nil).Maybe()1589+ p1.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 4}}).Return(nil).Maybe()
1590 p2 := portsmocks.NewMockPTY(t)1590 p2 := portsmocks.NewMockPTY(t)
1591- p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 4}).Return(nil).Maybe()1591+ p2.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 4}}).Return(nil).Maybe()
1592 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)1592 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
1593 d.procComm = nil1593 d.procComm = nil
1594 tb := testAttachmentTab(sess)1594 tb := testAttachmentTab(sess)
M internal/usecase/daemon/pane.go +2 -1
@@ -110,7 +110,7 @@ type pane struct {
110110 id layout.PaneID
111111 stableID string
112112 pty ports.PTY
113- mu sync.Mutex // guards screen, history, syncGen, rect, resizeApplying, resizeRetry, resizePending, PTY side effects, title, and owner publication
113+ mu sync.Mutex // guards screen, history, syncGen, rect, geometry, resize state, PTY side effects, title, and owner publication
114114 owner atomic.Pointer[paneOwner]
115115 // ownerGeneration is advanced only while mu is held. Readers consume the
116116 // generation through the immutable owner pointer above.
@@ -120,6 +120,7 @@ type pane struct {
120120 history *vt.History
121121 syncGen uint64
122122 rect domain.Rect
123+ geometry domain.Geometry
123124 // resizeApplying gates VT parsing across PTY.Resize. The reader continues
124125 // draining into resizePending so output is replayed against the target (or
125126 // retained old) screen only after apply resolves.
@@ -110,7 +110,7 @@ type pane struct {
110 id layout.PaneID110 id layout.PaneID
111 stableID string111 stableID string
112 pty ports.PTY112 pty ports.PTY
113- mu sync.Mutex // guards screen, history, syncGen, rect, resizeApplying, resizeRetry, resizePending, PTY side effects, title, and owner publication113+ mu sync.Mutex // guards screen, history, syncGen, rect, geometry, resize state, PTY side effects, title, and owner publication
114 owner atomic.Pointer[paneOwner]114 owner atomic.Pointer[paneOwner]
115 // ownerGeneration is advanced only while mu is held. Readers consume the115 // ownerGeneration is advanced only while mu is held. Readers consume the
116 // generation through the immutable owner pointer above.116 // generation through the immutable owner pointer above.
@@ -120,6 +120,7 @@ type pane struct {
120 history *vt.History120 history *vt.History
121 syncGen uint64121 syncGen uint64
122 rect domain.Rect122 rect domain.Rect
123+ geometry domain.Geometry
123 // resizeApplying gates VT parsing across PTY.Resize. The reader continues124 // resizeApplying gates VT parsing across PTY.Resize. The reader continues
124 // draining into resizePending so output is replayed against the target (or125 // draining into resizePending so output is replayed against the target (or
125 // retained old) screen only after apply resolves.126 // retained old) screen only after apply resolves.
M internal/usecase/daemon/pane_actions.go +4 -2
@@ -81,6 +81,7 @@ func (d *Daemon) spawnPaneOpAt(
8181 tabStableID := tb.stableID
8282 generation := tb.layoutGeneration
8383 tb.mu.Unlock()
84+ initialGeometry := sess.paneGeometry(rectSize(newRect))
8485
8586 paneStableID, err := newStableID("p")
8687 if err != nil {
@@ -88,7 +89,7 @@ func (d *Daemon) spawnPaneOpAt(
8889 }
8990 launch := d.shellLaunch(env)
9091 lifetime := d.newPaneProcessLifetime(tb.ctx)
91- pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID), cwd, rectSize(newRect))
92+ pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID), cwd, initialGeometry)
9293 if err != nil {
9394 lifetime.abort()
9495 if pty != nil {
@@ -97,8 +98,9 @@ func (d *Daemon) spawnPaneOpAt(
9798 d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")
9899 return paneFocusChange{}, domain.UserErr(domain.NoticePaneSpawn, "couldn't open pane: shell failed to start", err)
99100 }
100-
101101 p := newPaneWithStableIDAndTitle(newID, paneStableID, pty, rectSize(newRect), launch.title)
102+ p.geometry = initialGeometry
103+ setScreenGeometry(p.screen, initialGeometry)
102104 p.rect = newRect
103105
104106 tb.mu.Lock()
@@ -81,6 +81,7 @@ func (d *Daemon) spawnPaneOpAt(
81 tabStableID := tb.stableID81 tabStableID := tb.stableID
82 generation := tb.layoutGeneration82 generation := tb.layoutGeneration
83 tb.mu.Unlock()83 tb.mu.Unlock()
84+ initialGeometry := sess.paneGeometry(rectSize(newRect))
84 85
85 paneStableID, err := newStableID("p")86 paneStableID, err := newStableID("p")
86 if err != nil {87 if err != nil {
@@ -88,7 +89,7 @@ func (d *Daemon) spawnPaneOpAt(
88 }89 }
89 launch := d.shellLaunch(env)90 launch := d.shellLaunch(env)
90 lifetime := d.newPaneProcessLifetime(tb.ctx)91 lifetime := d.newPaneProcessLifetime(tb.ctx)
91- pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID), cwd, rectSize(newRect))92+ pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID), cwd, initialGeometry)
92 if err != nil {93 if err != nil {
93 lifetime.abort()94 lifetime.abort()
94 if pty != nil {95 if pty != nil {
@@ -97,8 +98,9 @@ func (d *Daemon) spawnPaneOpAt(
97 d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")98 d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")
98 return paneFocusChange{}, domain.UserErr(domain.NoticePaneSpawn, "couldn't open pane: shell failed to start", err)99 return paneFocusChange{}, domain.UserErr(domain.NoticePaneSpawn, "couldn't open pane: shell failed to start", err)
99 }100 }
100-
101 p := newPaneWithStableIDAndTitle(newID, paneStableID, pty, rectSize(newRect), launch.title)101 p := newPaneWithStableIDAndTitle(newID, paneStableID, pty, rectSize(newRect), launch.title)
102+ p.geometry = initialGeometry
103+ setScreenGeometry(p.screen, initialGeometry)
102 p.rect = newRect104 p.rect = newRect
103 105
104 tb.mu.Lock()106 tb.mu.Lock()
M internal/usecase/daemon/pane_actions_test.go +28 -28
@@ -264,7 +264,7 @@ func TestLayoutApplicationDoesNotHoldTabLock(t *testing.T) {
264264 tb.focusedPane().rect = domain.Rect{Width: 20, Height: 10}
265265 entered := make(chan struct{})
266266 release := make(chan struct{})
267- pty.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Run(func(domain.Size) {
267+ pty.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 41, Rows: 10}}).Run(func(domain.Geometry) {
268268 close(entered)
269269 <-release
270270 }).Return(nil).Once()
@@ -296,7 +296,7 @@ func TestLayoutApplicationRejectsStalePaneIdentity(t *testing.T) {
296296 oldPane.screen.Resize(20, 10)
297297 entered := make(chan struct{})
298298 release := make(chan struct{})
299- oldPTY.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Run(func(domain.Size) {
299+ oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 41, Rows: 10}}).Run(func(domain.Geometry) {
300300 close(entered)
301301 <-release
302302 }).Return(nil).Once()
@@ -310,7 +310,7 @@ func TestLayoutApplicationRejectsStalePaneIdentity(t *testing.T) {
310310
311311 newPTY := portsmocks.NewMockPTY(t)
312312 replacement := newPane("pane-1", newPTY, domain.Size{Cols: 20, Rows: 10})
313- newPTY.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Return(nil).Once()
313+ newPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 41, Rows: 10}}).Return(nil).Once()
314314 tb.mu.Lock()
315315 tb.panes["pane-1"] = replacement
316316 tb.bumpLayoutGenerationLocked()
@@ -470,9 +470,9 @@ func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) {
470470 t.Run(tt.name, func(t *testing.T) {
471471 d, sess, oldPTY, factory := newSplitTestDaemon(t, tt.size)
472472 newPTY := portsmocks.NewMockPTY(t)
473- oldPTY.EXPECT().Resize(rectSize(tt.wantOld)).Return(nil).Once()
473+ oldPTY.EXPECT().Resize(domain.Geometry{Size: rectSize(tt.wantOld)}).Return(nil).Once()
474474 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
475- factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", rectSize(tt.wantNew)).Return(newPTY, nil).Once()
475+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Geometry{Size: rectSize(tt.wantNew)}).Return(newPTY, nil).Once()
476476
477477 require.NoError(t, d.splitPane(sess, nil, tt.dir))
478478
@@ -497,9 +497,9 @@ func TestSplitPaneActionFocusesNewPaneForInitiatingAttachment(t *testing.T) {
497497 require.True(t, sess.registerAttachment(ac))
498498 require.True(t, sess.registerAttachment(other))
499499 newPTY := portsmocks.NewMockPTY(t)
500- oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
500+ oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(nil).Once()
501501 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
502- factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()
502+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(newPTY, nil).Once()
503503
504504 target := resolveDaemonActionTargetForAttachment(sess, ac)
505505 require.NoError(t, (daemonActions{d: d}).Run(daemonActionRequest{
@@ -535,9 +535,9 @@ func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) {
535535 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"}
536536 tb.nextPaneID = 3
537537 newPTY := portsmocks.NewMockPTY(t)
538- stackPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 3}).Return(nil).Once()
538+ stackPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 3}}).Return(nil).Once()
539539 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
540- factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 4}).Return(newPTY, nil).Once()
540+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Geometry{Size: domain.Size{Cols: 20, Rows: 4}}).Return(newPTY, nil).Once()
541541
542542 require.NoError(t, d.splitPane(sess, nil, layout.Right))
543543
@@ -554,7 +554,7 @@ func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) {
554554 func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) {
555555 d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
556556 cause := errors.New("open failed")
557- factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(nil, cause).Once()
557+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(nil, cause).Once()
558558
559559 tb := testAttachmentTab(sess)
560560 tb.mu.Lock()
@@ -588,8 +588,8 @@ func TestApplyLayoutResizesPTYsAndScreens(t *testing.T) {
588588 newPane := newPane("pane-2", newPTY, domain.Size{Cols: 41, Rows: 10})
589589 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"}
590590 tb.panes["pane-2"] = newPane
591- oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
592- newPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
591+ oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(nil).Once()
592+ newPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(nil).Once()
593593
594594 d.applyTabLayout(sess, tb)
595595
@@ -606,8 +606,8 @@ func TestResizeReflowsAllPanes(t *testing.T) {
606606 tb.panes["pane-2"] = newPane("pane-2", newPTY, domain.Size{Cols: 20, Rows: 10})
607607 tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
608608 tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
609- oldPTY.EXPECT().Resize(domain.Size{Cols: 30, Rows: 18}).Return(nil).Once()
610- newPTY.EXPECT().Resize(domain.Size{Cols: 29, Rows: 18}).Return(nil).Once()
609+ oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 30, Rows: 18}}).Return(nil).Once()
610+ newPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 29, Rows: 18}}).Return(nil).Once()
611611
612612 d.resize(sess, nil, domain.Size{Cols: 60, Rows: 20})
613613
@@ -689,7 +689,7 @@ func TestClosePaneRepaintFanoutRespectsAttachmentScope(t *testing.T) {
689689 tb.panes["pane-2"] = newPane("pane-2", closingPTY, domain.Size{Cols: 20, Rows: 10})
690690 tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
691691 tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
692- oldPTY.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Return(nil).Once()
692+ oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 41, Rows: 10}}).Return(nil).Once()
693693 closingPTY.EXPECT().Close().Return(nil).Once()
694694
695695 clients := make([]*attachedClient, 2)
@@ -761,7 +761,7 @@ func TestClosePaneActionDoesNotReenterDispatchBoundary(t *testing.T) {
761761 tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
762762 tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
763763 d.sessions[sess.id] = sess
764- oldPTY.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Return(nil).Once()
764+ oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 41, Rows: 10}}).Return(nil).Once()
765765 closingPTY.EXPECT().Close().Return(nil).Once()
766766
767767 done := make(chan error, 1)
@@ -789,7 +789,7 @@ func TestCloseFocusedPaneRemovesPaneReflowsAndIsIdempotent(t *testing.T) {
789789 tb.panes["pane-2"] = newPane("pane-2", closingPTY, domain.Size{Cols: 20, Rows: 10})
790790 tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
791791 tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
792- oldPTY.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Return(nil).Once()
792+ oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 41, Rows: 10}}).Return(nil).Once()
793793 closingPTY.EXPECT().Close().Return(nil).Once()
794794
795795 require.NoError(t, d.closeFocusedPane(sess, nil))
@@ -809,7 +809,7 @@ func TestReapPaneSharesClosePathAndIsIdempotent(t *testing.T) {
809809 tb.panes["pane-2"] = reaped
810810 tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
811811 tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
812- oldPTY.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Return(nil).Once()
812+ oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 41, Rows: 10}}).Return(nil).Once()
813813 reapedPTY.EXPECT().Close().Return(nil).Once()
814814
815815 d.reapPane(sess, tb, reaped)
@@ -850,7 +850,7 @@ func TestStackPaneCreatesStackAndToggleRestoresSplit(t *testing.T) {
850850 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 5})
851851 newPTY := portsmocks.NewMockPTY(t)
852852 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
853- factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 4}).Return(newPTY, nil).Once()
853+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Geometry{Size: domain.Size{Cols: 20, Rows: 4}}).Return(newPTY, nil).Once()
854854
855855 require.NoError(t, d.stackPane(sess, nil))
856856 tb := testAttachmentTab(sess)
@@ -859,8 +859,8 @@ func TestStackPaneCreatesStackAndToggleRestoresSplit(t *testing.T) {
859859 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Root.Expanded)
860860 require.Len(t, tb.panes, 2)
861861
862- oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once()
863- newPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once()
862+ oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 2}}).Return(nil).Once()
863+ newPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 2}}).Return(nil).Once()
864864 require.NoError(t, d.toggleStack(sess, nil))
865865 require.Equal(t, layout.Split, tb.tree.Root.Kind)
866866 require.Equal(t, layout.Vertical, tb.tree.Root.Dir)
@@ -870,9 +870,9 @@ func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) {
870870 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 3})
871871 newPTY := portsmocks.NewMockPTY(t)
872872 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
873- factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 2}).Return(newPTY, nil).Once()
873+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Geometry{Size: domain.Size{Cols: 20, Rows: 2}}).Return(newPTY, nil).Once()
874874 require.NoError(t, d.stackPane(sess, nil))
875- oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once()
875+ oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 2}}).Return(nil).Once()
876876
877877 require.NoError(t, d.focusDir(sess, nil, layout.Up, nil))
878878
@@ -916,9 +916,9 @@ func TestCloseOriginalPaneLeavesSurvivorFunctional(t *testing.T) {
916916 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
917917 newPTY, releaseNew := newBlockingPTYWithWrites(t, writes)
918918 defer releaseNew()
919- oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
919+ oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(nil).Once()
920920 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
921- factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()
921+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(newPTY, nil).Once()
922922
923923 require.NoError(t, d.splitPane(sess, nil, layout.Right))
924924 tb := testAttachmentTab(sess)
@@ -973,12 +973,12 @@ func TestSplitPaneUsesExactAuthoritativeEnvironment(t *testing.T) {
973973 d.shellOverride = false
974974 sess.env = []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/usr/bin/fish", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}
975975 newPTY := portsmocks.NewMockPTY(t)
976- oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
976+ oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(nil).Once()
977977 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
978978 var command string
979979 var gotEnv []string
980- factory.EXPECT().Open(mock.Anything, mock.Anything, []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).RunAndReturn(
981- func(_ context.Context, gotCommand string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
980+ factory.EXPECT().Open(mock.Anything, mock.Anything, []string(nil), mock.Anything, "/work", domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).RunAndReturn(
981+ func(_ context.Context, gotCommand string, _ []string, env []string, _ string, _ domain.Geometry) (ports.PTY, error) {
982982 command = gotCommand
983983 gotEnv = append([]string(nil), env...)
984984 return newPTY, nil
@@ -264,7 +264,7 @@ func TestLayoutApplicationDoesNotHoldTabLock(t *testing.T) {
264 tb.focusedPane().rect = domain.Rect{Width: 20, Height: 10}264 tb.focusedPane().rect = domain.Rect{Width: 20, Height: 10}
265 entered := make(chan struct{})265 entered := make(chan struct{})
266 release := make(chan struct{})266 release := make(chan struct{})
267- pty.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Run(func(domain.Size) {267+ pty.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 41, Rows: 10}}).Run(func(domain.Geometry) {
268 close(entered)268 close(entered)
269 <-release269 <-release
270 }).Return(nil).Once()270 }).Return(nil).Once()
@@ -296,7 +296,7 @@ func TestLayoutApplicationRejectsStalePaneIdentity(t *testing.T) {
296 oldPane.screen.Resize(20, 10)296 oldPane.screen.Resize(20, 10)
297 entered := make(chan struct{})297 entered := make(chan struct{})
298 release := make(chan struct{})298 release := make(chan struct{})
299- oldPTY.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Run(func(domain.Size) {299+ oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 41, Rows: 10}}).Run(func(domain.Geometry) {
300 close(entered)300 close(entered)
301 <-release301 <-release
302 }).Return(nil).Once()302 }).Return(nil).Once()
@@ -310,7 +310,7 @@ func TestLayoutApplicationRejectsStalePaneIdentity(t *testing.T) {
310 310
311 newPTY := portsmocks.NewMockPTY(t)311 newPTY := portsmocks.NewMockPTY(t)
312 replacement := newPane("pane-1", newPTY, domain.Size{Cols: 20, Rows: 10})312 replacement := newPane("pane-1", newPTY, domain.Size{Cols: 20, Rows: 10})
313- newPTY.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Return(nil).Once()313+ newPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 41, Rows: 10}}).Return(nil).Once()
314 tb.mu.Lock()314 tb.mu.Lock()
315 tb.panes["pane-1"] = replacement315 tb.panes["pane-1"] = replacement
316 tb.bumpLayoutGenerationLocked()316 tb.bumpLayoutGenerationLocked()
@@ -470,9 +470,9 @@ func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) {
470 t.Run(tt.name, func(t *testing.T) {470 t.Run(tt.name, func(t *testing.T) {
471 d, sess, oldPTY, factory := newSplitTestDaemon(t, tt.size)471 d, sess, oldPTY, factory := newSplitTestDaemon(t, tt.size)
472 newPTY := portsmocks.NewMockPTY(t)472 newPTY := portsmocks.NewMockPTY(t)
473- oldPTY.EXPECT().Resize(rectSize(tt.wantOld)).Return(nil).Once()473+ oldPTY.EXPECT().Resize(domain.Geometry{Size: rectSize(tt.wantOld)}).Return(nil).Once()
474 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()474 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
475- factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", rectSize(tt.wantNew)).Return(newPTY, nil).Once()475+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Geometry{Size: rectSize(tt.wantNew)}).Return(newPTY, nil).Once()
476 476
477 require.NoError(t, d.splitPane(sess, nil, tt.dir))477 require.NoError(t, d.splitPane(sess, nil, tt.dir))
478 478
@@ -497,9 +497,9 @@ func TestSplitPaneActionFocusesNewPaneForInitiatingAttachment(t *testing.T) {
497 require.True(t, sess.registerAttachment(ac))497 require.True(t, sess.registerAttachment(ac))
498 require.True(t, sess.registerAttachment(other))498 require.True(t, sess.registerAttachment(other))
499 newPTY := portsmocks.NewMockPTY(t)499 newPTY := portsmocks.NewMockPTY(t)
500- oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()500+ oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(nil).Once()
501 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()501 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
502- factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()502+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(newPTY, nil).Once()
503 503
504 target := resolveDaemonActionTargetForAttachment(sess, ac)504 target := resolveDaemonActionTargetForAttachment(sess, ac)
505 require.NoError(t, (daemonActions{d: d}).Run(daemonActionRequest{505 require.NoError(t, (daemonActions{d: d}).Run(daemonActionRequest{
@@ -535,9 +535,9 @@ func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) {
535 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"}535 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"}
536 tb.nextPaneID = 3536 tb.nextPaneID = 3
537 newPTY := portsmocks.NewMockPTY(t)537 newPTY := portsmocks.NewMockPTY(t)
538- stackPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 3}).Return(nil).Once()538+ stackPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 3}}).Return(nil).Once()
539 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()539 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
540- factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 4}).Return(newPTY, nil).Once()540+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Geometry{Size: domain.Size{Cols: 20, Rows: 4}}).Return(newPTY, nil).Once()
541 541
542 require.NoError(t, d.splitPane(sess, nil, layout.Right))542 require.NoError(t, d.splitPane(sess, nil, layout.Right))
543 543
@@ -554,7 +554,7 @@ func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) {
554 func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) {554 func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) {
555 d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})555 d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
556 cause := errors.New("open failed")556 cause := errors.New("open failed")
557- factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(nil, cause).Once()557+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(nil, cause).Once()
558 558
559 tb := testAttachmentTab(sess)559 tb := testAttachmentTab(sess)
560 tb.mu.Lock()560 tb.mu.Lock()
@@ -588,8 +588,8 @@ func TestApplyLayoutResizesPTYsAndScreens(t *testing.T) {
588 newPane := newPane("pane-2", newPTY, domain.Size{Cols: 41, Rows: 10})588 newPane := newPane("pane-2", newPTY, domain.Size{Cols: 41, Rows: 10})
589 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"}589 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"}
590 tb.panes["pane-2"] = newPane590 tb.panes["pane-2"] = newPane
591- oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()591+ oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(nil).Once()
592- newPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()592+ newPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(nil).Once()
593 593
594 d.applyTabLayout(sess, tb)594 d.applyTabLayout(sess, tb)
595 595
@@ -606,8 +606,8 @@ func TestResizeReflowsAllPanes(t *testing.T) {
606 tb.panes["pane-2"] = newPane("pane-2", newPTY, domain.Size{Cols: 20, Rows: 10})606 tb.panes["pane-2"] = newPane("pane-2", newPTY, domain.Size{Cols: 20, Rows: 10})
607 tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}607 tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
608 tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}608 tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
609- oldPTY.EXPECT().Resize(domain.Size{Cols: 30, Rows: 18}).Return(nil).Once()609+ oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 30, Rows: 18}}).Return(nil).Once()
610- newPTY.EXPECT().Resize(domain.Size{Cols: 29, Rows: 18}).Return(nil).Once()610+ newPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 29, Rows: 18}}).Return(nil).Once()
611 611
612 d.resize(sess, nil, domain.Size{Cols: 60, Rows: 20})612 d.resize(sess, nil, domain.Size{Cols: 60, Rows: 20})
613 613
@@ -689,7 +689,7 @@ func TestClosePaneRepaintFanoutRespectsAttachmentScope(t *testing.T) {
689 tb.panes["pane-2"] = newPane("pane-2", closingPTY, domain.Size{Cols: 20, Rows: 10})689 tb.panes["pane-2"] = newPane("pane-2", closingPTY, domain.Size{Cols: 20, Rows: 10})
690 tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}690 tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
691 tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}691 tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
692- oldPTY.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Return(nil).Once()692+ oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 41, Rows: 10}}).Return(nil).Once()
693 closingPTY.EXPECT().Close().Return(nil).Once()693 closingPTY.EXPECT().Close().Return(nil).Once()
694 694
695 clients := make([]*attachedClient, 2)695 clients := make([]*attachedClient, 2)
@@ -761,7 +761,7 @@ func TestClosePaneActionDoesNotReenterDispatchBoundary(t *testing.T) {
761 tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}761 tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
762 tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}762 tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
763 d.sessions[sess.id] = sess763 d.sessions[sess.id] = sess
764- oldPTY.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Return(nil).Once()764+ oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 41, Rows: 10}}).Return(nil).Once()
765 closingPTY.EXPECT().Close().Return(nil).Once()765 closingPTY.EXPECT().Close().Return(nil).Once()
766 766
767 done := make(chan error, 1)767 done := make(chan error, 1)
@@ -789,7 +789,7 @@ func TestCloseFocusedPaneRemovesPaneReflowsAndIsIdempotent(t *testing.T) {
789 tb.panes["pane-2"] = newPane("pane-2", closingPTY, domain.Size{Cols: 20, Rows: 10})789 tb.panes["pane-2"] = newPane("pane-2", closingPTY, domain.Size{Cols: 20, Rows: 10})
790 tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}790 tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
791 tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}791 tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
792- oldPTY.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Return(nil).Once()792+ oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 41, Rows: 10}}).Return(nil).Once()
793 closingPTY.EXPECT().Close().Return(nil).Once()793 closingPTY.EXPECT().Close().Return(nil).Once()
794 794
795 require.NoError(t, d.closeFocusedPane(sess, nil))795 require.NoError(t, d.closeFocusedPane(sess, nil))
@@ -809,7 +809,7 @@ func TestReapPaneSharesClosePathAndIsIdempotent(t *testing.T) {
809 tb.panes["pane-2"] = reaped809 tb.panes["pane-2"] = reaped
810 tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}810 tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
811 tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}811 tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
812- oldPTY.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Return(nil).Once()812+ oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 41, Rows: 10}}).Return(nil).Once()
813 reapedPTY.EXPECT().Close().Return(nil).Once()813 reapedPTY.EXPECT().Close().Return(nil).Once()
814 814
815 d.reapPane(sess, tb, reaped)815 d.reapPane(sess, tb, reaped)
@@ -850,7 +850,7 @@ func TestStackPaneCreatesStackAndToggleRestoresSplit(t *testing.T) {
850 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 5})850 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 5})
851 newPTY := portsmocks.NewMockPTY(t)851 newPTY := portsmocks.NewMockPTY(t)
852 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()852 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
853- factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 4}).Return(newPTY, nil).Once()853+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Geometry{Size: domain.Size{Cols: 20, Rows: 4}}).Return(newPTY, nil).Once()
854 854
855 require.NoError(t, d.stackPane(sess, nil))855 require.NoError(t, d.stackPane(sess, nil))
856 tb := testAttachmentTab(sess)856 tb := testAttachmentTab(sess)
@@ -859,8 +859,8 @@ func TestStackPaneCreatesStackAndToggleRestoresSplit(t *testing.T) {
859 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Root.Expanded)859 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Root.Expanded)
860 require.Len(t, tb.panes, 2)860 require.Len(t, tb.panes, 2)
861 861
862- oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once()862+ oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 2}}).Return(nil).Once()
863- newPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once()863+ newPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 2}}).Return(nil).Once()
864 require.NoError(t, d.toggleStack(sess, nil))864 require.NoError(t, d.toggleStack(sess, nil))
865 require.Equal(t, layout.Split, tb.tree.Root.Kind)865 require.Equal(t, layout.Split, tb.tree.Root.Kind)
866 require.Equal(t, layout.Vertical, tb.tree.Root.Dir)866 require.Equal(t, layout.Vertical, tb.tree.Root.Dir)
@@ -870,9 +870,9 @@ func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) {
870 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 3})870 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 3})
871 newPTY := portsmocks.NewMockPTY(t)871 newPTY := portsmocks.NewMockPTY(t)
872 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()872 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
873- factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 2}).Return(newPTY, nil).Once()873+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Geometry{Size: domain.Size{Cols: 20, Rows: 2}}).Return(newPTY, nil).Once()
874 require.NoError(t, d.stackPane(sess, nil))874 require.NoError(t, d.stackPane(sess, nil))
875- oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once()875+ oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 2}}).Return(nil).Once()
876 876
877 require.NoError(t, d.focusDir(sess, nil, layout.Up, nil))877 require.NoError(t, d.focusDir(sess, nil, layout.Up, nil))
878 878
@@ -916,9 +916,9 @@ func TestCloseOriginalPaneLeavesSurvivorFunctional(t *testing.T) {
916 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})916 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
917 newPTY, releaseNew := newBlockingPTYWithWrites(t, writes)917 newPTY, releaseNew := newBlockingPTYWithWrites(t, writes)
918 defer releaseNew()918 defer releaseNew()
919- oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()919+ oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(nil).Once()
920 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()920 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
921- factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()921+ factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(newPTY, nil).Once()
922 922
923 require.NoError(t, d.splitPane(sess, nil, layout.Right))923 require.NoError(t, d.splitPane(sess, nil, layout.Right))
924 tb := testAttachmentTab(sess)924 tb := testAttachmentTab(sess)
@@ -973,12 +973,12 @@ func TestSplitPaneUsesExactAuthoritativeEnvironment(t *testing.T) {
973 d.shellOverride = false973 d.shellOverride = false
974 sess.env = []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/usr/bin/fish", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}974 sess.env = []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/usr/bin/fish", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}
975 newPTY := portsmocks.NewMockPTY(t)975 newPTY := portsmocks.NewMockPTY(t)
976- oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()976+ oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(nil).Once()
977 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()977 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
978 var command string978 var command string
979 var gotEnv []string979 var gotEnv []string
980- factory.EXPECT().Open(mock.Anything, mock.Anything, []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).RunAndReturn(980+ factory.EXPECT().Open(mock.Anything, mock.Anything, []string(nil), mock.Anything, "/work", domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).RunAndReturn(
981- func(_ context.Context, gotCommand string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {981+ func(_ context.Context, gotCommand string, _ []string, env []string, _ string, _ domain.Geometry) (ports.PTY, error) {
982 command = gotCommand982 command = gotCommand
983 gotEnv = append([]string(nil), env...)983 gotEnv = append([]string(nil), env...)
984 return newPTY, nil984 return newPTY, nil
M internal/usecase/daemon/pane_lifetime_test.go +5 -5
@@ -36,9 +36,9 @@ func (p *lifetimePTY) Read([]byte) (int, error) {
3636 }
3737 }
3838
39-func (*lifetimePTY) Write(b []byte) (int, error) { return len(b), nil }
40-func (*lifetimePTY) Resize(domain.Size) error { return nil }
41-func (*lifetimePTY) Pid() int { return 1 }
39+func (*lifetimePTY) Write(b []byte) (int, error) { return len(b), nil }
40+func (*lifetimePTY) Resize(domain.Geometry) error { return nil }
41+func (*lifetimePTY) Pid() int { return 1 }
4242 func (*lifetimePTY) ForegroundPgid() (int, error) {
4343 return 1, nil
4444 }
@@ -54,7 +54,7 @@ type lifetimePTYFactory struct {
5454 contexts []context.Context
5555 }
5656
57-func (f *lifetimePTYFactory) Open(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
57+func (f *lifetimePTYFactory) Open(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (ports.PTY, error) {
5858 p := newLifetimePTY()
5959 p.ctx = ctx
6060 f.mu.Lock()
@@ -199,7 +199,7 @@ type paneErrorFactory struct {
199199 onOpen func(context.Context)
200200 }
201201
202-func (f *paneErrorFactory) Open(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
202+func (f *paneErrorFactory) Open(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (ports.PTY, error) {
203203 if f.onOpen != nil {
204204 f.onOpen(ctx)
205205 }
@@ -36,9 +36,9 @@ func (p *lifetimePTY) Read([]byte) (int, error) {
36 }36 }
37 }37 }
38 38
39-func (*lifetimePTY) Write(b []byte) (int, error) { return len(b), nil }39+func (*lifetimePTY) Write(b []byte) (int, error) { return len(b), nil }
40-func (*lifetimePTY) Resize(domain.Size) error { return nil }40+func (*lifetimePTY) Resize(domain.Geometry) error { return nil }
41-func (*lifetimePTY) Pid() int { return 1 }41+func (*lifetimePTY) Pid() int { return 1 }
42 func (*lifetimePTY) ForegroundPgid() (int, error) {42 func (*lifetimePTY) ForegroundPgid() (int, error) {
43 return 1, nil43 return 1, nil
44 }44 }
@@ -54,7 +54,7 @@ type lifetimePTYFactory struct {
54 contexts []context.Context54 contexts []context.Context
55 }55 }
56 56
57-func (f *lifetimePTYFactory) Open(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {57+func (f *lifetimePTYFactory) Open(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (ports.PTY, error) {
58 p := newLifetimePTY()58 p := newLifetimePTY()
59 p.ctx = ctx59 p.ctx = ctx
60 f.mu.Lock()60 f.mu.Lock()
@@ -199,7 +199,7 @@ type paneErrorFactory struct {
199 onOpen func(context.Context)199 onOpen func(context.Context)
200 }200 }
201 201
202-func (f *paneErrorFactory) Open(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {202+func (f *paneErrorFactory) Open(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (ports.PTY, error) {
203 if f.onOpen != nil {203 if f.onOpen != nil {
204 f.onOpen(ctx)204 f.onOpen(ctx)
205 }205 }
M internal/usecase/daemon/pane_owner_reader_test.go +4 -4
@@ -43,10 +43,10 @@ func (p *ownerRoutingPTY) Read(buf []byte) (int, error) {
4343 return copy(buf, step.data), step.err
4444 }
4545
46-func (*ownerRoutingPTY) Write(b []byte) (int, error) { return len(b), nil }
47-func (p *ownerRoutingPTY) Close() error { p.closes.Add(1); return nil }
48-func (*ownerRoutingPTY) Resize(domain.Size) error { return nil }
49-func (*ownerRoutingPTY) Pid() int { return 0 }
46+func (*ownerRoutingPTY) Write(b []byte) (int, error) { return len(b), nil }
47+func (p *ownerRoutingPTY) Close() error { p.closes.Add(1); return nil }
48+func (*ownerRoutingPTY) Resize(domain.Geometry) error { return nil }
49+func (*ownerRoutingPTY) Pid() int { return 0 }
5050 func (*ownerRoutingPTY) ForegroundPgid() (int, error) {
5151 return 0, nil
5252 }
@@ -43,10 +43,10 @@ func (p *ownerRoutingPTY) Read(buf []byte) (int, error) {
43 return copy(buf, step.data), step.err43 return copy(buf, step.data), step.err
44 }44 }
45 45
46-func (*ownerRoutingPTY) Write(b []byte) (int, error) { return len(b), nil }46+func (*ownerRoutingPTY) Write(b []byte) (int, error) { return len(b), nil }
47-func (p *ownerRoutingPTY) Close() error { p.closes.Add(1); return nil }47+func (p *ownerRoutingPTY) Close() error { p.closes.Add(1); return nil }
48-func (*ownerRoutingPTY) Resize(domain.Size) error { return nil }48+func (*ownerRoutingPTY) Resize(domain.Geometry) error { return nil }
49-func (*ownerRoutingPTY) Pid() int { return 0 }49+func (*ownerRoutingPTY) Pid() int { return 0 }
50 func (*ownerRoutingPTY) ForegroundPgid() (int, error) {50 func (*ownerRoutingPTY) ForegroundPgid() (int, error) {
51 return 0, nil51 return 0, nil
52 }52 }
M internal/usecase/daemon/pane_rearrange_test.go +2 -2
@@ -462,9 +462,9 @@ type paneRearrangePTY struct {
462462
463463 func (*paneRearrangePTY) Read([]byte) (int, error) { return 0, io.EOF }
464464 func (*paneRearrangePTY) Write(b []byte) (int, error) { return len(b), nil }
465-func (p *paneRearrangePTY) Resize(size domain.Size) error {
465+func (p *paneRearrangePTY) Resize(geometry domain.Geometry) error {
466466 p.mu.Lock()
467- p.resizes = append(p.resizes, size)
467+ p.resizes = append(p.resizes, geometry.Size)
468468 hook := p.onResize
469469 p.mu.Unlock()
470470 if hook != nil {
@@ -462,9 +462,9 @@ type paneRearrangePTY struct {
462 462
463 func (*paneRearrangePTY) Read([]byte) (int, error) { return 0, io.EOF }463 func (*paneRearrangePTY) Read([]byte) (int, error) { return 0, io.EOF }
464 func (*paneRearrangePTY) Write(b []byte) (int, error) { return len(b), nil }464 func (*paneRearrangePTY) Write(b []byte) (int, error) { return len(b), nil }
465-func (p *paneRearrangePTY) Resize(size domain.Size) error {465+func (p *paneRearrangePTY) Resize(geometry domain.Geometry) error {
466 p.mu.Lock()466 p.mu.Lock()
467- p.resizes = append(p.resizes, size)467+ p.resizes = append(p.resizes, geometry.Size)
468 hook := p.onResize468 hook := p.onResize
469 p.mu.Unlock()469 p.mu.Unlock()
470 if hook != nil {470 if hook != nil {
M internal/usecase/daemon/parked_route.go +1 -1
@@ -470,7 +470,7 @@ func (d *Daemon) transitionParkedRouteTarget(token attachmentConnectionToken, ta
470470 env := copyEnvironment(d.baseEnv)
471471 cwd := d.dirOrHome(current.cwd)
472472 var createErr error
473- created, createErr = d.resumeRemoteInactiveSessionLocked(target, cwd, token.ac.sizeSnapshot(), env, current)
473+ created, createErr = d.resumeRemoteInactiveSessionLocked(target, cwd, token.ac.geometrySnapshot(), env, current)
474474 return created, createErr
475475 },
476476 })
@@ -470,7 +470,7 @@ func (d *Daemon) transitionParkedRouteTarget(token attachmentConnectionToken, ta
470 env := copyEnvironment(d.baseEnv)470 env := copyEnvironment(d.baseEnv)
471 cwd := d.dirOrHome(current.cwd)471 cwd := d.dirOrHome(current.cwd)
472 var createErr error472 var createErr error
473- created, createErr = d.resumeRemoteInactiveSessionLocked(target, cwd, token.ac.sizeSnapshot(), env, current)473+ created, createErr = d.resumeRemoteInactiveSessionLocked(target, cwd, token.ac.geometrySnapshot(), env, current)
474 return created, createErr474 return created, createErr
475 },475 },
476 })476 })
M internal/usecase/daemon/performance_benchmark_test.go +2 -2
@@ -956,10 +956,10 @@ type scriptedPerformancePTY struct {
956956 fails uint64
957957 }
958958
959-func (p *scriptedPerformancePTY) Resize(size domain.Size) error {
959+func (p *scriptedPerformancePTY) Resize(geometry domain.Geometry) error {
960960 p.mu.Lock()
961961 defer p.mu.Unlock()
962- p.sizes = append(p.sizes, size)
962+ p.sizes = append(p.sizes, geometry.Size)
963963 index := len(p.sizes) - 1
964964 if index < len(p.errs) && p.errs[index] != nil {
965965 p.fails++
@@ -956,10 +956,10 @@ type scriptedPerformancePTY struct {
956 fails uint64956 fails uint64
957 }957 }
958 958
959-func (p *scriptedPerformancePTY) Resize(size domain.Size) error {959+func (p *scriptedPerformancePTY) Resize(geometry domain.Geometry) error {
960 p.mu.Lock()960 p.mu.Lock()
961 defer p.mu.Unlock()961 defer p.mu.Unlock()
962- p.sizes = append(p.sizes, size)962+ p.sizes = append(p.sizes, geometry.Size)
963 index := len(p.sizes) - 1963 index := len(p.sizes) - 1
964 if index < len(p.errs) && p.errs[index] != nil {964 if index < len(p.errs) && p.errs[index] != nil {
965 p.fails++965 p.fails++
M internal/usecase/daemon/picker.go +2 -2
@@ -1256,7 +1256,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
12561256 from.mu.Lock()
12571257 cwd, env := d.dirOrHome(current.cwd), copyEnvironment(from.env)
12581258 from.mu.Unlock()
1259- created, createErr := d.resumeInactiveSessionLocked(target.Name, cwd, ac.sizeSnapshot(), env, current, current.tabNames)
1259+ created, createErr := d.resumeInactiveSessionLocked(target.Name, cwd, ac.geometrySnapshot(), env, current, current.tabNames)
12601260 targetSess = created
12611261 return created, createErr
12621262 },
@@ -1287,7 +1287,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
12871287 return nil, attachmentTransitionResult{}, false, errAttachmentTransition
12881288 }
12891289 cwd := d.dirOrHome(current.cwd)
1290- targetSess, err := d.resumeInactiveSessionLocked(target.Name, cwd, ac.sizeSnapshot(), env, current, current.tabNames)
1290+ targetSess, err := d.resumeInactiveSessionLocked(target.Name, cwd, ac.geometrySnapshot(), env, current, current.tabNames)
12911291 if err != nil {
12921292 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
12931293 return nil, attachmentTransitionResult{}, false, err
@@ -1256,7 +1256,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
1256 from.mu.Lock()1256 from.mu.Lock()
1257 cwd, env := d.dirOrHome(current.cwd), copyEnvironment(from.env)1257 cwd, env := d.dirOrHome(current.cwd), copyEnvironment(from.env)
1258 from.mu.Unlock()1258 from.mu.Unlock()
1259- created, createErr := d.resumeInactiveSessionLocked(target.Name, cwd, ac.sizeSnapshot(), env, current, current.tabNames)1259+ created, createErr := d.resumeInactiveSessionLocked(target.Name, cwd, ac.geometrySnapshot(), env, current, current.tabNames)
1260 targetSess = created1260 targetSess = created
1261 return created, createErr1261 return created, createErr
1262 },1262 },
@@ -1287,7 +1287,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
1287 return nil, attachmentTransitionResult{}, false, errAttachmentTransition1287 return nil, attachmentTransitionResult{}, false, errAttachmentTransition
1288 }1288 }
1289 cwd := d.dirOrHome(current.cwd)1289 cwd := d.dirOrHome(current.cwd)
1290- targetSess, err := d.resumeInactiveSessionLocked(target.Name, cwd, ac.sizeSnapshot(), env, current, current.tabNames)1290+ targetSess, err := d.resumeInactiveSessionLocked(target.Name, cwd, ac.geometrySnapshot(), env, current, current.tabNames)
1291 if err != nil {1291 if err != nil {
1292 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)1292 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
1293 return nil, attachmentTransitionResult{}, false, err1293 return nil, attachmentTransitionResult{}, false, err
M internal/usecase/daemon/picker_test.go +4 -4
@@ -1392,8 +1392,8 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
13921392 var opens [][]string
13931393 f := portsmocks.NewMockPTYFactory(t)
13941394 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1395- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1396- func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1395+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Geometry) bool { return got.Size == normalSize })).RunAndReturn(
1396+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Geometry) (ports.PTY, error) {
13971397 opens = append(opens, append([]string(nil), env...))
13981398 if len(opens) == 1 {
13991399 return p1, nil
@@ -1402,8 +1402,8 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
14021402 },
14031403 ).Twice()
14041404 floating := newQuietPTY()
1405- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
1406- return got != normalSize && got.Valid()
1405+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Geometry) bool {
1406+ return got.Size != normalSize && got.Size.Valid()
14071407 })).Return(floating, nil).Once()
14081408 d := newTestDaemon(t, f, stubClock{})
14091409 d.inactive["old"] = inactiveSession{name: "old", cwd: t.TempDir(), createdAt: 1, state: ports.SessionDown}
@@ -1392,8 +1392,8 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
1392 var opens [][]string1392 var opens [][]string
1393 f := portsmocks.NewMockPTYFactory(t)1393 f := portsmocks.NewMockPTYFactory(t)
1394 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}1394 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1395- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(1395+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Geometry) bool { return got.Size == normalSize })).RunAndReturn(
1396- func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {1396+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Geometry) (ports.PTY, error) {
1397 opens = append(opens, append([]string(nil), env...))1397 opens = append(opens, append([]string(nil), env...))
1398 if len(opens) == 1 {1398 if len(opens) == 1 {
1399 return p1, nil1399 return p1, nil
@@ -1402,8 +1402,8 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
1402 },1402 },
1403 ).Twice()1403 ).Twice()
1404 floating := newQuietPTY()1404 floating := newQuietPTY()
1405- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {1405+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Geometry) bool {
1406- return got != normalSize && got.Valid()1406+ return got.Size != normalSize && got.Size.Valid()
1407 })).Return(floating, nil).Once()1407 })).Return(floating, nil).Once()
1408 d := newTestDaemon(t, f, stubClock{})1408 d := newTestDaemon(t, f, stubClock{})
1409 d.inactive["old"] = inactiveSession{name: "old", cwd: t.TempDir(), createdAt: 1, state: ports.SessionDown}1409 d.inactive["old"] = inactiveSession{name: "old", cwd: t.TempDir(), createdAt: 1, state: ports.SessionDown}
M internal/usecase/daemon/prompt_test.go +1 -1
@@ -13,7 +13,7 @@ import (
1313
1414 type failingPTYFactory struct{ err error }
1515
16-func (f failingPTYFactory) Open(context.Context, string, []string, []string, string, domain.Size) (ports.PTY, error) {
16+func (f failingPTYFactory) Open(context.Context, string, []string, []string, string, domain.Geometry) (ports.PTY, error) {
1717 return nil, f.err
1818 }
1919
@@ -13,7 +13,7 @@ import (
13 13
14 type failingPTYFactory struct{ err error }14 type failingPTYFactory struct{ err error }
15 15
16-func (f failingPTYFactory) Open(context.Context, string, []string, []string, string, domain.Size) (ports.PTY, error) {16+func (f failingPTYFactory) Open(context.Context, string, []string, []string, string, domain.Geometry) (ports.PTY, error) {
17 return nil, f.err17 return nil, f.err
18 }18 }
19 19
M internal/usecase/daemon/remote_attach.go +3 -3
@@ -79,7 +79,7 @@ func (d *Daemon) routeRemoteTargetWithContext(ctx context.Context, h ports.Hello
7979 // trusted for this branch.
8080 env := copyEnvironment(d.baseEnv)
8181 cwd := d.dirOrHome(inactive.cwd)
82- sess, err := d.resumeRemoteInactiveSessionLocked(target, cwd, h.Size, env, inactive)
82+ sess, err := d.resumeRemoteInactiveSessionLocked(target, cwd, h.Geometry(), env, inactive)
8383 if err != nil {
8484 d.mu.Unlock()
8585 if errors.Is(err, errAttachmentTransition) || errors.Is(err, errSessionNameInUse) {
@@ -91,7 +91,7 @@ func (d *Daemon) routeRemoteTargetWithContext(ctx context.Context, h ports.Hello
9191 return sess, ac, err
9292 }
9393
94-func (d *Daemon) resumeRemoteInactiveSessionLocked(target domain.RemoteSessionTarget, cwd string, size domain.Size, env []string, expected inactiveSession) (*session, error) {
94+func (d *Daemon) resumeRemoteInactiveSessionLocked(target domain.RemoteSessionTarget, cwd string, geometry domain.Geometry, env []string, expected inactiveSession) (*session, error) {
9595 validate := func(current inactiveSession, _ domain.CatalogueRecord, authoritativeExists bool) bool {
9696 if !target.Stopped || d.persistEnabled && !authoritativeExists {
9797 return false
@@ -99,7 +99,7 @@ func (d *Daemon) resumeRemoteInactiveSessionLocked(target domain.RemoteSessionTa
9999 _, ok := target.ResolveTab(stoppedTabMetadata(current))
100100 return ok
101101 }
102- return d.createSessionLockedWithModeAndInactiveFence(target.SessionName, false, cwd, size, env, &expected, validate, expected.tabNames)
102+ return d.createSessionLockedWithModeAndInactiveFence(target.SessionName, false, cwd, geometry, env, &expected, validate, expected.tabNames)
103103 }
104104
105105 func (d *Daemon) sendNavigationActionForAttachment(token attachmentConnectionToken, action ports.NavigationAction) error {
@@ -79,7 +79,7 @@ func (d *Daemon) routeRemoteTargetWithContext(ctx context.Context, h ports.Hello
79 // trusted for this branch.79 // trusted for this branch.
80 env := copyEnvironment(d.baseEnv)80 env := copyEnvironment(d.baseEnv)
81 cwd := d.dirOrHome(inactive.cwd)81 cwd := d.dirOrHome(inactive.cwd)
82- sess, err := d.resumeRemoteInactiveSessionLocked(target, cwd, h.Size, env, inactive)82+ sess, err := d.resumeRemoteInactiveSessionLocked(target, cwd, h.Geometry(), env, inactive)
83 if err != nil {83 if err != nil {
84 d.mu.Unlock()84 d.mu.Unlock()
85 if errors.Is(err, errAttachmentTransition) || errors.Is(err, errSessionNameInUse) {85 if errors.Is(err, errAttachmentTransition) || errors.Is(err, errSessionNameInUse) {
@@ -91,7 +91,7 @@ func (d *Daemon) routeRemoteTargetWithContext(ctx context.Context, h ports.Hello
91 return sess, ac, err91 return sess, ac, err
92 }92 }
93 93
94-func (d *Daemon) resumeRemoteInactiveSessionLocked(target domain.RemoteSessionTarget, cwd string, size domain.Size, env []string, expected inactiveSession) (*session, error) {94+func (d *Daemon) resumeRemoteInactiveSessionLocked(target domain.RemoteSessionTarget, cwd string, geometry domain.Geometry, env []string, expected inactiveSession) (*session, error) {
95 validate := func(current inactiveSession, _ domain.CatalogueRecord, authoritativeExists bool) bool {95 validate := func(current inactiveSession, _ domain.CatalogueRecord, authoritativeExists bool) bool {
96 if !target.Stopped || d.persistEnabled && !authoritativeExists {96 if !target.Stopped || d.persistEnabled && !authoritativeExists {
97 return false97 return false
@@ -99,7 +99,7 @@ func (d *Daemon) resumeRemoteInactiveSessionLocked(target domain.RemoteSessionTa
99 _, ok := target.ResolveTab(stoppedTabMetadata(current))99 _, ok := target.ResolveTab(stoppedTabMetadata(current))
100 return ok100 return ok
101 }101 }
102- return d.createSessionLockedWithModeAndInactiveFence(target.SessionName, false, cwd, size, env, &expected, validate, expected.tabNames)102+ return d.createSessionLockedWithModeAndInactiveFence(target.SessionName, false, cwd, geometry, env, &expected, validate, expected.tabNames)
103 }103 }
104 104
105 func (d *Daemon) sendNavigationActionForAttachment(token attachmentConnectionToken, action ports.NavigationAction) error {105 func (d *Daemon) sendNavigationActionForAttachment(token attachmentConnectionToken, action ports.NavigationAction) error {
M internal/usecase/daemon/render_test.go +7 -7
@@ -264,10 +264,10 @@ func (p *concurrentExitPTY) Read([]byte) (int, error) {
264264 <-p.release
265265 return 0, io.EOF
266266 }
267-func (*concurrentExitPTY) Write(b []byte) (int, error) { return len(b), nil }
268-func (*concurrentExitPTY) Close() error { return nil }
269-func (*concurrentExitPTY) Resize(domain.Size) error { return nil }
270-func (*concurrentExitPTY) Pid() int { return 0 }
267+func (*concurrentExitPTY) Write(b []byte) (int, error) { return len(b), nil }
268+func (*concurrentExitPTY) Close() error { return nil }
269+func (*concurrentExitPTY) Resize(domain.Geometry) error { return nil }
270+func (*concurrentExitPTY) Pid() int { return 0 }
271271 func (*concurrentExitPTY) ForegroundPgid() (int, error) {
272272 return 0, nil
273273 }
@@ -666,8 +666,8 @@ func TestPTYEOFFinalTabKillsSessionAndDetaches(t *testing.T) {
666666
667667 func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
668668 p := portsmocks.NewMockPTY(t)
669- p.EXPECT().Resize(domain.Size{Cols: 4, Rows: 2}).Return(nil).Once()
670- p.EXPECT().Resize(domain.Size{Cols: 6, Rows: 4}).Return(nil).Once()
669+ p.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 4, Rows: 2}}).Return(nil).Once()
670+ p.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 6, Rows: 4}}).Return(nil).Once()
671671
672672 win := newTab(p, domain.Size{Cols: 4, Rows: 4})
673673 for y, text := range []string{"0000", "1111", "2222", "3333"} {
@@ -922,7 +922,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
922922 p := portsmocks.NewMockPTY(t)
923923 var screenWidthAtResize int
924924 win := newTab(newScriptPTY(nil), domain.Size{Cols: 80, Rows: 24})
925- p.EXPECT().Resize(domain.Size{Cols: 100, Rows: 28}).RunAndReturn(func(sz domain.Size) error {
925+ p.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 100, Rows: 28}}).RunAndReturn(func(domain.Geometry) error {
926926 // The screen must not yet be resized when the PTY is: proves order.
927927 screenWidthAtResize = win.focusedPane().screen.Frame.Width
928928 return nil
@@ -264,10 +264,10 @@ func (p *concurrentExitPTY) Read([]byte) (int, error) {
264 <-p.release264 <-p.release
265 return 0, io.EOF265 return 0, io.EOF
266 }266 }
267-func (*concurrentExitPTY) Write(b []byte) (int, error) { return len(b), nil }267+func (*concurrentExitPTY) Write(b []byte) (int, error) { return len(b), nil }
268-func (*concurrentExitPTY) Close() error { return nil }268+func (*concurrentExitPTY) Close() error { return nil }
269-func (*concurrentExitPTY) Resize(domain.Size) error { return nil }269+func (*concurrentExitPTY) Resize(domain.Geometry) error { return nil }
270-func (*concurrentExitPTY) Pid() int { return 0 }270+func (*concurrentExitPTY) Pid() int { return 0 }
271 func (*concurrentExitPTY) ForegroundPgid() (int, error) {271 func (*concurrentExitPTY) ForegroundPgid() (int, error) {
272 return 0, nil272 return 0, nil
273 }273 }
@@ -666,8 +666,8 @@ func TestPTYEOFFinalTabKillsSessionAndDetaches(t *testing.T) {
666 666
667 func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {667 func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
668 p := portsmocks.NewMockPTY(t)668 p := portsmocks.NewMockPTY(t)
669- p.EXPECT().Resize(domain.Size{Cols: 4, Rows: 2}).Return(nil).Once()669+ p.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 4, Rows: 2}}).Return(nil).Once()
670- p.EXPECT().Resize(domain.Size{Cols: 6, Rows: 4}).Return(nil).Once()670+ p.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 6, Rows: 4}}).Return(nil).Once()
671 671
672 win := newTab(p, domain.Size{Cols: 4, Rows: 4})672 win := newTab(p, domain.Size{Cols: 4, Rows: 4})
673 for y, text := range []string{"0000", "1111", "2222", "3333"} {673 for y, text := range []string{"0000", "1111", "2222", "3333"} {
@@ -922,7 +922,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
922 p := portsmocks.NewMockPTY(t)922 p := portsmocks.NewMockPTY(t)
923 var screenWidthAtResize int923 var screenWidthAtResize int
924 win := newTab(newScriptPTY(nil), domain.Size{Cols: 80, Rows: 24})924 win := newTab(newScriptPTY(nil), domain.Size{Cols: 80, Rows: 24})
925- p.EXPECT().Resize(domain.Size{Cols: 100, Rows: 28}).RunAndReturn(func(sz domain.Size) error {925+ p.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 100, Rows: 28}}).RunAndReturn(func(domain.Geometry) error {
926 // The screen must not yet be resized when the PTY is: proves order.926 // The screen must not yet be resized when the PTY is: proves order.
927 screenWidthAtResize = win.focusedPane().screen.Frame.Width927 screenWidthAtResize = win.focusedPane().screen.Frame.Width
928 return nil928 return nil
M internal/usecase/daemon/resize_mode_test.go +1 -1
@@ -136,7 +136,7 @@ func (*resizeLockProbePTY) Write(b []byte) (int, error) { return len(b), nil }
136136 func (*resizeLockProbePTY) Close() error { return nil }
137137 func (*resizeLockProbePTY) Pid() int { return 0 }
138138 func (*resizeLockProbePTY) ForegroundPgid() (int, error) { return 0, nil }
139-func (p *resizeLockProbePTY) Resize(domain.Size) error {
139+func (p *resizeLockProbePTY) Resize(domain.Geometry) error {
140140 p.once.Do(func() {
141141 func() {
142142 defer close(p.entered)
@@ -136,7 +136,7 @@ func (*resizeLockProbePTY) Write(b []byte) (int, error) { return len(b), nil }
136 func (*resizeLockProbePTY) Close() error { return nil }136 func (*resizeLockProbePTY) Close() error { return nil }
137 func (*resizeLockProbePTY) Pid() int { return 0 }137 func (*resizeLockProbePTY) Pid() int { return 0 }
138 func (*resizeLockProbePTY) ForegroundPgid() (int, error) { return 0, nil }138 func (*resizeLockProbePTY) ForegroundPgid() (int, error) { return 0, nil }
139-func (p *resizeLockProbePTY) Resize(domain.Size) error {139+func (p *resizeLockProbePTY) Resize(domain.Geometry) error {
140 p.once.Do(func() {140 p.once.Do(func() {
141 func() {141 func() {
142 defer close(p.entered)142 defer close(p.entered)
M internal/usecase/daemon/resume.go +11 -1
@@ -597,6 +597,12 @@ func (d *Daemon) resumeParked(h ports.Hello, tr ports.Transport, sz domain.Size)
597597 sess, resumed, ok, err := d.resumeParkedLocked(h, tr, sz)
598598 d.mu.Unlock()
599599 ac.sendMu.Unlock()
600+ if err == nil && ok && sess != nil && resumed != nil {
601+ // Resume publication normally schedules reconciliation asynchronously;
602+ // complete the claiming terminal's geometry before the resumed
603+ // attachment is allowed to produce its first frame.
604+ d.recalculateSessionGeometry(sess, resumed)
605+ }
600606 if err != nil && resumed != nil {
601607 d.abortResumeClaim(resumed)
602608 }
@@ -640,7 +646,11 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
640646 ac.rebaseOutput()
641647 ac.output.setWindow(h.MaxOutputInFlight)
642648 ac.replaceTransport(tr)
643- ac.setSize(sz)
649+ geometry := h.Geometry()
650+ if geometry.Size != sz {
651+ geometry = domain.Geometry{Size: sz}
652+ }
653+ ac.setGeometry(geometry)
644654 ac.resumeToken = d.nextResumeTokenLocked()
645655 ac.parked = false
646656 // The resumed session's snapshot is the sole source for future PTY children.
@@ -597,6 +597,12 @@ func (d *Daemon) resumeParked(h ports.Hello, tr ports.Transport, sz domain.Size)
597 sess, resumed, ok, err := d.resumeParkedLocked(h, tr, sz)597 sess, resumed, ok, err := d.resumeParkedLocked(h, tr, sz)
598 d.mu.Unlock()598 d.mu.Unlock()
599 ac.sendMu.Unlock()599 ac.sendMu.Unlock()
600+ if err == nil && ok && sess != nil && resumed != nil {
601+ // Resume publication normally schedules reconciliation asynchronously;
602+ // complete the claiming terminal's geometry before the resumed
603+ // attachment is allowed to produce its first frame.
604+ d.recalculateSessionGeometry(sess, resumed)
605+ }
600 if err != nil && resumed != nil {606 if err != nil && resumed != nil {
601 d.abortResumeClaim(resumed)607 d.abortResumeClaim(resumed)
602 }608 }
@@ -640,7 +646,11 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
640 ac.rebaseOutput()646 ac.rebaseOutput()
641 ac.output.setWindow(h.MaxOutputInFlight)647 ac.output.setWindow(h.MaxOutputInFlight)
642 ac.replaceTransport(tr)648 ac.replaceTransport(tr)
643- ac.setSize(sz)649+ geometry := h.Geometry()
650+ if geometry.Size != sz {
651+ geometry = domain.Geometry{Size: sz}
652+ }
653+ ac.setGeometry(geometry)
644 ac.resumeToken = d.nextResumeTokenLocked()654 ac.resumeToken = d.nextResumeTokenLocked()
645 ac.parked = false655 ac.parked = false
646 // The resumed session's snapshot is the sole source for future PTY children.656 // The resumed session's snapshot is the sole source for future PTY children.
M internal/usecase/daemon/resume_test.go +2 -2
@@ -1574,8 +1574,8 @@ func TestResumeParkedReplacesFuturePTYEnvironment(t *testing.T) {
15741574 var envs [][]string
15751575 factory := portsmocks.NewMockPTYFactory(t)
15761576 factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1577- func(_ context.Context, command string, _ []string, env []string, _ string, size domain.Size) (ports.PTY, error) {
1578- if size != (domain.Size{Cols: 80, Rows: 22}) {
1577+ func(_ context.Context, command string, _ []string, env []string, _ string, geometry domain.Geometry) (ports.PTY, error) {
1578+ if geometry.Size != (domain.Size{Cols: 80, Rows: 22}) {
15791579 return newQuietPTY(), nil
15801580 }
15811581 commands = append(commands, command)
@@ -1574,8 +1574,8 @@ func TestResumeParkedReplacesFuturePTYEnvironment(t *testing.T) {
1574 var envs [][]string1574 var envs [][]string
1575 factory := portsmocks.NewMockPTYFactory(t)1575 factory := portsmocks.NewMockPTYFactory(t)
1576 factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(1576 factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1577- func(_ context.Context, command string, _ []string, env []string, _ string, size domain.Size) (ports.PTY, error) {1577+ func(_ context.Context, command string, _ []string, env []string, _ string, geometry domain.Geometry) (ports.PTY, error) {
1578- if size != (domain.Size{Cols: 80, Rows: 22}) {1578+ if geometry.Size != (domain.Size{Cols: 80, Rows: 22}) {
1579 return newQuietPTY(), nil1579 return newQuietPTY(), nil
1580 }1580 }
1581 commands = append(commands, command)1581 commands = append(commands, command)
M internal/usecase/daemon/session.go +33 -15
@@ -182,19 +182,19 @@ func (d *Daemon) touchMRU(entry *session) {
182182 entry.mu.Unlock()
183183 }
184184
185-func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, env []string, restoredTabNames ...[]string) (*session, error) {
186- return d.createSessionLockedWithMode(name, ephemeral, cwd, sz, env, restoredTabNames...)
185+func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, geometry domain.Geometry, env []string, restoredTabNames ...[]string) (*session, error) {
186+ return d.createSessionLockedWithMode(name, ephemeral, cwd, geometry, env, restoredTabNames...)
187187 }
188188
189189 // resumeInactiveSessionLocked resumes exactly expectedInactive. It rechecks
190190 // lifecycle and resumability after catalogue I/O, which intentionally runs
191191 // without d.mu.
192-func (d *Daemon) resumeInactiveSessionLocked(name string, cwd string, sz domain.Size, env []string, expectedInactive inactiveSession, restoredTabNames ...[]string) (*session, error) {
193- return d.createSessionLockedWithModeAndInactiveFence(name, false, cwd, sz, env, &expectedInactive, nil, restoredTabNames...)
192+func (d *Daemon) resumeInactiveSessionLocked(name string, cwd string, geometry domain.Geometry, env []string, expectedInactive inactiveSession, restoredTabNames ...[]string) (*session, error) {
193+ return d.createSessionLockedWithModeAndInactiveFence(name, false, cwd, geometry, env, &expectedInactive, nil, restoredTabNames...)
194194 }
195195
196-func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd string, sz domain.Size, env []string, restoredTabNames ...[]string) (*session, error) {
197- return d.createSessionLockedWithModeAndInactiveFence(name, ephemeral, cwd, sz, env, nil, nil, restoredTabNames...)
196+func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd string, geometry domain.Geometry, env []string, restoredTabNames ...[]string) (*session, error) {
197+ return d.createSessionLockedWithModeAndInactiveFence(name, ephemeral, cwd, geometry, env, nil, nil, restoredTabNames...)
198198 }
199199
200200 type inactiveResumeValidator func(inactiveSession, domain.CatalogueRecord, bool) bool
@@ -219,8 +219,10 @@ func inactiveResumeFenceAllows(current inactiveSession, exists bool, expected *i
219219 return validate == nil || validate(current, authoritative, authoritativeExists)
220220 }
221221
222-func (d *Daemon) createSessionLockedWithModeAndInactiveFence(name string, ephemeral bool, cwd string, sz domain.Size, env []string, expectedInactive *inactiveSession, validateInactive inactiveResumeValidator, restoredTabNames ...[]string) (*session, error) {
222+func (d *Daemon) createSessionLockedWithModeAndInactiveFence(name string, ephemeral bool, cwd string, geometry domain.Geometry, env []string, expectedInactive *inactiveSession, validateInactive inactiveResumeValidator, restoredTabNames ...[]string) (*session, error) {
223223 env = copyEnvironment(env)
224+ geometry = geometry.NormalizePixels()
225+ sz := geometry.Size
224226 if _, reserved := d.creating[name]; reserved {
225227 return nil, errSessionNameInUse
226228 }
@@ -285,6 +287,7 @@ func (d *Daemon) createSessionLockedWithModeAndInactiveFence(name string, epheme
285287 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", errors.New("daemon: PTY factory is unavailable"))
286288 }
287289 tbSize := contentSize(sz)
290+ initialPaneGeometry := scalePaneGeometry(geometry, tbSize)
288291 var names []string
289292 var tabRecords []domain.CatalogueTabRecord
290293 if resuming && len(stopped.tabRecords) != 0 {
@@ -308,7 +311,7 @@ func (d *Daemon) createSessionLockedWithModeAndInactiveFence(name string, epheme
308311 }
309312 launch := d.shellLaunch(env)
310313 lifetime := d.newPaneProcessLifetime(d.serveCtx)
311- pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID), cwd, tbSize)
314+ pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID), cwd, initialPaneGeometry)
312315 if err != nil {
313316 lifetime.abort()
314317 if pty != nil {
@@ -318,7 +321,12 @@ func (d *Daemon) createSessionLockedWithModeAndInactiveFence(name string, epheme
318321 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")
319322 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session: shell failed to start", err)
320323 }
324+ appliedPaneGeometry := initialPaneGeometry
321325 tb := newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, tbSize, launch.title)
326+ if pane := tb.focusedPane(); pane != nil {
327+ pane.geometry = appliedPaneGeometry
328+ setScreenGeometry(pane.screen, appliedPaneGeometry)
329+ }
322330 if !lifetime.publish(tb.focusedPane()) {
323331 lifetime.abort()
324332 _ = pty.Close()
@@ -453,7 +461,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
453461 if err := domain.ValidateSessionName(name); err != nil {
454462 return err
455463 }
456- sz := ac.sizeSnapshot()
464+ geometry := ac.geometrySnapshot()
457465 d.mu.Lock()
458466 if d.closing {
459467 d.mu.Unlock()
@@ -473,7 +481,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
473481 return errors.New("client detached")
474482 }
475483
476- newSess, err := d.createSessionLockedWithMode(name, false, cwd, sz, env)
484+ newSess, err := d.createSessionLockedWithMode(name, false, cwd, geometry, env)
477485 d.mu.Unlock()
478486 if err != nil {
479487 return err
@@ -537,7 +545,7 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
537545 cwd, env := source.cwd, copyEnvironment(source.env)
538546 source.mu.Unlock()
539547 var createErr error
540- created, createErr = d.createSessionLockedWithMode(name, false, cwd, token.ac.sizeSnapshot(), env)
548+ created, createErr = d.createSessionLockedWithMode(name, false, cwd, token.ac.geometrySnapshot(), env)
541549 return created, createErr
542550 },
543551 })
@@ -556,7 +564,7 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
556564 }
557565
558566 func (d *Daemon) createEphemeralSessionAndSwitch(from *session, ac *attachedClient) error {
559- sz := ac.sizeSnapshot()
567+ geometry := ac.geometrySnapshot()
560568 d.mu.Lock()
561569 if d.closing {
562570 d.mu.Unlock()
@@ -571,7 +579,7 @@ func (d *Daemon) createEphemeralSessionAndSwitch(from *session, ac *attachedClie
571579 return errors.New("client detached")
572580 }
573581 name := d.allocEphemeralNameLocked()
574- newSess, err := d.createSessionLockedWithMode(name, true, cwd, sz, env)
582+ newSess, err := d.createSessionLockedWithMode(name, true, cwd, geometry, env)
575583 d.mu.Unlock()
576584 if err != nil {
577585 return err
@@ -613,7 +621,7 @@ func (d *Daemon) createEphemeralSessionAndSwitchForAttachment(token attachmentCo
613621 source.mu.Unlock()
614622 name := d.allocEphemeralNameLocked()
615623 var createErr error
616- created, createErr = d.createSessionLockedWithMode(name, true, cwd, token.ac.sizeSnapshot(), env)
624+ created, createErr = d.createSessionLockedWithMode(name, true, cwd, token.ac.geometrySnapshot(), env)
617625 return created, createErr
618626 },
619627 })
@@ -642,13 +650,19 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, _ dom
642650 attachments := sess.snapshotAttachmentsLocked()
643651 sess.mu.Unlock()
644652 tbSize := contentSize(sess.fullViewportSize())
653+ claimGeometry := domain.Geometry{Size: tbSize}
654+ if source, ok := sess.geometrySourceSnapshot(); ok {
655+ claimGeometry = scalePaneGeometry(source.geometry, tbSize)
656+ } else if ac != nil {
657+ claimGeometry = scalePaneGeometry(ac.geometrySnapshot(), tbSize)
658+ }
645659 tabStableID, paneStableID, err := d.newTabPaneStableIDs()
646660 if err != nil {
647661 return err
648662 }
649663 launch := d.shellLaunch(env)
650664 lifetime := d.newPaneProcessLifetime(sess.ctx)
651- pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID), cwd, tbSize)
665+ pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID), cwd, claimGeometry)
652666 if err != nil {
653667 lifetime.abort()
654668 if pty != nil {
@@ -658,6 +672,10 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, _ dom
658672 return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err)
659673 }
660674 tb := newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, tbSize, launch.title)
675+ if pane := tb.focusedPane(); pane != nil {
676+ pane.geometry = claimGeometry
677+ setScreenGeometry(pane.screen, claimGeometry)
678+ }
661679 themeClient := ac
662680 if themeClient == nil && len(attachments) != 0 {
663681 themeClient = attachments[0]
@@ -182,19 +182,19 @@ func (d *Daemon) touchMRU(entry *session) {
182 entry.mu.Unlock()182 entry.mu.Unlock()
183 }183 }
184 184
185-func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, env []string, restoredTabNames ...[]string) (*session, error) {185+func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, geometry domain.Geometry, env []string, restoredTabNames ...[]string) (*session, error) {
186- return d.createSessionLockedWithMode(name, ephemeral, cwd, sz, env, restoredTabNames...)186+ return d.createSessionLockedWithMode(name, ephemeral, cwd, geometry, env, restoredTabNames...)
187 }187 }
188 188
189 // resumeInactiveSessionLocked resumes exactly expectedInactive. It rechecks189 // resumeInactiveSessionLocked resumes exactly expectedInactive. It rechecks
190 // lifecycle and resumability after catalogue I/O, which intentionally runs190 // lifecycle and resumability after catalogue I/O, which intentionally runs
191 // without d.mu.191 // without d.mu.
192-func (d *Daemon) resumeInactiveSessionLocked(name string, cwd string, sz domain.Size, env []string, expectedInactive inactiveSession, restoredTabNames ...[]string) (*session, error) {192+func (d *Daemon) resumeInactiveSessionLocked(name string, cwd string, geometry domain.Geometry, env []string, expectedInactive inactiveSession, restoredTabNames ...[]string) (*session, error) {
193- return d.createSessionLockedWithModeAndInactiveFence(name, false, cwd, sz, env, &expectedInactive, nil, restoredTabNames...)193+ return d.createSessionLockedWithModeAndInactiveFence(name, false, cwd, geometry, env, &expectedInactive, nil, restoredTabNames...)
194 }194 }
195 195
196-func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd string, sz domain.Size, env []string, restoredTabNames ...[]string) (*session, error) {196+func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd string, geometry domain.Geometry, env []string, restoredTabNames ...[]string) (*session, error) {
197- return d.createSessionLockedWithModeAndInactiveFence(name, ephemeral, cwd, sz, env, nil, nil, restoredTabNames...)197+ return d.createSessionLockedWithModeAndInactiveFence(name, ephemeral, cwd, geometry, env, nil, nil, restoredTabNames...)
198 }198 }
199 199
200 type inactiveResumeValidator func(inactiveSession, domain.CatalogueRecord, bool) bool200 type inactiveResumeValidator func(inactiveSession, domain.CatalogueRecord, bool) bool
@@ -219,8 +219,10 @@ func inactiveResumeFenceAllows(current inactiveSession, exists bool, expected *i
219 return validate == nil || validate(current, authoritative, authoritativeExists)219 return validate == nil || validate(current, authoritative, authoritativeExists)
220 }220 }
221 221
222-func (d *Daemon) createSessionLockedWithModeAndInactiveFence(name string, ephemeral bool, cwd string, sz domain.Size, env []string, expectedInactive *inactiveSession, validateInactive inactiveResumeValidator, restoredTabNames ...[]string) (*session, error) {222+func (d *Daemon) createSessionLockedWithModeAndInactiveFence(name string, ephemeral bool, cwd string, geometry domain.Geometry, env []string, expectedInactive *inactiveSession, validateInactive inactiveResumeValidator, restoredTabNames ...[]string) (*session, error) {
223 env = copyEnvironment(env)223 env = copyEnvironment(env)
224+ geometry = geometry.NormalizePixels()
225+ sz := geometry.Size
224 if _, reserved := d.creating[name]; reserved {226 if _, reserved := d.creating[name]; reserved {
225 return nil, errSessionNameInUse227 return nil, errSessionNameInUse
226 }228 }
@@ -285,6 +287,7 @@ func (d *Daemon) createSessionLockedWithModeAndInactiveFence(name string, epheme
285 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", errors.New("daemon: PTY factory is unavailable"))287 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", errors.New("daemon: PTY factory is unavailable"))
286 }288 }
287 tbSize := contentSize(sz)289 tbSize := contentSize(sz)
290+ initialPaneGeometry := scalePaneGeometry(geometry, tbSize)
288 var names []string291 var names []string
289 var tabRecords []domain.CatalogueTabRecord292 var tabRecords []domain.CatalogueTabRecord
290 if resuming && len(stopped.tabRecords) != 0 {293 if resuming && len(stopped.tabRecords) != 0 {
@@ -308,7 +311,7 @@ func (d *Daemon) createSessionLockedWithModeAndInactiveFence(name string, epheme
308 }311 }
309 launch := d.shellLaunch(env)312 launch := d.shellLaunch(env)
310 lifetime := d.newPaneProcessLifetime(d.serveCtx)313 lifetime := d.newPaneProcessLifetime(d.serveCtx)
311- pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID), cwd, tbSize)314+ pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID), cwd, initialPaneGeometry)
312 if err != nil {315 if err != nil {
313 lifetime.abort()316 lifetime.abort()
314 if pty != nil {317 if pty != nil {
@@ -318,7 +321,12 @@ func (d *Daemon) createSessionLockedWithModeAndInactiveFence(name string, epheme
318 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")321 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")
319 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session: shell failed to start", err)322 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session: shell failed to start", err)
320 }323 }
324+ appliedPaneGeometry := initialPaneGeometry
321 tb := newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, tbSize, launch.title)325 tb := newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, tbSize, launch.title)
326+ if pane := tb.focusedPane(); pane != nil {
327+ pane.geometry = appliedPaneGeometry
328+ setScreenGeometry(pane.screen, appliedPaneGeometry)
329+ }
322 if !lifetime.publish(tb.focusedPane()) {330 if !lifetime.publish(tb.focusedPane()) {
323 lifetime.abort()331 lifetime.abort()
324 _ = pty.Close()332 _ = pty.Close()
@@ -453,7 +461,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
453 if err := domain.ValidateSessionName(name); err != nil {461 if err := domain.ValidateSessionName(name); err != nil {
454 return err462 return err
455 }463 }
456- sz := ac.sizeSnapshot()464+ geometry := ac.geometrySnapshot()
457 d.mu.Lock()465 d.mu.Lock()
458 if d.closing {466 if d.closing {
459 d.mu.Unlock()467 d.mu.Unlock()
@@ -473,7 +481,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
473 return errors.New("client detached")481 return errors.New("client detached")
474 }482 }
475 483
476- newSess, err := d.createSessionLockedWithMode(name, false, cwd, sz, env)484+ newSess, err := d.createSessionLockedWithMode(name, false, cwd, geometry, env)
477 d.mu.Unlock()485 d.mu.Unlock()
478 if err != nil {486 if err != nil {
479 return err487 return err
@@ -537,7 +545,7 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
537 cwd, env := source.cwd, copyEnvironment(source.env)545 cwd, env := source.cwd, copyEnvironment(source.env)
538 source.mu.Unlock()546 source.mu.Unlock()
539 var createErr error547 var createErr error
540- created, createErr = d.createSessionLockedWithMode(name, false, cwd, token.ac.sizeSnapshot(), env)548+ created, createErr = d.createSessionLockedWithMode(name, false, cwd, token.ac.geometrySnapshot(), env)
541 return created, createErr549 return created, createErr
542 },550 },
543 })551 })
@@ -556,7 +564,7 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
556 }564 }
557 565
558 func (d *Daemon) createEphemeralSessionAndSwitch(from *session, ac *attachedClient) error {566 func (d *Daemon) createEphemeralSessionAndSwitch(from *session, ac *attachedClient) error {
559- sz := ac.sizeSnapshot()567+ geometry := ac.geometrySnapshot()
560 d.mu.Lock()568 d.mu.Lock()
561 if d.closing {569 if d.closing {
562 d.mu.Unlock()570 d.mu.Unlock()
@@ -571,7 +579,7 @@ func (d *Daemon) createEphemeralSessionAndSwitch(from *session, ac *attachedClie
571 return errors.New("client detached")579 return errors.New("client detached")
572 }580 }
573 name := d.allocEphemeralNameLocked()581 name := d.allocEphemeralNameLocked()
574- newSess, err := d.createSessionLockedWithMode(name, true, cwd, sz, env)582+ newSess, err := d.createSessionLockedWithMode(name, true, cwd, geometry, env)
575 d.mu.Unlock()583 d.mu.Unlock()
576 if err != nil {584 if err != nil {
577 return err585 return err
@@ -613,7 +621,7 @@ func (d *Daemon) createEphemeralSessionAndSwitchForAttachment(token attachmentCo
613 source.mu.Unlock()621 source.mu.Unlock()
614 name := d.allocEphemeralNameLocked()622 name := d.allocEphemeralNameLocked()
615 var createErr error623 var createErr error
616- created, createErr = d.createSessionLockedWithMode(name, true, cwd, token.ac.sizeSnapshot(), env)624+ created, createErr = d.createSessionLockedWithMode(name, true, cwd, token.ac.geometrySnapshot(), env)
617 return created, createErr625 return created, createErr
618 },626 },
619 })627 })
@@ -642,13 +650,19 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, _ dom
642 attachments := sess.snapshotAttachmentsLocked()650 attachments := sess.snapshotAttachmentsLocked()
643 sess.mu.Unlock()651 sess.mu.Unlock()
644 tbSize := contentSize(sess.fullViewportSize())652 tbSize := contentSize(sess.fullViewportSize())
653+ claimGeometry := domain.Geometry{Size: tbSize}
654+ if source, ok := sess.geometrySourceSnapshot(); ok {
655+ claimGeometry = scalePaneGeometry(source.geometry, tbSize)
656+ } else if ac != nil {
657+ claimGeometry = scalePaneGeometry(ac.geometrySnapshot(), tbSize)
658+ }
645 tabStableID, paneStableID, err := d.newTabPaneStableIDs()659 tabStableID, paneStableID, err := d.newTabPaneStableIDs()
646 if err != nil {660 if err != nil {
647 return err661 return err
648 }662 }
649 launch := d.shellLaunch(env)663 launch := d.shellLaunch(env)
650 lifetime := d.newPaneProcessLifetime(sess.ctx)664 lifetime := d.newPaneProcessLifetime(sess.ctx)
651- pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID), cwd, tbSize)665+ pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID), cwd, claimGeometry)
652 if err != nil {666 if err != nil {
653 lifetime.abort()667 lifetime.abort()
654 if pty != nil {668 if pty != nil {
@@ -658,6 +672,10 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, _ dom
658 return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err)672 return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err)
659 }673 }
660 tb := newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, tbSize, launch.title)674 tb := newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, tbSize, launch.title)
675+ if pane := tb.focusedPane(); pane != nil {
676+ pane.geometry = claimGeometry
677+ setScreenGeometry(pane.screen, claimGeometry)
678+ }
661 themeClient := ac679 themeClient := ac
662 if themeClient == nil && len(attachments) != 0 {680 if themeClient == nil && len(attachments) != 0 {
663 themeClient = attachments[0]681 themeClient = attachments[0]
M internal/usecase/daemon/session_core.go +3 -2
@@ -29,8 +29,9 @@ type sessionCore struct {
2929 // controls shared PTY/layout geometry. The pointer is published atomically
3030 // so resize transactions can reject stale claims without taking session.mu.
3131 // geometryMu serializes claim publication with the final shared-layout commit.
32- geometryMu sync.Mutex
33- geometryOwner atomic.Pointer[attachedClient]
32+ geometryMu sync.Mutex
33+ geometryOwner atomic.Pointer[attachedClient]
34+ appliedGeometry domain.Geometry // guarded by geometryMu
3435 // geometryClaimSeq is mutated while mu and geometryMu are held; its value
3536 // is copied into attachedClient.geometryClaim.
3637 geometryClaimSeq uint64
@@ -29,8 +29,9 @@ type sessionCore struct {
29 // controls shared PTY/layout geometry. The pointer is published atomically29 // controls shared PTY/layout geometry. The pointer is published atomically
30 // so resize transactions can reject stale claims without taking session.mu.30 // so resize transactions can reject stale claims without taking session.mu.
31 // geometryMu serializes claim publication with the final shared-layout commit.31 // geometryMu serializes claim publication with the final shared-layout commit.
32- geometryMu sync.Mutex32+ geometryMu sync.Mutex
33- geometryOwner atomic.Pointer[attachedClient]33+ geometryOwner atomic.Pointer[attachedClient]
34+ appliedGeometry domain.Geometry // guarded by geometryMu
34 // geometryClaimSeq is mutated while mu and geometryMu are held; its value35 // geometryClaimSeq is mutated while mu and geometryMu are held; its value
35 // is copied into attachedClient.geometryClaim.36 // is copied into attachedClient.geometryClaim.
36 geometryClaimSeq uint6437 geometryClaimSeq uint64
M internal/usecase/daemon/session_geometry.go +48 -8
@@ -8,7 +8,7 @@ import "github.com/bnema/vev/internal/domain"
88 // directly instead of publishing them through the attachment registry.
99 type sessionGeometrySource struct {
1010 attachment *attachedClient
11- size domain.Size
11+ geometry domain.Geometry
1212 claim uint64
1313 }
1414
@@ -25,18 +25,18 @@ func (s *session) geometrySourceSnapshot() (sessionGeometrySource, bool) {
2525 s.mu.Lock()
2626 s.refreshGeometryOwnerLocked()
2727 owner := s.geometryOwner.Load()
28- var size domain.Size
28+ var geometry domain.Geometry
2929 var claim uint64
3030 if owner != nil {
31- size = owner.geometryClaimSizeSnapshot()
31+ geometry = owner.geometryClaimSnapshot()
3232 claim = owner.geometryClaim.Load()
3333 }
3434 s.mu.Unlock()
3535
36- if owner == nil || !size.Valid() {
36+ if owner == nil || !geometry.Valid() {
3737 return sessionGeometrySource{}, false
3838 }
39- return sessionGeometrySource{attachment: owner, size: size, claim: claim}, true
39+ return sessionGeometrySource{attachment: owner, geometry: geometry, claim: claim}, true
4040 }
4141
4242 // geometryClaimTokenCurrent is deliberately lock-free. applySessionLayout invokes
@@ -51,7 +51,36 @@ func (s *session) geometryClaimTokenCurrent(attachment *attachedClient, claim ui
5151
5252 func (s *session) geometryClaimCurrent(source sessionGeometrySource) bool {
5353 return s.geometryClaimTokenCurrent(source.attachment, source.claim) &&
54- (source.attachment == nil || source.attachment.geometryClaimSizeSnapshot() == source.size)
54+ (source.attachment == nil || source.attachment.geometryClaimSnapshot() == source.geometry)
55+}
56+
57+// paneGeometry maps the controlling terminal's optional pixel dimensions to a
58+// pane's cell rectangle. Integer division deliberately truncates: the kernel
59+// winsize and CSI reports cannot represent fractional pixels.
60+// scalePaneGeometry maps a full terminal claim to one pane rectangle. Pixel
61+// dimensions are deliberately scaled with integer truncation, matching the
62+// mapping used by paneGeometry, but it is also usable while a session is still
63+// private and has not published its attachment owner.
64+func scalePaneGeometry(full domain.Geometry, size domain.Size) domain.Geometry {
65+ geometry := domain.Geometry{Size: size}
66+ if !full.Valid() || !size.Valid() || !full.PixelsKnown() {
67+ return geometry
68+ }
69+ geometry.PixelWidth = int(int64(full.PixelWidth) * int64(size.Cols) / int64(full.Cols))
70+ geometry.PixelHeight = int(int64(full.PixelHeight) * int64(size.Rows) / int64(full.Rows))
71+ return geometry.NormalizePixels()
72+}
73+
74+func (s *session) paneGeometry(size domain.Size) domain.Geometry {
75+ geometry := domain.Geometry{Size: size}
76+ if s == nil || !size.Valid() {
77+ return geometry
78+ }
79+ owner := s.geometryOwner.Load()
80+ if owner == nil {
81+ return geometry
82+ }
83+ return scalePaneGeometry(owner.geometryClaimSnapshot(), size)
5584 }
5685
5786 // sessionGeometryViewport is called with a non-nil session by the daemon
@@ -61,7 +90,7 @@ func sessionGeometryViewport(sess *session) (domain.Size, sessionGeometrySource,
6190 if !ok {
6291 return domain.Size{}, sessionGeometrySource{}, false
6392 }
64- content := contentSize(source.size)
93+ content := contentSize(source.geometry.Size)
6594 return domain.Size{Cols: content.Cols, Rows: content.Rows + tabChromeRows}, source, true
6695 }
6796
@@ -84,10 +113,21 @@ func (d *Daemon) recalculateSessionGeometry(sess *session, source *attachedClien
84113 return false
85114 }
86115 current := func() bool { return sess.geometryClaimCurrent(owner) }
87- if contentSize(sess.fullViewportSize()) == contentSize(want) {
116+ sess.geometryMu.Lock()
117+ appliedGeometry := sess.appliedGeometry
118+ sess.geometryMu.Unlock()
119+ pixelGeometryChanged := (appliedGeometry.PixelsKnown() || owner.geometry.PixelsKnown()) && appliedGeometry != owner.geometry
120+ if !pixelGeometryChanged && contentSize(sess.fullViewportSize()) == contentSize(want) {
88121 return false
89122 }
90123 failed, ok := d.applySessionLayout(sess, want, current, nil)
124+ if ok {
125+ sess.geometryMu.Lock()
126+ if current() {
127+ sess.appliedGeometry = owner.geometry
128+ }
129+ sess.geometryMu.Unlock()
130+ }
91131 if ok && len(failed) != 0 {
92132 seen := make(map[*tab]struct{}, len(failed))
93133 for _, member := range failed {
@@ -8,7 +8,7 @@ import "github.com/bnema/vev/internal/domain"
8 // directly instead of publishing them through the attachment registry.8 // directly instead of publishing them through the attachment registry.
9 type sessionGeometrySource struct {9 type sessionGeometrySource struct {
10 attachment *attachedClient10 attachment *attachedClient
11- size domain.Size11+ geometry domain.Geometry
12 claim uint6412 claim uint64
13 }13 }
14 14
@@ -25,18 +25,18 @@ func (s *session) geometrySourceSnapshot() (sessionGeometrySource, bool) {
25 s.mu.Lock()25 s.mu.Lock()
26 s.refreshGeometryOwnerLocked()26 s.refreshGeometryOwnerLocked()
27 owner := s.geometryOwner.Load()27 owner := s.geometryOwner.Load()
28- var size domain.Size28+ var geometry domain.Geometry
29 var claim uint6429 var claim uint64
30 if owner != nil {30 if owner != nil {
31- size = owner.geometryClaimSizeSnapshot()31+ geometry = owner.geometryClaimSnapshot()
32 claim = owner.geometryClaim.Load()32 claim = owner.geometryClaim.Load()
33 }33 }
34 s.mu.Unlock()34 s.mu.Unlock()
35 35
36- if owner == nil || !size.Valid() {36+ if owner == nil || !geometry.Valid() {
37 return sessionGeometrySource{}, false37 return sessionGeometrySource{}, false
38 }38 }
39- return sessionGeometrySource{attachment: owner, size: size, claim: claim}, true39+ return sessionGeometrySource{attachment: owner, geometry: geometry, claim: claim}, true
40 }40 }
41 41
42 // geometryClaimTokenCurrent is deliberately lock-free. applySessionLayout invokes42 // geometryClaimTokenCurrent is deliberately lock-free. applySessionLayout invokes
@@ -51,7 +51,36 @@ func (s *session) geometryClaimTokenCurrent(attachment *attachedClient, claim ui
51 51
52 func (s *session) geometryClaimCurrent(source sessionGeometrySource) bool {52 func (s *session) geometryClaimCurrent(source sessionGeometrySource) bool {
53 return s.geometryClaimTokenCurrent(source.attachment, source.claim) &&53 return s.geometryClaimTokenCurrent(source.attachment, source.claim) &&
54- (source.attachment == nil || source.attachment.geometryClaimSizeSnapshot() == source.size)54+ (source.attachment == nil || source.attachment.geometryClaimSnapshot() == source.geometry)
55+}
56+
57+// paneGeometry maps the controlling terminal's optional pixel dimensions to a
58+// pane's cell rectangle. Integer division deliberately truncates: the kernel
59+// winsize and CSI reports cannot represent fractional pixels.
60+// scalePaneGeometry maps a full terminal claim to one pane rectangle. Pixel
61+// dimensions are deliberately scaled with integer truncation, matching the
62+// mapping used by paneGeometry, but it is also usable while a session is still
63+// private and has not published its attachment owner.
64+func scalePaneGeometry(full domain.Geometry, size domain.Size) domain.Geometry {
65+ geometry := domain.Geometry{Size: size}
66+ if !full.Valid() || !size.Valid() || !full.PixelsKnown() {
67+ return geometry
68+ }
69+ geometry.PixelWidth = int(int64(full.PixelWidth) * int64(size.Cols) / int64(full.Cols))
70+ geometry.PixelHeight = int(int64(full.PixelHeight) * int64(size.Rows) / int64(full.Rows))
71+ return geometry.NormalizePixels()
72+}
73+
74+func (s *session) paneGeometry(size domain.Size) domain.Geometry {
75+ geometry := domain.Geometry{Size: size}
76+ if s == nil || !size.Valid() {
77+ return geometry
78+ }
79+ owner := s.geometryOwner.Load()
80+ if owner == nil {
81+ return geometry
82+ }
83+ return scalePaneGeometry(owner.geometryClaimSnapshot(), size)
55 }84 }
56 85
57 // sessionGeometryViewport is called with a non-nil session by the daemon86 // sessionGeometryViewport is called with a non-nil session by the daemon
@@ -61,7 +90,7 @@ func sessionGeometryViewport(sess *session) (domain.Size, sessionGeometrySource,
61 if !ok {90 if !ok {
62 return domain.Size{}, sessionGeometrySource{}, false91 return domain.Size{}, sessionGeometrySource{}, false
63 }92 }
64- content := contentSize(source.size)93+ content := contentSize(source.geometry.Size)
65 return domain.Size{Cols: content.Cols, Rows: content.Rows + tabChromeRows}, source, true94 return domain.Size{Cols: content.Cols, Rows: content.Rows + tabChromeRows}, source, true
66 }95 }
67 96
@@ -84,10 +113,21 @@ func (d *Daemon) recalculateSessionGeometry(sess *session, source *attachedClien
84 return false113 return false
85 }114 }
86 current := func() bool { return sess.geometryClaimCurrent(owner) }115 current := func() bool { return sess.geometryClaimCurrent(owner) }
87- if contentSize(sess.fullViewportSize()) == contentSize(want) {116+ sess.geometryMu.Lock()
117+ appliedGeometry := sess.appliedGeometry
118+ sess.geometryMu.Unlock()
119+ pixelGeometryChanged := (appliedGeometry.PixelsKnown() || owner.geometry.PixelsKnown()) && appliedGeometry != owner.geometry
120+ if !pixelGeometryChanged && contentSize(sess.fullViewportSize()) == contentSize(want) {
88 return false121 return false
89 }122 }
90 failed, ok := d.applySessionLayout(sess, want, current, nil)123 failed, ok := d.applySessionLayout(sess, want, current, nil)
124+ if ok {
125+ sess.geometryMu.Lock()
126+ if current() {
127+ sess.appliedGeometry = owner.geometry
128+ }
129+ sess.geometryMu.Unlock()
130+ }
91 if ok && len(failed) != 0 {131 if ok && len(failed) != 0 {
92 seen := make(map[*tab]struct{}, len(failed))132 seen := make(map[*tab]struct{}, len(failed))
93 for _, member := range failed {133 for _, member := range failed {
M internal/usecase/daemon/session_test.go +16 -16
@@ -25,8 +25,8 @@ import (
2525
2626 // --- test doubles -----------------------------------------------------------
2727 func expectFloatingPrewarmOpen(factory *portsmocks.MockPTYFactory, normalSize domain.Size, floating ports.PTY) {
28- factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
29- return got != normalSize && got.Valid()
28+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Geometry) bool {
29+ return got.Size != normalSize && got.Size.Valid()
3030 })).Return(floating, nil).Maybe()
3131 }
3232
@@ -213,11 +213,11 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
213213
214214 f := portsmocks.NewMockPTYFactory(t)
215215 normalSize := domain.Size{Cols: 80, Rows: 22}
216- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).Return(p1, nil).Once()
216+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Geometry) bool { return got.Size == normalSize })).Return(p1, nil).Once()
217217 floating := newQuietPTY()
218218 expectFloatingPrewarmOpen(f, normalSize, floating)
219- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
220- func(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
219+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Geometry) bool { return got.Size == normalSize })).RunAndReturn(
220+ func(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (ports.PTY, error) {
221221 openCtx <- ctx
222222 select {
223223 case <-ctx.Done():
@@ -494,13 +494,13 @@ func TestHelloRacingShutdownIsRejected(t *testing.T) {
494494 var opensAfterShutdown atomic.Int32
495495 d := newTestDaemon(t, f, stubClock{})
496496 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
497- func(_ context.Context, _ string, _ []string, _ []string, _ string, size domain.Size) (ports.PTY, error) {
497+ func(_ context.Context, _ string, _ []string, _ []string, _ string, geometry domain.Geometry) (ports.PTY, error) {
498498 select {
499499 case <-d.done:
500500 opensAfterShutdown.Add(1)
501501 default:
502502 }
503- if size == normalSize {
503+ if geometry.Size == normalSize {
504504 return p, nil
505505 }
506506 select {
@@ -1255,7 +1255,7 @@ func TestNewSessionAssignsStableIDsAndChildEnv(t *testing.T) {
12551255 var gotEnv []string
12561256 f := portsmocks.NewMockPTYFactory(t)
12571257 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1258- func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1258+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Geometry) (ports.PTY, error) {
12591259 gotEnv = append([]string(nil), env...)
12601260 return p, nil
12611261 },
@@ -1430,8 +1430,8 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
14301430 var opens [][]string
14311431 f := portsmocks.NewMockPTYFactory(t)
14321432 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1433- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1434- func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1433+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Geometry) bool { return got.Size == normalSize })).RunAndReturn(
1434+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Geometry) (ports.PTY, error) {
14351435 opens = append(opens, append([]string(nil), env...))
14361436 if len(opens) == 1 {
14371437 return p1, nil
@@ -1473,8 +1473,8 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
14731473 var opens [][]string
14741474 f := portsmocks.NewMockPTYFactory(t)
14751475 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1476- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1477- func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1476+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Geometry) bool { return got.Size == normalSize })).RunAndReturn(
1477+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Geometry) (ports.PTY, error) {
14781478 opens = append(opens, append([]string(nil), env...))
14791479 if len(opens) == 1 {
14801480 return p1, nil
@@ -1518,8 +1518,8 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
15181518 var opens [][]string
15191519 f := portsmocks.NewMockPTYFactory(t)
15201520 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1521- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1522- func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1521+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Geometry) bool { return got.Size == normalSize })).RunAndReturn(
1522+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Geometry) (ports.PTY, error) {
15231523 opens = append(opens, append([]string(nil), env...))
15241524 if len(opens) == 1 {
15251525 return p1, nil
@@ -1586,8 +1586,8 @@ func TestAttachEnvironmentReplacesFuturePTYInputs(t *testing.T) {
15861586 var envs [][]string
15871587 f := portsmocks.NewMockPTYFactory(t)
15881588 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1589- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1590- func(_ context.Context, command string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1589+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Geometry) bool { return got.Size == normalSize })).RunAndReturn(
1590+ func(_ context.Context, command string, _ []string, env []string, _ string, _ domain.Geometry) (ports.PTY, error) {
15911591 commands = append(commands, command)
15921592 envs = append(envs, append([]string(nil), env...))
15931593 if len(envs) == 1 {
@@ -25,8 +25,8 @@ import (
25 25
26 // --- test doubles -----------------------------------------------------------26 // --- test doubles -----------------------------------------------------------
27 func expectFloatingPrewarmOpen(factory *portsmocks.MockPTYFactory, normalSize domain.Size, floating ports.PTY) {27 func expectFloatingPrewarmOpen(factory *portsmocks.MockPTYFactory, normalSize domain.Size, floating ports.PTY) {
28- factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {28+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Geometry) bool {
29- return got != normalSize && got.Valid()29+ return got.Size != normalSize && got.Size.Valid()
30 })).Return(floating, nil).Maybe()30 })).Return(floating, nil).Maybe()
31 }31 }
32 32
@@ -213,11 +213,11 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
213 213
214 f := portsmocks.NewMockPTYFactory(t)214 f := portsmocks.NewMockPTYFactory(t)
215 normalSize := domain.Size{Cols: 80, Rows: 22}215 normalSize := domain.Size{Cols: 80, Rows: 22}
216- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).Return(p1, nil).Once()216+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Geometry) bool { return got.Size == normalSize })).Return(p1, nil).Once()
217 floating := newQuietPTY()217 floating := newQuietPTY()
218 expectFloatingPrewarmOpen(f, normalSize, floating)218 expectFloatingPrewarmOpen(f, normalSize, floating)
219- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(219+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Geometry) bool { return got.Size == normalSize })).RunAndReturn(
220- func(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {220+ func(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (ports.PTY, error) {
221 openCtx <- ctx221 openCtx <- ctx
222 select {222 select {
223 case <-ctx.Done():223 case <-ctx.Done():
@@ -494,13 +494,13 @@ func TestHelloRacingShutdownIsRejected(t *testing.T) {
494 var opensAfterShutdown atomic.Int32494 var opensAfterShutdown atomic.Int32
495 d := newTestDaemon(t, f, stubClock{})495 d := newTestDaemon(t, f, stubClock{})
496 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(496 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
497- func(_ context.Context, _ string, _ []string, _ []string, _ string, size domain.Size) (ports.PTY, error) {497+ func(_ context.Context, _ string, _ []string, _ []string, _ string, geometry domain.Geometry) (ports.PTY, error) {
498 select {498 select {
499 case <-d.done:499 case <-d.done:
500 opensAfterShutdown.Add(1)500 opensAfterShutdown.Add(1)
501 default:501 default:
502 }502 }
503- if size == normalSize {503+ if geometry.Size == normalSize {
504 return p, nil504 return p, nil
505 }505 }
506 select {506 select {
@@ -1255,7 +1255,7 @@ func TestNewSessionAssignsStableIDsAndChildEnv(t *testing.T) {
1255 var gotEnv []string1255 var gotEnv []string
1256 f := portsmocks.NewMockPTYFactory(t)1256 f := portsmocks.NewMockPTYFactory(t)
1257 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(1257 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1258- func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {1258+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Geometry) (ports.PTY, error) {
1259 gotEnv = append([]string(nil), env...)1259 gotEnv = append([]string(nil), env...)
1260 return p, nil1260 return p, nil
1261 },1261 },
@@ -1430,8 +1430,8 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1430 var opens [][]string1430 var opens [][]string
1431 f := portsmocks.NewMockPTYFactory(t)1431 f := portsmocks.NewMockPTYFactory(t)
1432 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}1432 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1433- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(1433+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Geometry) bool { return got.Size == normalSize })).RunAndReturn(
1434- func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {1434+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Geometry) (ports.PTY, error) {
1435 opens = append(opens, append([]string(nil), env...))1435 opens = append(opens, append([]string(nil), env...))
1436 if len(opens) == 1 {1436 if len(opens) == 1 {
1437 return p1, nil1437 return p1, nil
@@ -1473,8 +1473,8 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1473 var opens [][]string1473 var opens [][]string
1474 f := portsmocks.NewMockPTYFactory(t)1474 f := portsmocks.NewMockPTYFactory(t)
1475 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}1475 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1476- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(1476+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Geometry) bool { return got.Size == normalSize })).RunAndReturn(
1477- func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {1477+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Geometry) (ports.PTY, error) {
1478 opens = append(opens, append([]string(nil), env...))1478 opens = append(opens, append([]string(nil), env...))
1479 if len(opens) == 1 {1479 if len(opens) == 1 {
1480 return p1, nil1480 return p1, nil
@@ -1518,8 +1518,8 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
1518 var opens [][]string1518 var opens [][]string
1519 f := portsmocks.NewMockPTYFactory(t)1519 f := portsmocks.NewMockPTYFactory(t)
1520 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}1520 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1521- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(1521+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Geometry) bool { return got.Size == normalSize })).RunAndReturn(
1522- func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {1522+ func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Geometry) (ports.PTY, error) {
1523 opens = append(opens, append([]string(nil), env...))1523 opens = append(opens, append([]string(nil), env...))
1524 if len(opens) == 1 {1524 if len(opens) == 1 {
1525 return p1, nil1525 return p1, nil
@@ -1586,8 +1586,8 @@ func TestAttachEnvironmentReplacesFuturePTYInputs(t *testing.T) {
1586 var envs [][]string1586 var envs [][]string
1587 f := portsmocks.NewMockPTYFactory(t)1587 f := portsmocks.NewMockPTYFactory(t)
1588 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}1588 normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1589- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(1589+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Geometry) bool { return got.Size == normalSize })).RunAndReturn(
1590- func(_ context.Context, command string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {1590+ func(_ context.Context, command string, _ []string, env []string, _ string, _ domain.Geometry) (ports.PTY, error) {
1591 commands = append(commands, command)1591 commands = append(commands, command)
1592 envs = append(envs, append([]string(nil), env...))1592 envs = append(envs, append([]string(nil), env...))
1593 if len(envs) == 1 {1593 if len(envs) == 1 {
M internal/usecase/daemon/snapshot_restore.go +3 -2
@@ -261,7 +261,8 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable
261261 }
262262 launch := d.shellLaunch(restoreEnv)
263263 lifetime := d.newPaneProcessLifetime(ctx, tabCtx)
264- pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(restoreEnv, sessionName, tabStableID, paneStableID), paneSnap.Cwd, restorePTYSize(contentRect, tabSize))
264+ ptyGeometry := domain.Geometry{Size: restorePTYSize(contentRect, tabSize)}
265+ pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(restoreEnv, sessionName, tabStableID, paneStableID), paneSnap.Cwd, ptyGeometry)
265266 if err != nil {
266267 lifetime.abort()
267268 if pty != nil {
@@ -269,7 +270,7 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable
269270 }
270271 return nil, err
271272 }
272- p := newPaneWithStableIDAndTitle(paneSnap.ID, paneStableID, pty, restorePTYSize(contentRect, tabSize), launch.title)
273+ p := newPaneWithStableIDAndTitle(paneSnap.ID, paneStableID, pty, ptyGeometry.Size, launch.title)
273274 if !lifetime.publish(p) {
274275 _ = pty.Close()
275276 return nil, lifetime.ctx.Err()
@@ -261,7 +261,8 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable
261 }261 }
262 launch := d.shellLaunch(restoreEnv)262 launch := d.shellLaunch(restoreEnv)
263 lifetime := d.newPaneProcessLifetime(ctx, tabCtx)263 lifetime := d.newPaneProcessLifetime(ctx, tabCtx)
264- pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(restoreEnv, sessionName, tabStableID, paneStableID), paneSnap.Cwd, restorePTYSize(contentRect, tabSize))264+ ptyGeometry := domain.Geometry{Size: restorePTYSize(contentRect, tabSize)}
265+ pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(restoreEnv, sessionName, tabStableID, paneStableID), paneSnap.Cwd, ptyGeometry)
265 if err != nil {266 if err != nil {
266 lifetime.abort()267 lifetime.abort()
267 if pty != nil {268 if pty != nil {
@@ -269,7 +270,7 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable
269 }270 }
270 return nil, err271 return nil, err
271 }272 }
272- p := newPaneWithStableIDAndTitle(paneSnap.ID, paneStableID, pty, restorePTYSize(contentRect, tabSize), launch.title)273+ p := newPaneWithStableIDAndTitle(paneSnap.ID, paneStableID, pty, ptyGeometry.Size, launch.title)
273 if !lifetime.publish(p) {274 if !lifetime.publish(p) {
274 _ = pty.Close()275 _ = pty.Close()
275 return nil, lifetime.ctx.Err()276 return nil, lifetime.ctx.Err()
M internal/usecase/daemon/snapshot_restore_test.go +1 -1
@@ -347,7 +347,7 @@ func TestSnapshotRestoreUsesDaemonTerminalCapabilities(t *testing.T) {
347347 var gotEnv []string
348348 factory := portsmocks.NewMockPTYFactory(t)
349349 factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
350- RunAndReturn(func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
350+ RunAndReturn(func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Geometry) (ports.PTY, error) {
351351 gotEnv = append([]string(nil), env...)
352352 return pty, nil
353353 }).Once()
@@ -347,7 +347,7 @@ func TestSnapshotRestoreUsesDaemonTerminalCapabilities(t *testing.T) {
347 var gotEnv []string347 var gotEnv []string
348 factory := portsmocks.NewMockPTYFactory(t)348 factory := portsmocks.NewMockPTYFactory(t)
349 factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).349 factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
350- RunAndReturn(func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {350+ RunAndReturn(func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Geometry) (ports.PTY, error) {
351 gotEnv = append([]string(nil), env...)351 gotEnv = append([]string(nil), env...)
352 return pty, nil352 return pty, nil
353 }).Once()353 }).Once()
M internal/usecase/daemon/transactional_resize.go +35 -6
@@ -3,6 +3,7 @@ package daemon
33 import (
44 "context"
55
6+ vt "github.com/bnema/vev-vt"
67 "github.com/bnema/vev/internal/domain"
78 "github.com/bnema/vev/internal/ports"
89 "github.com/bnema/vev/internal/usecase/layout"
@@ -25,6 +26,7 @@ type resizeMember struct {
2526 pane *pane
2627 owner paneEffectLease
2728 rect domain.Rect
29+ geometry domain.Geometry
2830 floating floatingGeometry
2931 isFloating bool
3032 // floatingGeneration identifies the exact popup slot accepted by a failed
@@ -37,6 +39,20 @@ type resizeMember struct {
3739 screenResized bool
3840 }
3941
42+func resizePTYGeometry(pty ports.PTY, geometry domain.Geometry) error {
43+ return pty.Resize(geometry.NormalizePixels())
44+}
45+
46+func setScreenGeometry(screen *vt.Screen, geometry domain.Geometry) {
47+ if screen == nil {
48+ return
49+ }
50+ screen.SetGeometry(vt.Geometry{
51+ Cols: geometry.Cols, Rows: geometry.Rows,
52+ PixelWidth: geometry.PixelWidth, PixelHeight: geometry.PixelHeight,
53+ })
54+}
55+
4056 type preparedTabLayout struct {
4157 tab *tab
4258 generation uint64
@@ -71,7 +87,11 @@ func prepareTabLayoutForSizeLocked(sess *session, tb *tab, size domain.Size) pre
7187 p.mu.Lock()
7288 owner := p.effectLeaseLocked()
7389 p.mu.Unlock()
74- plan.members = append(plan.members, resizeMember{session: sess, tab: tb, pane: p, owner: owner, rect: placement.Content})
90+ paneSize := rectSize(placement.Content)
91+ plan.members = append(plan.members, resizeMember{
92+ session: sess, tab: tb, pane: p, owner: owner, rect: placement.Content,
93+ geometry: sess.paneGeometry(paneSize),
94+ })
7595 }
7696 }
7797 return plan
@@ -82,6 +102,9 @@ func prepareTabLayoutForSizeLocked(sess *session, tb *tab, size domain.Size) pre
82102 func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) {
83103 for i := range plan.members {
84104 member := &plan.members[i]
105+ if !member.geometry.Valid() {
106+ member.geometry = domain.Geometry{Size: rectSize(member.rect)}
107+ }
85108 p := member.pane
86109 p.resizeMu.Lock()
87110 p.mu.Lock()
@@ -98,14 +121,15 @@ func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) {
98121 // Reapply even if the committed rectangle already matches: the PTY may
99122 // still be at that rejected attempt's external size. An accepted failed
100123 // attempt records resizeRetry for the same reason after its gate closes.
101- needsApply := p.resizeApplying || p.resizeRetry || old.Width != member.rect.Width || old.Height != member.rect.Height
124+ pixelGeometryChanged := (p.geometry.PixelsKnown() || member.geometry.PixelsKnown()) && p.geometry != member.geometry
125+ needsApply := p.resizeApplying || p.resizeRetry || old.Width != member.rect.Width || old.Height != member.rect.Height || pixelGeometryChanged
102126 if needsApply && pty != nil {
103127 p.resizeApplying = true
104128 }
105129 p.mu.Unlock()
106130 if !needsApply || pty == nil {
107131 member.ok = true
108- } else if err := pty.Resize(rectSize(member.rect)); err != nil {
132+ } else if err := resizePTYGeometry(pty, member.geometry); err != nil {
109133 d.log.Warn("pty resize failed", "err", err)
110134 p.mu.Lock()
111135 current := resizeMemberOwnerCurrent(member)
@@ -184,7 +208,8 @@ func commitPreparedTabLayoutLocked(plan *preparedTabLayout) bool {
184208 member.pane.rect = member.rect
185209 if member.ok {
186210 member.pane.resizeRetry = false
187- member.pane.screen.Resize(member.rect.Width, member.rect.Height)
211+ member.pane.geometry = member.geometry
212+ setScreenGeometry(member.pane.screen, member.geometry)
188213 }
189214 member.pane.mu.Unlock()
190215 }
@@ -460,8 +485,11 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur
460485 p.mu.Lock()
461486 owner := p.effectLeaseLocked()
462487 p.mu.Unlock()
488+ ptyRect := geometry.ptyRect()
463489 plan := preparedTabLayout{members: []resizeMember{{
464- session: sess, tab: tb, pane: p, owner: owner, rect: geometry.ptyRect(), floating: geometry, isFloating: true, floatingGeneration: generation,
490+ session: sess, tab: tb, pane: p, owner: owner, rect: ptyRect,
491+ geometry: sess.paneGeometry(rectSize(ptyRect)), floating: geometry,
492+ isFloating: true, floatingGeneration: generation,
465493 }}}
466494 d.applyPreparedTabMembers(&plan)
467495
@@ -477,9 +505,10 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur
477505 currentSlot = resizeMemberOwnerCurrent(&plan.members[0])
478506 if currentSlot {
479507 p.rect = plan.members[0].rect
508+ p.geometry = plan.members[0].geometry
480509 p.popupGeometry = geometry
481510 p.resizeRetry = false
482- p.screen.Resize(plan.members[0].rect.Width, plan.members[0].rect.Height)
511+ setScreenGeometry(p.screen, plan.members[0].geometry)
483512 }
484513 p.mu.Unlock()
485514 }
@@ -3,6 +3,7 @@ package daemon
3 import (3 import (
4 "context"4 "context"
5 5
6+ vt "github.com/bnema/vev-vt"
6 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
8 "github.com/bnema/vev/internal/usecase/layout"9 "github.com/bnema/vev/internal/usecase/layout"
@@ -25,6 +26,7 @@ type resizeMember struct {
25 pane *pane26 pane *pane
26 owner paneEffectLease27 owner paneEffectLease
27 rect domain.Rect28 rect domain.Rect
29+ geometry domain.Geometry
28 floating floatingGeometry30 floating floatingGeometry
29 isFloating bool31 isFloating bool
30 // floatingGeneration identifies the exact popup slot accepted by a failed32 // floatingGeneration identifies the exact popup slot accepted by a failed
@@ -37,6 +39,20 @@ type resizeMember struct {
37 screenResized bool39 screenResized bool
38 }40 }
39 41
42+func resizePTYGeometry(pty ports.PTY, geometry domain.Geometry) error {
43+ return pty.Resize(geometry.NormalizePixels())
44+}
45+
46+func setScreenGeometry(screen *vt.Screen, geometry domain.Geometry) {
47+ if screen == nil {
48+ return
49+ }
50+ screen.SetGeometry(vt.Geometry{
51+ Cols: geometry.Cols, Rows: geometry.Rows,
52+ PixelWidth: geometry.PixelWidth, PixelHeight: geometry.PixelHeight,
53+ })
54+}
55+
40 type preparedTabLayout struct {56 type preparedTabLayout struct {
41 tab *tab57 tab *tab
42 generation uint6458 generation uint64
@@ -71,7 +87,11 @@ func prepareTabLayoutForSizeLocked(sess *session, tb *tab, size domain.Size) pre
71 p.mu.Lock()87 p.mu.Lock()
72 owner := p.effectLeaseLocked()88 owner := p.effectLeaseLocked()
73 p.mu.Unlock()89 p.mu.Unlock()
74- plan.members = append(plan.members, resizeMember{session: sess, tab: tb, pane: p, owner: owner, rect: placement.Content})90+ paneSize := rectSize(placement.Content)
91+ plan.members = append(plan.members, resizeMember{
92+ session: sess, tab: tb, pane: p, owner: owner, rect: placement.Content,
93+ geometry: sess.paneGeometry(paneSize),
94+ })
75 }95 }
76 }96 }
77 return plan97 return plan
@@ -82,6 +102,9 @@ func prepareTabLayoutForSizeLocked(sess *session, tb *tab, size domain.Size) pre
82 func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) {102 func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) {
83 for i := range plan.members {103 for i := range plan.members {
84 member := &plan.members[i]104 member := &plan.members[i]
105+ if !member.geometry.Valid() {
106+ member.geometry = domain.Geometry{Size: rectSize(member.rect)}
107+ }
85 p := member.pane108 p := member.pane
86 p.resizeMu.Lock()109 p.resizeMu.Lock()
87 p.mu.Lock()110 p.mu.Lock()
@@ -98,14 +121,15 @@ func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) {
98 // Reapply even if the committed rectangle already matches: the PTY may121 // Reapply even if the committed rectangle already matches: the PTY may
99 // still be at that rejected attempt's external size. An accepted failed122 // still be at that rejected attempt's external size. An accepted failed
100 // attempt records resizeRetry for the same reason after its gate closes.123 // attempt records resizeRetry for the same reason after its gate closes.
101- needsApply := p.resizeApplying || p.resizeRetry || old.Width != member.rect.Width || old.Height != member.rect.Height124+ pixelGeometryChanged := (p.geometry.PixelsKnown() || member.geometry.PixelsKnown()) && p.geometry != member.geometry
125+ needsApply := p.resizeApplying || p.resizeRetry || old.Width != member.rect.Width || old.Height != member.rect.Height || pixelGeometryChanged
102 if needsApply && pty != nil {126 if needsApply && pty != nil {
103 p.resizeApplying = true127 p.resizeApplying = true
104 }128 }
105 p.mu.Unlock()129 p.mu.Unlock()
106 if !needsApply || pty == nil {130 if !needsApply || pty == nil {
107 member.ok = true131 member.ok = true
108- } else if err := pty.Resize(rectSize(member.rect)); err != nil {132+ } else if err := resizePTYGeometry(pty, member.geometry); err != nil {
109 d.log.Warn("pty resize failed", "err", err)133 d.log.Warn("pty resize failed", "err", err)
110 p.mu.Lock()134 p.mu.Lock()
111 current := resizeMemberOwnerCurrent(member)135 current := resizeMemberOwnerCurrent(member)
@@ -184,7 +208,8 @@ func commitPreparedTabLayoutLocked(plan *preparedTabLayout) bool {
184 member.pane.rect = member.rect208 member.pane.rect = member.rect
185 if member.ok {209 if member.ok {
186 member.pane.resizeRetry = false210 member.pane.resizeRetry = false
187- member.pane.screen.Resize(member.rect.Width, member.rect.Height)211+ member.pane.geometry = member.geometry
212+ setScreenGeometry(member.pane.screen, member.geometry)
188 }213 }
189 member.pane.mu.Unlock()214 member.pane.mu.Unlock()
190 }215 }
@@ -460,8 +485,11 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur
460 p.mu.Lock()485 p.mu.Lock()
461 owner := p.effectLeaseLocked()486 owner := p.effectLeaseLocked()
462 p.mu.Unlock()487 p.mu.Unlock()
488+ ptyRect := geometry.ptyRect()
463 plan := preparedTabLayout{members: []resizeMember{{489 plan := preparedTabLayout{members: []resizeMember{{
464- session: sess, tab: tb, pane: p, owner: owner, rect: geometry.ptyRect(), floating: geometry, isFloating: true, floatingGeneration: generation,490+ session: sess, tab: tb, pane: p, owner: owner, rect: ptyRect,
491+ geometry: sess.paneGeometry(rectSize(ptyRect)), floating: geometry,
492+ isFloating: true, floatingGeneration: generation,
465 }}}493 }}}
466 d.applyPreparedTabMembers(&plan)494 d.applyPreparedTabMembers(&plan)
467 495
@@ -477,9 +505,10 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur
477 currentSlot = resizeMemberOwnerCurrent(&plan.members[0])505 currentSlot = resizeMemberOwnerCurrent(&plan.members[0])
478 if currentSlot {506 if currentSlot {
479 p.rect = plan.members[0].rect507 p.rect = plan.members[0].rect
508+ p.geometry = plan.members[0].geometry
480 p.popupGeometry = geometry509 p.popupGeometry = geometry
481 p.resizeRetry = false510 p.resizeRetry = false
482- p.screen.Resize(plan.members[0].rect.Width, plan.members[0].rect.Height)511+ setScreenGeometry(p.screen, plan.members[0].geometry)
483 }512 }
484 p.mu.Unlock()513 p.mu.Unlock()
485 }514 }
M internal/usecase/daemon/transactional_resize_test.go +3 -3
@@ -28,9 +28,9 @@ type transactionalResizePTY struct {
2828 onWrite func([]byte)
2929 }
3030
31-func (p *transactionalResizePTY) Resize(size domain.Size) error {
31+func (p *transactionalResizePTY) Resize(geometry domain.Geometry) error {
3232 p.mu.Lock()
33- p.sizes = append(p.sizes, size)
33+ p.sizes = append(p.sizes, geometry.Size)
3434 n := len(p.sizes)
3535 hook := p.onResize
3636 var err error
@@ -92,7 +92,7 @@ func newResizeReaderPTY(redraw []byte) *resizeReaderPTY {
9292 return &resizeReaderPTY{redraw: redraw, reads: make(chan []byte), accepted: make(chan struct{}), closed: make(chan struct{})}
9393 }
9494
95-func (p *resizeReaderPTY) Resize(domain.Size) error {
95+func (p *resizeReaderPTY) Resize(domain.Geometry) error {
9696 p.reads <- p.redraw
9797 <-p.accepted
9898 // A second, empty read cannot begin until ptyReader has processed the
@@ -28,9 +28,9 @@ type transactionalResizePTY struct {
28 onWrite func([]byte)28 onWrite func([]byte)
29 }29 }
30 30
31-func (p *transactionalResizePTY) Resize(size domain.Size) error {31+func (p *transactionalResizePTY) Resize(geometry domain.Geometry) error {
32 p.mu.Lock()32 p.mu.Lock()
33- p.sizes = append(p.sizes, size)33+ p.sizes = append(p.sizes, geometry.Size)
34 n := len(p.sizes)34 n := len(p.sizes)
35 hook := p.onResize35 hook := p.onResize
36 var err error36 var err error
@@ -92,7 +92,7 @@ func newResizeReaderPTY(redraw []byte) *resizeReaderPTY {
92 return &resizeReaderPTY{redraw: redraw, reads: make(chan []byte), accepted: make(chan struct{}), closed: make(chan struct{})}92 return &resizeReaderPTY{redraw: redraw, reads: make(chan []byte), accepted: make(chan struct{}), closed: make(chan struct{})}
93 }93 }
94 94
95-func (p *resizeReaderPTY) Resize(domain.Size) error {95+func (p *resizeReaderPTY) Resize(domain.Geometry) error {
96 p.reads <- p.redraw96 p.reads <- p.redraw
97 <-p.accepted97 <-p.accepted
98 // A second, empty read cannot begin until ptyReader has processed the98 // A second, empty read cannot begin until ptyReader has processed the
M pkg/rawterm/rawterm.go +20 -6
@@ -17,8 +17,9 @@ type State struct {
1717 termios syscall.Termios
1818 }
1919
20-// winsize mirrors the kernel's struct winsize for TIOCGWINSZ/TIOCSWINSZ.
21-type winsize struct {
20+// Winsize mirrors the kernel's struct winsize for TIOCGWINSZ/TIOCSWINSZ.
21+// Zero pixel dimensions mean the controlling terminal did not report them.
22+type Winsize struct {
2223 Row uint16
2324 Col uint16
2425 Xpixel uint16
@@ -79,16 +80,29 @@ func Restore(fd int, s *State) error {
7980
8081 // GetSize returns the terminal's current column and row count.
8182 func GetSize(fd int) (cols, rows int, err error) {
82- var ws winsize
83- if err := ioctl(fd, syscall.TIOCGWINSZ, unsafe.Pointer(&ws)); err != nil {
84- return 0, 0, fmt.Errorf("rawterm: get winsize: %w", err)
83+ ws, err := GetWinsize(fd)
84+ if err != nil {
85+ return 0, 0, err
8586 }
8687 return int(ws.Col), int(ws.Row), nil
8788 }
8889
90+// GetWinsize returns the terminal's cell and optional pixel dimensions.
91+func GetWinsize(fd int) (Winsize, error) {
92+ var ws Winsize
93+ if err := ioctl(fd, syscall.TIOCGWINSZ, unsafe.Pointer(&ws)); err != nil {
94+ return Winsize{}, fmt.Errorf("rawterm: get winsize: %w", err)
95+ }
96+ return ws, nil
97+}
98+
8999 // SetWinsize sets the terminal's column and row count.
90100 func SetWinsize(fd int, cols, rows uint16) error {
91- ws := winsize{Row: rows, Col: cols}
101+ return SetWinsizeFull(fd, Winsize{Row: rows, Col: cols})
102+}
103+
104+// SetWinsizeFull sets the terminal's cell and optional pixel dimensions.
105+func SetWinsizeFull(fd int, ws Winsize) error {
92106 if err := ioctl(fd, syscall.TIOCSWINSZ, unsafe.Pointer(&ws)); err != nil {
93107 return fmt.Errorf("rawterm: set winsize: %w", err)
94108 }
@@ -17,8 +17,9 @@ type State struct {
17 termios syscall.Termios17 termios syscall.Termios
18 }18 }
19 19
20-// winsize mirrors the kernel's struct winsize for TIOCGWINSZ/TIOCSWINSZ.20+// Winsize mirrors the kernel's struct winsize for TIOCGWINSZ/TIOCSWINSZ.
21-type winsize struct {21+// Zero pixel dimensions mean the controlling terminal did not report them.
22+type Winsize struct {
22 Row uint1623 Row uint16
23 Col uint1624 Col uint16
24 Xpixel uint1625 Xpixel uint16
@@ -79,16 +80,29 @@ func Restore(fd int, s *State) error {
79 80
80 // GetSize returns the terminal's current column and row count.81 // GetSize returns the terminal's current column and row count.
81 func GetSize(fd int) (cols, rows int, err error) {82 func GetSize(fd int) (cols, rows int, err error) {
82- var ws winsize83+ ws, err := GetWinsize(fd)
83- if err := ioctl(fd, syscall.TIOCGWINSZ, unsafe.Pointer(&ws)); err != nil {84+ if err != nil {
84- return 0, 0, fmt.Errorf("rawterm: get winsize: %w", err)85+ return 0, 0, err
85 }86 }
86 return int(ws.Col), int(ws.Row), nil87 return int(ws.Col), int(ws.Row), nil
87 }88 }
88 89
90+// GetWinsize returns the terminal's cell and optional pixel dimensions.
91+func GetWinsize(fd int) (Winsize, error) {
92+ var ws Winsize
93+ if err := ioctl(fd, syscall.TIOCGWINSZ, unsafe.Pointer(&ws)); err != nil {
94+ return Winsize{}, fmt.Errorf("rawterm: get winsize: %w", err)
95+ }
96+ return ws, nil
97+}
98+
89 // SetWinsize sets the terminal's column and row count.99 // SetWinsize sets the terminal's column and row count.
90 func SetWinsize(fd int, cols, rows uint16) error {100 func SetWinsize(fd int, cols, rows uint16) error {
91- ws := winsize{Row: rows, Col: cols}101+ return SetWinsizeFull(fd, Winsize{Row: rows, Col: cols})
102+}
103+
104+// SetWinsizeFull sets the terminal's cell and optional pixel dimensions.
105+func SetWinsizeFull(fd int, ws Winsize) error {
92 if err := ioctl(fd, syscall.TIOCSWINSZ, unsafe.Pointer(&ws)); err != nil {106 if err := ioctl(fd, syscall.TIOCSWINSZ, unsafe.Pointer(&ws)); err != nil {
93 return fmt.Errorf("rawterm: set winsize: %w", err)107 return fmt.Errorf("rawterm: set winsize: %w", err)
94 }108 }
M pkg/rawterm/rawterm_test.go +18 -0
@@ -83,6 +83,24 @@ func TestSetWinsizeGetSizeRoundtrip(t *testing.T) {
8383 }
8484 }
8585
86+func TestWinsizeFullRoundtrip(t *testing.T) {
87+ master, slave := openPTYPair(t)
88+ defer func() { _ = master.Close() }()
89+ defer func() { _ = slave.Close() }()
90+
91+ want := Winsize{Col: 80, Row: 24, Xpixel: 640, Ypixel: 384}
92+ if err := SetWinsizeFull(int(slave.Fd()), want); err != nil {
93+ t.Fatalf("SetWinsizeFull: %v", err)
94+ }
95+ got, err := GetWinsize(int(slave.Fd()))
96+ if err != nil {
97+ t.Fatalf("GetWinsize: %v", err)
98+ }
99+ if got != want {
100+ t.Fatalf("GetWinsize = %+v, want %+v", got, want)
101+ }
102+}
103+
86104 func TestMakeRawRestore(t *testing.T) {
87105 master, slave := openPTYPair(t)
88106 defer func() { _ = master.Close() }()
@@ -83,6 +83,24 @@ func TestSetWinsizeGetSizeRoundtrip(t *testing.T) {
83 }83 }
84 }84 }
85 85
86+func TestWinsizeFullRoundtrip(t *testing.T) {
87+ master, slave := openPTYPair(t)
88+ defer func() { _ = master.Close() }()
89+ defer func() { _ = slave.Close() }()
90+
91+ want := Winsize{Col: 80, Row: 24, Xpixel: 640, Ypixel: 384}
92+ if err := SetWinsizeFull(int(slave.Fd()), want); err != nil {
93+ t.Fatalf("SetWinsizeFull: %v", err)
94+ }
95+ got, err := GetWinsize(int(slave.Fd()))
96+ if err != nil {
97+ t.Fatalf("GetWinsize: %v", err)
98+ }
99+ if got != want {
100+ t.Fatalf("GetWinsize = %+v, want %+v", got, want)
101+ }
102+}
103+
86 func TestMakeRawRestore(t *testing.T) {104 func TestMakeRawRestore(t *testing.T) {
87 master, slave := openPTYPair(t)105 master, slave := openPTYPair(t)
88 defer func() { _ = master.Close() }()106 defer func() { _ = master.Close() }()

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