detached 2889147

Merge pull request #2 from bnema/bugfix/post-picker-batch

fix(bugs): apply post-picker bugfix batch
commit
28891479a42f7676a7a8b531af7e4ce360e263eb
author
bnema · · 2026-07-03 05:37 UTC
committer
GitHub · · 2026-07-03 05:37 UTC
merge of
cf5ca03 + f9c00e7 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
18 files · +514 -213
M internal/adapters/pty/pty_linux.go +3 -1
@@ -18,6 +18,7 @@ import (
1818 "time"
1919
2020 "github.com/bnema/vev/internal/domain"
21+ "github.com/bnema/vev/internal/platform"
2122 "github.com/bnema/vev/internal/ports"
2223 "golang.org/x/sys/unix"
2324 )
@@ -37,7 +38,7 @@ func NewFactory() *Factory { return &Factory{} }
3738 // verbatim (nil means inherit the current process environment); the caller
3839 // decides TERM and friends. sz sets the terminal window size before the child
3940 // starts, so the child observes the correct dimensions on its first query.
40-func (Factory) Open(command string, args []string, env []string, sz domain.Size) (ports.PTY, error) {
41+func (Factory) Open(command string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
4142 masterFd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY|unix.O_CLOEXEC, 0)
4243 if err != nil {
4344 return nil, fmt.Errorf("pty: open /dev/ptmx: %w", err)
@@ -80,6 +81,7 @@ func (Factory) Open(command string, args []string, env []string, sz domain.Size)
8081
8182 cmd := exec.Command(command, args...)
8283 cmd.Env = env
84+ cmd.Dir = platform.DirOrHome(dir)
8385 cmd.Stdin = slave
8486 cmd.Stdout = slave
8587 cmd.Stderr = slave
@@ -18,6 +18,7 @@ import (
18 "time"18 "time"
19 19
20 "github.com/bnema/vev/internal/domain"20 "github.com/bnema/vev/internal/domain"
21+ "github.com/bnema/vev/internal/platform"
21 "github.com/bnema/vev/internal/ports"22 "github.com/bnema/vev/internal/ports"
22 "golang.org/x/sys/unix"23 "golang.org/x/sys/unix"
23 )24 )
@@ -37,7 +38,7 @@ func NewFactory() *Factory { return &Factory{} }
37 // verbatim (nil means inherit the current process environment); the caller38 // verbatim (nil means inherit the current process environment); the caller
38 // decides TERM and friends. sz sets the terminal window size before the child39 // decides TERM and friends. sz sets the terminal window size before the child
39 // starts, so the child observes the correct dimensions on its first query.40 // starts, so the child observes the correct dimensions on its first query.
40-func (Factory) Open(command string, args []string, env []string, sz domain.Size) (ports.PTY, error) {41+func (Factory) Open(command string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
41 masterFd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY|unix.O_CLOEXEC, 0)42 masterFd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY|unix.O_CLOEXEC, 0)
42 if err != nil {43 if err != nil {
43 return nil, fmt.Errorf("pty: open /dev/ptmx: %w", err)44 return nil, fmt.Errorf("pty: open /dev/ptmx: %w", err)
@@ -80,6 +81,7 @@ func (Factory) Open(command string, args []string, env []string, sz domain.Size)
80 81
81 cmd := exec.Command(command, args...)82 cmd := exec.Command(command, args...)
82 cmd.Env = env83 cmd.Env = env
84+ cmd.Dir = platform.DirOrHome(dir)
83 cmd.Stdin = slave85 cmd.Stdin = slave
84 cmd.Stdout = slave86 cmd.Stdout = slave
85 cmd.Stderr = slave87 cmd.Stderr = slave
M internal/adapters/pty/pty_linux_test.go +6 -6
@@ -53,7 +53,7 @@ func TestOpen_ChildOutputToEOF(t *testing.T) {
5353 t.Skip("skipping pty integration test in -short mode")
5454 }
5555 f := newFactory()
56- p, err := f.Open("sh", []string{"-c", "printf hello"}, os.Environ(), domain.Size{Cols: 80, Rows: 24})
56+ p, err := f.Open("sh", []string{"-c", "printf hello"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
5757 require.NoError(t, err)
5858 t.Cleanup(func() { _ = p.Close() })
5959
@@ -70,7 +70,7 @@ func TestOpen_EchoRoundtrip(t *testing.T) {
7070 t.Skip("skipping pty integration test in -short mode")
7171 }
7272 f := newFactory()
73- p, err := f.Open("cat", nil, os.Environ(), domain.Size{Cols: 80, Rows: 24})
73+ p, err := f.Open("cat", nil, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
7474 require.NoError(t, err)
7575 t.Cleanup(func() { _ = p.Close() })
7676
@@ -127,7 +127,7 @@ func TestResize_SttySize(t *testing.T) {
127127 for _, tt := range tests {
128128 t.Run(tt.name, func(t *testing.T) {
129129 f := newFactory()
130- p, err := f.Open("sh", []string{"-c", tt.script}, os.Environ(), tt.initial)
130+ p, err := f.Open("sh", []string{"-c", tt.script}, os.Environ(), "", tt.initial)
131131 require.NoError(t, err)
132132 t.Cleanup(func() { _ = p.Close() })
133133
@@ -146,7 +146,7 @@ func TestClose_ReapsChildNoZombie(t *testing.T) {
146146 t.Skip("skipping pty integration test in -short mode")
147147 }
148148 f := newFactory()
149- p, err := f.Open("sh", []string{"-c", "sleep 30"}, os.Environ(), domain.Size{Cols: 80, Rows: 24})
149+ p, err := f.Open("sh", []string{"-c", "sleep 30"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
150150 require.NoError(t, err)
151151
152152 pid := p.Pid()
@@ -177,7 +177,7 @@ func TestClose_TerminatesLongRunningChild(t *testing.T) {
177177 t.Skip("skipping pty integration test in -short mode")
178178 }
179179 f := newFactory()
180- p, err := f.Open("sh", []string{"-c", "sleep 60"}, os.Environ(), domain.Size{Cols: 80, Rows: 24})
180+ p, err := f.Open("sh", []string{"-c", "sleep 60"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
181181 require.NoError(t, err)
182182 pid := p.Pid()
183183
@@ -198,7 +198,7 @@ func TestResize_DeliversSIGWINCH(t *testing.T) {
198198 // deliver SIGWINCH to the foreground process group.
199199 script := `trap 'echo GOTWINCH; exit 0' WINCH; echo READY; i=0; while [ $i -lt 200 ]; do sleep 0.05; i=$((i+1)); done`
200200 f := newFactory()
201- p, err := f.Open("sh", []string{"-c", script}, os.Environ(), domain.Size{Cols: 80, Rows: 24})
201+ p, err := f.Open("sh", []string{"-c", script}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
202202 require.NoError(t, err)
203203 t.Cleanup(func() { _ = p.Close() })
204204
@@ -53,7 +53,7 @@ func TestOpen_ChildOutputToEOF(t *testing.T) {
53 t.Skip("skipping pty integration test in -short mode")53 t.Skip("skipping pty integration test in -short mode")
54 }54 }
55 f := newFactory()55 f := newFactory()
56- p, err := f.Open("sh", []string{"-c", "printf hello"}, os.Environ(), domain.Size{Cols: 80, Rows: 24})56+ p, err := f.Open("sh", []string{"-c", "printf hello"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
57 require.NoError(t, err)57 require.NoError(t, err)
58 t.Cleanup(func() { _ = p.Close() })58 t.Cleanup(func() { _ = p.Close() })
59 59
@@ -70,7 +70,7 @@ func TestOpen_EchoRoundtrip(t *testing.T) {
70 t.Skip("skipping pty integration test in -short mode")70 t.Skip("skipping pty integration test in -short mode")
71 }71 }
72 f := newFactory()72 f := newFactory()
73- p, err := f.Open("cat", nil, os.Environ(), domain.Size{Cols: 80, Rows: 24})73+ p, err := f.Open("cat", nil, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
74 require.NoError(t, err)74 require.NoError(t, err)
75 t.Cleanup(func() { _ = p.Close() })75 t.Cleanup(func() { _ = p.Close() })
76 76
@@ -127,7 +127,7 @@ func TestResize_SttySize(t *testing.T) {
127 for _, tt := range tests {127 for _, tt := range tests {
128 t.Run(tt.name, func(t *testing.T) {128 t.Run(tt.name, func(t *testing.T) {
129 f := newFactory()129 f := newFactory()
130- p, err := f.Open("sh", []string{"-c", tt.script}, os.Environ(), tt.initial)130+ p, err := f.Open("sh", []string{"-c", tt.script}, os.Environ(), "", tt.initial)
131 require.NoError(t, err)131 require.NoError(t, err)
132 t.Cleanup(func() { _ = p.Close() })132 t.Cleanup(func() { _ = p.Close() })
133 133
@@ -146,7 +146,7 @@ func TestClose_ReapsChildNoZombie(t *testing.T) {
146 t.Skip("skipping pty integration test in -short mode")146 t.Skip("skipping pty integration test in -short mode")
147 }147 }
148 f := newFactory()148 f := newFactory()
149- p, err := f.Open("sh", []string{"-c", "sleep 30"}, os.Environ(), domain.Size{Cols: 80, Rows: 24})149+ p, err := f.Open("sh", []string{"-c", "sleep 30"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
150 require.NoError(t, err)150 require.NoError(t, err)
151 151
152 pid := p.Pid()152 pid := p.Pid()
@@ -177,7 +177,7 @@ func TestClose_TerminatesLongRunningChild(t *testing.T) {
177 t.Skip("skipping pty integration test in -short mode")177 t.Skip("skipping pty integration test in -short mode")
178 }178 }
179 f := newFactory()179 f := newFactory()
180- p, err := f.Open("sh", []string{"-c", "sleep 60"}, os.Environ(), domain.Size{Cols: 80, Rows: 24})180+ p, err := f.Open("sh", []string{"-c", "sleep 60"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
181 require.NoError(t, err)181 require.NoError(t, err)
182 pid := p.Pid()182 pid := p.Pid()
183 183
@@ -198,7 +198,7 @@ func TestResize_DeliversSIGWINCH(t *testing.T) {
198 // deliver SIGWINCH to the foreground process group.198 // deliver SIGWINCH to the foreground process group.
199 script := `trap 'echo GOTWINCH; exit 0' WINCH; echo READY; i=0; while [ $i -lt 200 ]; do sleep 0.05; i=$((i+1)); done`199 script := `trap 'echo GOTWINCH; exit 0' WINCH; echo READY; i=0; while [ $i -lt 200 ]; do sleep 0.05; i=$((i+1)); done`
200 f := newFactory()200 f := newFactory()
201- p, err := f.Open("sh", []string{"-c", script}, os.Environ(), domain.Size{Cols: 80, Rows: 24})201+ p, err := f.Open("sh", []string{"-c", script}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24})
202 require.NoError(t, err)202 require.NoError(t, err)
203 t.Cleanup(func() { _ = p.Close() })203 t.Cleanup(func() { _ = p.Close() })
204 204
M internal/app/run.go +4 -0
@@ -216,6 +216,10 @@ func runDaemon() error {
216216 // attach loop. Logging goes to the shared file: the client must never write
217217 // to the console while the terminal is raw.
218218 func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) error {
219+ if os.Getenv("VEV") != "" {
220+ return errors.New("vev: sessions should be nested with care; unset VEV to force")
221+ }
222+
219223 logFile, err := setupLogging()
220224 if err != nil {
221225 return err
@@ -216,6 +216,10 @@ func runDaemon() error {
216 // attach loop. Logging goes to the shared file: the client must never write216 // attach loop. Logging goes to the shared file: the client must never write
217 // to the console while the terminal is raw.217 // to the console while the terminal is raw.
218 func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) error {218 func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) error {
219+ if os.Getenv("VEV") != "" {
220+ return errors.New("vev: sessions should be nested with care; unset VEV to force")
221+ }
222+
219 logFile, err := setupLogging()223 logFile, err := setupLogging()
220 if err != nil {224 if err != nil {
221 return err225 return err
M internal/app/run_test.go +13 -0
@@ -1,7 +1,9 @@
11 package app
22
33 import (
4+ "context"
45 "errors"
6+ "strings"
57 "testing"
68
79 "github.com/bnema/vev/internal/ports"
@@ -76,3 +78,14 @@ func TestParseArgs(t *testing.T) {
7678 })
7779 }
7880 }
81+
82+func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) {
83+ t.Setenv("VEV", "outer")
84+ err := runAttach(context.Background(), ports.IntentEphemeral, "", "")
85+ if err == nil {
86+ t.Fatal("runAttach with VEV set returned nil error")
87+ }
88+ if !strings.Contains(err.Error(), "sessions should be nested with care") {
89+ t.Fatalf("runAttach error = %q, want nested-session warning", err)
90+ }
91+}
@@ -1,7 +1,9 @@
1 package app1 package app
2 2
3 import (3 import (
4+ "context"
4 "errors"5 "errors"
6+ "strings"
5 "testing"7 "testing"
6 8
7 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
@@ -76,3 +78,14 @@ func TestParseArgs(t *testing.T) {
76 })78 })
77 }79 }
78 }80 }
81+
82+func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) {
83+ t.Setenv("VEV", "outer")
84+ err := runAttach(context.Background(), ports.IntentEphemeral, "", "")
85+ if err == nil {
86+ t.Fatal("runAttach with VEV set returned nil error")
87+ }
88+ if !strings.Contains(err.Error(), "sessions should be nested with care") {
89+ t.Fatalf("runAttach error = %q, want nested-session warning", err)
90+ }
91+}
M internal/app/spawn.go +2 -0
@@ -11,6 +11,7 @@ import (
1111 "time"
1212
1313 "github.com/bnema/vev/internal/adapters/ipc"
14+ "github.com/bnema/vev/internal/platform"
1415 "github.com/bnema/vev/internal/ports"
1516 )
1617
@@ -144,6 +145,7 @@ func realSpawn() error {
144145 defer func() { _ = devNull.Close() }()
145146
146147 cmd := exec.Command("/proc/self/exe", "--daemon")
148+ cmd.Dir = platform.DirOrHome("")
147149 cmd.Stdin = devNull
148150 cmd.Stdout = devNull
149151 cmd.Stderr = devNull
@@ -11,6 +11,7 @@ import (
11 "time"11 "time"
12 12
13 "github.com/bnema/vev/internal/adapters/ipc"13 "github.com/bnema/vev/internal/adapters/ipc"
14+ "github.com/bnema/vev/internal/platform"
14 "github.com/bnema/vev/internal/ports"15 "github.com/bnema/vev/internal/ports"
15 )16 )
16 17
@@ -144,6 +145,7 @@ func realSpawn() error {
144 defer func() { _ = devNull.Close() }()145 defer func() { _ = devNull.Close() }()
145 146
146 cmd := exec.Command("/proc/self/exe", "--daemon")147 cmd := exec.Command("/proc/self/exe", "--daemon")
148+ cmd.Dir = platform.DirOrHome("")
147 cmd.Stdin = devNull149 cmd.Stdin = devNull
148 cmd.Stdout = devNull150 cmd.Stdout = devNull
149 cmd.Stderr = devNull151 cmd.Stderr = devNull
A internal/platform/dir.go +17 -0
@@ -0,0 +1,17 @@
1+package platform
2+
3+import "os"
4+
5+// DirOrHome returns dir when it names an existing directory. Otherwise it
6+// falls back to the current user's home directory, then to / as a last resort.
7+func DirOrHome(dir string) string {
8+ if dir != "" {
9+ if st, err := os.Stat(dir); err == nil && st.IsDir() {
10+ return dir
11+ }
12+ }
13+ if home, err := os.UserHomeDir(); err == nil && home != "" {
14+ return home
15+ }
16+ return "/"
17+}
@@ -0,0 +1,17 @@
1+package platform
2+
3+import "os"
4+
5+// DirOrHome returns dir when it names an existing directory. Otherwise it
6+// falls back to the current user's home directory, then to / as a last resort.
7+func DirOrHome(dir string) string {
8+ if dir != "" {
9+ if st, err := os.Stat(dir); err == nil && st.IsDir() {
10+ return dir
11+ }
12+ }
13+ if home, err := os.UserHomeDir(); err == nil && home != "" {
14+ return home
15+ }
16+ return "/"
17+}
M internal/ports/frame.go +1 -1
@@ -1,7 +1,7 @@
11 package ports
22
33 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 1
4+const ProtocolVersion uint16 = 2
55
66 // MsgType identifies the kind of payload carried by a Frame.
77 type MsgType uint8
@@ -1,7 +1,7 @@
1 package ports1 package ports
2 2
3 // ProtocolVersion is the current vev IPC wire protocol version.3 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 14+const ProtocolVersion uint16 = 2
5 5
6 // MsgType identifies the kind of payload carried by a Frame.6 // MsgType identifies the kind of payload carried by a Frame.
7 type MsgType uint87 type MsgType uint8
M internal/ports/mocks/mocks.go +20 -14
@@ -327,8 +327,8 @@ func (_m *MockPTYFactory) EXPECT() *MockPTYFactory_Expecter {
327327 }
328328
329329 // Open provides a mock function for the type MockPTYFactory
330-func (_mock *MockPTYFactory) Open(cmd string, args []string, env []string, sz domain.Size) (ports.PTY, error) {
331- ret := _mock.Called(cmd, args, env, sz)
330+func (_mock *MockPTYFactory) Open(cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
331+ ret := _mock.Called(cmd, args, env, dir, sz)
332332
333333 if len(ret) == 0 {
334334 panic("no return value specified for Open")
@@ -336,18 +336,18 @@ func (_mock *MockPTYFactory) Open(cmd string, args []string, env []string, sz do
336336
337337 var r0 ports.PTY
338338 var r1 error
339- if returnFunc, ok := ret.Get(0).(func(string, []string, []string, domain.Size) (ports.PTY, error)); ok {
340- return returnFunc(cmd, args, env, sz)
339+ if returnFunc, ok := ret.Get(0).(func(string, []string, []string, string, domain.Size) (ports.PTY, error)); ok {
340+ return returnFunc(cmd, args, env, dir, sz)
341341 }
342- if returnFunc, ok := ret.Get(0).(func(string, []string, []string, domain.Size) ports.PTY); ok {
343- r0 = returnFunc(cmd, args, env, sz)
342+ if returnFunc, ok := ret.Get(0).(func(string, []string, []string, string, domain.Size) ports.PTY); ok {
343+ r0 = returnFunc(cmd, args, env, dir, sz)
344344 } else {
345345 if ret.Get(0) != nil {
346346 r0 = ret.Get(0).(ports.PTY)
347347 }
348348 }
349- if returnFunc, ok := ret.Get(1).(func(string, []string, []string, domain.Size) error); ok {
350- r1 = returnFunc(cmd, args, env, sz)
349+ if returnFunc, ok := ret.Get(1).(func(string, []string, []string, string, domain.Size) error); ok {
350+ r1 = returnFunc(cmd, args, env, dir, sz)
351351 } else {
352352 r1 = ret.Error(1)
353353 }
@@ -363,12 +363,13 @@ type MockPTYFactory_Open_Call struct {
363363 // - cmd string
364364 // - args []string
365365 // - env []string
366+// - dir string
366367 // - sz domain.Size
367-func (_e *MockPTYFactory_Expecter) Open(cmd any, args any, env any, sz any) *MockPTYFactory_Open_Call {
368- return &MockPTYFactory_Open_Call{Call: _e.mock.On("Open", cmd, args, env, sz)}
368+func (_e *MockPTYFactory_Expecter) Open(cmd any, args any, env any, dir any, sz any) *MockPTYFactory_Open_Call {
369+ return &MockPTYFactory_Open_Call{Call: _e.mock.On("Open", cmd, args, env, dir, sz)}
369370 }
370371
371-func (_c *MockPTYFactory_Open_Call) Run(run func(cmd string, args []string, env []string, sz domain.Size)) *MockPTYFactory_Open_Call {
372+func (_c *MockPTYFactory_Open_Call) Run(run func(cmd string, args []string, env []string, dir string, sz domain.Size)) *MockPTYFactory_Open_Call {
372373 _c.Call.Run(func(args mock.Arguments) {
373374 var arg0 string
374375 if args[0] != nil {
@@ -382,15 +383,20 @@ func (_c *MockPTYFactory_Open_Call) Run(run func(cmd string, args []string, env
382383 if args[2] != nil {
383384 arg2 = args[2].([]string)
384385 }
385- var arg3 domain.Size
386+ var arg3 string
386387 if args[3] != nil {
387- arg3 = args[3].(domain.Size)
388+ arg3 = args[3].(string)
389+ }
390+ var arg4 domain.Size
391+ if args[4] != nil {
392+ arg4 = args[4].(domain.Size)
388393 }
389394 run(
390395 arg0,
391396 arg1,
392397 arg2,
393398 arg3,
399+ arg4,
394400 )
395401 })
396402 return _c
@@ -401,7 +407,7 @@ func (_c *MockPTYFactory_Open_Call) Return(pTY ports.PTY, err error) *MockPTYFac
401407 return _c
402408 }
403409
404-func (_c *MockPTYFactory_Open_Call) RunAndReturn(run func(cmd string, args []string, env []string, sz domain.Size) (ports.PTY, error)) *MockPTYFactory_Open_Call {
410+func (_c *MockPTYFactory_Open_Call) RunAndReturn(run func(cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error)) *MockPTYFactory_Open_Call {
405411 _c.Call.Return(run)
406412 return _c
407413 }
@@ -327,8 +327,8 @@ func (_m *MockPTYFactory) EXPECT() *MockPTYFactory_Expecter {
327 }327 }
328 328
329 // Open provides a mock function for the type MockPTYFactory329 // Open provides a mock function for the type MockPTYFactory
330-func (_mock *MockPTYFactory) Open(cmd string, args []string, env []string, sz domain.Size) (ports.PTY, error) {330+func (_mock *MockPTYFactory) Open(cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
331- ret := _mock.Called(cmd, args, env, sz)331+ ret := _mock.Called(cmd, args, env, dir, sz)
332 332
333 if len(ret) == 0 {333 if len(ret) == 0 {
334 panic("no return value specified for Open")334 panic("no return value specified for Open")
@@ -336,18 +336,18 @@ func (_mock *MockPTYFactory) Open(cmd string, args []string, env []string, sz do
336 336
337 var r0 ports.PTY337 var r0 ports.PTY
338 var r1 error338 var r1 error
339- if returnFunc, ok := ret.Get(0).(func(string, []string, []string, domain.Size) (ports.PTY, error)); ok {339+ if returnFunc, ok := ret.Get(0).(func(string, []string, []string, string, domain.Size) (ports.PTY, error)); ok {
340- return returnFunc(cmd, args, env, sz)340+ return returnFunc(cmd, args, env, dir, sz)
341 }341 }
342- if returnFunc, ok := ret.Get(0).(func(string, []string, []string, domain.Size) ports.PTY); ok {342+ if returnFunc, ok := ret.Get(0).(func(string, []string, []string, string, domain.Size) ports.PTY); ok {
343- r0 = returnFunc(cmd, args, env, sz)343+ r0 = returnFunc(cmd, args, env, dir, sz)
344 } else {344 } else {
345 if ret.Get(0) != nil {345 if ret.Get(0) != nil {
346 r0 = ret.Get(0).(ports.PTY)346 r0 = ret.Get(0).(ports.PTY)
347 }347 }
348 }348 }
349- if returnFunc, ok := ret.Get(1).(func(string, []string, []string, domain.Size) error); ok {349+ if returnFunc, ok := ret.Get(1).(func(string, []string, []string, string, domain.Size) error); ok {
350- r1 = returnFunc(cmd, args, env, sz)350+ r1 = returnFunc(cmd, args, env, dir, sz)
351 } else {351 } else {
352 r1 = ret.Error(1)352 r1 = ret.Error(1)
353 }353 }
@@ -363,12 +363,13 @@ type MockPTYFactory_Open_Call struct {
363 // - cmd string363 // - cmd string
364 // - args []string364 // - args []string
365 // - env []string365 // - env []string
366+// - dir string
366 // - sz domain.Size367 // - sz domain.Size
367-func (_e *MockPTYFactory_Expecter) Open(cmd any, args any, env any, sz any) *MockPTYFactory_Open_Call {368+func (_e *MockPTYFactory_Expecter) Open(cmd any, args any, env any, dir any, sz any) *MockPTYFactory_Open_Call {
368- return &MockPTYFactory_Open_Call{Call: _e.mock.On("Open", cmd, args, env, sz)}369+ return &MockPTYFactory_Open_Call{Call: _e.mock.On("Open", cmd, args, env, dir, sz)}
369 }370 }
370 371
371-func (_c *MockPTYFactory_Open_Call) Run(run func(cmd string, args []string, env []string, sz domain.Size)) *MockPTYFactory_Open_Call {372+func (_c *MockPTYFactory_Open_Call) Run(run func(cmd string, args []string, env []string, dir string, sz domain.Size)) *MockPTYFactory_Open_Call {
372 _c.Call.Run(func(args mock.Arguments) {373 _c.Call.Run(func(args mock.Arguments) {
373 var arg0 string374 var arg0 string
374 if args[0] != nil {375 if args[0] != nil {
@@ -382,15 +383,20 @@ func (_c *MockPTYFactory_Open_Call) Run(run func(cmd string, args []string, env
382 if args[2] != nil {383 if args[2] != nil {
383 arg2 = args[2].([]string)384 arg2 = args[2].([]string)
384 }385 }
385- var arg3 domain.Size386+ var arg3 string
386 if args[3] != nil {387 if args[3] != nil {
387- arg3 = args[3].(domain.Size)388+ arg3 = args[3].(string)
389+ }
390+ var arg4 domain.Size
391+ if args[4] != nil {
392+ arg4 = args[4].(domain.Size)
388 }393 }
389 run(394 run(
390 arg0,395 arg0,
391 arg1,396 arg1,
392 arg2,397 arg2,
393 arg3,398 arg3,
399+ arg4,
394 )400 )
395 })401 })
396 return _c402 return _c
@@ -401,7 +407,7 @@ func (_c *MockPTYFactory_Open_Call) Return(pTY ports.PTY, err error) *MockPTYFac
401 return _c407 return _c
402 }408 }
403 409
404-func (_c *MockPTYFactory_Open_Call) RunAndReturn(run func(cmd string, args []string, env []string, sz domain.Size) (ports.PTY, error)) *MockPTYFactory_Open_Call {410+func (_c *MockPTYFactory_Open_Call) RunAndReturn(run func(cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error)) *MockPTYFactory_Open_Call {
405 _c.Call.Return(run)411 _c.Call.Return(run)
406 return _c412 return _c
407 }413 }
M internal/ports/ports.go +1 -1
@@ -16,7 +16,7 @@ type PTY interface {
1616
1717 // PTYFactory creates PTYs by spawning a command attached to a new pseudo-terminal.
1818 type PTYFactory interface {
19- Open(cmd string, args []string, env []string, sz domain.Size) (PTY, error)
19+ Open(cmd string, args []string, env []string, dir string, sz domain.Size) (PTY, error)
2020 }
2121
2222 // Terminal is the CLIENT-side controlling terminal.
@@ -16,7 +16,7 @@ type PTY interface {
16 16
17 // PTYFactory creates PTYs by spawning a command attached to a new pseudo-terminal.17 // PTYFactory creates PTYs by spawning a command attached to a new pseudo-terminal.
18 type PTYFactory interface {18 type PTYFactory interface {
19- Open(cmd string, args []string, env []string, sz domain.Size) (PTY, error)19+ Open(cmd string, args []string, env []string, dir string, sz domain.Size) (PTY, error)
20 }20 }
21 21
22 // Terminal is the CLIENT-side controlling terminal.22 // Terminal is the CLIENT-side controlling terminal.
M internal/ports/wire.go +13 -0
@@ -52,6 +52,7 @@ type Hello struct {
5252 Name string
5353 Size domain.Size
5454 TermEnv string
55+ Cwd string
5556 }
5657
5758 // Input carries raw bytes typed/pasted by the client, destined for the PTY.
@@ -193,6 +194,14 @@ func (r *payloadReader) done() error {
193194 return nil
194195 }
195196
197+// PeekHelloVersion returns the leading protocol version from a Hello payload.
198+func PeekHelloVersion(b []byte) (uint16, bool) {
199+ if len(b) < 2 {
200+ return 0, false
201+ }
202+ return binary.BigEndian.Uint16(b), true
203+}
204+
196205 // MarshalHello encodes h into a Hello message payload.
197206 func MarshalHello(h Hello) []byte {
198207 w := payloadWriter{}
@@ -202,6 +211,7 @@ func MarshalHello(h Hello) []byte {
202211 w.putUint16(uint16(h.Size.Cols))
203212 w.putUint16(uint16(h.Size.Rows))
204213 w.putString(h.TermEnv)
214+ w.putString(h.Cwd)
205215 return w.b
206216 }
207217
@@ -232,6 +242,9 @@ func UnmarshalHello(b []byte) (Hello, error) {
232242 if h.TermEnv, err = r.getString(); err != nil {
233243 return Hello{}, err
234244 }
245+ if h.Cwd, err = r.getString(); err != nil {
246+ return Hello{}, err
247+ }
235248 if err := r.done(); err != nil {
236249 return Hello{}, err
237250 }
@@ -52,6 +52,7 @@ type Hello struct {
52 Name string52 Name string
53 Size domain.Size53 Size domain.Size
54 TermEnv string54 TermEnv string
55+ Cwd string
55 }56 }
56 57
57 // Input carries raw bytes typed/pasted by the client, destined for the PTY.58 // Input carries raw bytes typed/pasted by the client, destined for the PTY.
@@ -193,6 +194,14 @@ func (r *payloadReader) done() error {
193 return nil194 return nil
194 }195 }
195 196
197+// PeekHelloVersion returns the leading protocol version from a Hello payload.
198+func PeekHelloVersion(b []byte) (uint16, bool) {
199+ if len(b) < 2 {
200+ return 0, false
201+ }
202+ return binary.BigEndian.Uint16(b), true
203+}
204+
196 // MarshalHello encodes h into a Hello message payload.205 // MarshalHello encodes h into a Hello message payload.
197 func MarshalHello(h Hello) []byte {206 func MarshalHello(h Hello) []byte {
198 w := payloadWriter{}207 w := payloadWriter{}
@@ -202,6 +211,7 @@ func MarshalHello(h Hello) []byte {
202 w.putUint16(uint16(h.Size.Cols))211 w.putUint16(uint16(h.Size.Cols))
203 w.putUint16(uint16(h.Size.Rows))212 w.putUint16(uint16(h.Size.Rows))
204 w.putString(h.TermEnv)213 w.putString(h.TermEnv)
214+ w.putString(h.Cwd)
205 return w.b215 return w.b
206 }216 }
207 217
@@ -232,6 +242,9 @@ func UnmarshalHello(b []byte) (Hello, error) {
232 if h.TermEnv, err = r.getString(); err != nil {242 if h.TermEnv, err = r.getString(); err != nil {
233 return Hello{}, err243 return Hello{}, err
234 }244 }
245+ if h.Cwd, err = r.getString(); err != nil {
246+ return Hello{}, err
247+ }
235 if err := r.done(); err != nil {248 if err := r.done(); err != nil {
236 return Hello{}, err249 return Hello{}, err
237 }250 }
M internal/ports/wire_test.go +4 -2
@@ -64,8 +64,9 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
6464 Name: "w0",
6565 Size: domain.Size{Cols: 80, Rows: 24},
6666 TermEnv: "xterm-256color",
67+ Cwd: "/tmp/project",
6768 },
68- want: []byte{0x00, 0x01, 0x01, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72},
69+ want: []byte{0x00, 0x01, 0x01, 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},
6970 },
7071 {
7172 name: "empty strings",
@@ -75,8 +76,9 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
7576 Name: "",
7677 Size: domain.Size{Cols: 0, Rows: 0},
7778 TermEnv: "",
79+ Cwd: "",
7880 },
79- want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
81+ want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
8082 },
8183 }
8284
@@ -64,8 +64,9 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
64 Name: "w0",64 Name: "w0",
65 Size: domain.Size{Cols: 80, Rows: 24},65 Size: domain.Size{Cols: 80, Rows: 24},
66 TermEnv: "xterm-256color",66 TermEnv: "xterm-256color",
67+ Cwd: "/tmp/project",
67 },68 },
68- want: []byte{0x00, 0x01, 0x01, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72},69+ want: []byte{0x00, 0x01, 0x01, 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},
69 },70 },
70 {71 {
71 name: "empty strings",72 name: "empty strings",
@@ -75,8 +76,9 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
75 Name: "",76 Name: "",
76 Size: domain.Size{Cols: 0, Rows: 0},77 Size: domain.Size{Cols: 0, Rows: 0},
77 TermEnv: "",78 TermEnv: "",
79+ Cwd: "",
78 },80 },
79- want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},81+ want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
80 },82 },
81 }83 }
82 84
M internal/usecase/client/client.go +5 -0
@@ -111,12 +111,17 @@ func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal,
111111 if err != nil {
112112 return fmt.Errorf("vev: reading terminal size: %w", err)
113113 }
114+ cwd, err := os.Getwd()
115+ if err != nil {
116+ cwd = ""
117+ }
114118 hello := ports.Hello{
115119 Version: ports.ProtocolVersion,
116120 Intent: intent,
117121 Name: name,
118122 Size: size,
119123 TermEnv: os.Getenv("TERM"),
124+ Cwd: cwd,
120125 }
121126 if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {
122127 return fmt.Errorf("vev: sending hello: %w", err)
@@ -111,12 +111,17 @@ func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal,
111 if err != nil {111 if err != nil {
112 return fmt.Errorf("vev: reading terminal size: %w", err)112 return fmt.Errorf("vev: reading terminal size: %w", err)
113 }113 }
114+ cwd, err := os.Getwd()
115+ if err != nil {
116+ cwd = ""
117+ }
114 hello := ports.Hello{118 hello := ports.Hello{
115 Version: ports.ProtocolVersion,119 Version: ports.ProtocolVersion,
116 Intent: intent,120 Intent: intent,
117 Name: name,121 Name: name,
118 Size: size,122 Size: size,
119 TermEnv: os.Getenv("TERM"),123 TermEnv: os.Getenv("TERM"),
124+ Cwd: cwd,
120 }125 }
121 if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {126 if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {
122 return fmt.Errorf("vev: sending hello: %w", err)127 return fmt.Errorf("vev: sending hello: %w", err)
M internal/usecase/daemon/daemon.go +118 -6
@@ -134,6 +134,7 @@ type session struct {
134134 tabs []*tab
135135 active int
136136 client *attachedClient
137+ cwd string
137138 }
138139
139140 // tab is one PTY-backed screen. dirty is a cap-1 channel so bursty output
@@ -170,11 +171,13 @@ type attachedClient struct {
170171 copyMu sync.Mutex
171172 copyMode *scopy.Mode
172173 copyPending []byte
174+ copyESC pendingByteTimer
173175 copyFeedback string
174176 pickerMu sync.Mutex
175177 picker *picker.Model
176178 pickerPreview *tab
177179 pickerPending []byte
180+ pickerESC pendingByteTimer
178181 mouseScan mouse.Scanner
179182 lastCursor cursorOut
180183 sendMu sync.Mutex
@@ -189,6 +192,35 @@ type cursorOut struct {
189192 hasStyle bool
190193 }
191194
195+type pendingByteTimer struct {
196+ timer ports.Timer
197+ done chan struct{}
198+}
199+
200+func (p *pendingByteTimer) retain(clock ports.Clock, delay time.Duration, onFire func(ports.Timer)) {
201+ p.timer = clock.NewTimer(delay)
202+ p.done = make(chan struct{})
203+ go func(timer ports.Timer, done <-chan struct{}) {
204+ select {
205+ case <-timer.C():
206+ case <-done:
207+ return
208+ }
209+ onFire(timer)
210+ }(p.timer, p.done)
211+}
212+
213+func (p *pendingByteTimer) stop() {
214+ if p.timer != nil {
215+ p.timer.Stop()
216+ p.timer = nil
217+ }
218+ if p.done != nil {
219+ close(p.done)
220+ p.done = nil
221+ }
222+}
223+
192224 func (ac *attachedClient) currentSession() *session {
193225 ac.sessMu.Lock()
194226 defer ac.sessMu.Unlock()
@@ -472,7 +504,11 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
472504 func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
473505 h, err := ports.UnmarshalHello(f.Payload)
474506 if err != nil {
475- _ = tr.Send(frameError(ports.ErrInternal, "malformed hello"))
507+ if version, ok := ports.PeekHelloVersion(f.Payload); ok && version != ports.ProtocolVersion {
508+ _ = tr.Send(frameError(ports.ErrVersionMismatch, "protocol version mismatch"))
509+ } else {
510+ _ = tr.Send(frameError(ports.ErrInternal, "malformed hello"))
511+ }
476512 _ = tr.Close()
477513 return
478514 }
@@ -530,7 +566,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
530566 switch h.Intent {
531567 case ports.IntentEphemeral:
532568 name := d.allocEphemeralNameLocked()
533- sess, err := d.createSessionLocked(name, true, sz)
569+ sess, err := d.createSessionLocked(name, true, h.Cwd, sz)
534570 d.mu.Unlock()
535571 if err != nil {
536572 return nil, nil, err
@@ -546,7 +582,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
546582 d.mu.Unlock()
547583 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}
548584 }
549- sess, err := d.createSessionLocked(h.Name, false, sz)
585+ sess, err := d.createSessionLocked(h.Name, false, h.Cwd, sz)
550586 d.mu.Unlock()
551587 if err != nil {
552588 return nil, nil, err
@@ -569,9 +605,9 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
569605
570606 // createSessionLocked spawns a PTY-backed session and starts its reader and
571607 // scheduler goroutines. Caller holds d.mu.
572-func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size) (*session, error) {
608+func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size) (*session, error) {
573609 tbSize := tabSize(sz)
574- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), tbSize)
610+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize)
575611 if err != nil {
576612 return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)
577613 }
@@ -589,6 +625,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size
589625 ctx: sctx,
590626 cancel: cancel,
591627 tabs: []*tab{tb},
628+ cwd: cwd,
592629 }
593630 d.sessions[id] = sess
594631 d.startTabGoroutines(sess, tb)
@@ -597,7 +634,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size
597634
598635 func (d *Daemon) createTab(sess *session, sz domain.Size) error {
599636 tbSize := tabSize(sz)
600- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(sess.name), tbSize)
637+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(sess.name), sess.cwd, tbSize)
601638 if err != nil {
602639 return fmt.Errorf("daemon: spawning tab for session %q: %w", sess.name, err)
603640 }
@@ -894,11 +931,13 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
894931 ac.copyMu.Lock()
895932 if ac.copyMode == nil {
896933 ac.copyPending = nil
934+ d.stopCopyPendingTimerLocked(ac)
897935 ac.copyMu.Unlock()
898936 tb.mu.Unlock()
899937 return
900938 }
901939 if len(ac.copyPending) > 0 {
940+ d.stopCopyPendingTimerLocked(ac)
902941 combined := make([]byte, 0, len(ac.copyPending)+len(data))
903942 combined = append(combined, ac.copyPending...)
904943 combined = append(combined, data...)
@@ -938,6 +977,10 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
938977 changed = true
939978 continue
940979 }
980+ if len(tail) == 1 {
981+ d.retainCopyESCLocked(ac)
982+ break
983+ }
941984 if isCopyEscapePrefix(tail) {
942985 ac.copyPending = append(ac.copyPending[:0], tail...)
943986 break
@@ -951,6 +994,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
951994 text = ac.copyMode.SelectedText(snap)
952995 }
953996 if exit {
997+ d.stopCopyPendingTimerLocked(ac)
954998 ac.copyMode = nil
955999 }
9561000 ac.copyMu.Unlock()
@@ -981,6 +1025,35 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
9811025 }
9821026 }
9831027
1028+func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
1029+ mode := ac.copyMode
1030+ ac.copyPending = append(ac.copyPending[:0], keys.ESC)
1031+ ac.copyESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
1032+ ac.copyMu.Lock()
1033+ if ac.copyESC.timer != timer {
1034+ ac.copyMu.Unlock()
1035+ return
1036+ }
1037+ ac.copyPending = nil
1038+ ac.copyESC.timer = nil
1039+ ac.copyESC.done = nil
1040+ if ac.copyMode != mode || ac.copyMode == nil {
1041+ ac.copyMu.Unlock()
1042+ return
1043+ }
1044+ ac.copyMode = nil
1045+ ac.copyMu.Unlock()
1046+
1047+ if sess := ac.currentSession(); sess != nil {
1048+ d.paint(sess, ac, true)
1049+ }
1050+ })
1051+}
1052+
1053+func (d *Daemon) stopCopyPendingTimerLocked(ac *attachedClient) {
1054+ ac.copyESC.stop()
1055+}
1056+
9841057 func routeCopyEscape(m *scopy.Mode, snap scopy.Snapshot, data []byte) (int, bool) {
9851058 if len(data) >= 3 && data[1] == '[' {
9861059 switch data[2] {
@@ -1055,10 +1128,12 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
10551128 ac.pickerMu.Lock()
10561129 if ac.picker == nil {
10571130 ac.pickerPending = nil
1131+ d.stopPickerPendingTimerLocked(ac)
10581132 ac.pickerMu.Unlock()
10591133 return
10601134 }
10611135 if len(ac.pickerPending) > 0 {
1136+ d.stopPickerPendingTimerLocked(ac)
10621137 combined := make([]byte, 0, len(ac.pickerPending)+len(data))
10631138 combined = append(combined, ac.pickerPending...)
10641139 combined = append(combined, data...)
@@ -1088,6 +1163,10 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
10881163 changed = true
10891164 continue
10901165 }
1166+ if len(tail) == 1 {
1167+ d.retainPickerESCLocked(ac)
1168+ break
1169+ }
10911170 if isPickerEscapePrefix(tail) {
10921171 ac.pickerPending = append(ac.pickerPending[:0], tail...)
10931172 break
@@ -1118,6 +1197,31 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
11181197 }
11191198 }
11201199
1200+func (d *Daemon) retainPickerESCLocked(ac *attachedClient) {
1201+ ac.pickerPending = append(ac.pickerPending[:0], keys.ESC)
1202+ ac.pickerESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
1203+ ac.pickerMu.Lock()
1204+ if ac.pickerESC.timer != timer || len(ac.pickerPending) != 1 || ac.pickerPending[0] != keys.ESC || ac.picker == nil {
1205+ ac.pickerMu.Unlock()
1206+ return
1207+ }
1208+ ac.pickerPending = nil
1209+ ac.pickerESC.timer = nil
1210+ ac.pickerESC.done = nil
1211+ ac.picker = nil
1212+ ac.pickerMu.Unlock()
1213+
1214+ d.unregisterPreview(ac)
1215+ if sess := ac.currentSession(); sess != nil {
1216+ d.paint(sess, ac, true)
1217+ }
1218+ })
1219+}
1220+
1221+func (d *Daemon) stopPickerPendingTimerLocked(ac *attachedClient) {
1222+ ac.pickerESC.stop()
1223+}
1224+
11211225 func routePickerEscape(m *picker.Model, data []byte) (int, bool) {
11221226 if len(data) >= 3 && data[1] == '[' {
11231227 switch data[2] {
@@ -1205,9 +1309,11 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
12051309 if previewer == nil {
12061310 return
12071311 }
1312+ cleared := false
12081313 previewer.pickerMu.Lock()
12091314 if previewer.pickerPreview == tb {
12101315 previewer.pickerPreview = nil
1316+ cleared = true
12111317 }
12121318 previewer.pickerMu.Unlock()
12131319 tb.mu.Lock()
@@ -1215,12 +1321,18 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
12151321 tb.previewClient = nil
12161322 }
12171323 tb.mu.Unlock()
1324+ if cleared {
1325+ if sess := previewer.currentSession(); sess != nil {
1326+ d.paint(sess, previewer, true)
1327+ }
1328+ }
12181329 }
12191330
12201331 func (d *Daemon) closePicker(ac *attachedClient) {
12211332 ac.pickerMu.Lock()
12221333 ac.picker = nil
12231334 ac.pickerPending = nil
1335+ d.stopPickerPendingTimerLocked(ac)
12241336 ac.pickerMu.Unlock()
12251337 d.unregisterPreview(ac)
12261338 }
@@ -134,6 +134,7 @@ type session struct {
134 tabs []*tab134 tabs []*tab
135 active int135 active int
136 client *attachedClient136 client *attachedClient
137+ cwd string
137 }138 }
138 139
139 // tab is one PTY-backed screen. dirty is a cap-1 channel so bursty output140 // tab is one PTY-backed screen. dirty is a cap-1 channel so bursty output
@@ -170,11 +171,13 @@ type attachedClient struct {
170 copyMu sync.Mutex171 copyMu sync.Mutex
171 copyMode *scopy.Mode172 copyMode *scopy.Mode
172 copyPending []byte173 copyPending []byte
174+ copyESC pendingByteTimer
173 copyFeedback string175 copyFeedback string
174 pickerMu sync.Mutex176 pickerMu sync.Mutex
175 picker *picker.Model177 picker *picker.Model
176 pickerPreview *tab178 pickerPreview *tab
177 pickerPending []byte179 pickerPending []byte
180+ pickerESC pendingByteTimer
178 mouseScan mouse.Scanner181 mouseScan mouse.Scanner
179 lastCursor cursorOut182 lastCursor cursorOut
180 sendMu sync.Mutex183 sendMu sync.Mutex
@@ -189,6 +192,35 @@ type cursorOut struct {
189 hasStyle bool192 hasStyle bool
190 }193 }
191 194
195+type pendingByteTimer struct {
196+ timer ports.Timer
197+ done chan struct{}
198+}
199+
200+func (p *pendingByteTimer) retain(clock ports.Clock, delay time.Duration, onFire func(ports.Timer)) {
201+ p.timer = clock.NewTimer(delay)
202+ p.done = make(chan struct{})
203+ go func(timer ports.Timer, done <-chan struct{}) {
204+ select {
205+ case <-timer.C():
206+ case <-done:
207+ return
208+ }
209+ onFire(timer)
210+ }(p.timer, p.done)
211+}
212+
213+func (p *pendingByteTimer) stop() {
214+ if p.timer != nil {
215+ p.timer.Stop()
216+ p.timer = nil
217+ }
218+ if p.done != nil {
219+ close(p.done)
220+ p.done = nil
221+ }
222+}
223+
192 func (ac *attachedClient) currentSession() *session {224 func (ac *attachedClient) currentSession() *session {
193 ac.sessMu.Lock()225 ac.sessMu.Lock()
194 defer ac.sessMu.Unlock()226 defer ac.sessMu.Unlock()
@@ -472,7 +504,11 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
472 func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {504 func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
473 h, err := ports.UnmarshalHello(f.Payload)505 h, err := ports.UnmarshalHello(f.Payload)
474 if err != nil {506 if err != nil {
475- _ = tr.Send(frameError(ports.ErrInternal, "malformed hello"))507+ if version, ok := ports.PeekHelloVersion(f.Payload); ok && version != ports.ProtocolVersion {
508+ _ = tr.Send(frameError(ports.ErrVersionMismatch, "protocol version mismatch"))
509+ } else {
510+ _ = tr.Send(frameError(ports.ErrInternal, "malformed hello"))
511+ }
476 _ = tr.Close()512 _ = tr.Close()
477 return513 return
478 }514 }
@@ -530,7 +566,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
530 switch h.Intent {566 switch h.Intent {
531 case ports.IntentEphemeral:567 case ports.IntentEphemeral:
532 name := d.allocEphemeralNameLocked()568 name := d.allocEphemeralNameLocked()
533- sess, err := d.createSessionLocked(name, true, sz)569+ sess, err := d.createSessionLocked(name, true, h.Cwd, sz)
534 d.mu.Unlock()570 d.mu.Unlock()
535 if err != nil {571 if err != nil {
536 return nil, nil, err572 return nil, nil, err
@@ -546,7 +582,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
546 d.mu.Unlock()582 d.mu.Unlock()
547 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}583 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}
548 }584 }
549- sess, err := d.createSessionLocked(h.Name, false, sz)585+ sess, err := d.createSessionLocked(h.Name, false, h.Cwd, sz)
550 d.mu.Unlock()586 d.mu.Unlock()
551 if err != nil {587 if err != nil {
552 return nil, nil, err588 return nil, nil, err
@@ -569,9 +605,9 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
569 605
570 // createSessionLocked spawns a PTY-backed session and starts its reader and606 // createSessionLocked spawns a PTY-backed session and starts its reader and
571 // scheduler goroutines. Caller holds d.mu.607 // scheduler goroutines. Caller holds d.mu.
572-func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size) (*session, error) {608+func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size) (*session, error) {
573 tbSize := tabSize(sz)609 tbSize := tabSize(sz)
574- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), tbSize)610+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize)
575 if err != nil {611 if err != nil {
576 return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)612 return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)
577 }613 }
@@ -589,6 +625,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size
589 ctx: sctx,625 ctx: sctx,
590 cancel: cancel,626 cancel: cancel,
591 tabs: []*tab{tb},627 tabs: []*tab{tb},
628+ cwd: cwd,
592 }629 }
593 d.sessions[id] = sess630 d.sessions[id] = sess
594 d.startTabGoroutines(sess, tb)631 d.startTabGoroutines(sess, tb)
@@ -597,7 +634,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size
597 634
598 func (d *Daemon) createTab(sess *session, sz domain.Size) error {635 func (d *Daemon) createTab(sess *session, sz domain.Size) error {
599 tbSize := tabSize(sz)636 tbSize := tabSize(sz)
600- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(sess.name), tbSize)637+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(sess.name), sess.cwd, tbSize)
601 if err != nil {638 if err != nil {
602 return fmt.Errorf("daemon: spawning tab for session %q: %w", sess.name, err)639 return fmt.Errorf("daemon: spawning tab for session %q: %w", sess.name, err)
603 }640 }
@@ -894,11 +931,13 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
894 ac.copyMu.Lock()931 ac.copyMu.Lock()
895 if ac.copyMode == nil {932 if ac.copyMode == nil {
896 ac.copyPending = nil933 ac.copyPending = nil
934+ d.stopCopyPendingTimerLocked(ac)
897 ac.copyMu.Unlock()935 ac.copyMu.Unlock()
898 tb.mu.Unlock()936 tb.mu.Unlock()
899 return937 return
900 }938 }
901 if len(ac.copyPending) > 0 {939 if len(ac.copyPending) > 0 {
940+ d.stopCopyPendingTimerLocked(ac)
902 combined := make([]byte, 0, len(ac.copyPending)+len(data))941 combined := make([]byte, 0, len(ac.copyPending)+len(data))
903 combined = append(combined, ac.copyPending...)942 combined = append(combined, ac.copyPending...)
904 combined = append(combined, data...)943 combined = append(combined, data...)
@@ -938,6 +977,10 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
938 changed = true977 changed = true
939 continue978 continue
940 }979 }
980+ if len(tail) == 1 {
981+ d.retainCopyESCLocked(ac)
982+ break
983+ }
941 if isCopyEscapePrefix(tail) {984 if isCopyEscapePrefix(tail) {
942 ac.copyPending = append(ac.copyPending[:0], tail...)985 ac.copyPending = append(ac.copyPending[:0], tail...)
943 break986 break
@@ -951,6 +994,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
951 text = ac.copyMode.SelectedText(snap)994 text = ac.copyMode.SelectedText(snap)
952 }995 }
953 if exit {996 if exit {
997+ d.stopCopyPendingTimerLocked(ac)
954 ac.copyMode = nil998 ac.copyMode = nil
955 }999 }
956 ac.copyMu.Unlock()1000 ac.copyMu.Unlock()
@@ -981,6 +1025,35 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
981 }1025 }
982 }1026 }
983 1027
1028+func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
1029+ mode := ac.copyMode
1030+ ac.copyPending = append(ac.copyPending[:0], keys.ESC)
1031+ ac.copyESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
1032+ ac.copyMu.Lock()
1033+ if ac.copyESC.timer != timer {
1034+ ac.copyMu.Unlock()
1035+ return
1036+ }
1037+ ac.copyPending = nil
1038+ ac.copyESC.timer = nil
1039+ ac.copyESC.done = nil
1040+ if ac.copyMode != mode || ac.copyMode == nil {
1041+ ac.copyMu.Unlock()
1042+ return
1043+ }
1044+ ac.copyMode = nil
1045+ ac.copyMu.Unlock()
1046+
1047+ if sess := ac.currentSession(); sess != nil {
1048+ d.paint(sess, ac, true)
1049+ }
1050+ })
1051+}
1052+
1053+func (d *Daemon) stopCopyPendingTimerLocked(ac *attachedClient) {
1054+ ac.copyESC.stop()
1055+}
1056+
984 func routeCopyEscape(m *scopy.Mode, snap scopy.Snapshot, data []byte) (int, bool) {1057 func routeCopyEscape(m *scopy.Mode, snap scopy.Snapshot, data []byte) (int, bool) {
985 if len(data) >= 3 && data[1] == '[' {1058 if len(data) >= 3 && data[1] == '[' {
986 switch data[2] {1059 switch data[2] {
@@ -1055,10 +1128,12 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
1055 ac.pickerMu.Lock()1128 ac.pickerMu.Lock()
1056 if ac.picker == nil {1129 if ac.picker == nil {
1057 ac.pickerPending = nil1130 ac.pickerPending = nil
1131+ d.stopPickerPendingTimerLocked(ac)
1058 ac.pickerMu.Unlock()1132 ac.pickerMu.Unlock()
1059 return1133 return
1060 }1134 }
1061 if len(ac.pickerPending) > 0 {1135 if len(ac.pickerPending) > 0 {
1136+ d.stopPickerPendingTimerLocked(ac)
1062 combined := make([]byte, 0, len(ac.pickerPending)+len(data))1137 combined := make([]byte, 0, len(ac.pickerPending)+len(data))
1063 combined = append(combined, ac.pickerPending...)1138 combined = append(combined, ac.pickerPending...)
1064 combined = append(combined, data...)1139 combined = append(combined, data...)
@@ -1088,6 +1163,10 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
1088 changed = true1163 changed = true
1089 continue1164 continue
1090 }1165 }
1166+ if len(tail) == 1 {
1167+ d.retainPickerESCLocked(ac)
1168+ break
1169+ }
1091 if isPickerEscapePrefix(tail) {1170 if isPickerEscapePrefix(tail) {
1092 ac.pickerPending = append(ac.pickerPending[:0], tail...)1171 ac.pickerPending = append(ac.pickerPending[:0], tail...)
1093 break1172 break
@@ -1118,6 +1197,31 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
1118 }1197 }
1119 }1198 }
1120 1199
1200+func (d *Daemon) retainPickerESCLocked(ac *attachedClient) {
1201+ ac.pickerPending = append(ac.pickerPending[:0], keys.ESC)
1202+ ac.pickerESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
1203+ ac.pickerMu.Lock()
1204+ if ac.pickerESC.timer != timer || len(ac.pickerPending) != 1 || ac.pickerPending[0] != keys.ESC || ac.picker == nil {
1205+ ac.pickerMu.Unlock()
1206+ return
1207+ }
1208+ ac.pickerPending = nil
1209+ ac.pickerESC.timer = nil
1210+ ac.pickerESC.done = nil
1211+ ac.picker = nil
1212+ ac.pickerMu.Unlock()
1213+
1214+ d.unregisterPreview(ac)
1215+ if sess := ac.currentSession(); sess != nil {
1216+ d.paint(sess, ac, true)
1217+ }
1218+ })
1219+}
1220+
1221+func (d *Daemon) stopPickerPendingTimerLocked(ac *attachedClient) {
1222+ ac.pickerESC.stop()
1223+}
1224+
1121 func routePickerEscape(m *picker.Model, data []byte) (int, bool) {1225 func routePickerEscape(m *picker.Model, data []byte) (int, bool) {
1122 if len(data) >= 3 && data[1] == '[' {1226 if len(data) >= 3 && data[1] == '[' {
1123 switch data[2] {1227 switch data[2] {
@@ -1205,9 +1309,11 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
1205 if previewer == nil {1309 if previewer == nil {
1206 return1310 return
1207 }1311 }
1312+ cleared := false
1208 previewer.pickerMu.Lock()1313 previewer.pickerMu.Lock()
1209 if previewer.pickerPreview == tb {1314 if previewer.pickerPreview == tb {
1210 previewer.pickerPreview = nil1315 previewer.pickerPreview = nil
1316+ cleared = true
1211 }1317 }
1212 previewer.pickerMu.Unlock()1318 previewer.pickerMu.Unlock()
1213 tb.mu.Lock()1319 tb.mu.Lock()
@@ -1215,12 +1321,18 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
1215 tb.previewClient = nil1321 tb.previewClient = nil
1216 }1322 }
1217 tb.mu.Unlock()1323 tb.mu.Unlock()
1324+ if cleared {
1325+ if sess := previewer.currentSession(); sess != nil {
1326+ d.paint(sess, previewer, true)
1327+ }
1328+ }
1218 }1329 }
1219 1330
1220 func (d *Daemon) closePicker(ac *attachedClient) {1331 func (d *Daemon) closePicker(ac *attachedClient) {
1221 ac.pickerMu.Lock()1332 ac.pickerMu.Lock()
1222 ac.picker = nil1333 ac.picker = nil
1223 ac.pickerPending = nil1334 ac.pickerPending = nil
1335+ d.stopPickerPendingTimerLocked(ac)
1224 ac.pickerMu.Unlock()1336 ac.pickerMu.Unlock()
1225 d.unregisterPreview(ac)1337 d.unregisterPreview(ac)
1226 }1338 }
M internal/usecase/daemon/daemon_test.go +180 -18
@@ -114,7 +114,7 @@ func newBlockingPTYWithWrites(t *testing.T, writes chan<- []byte) (*portsmocks.M
114114 func newFactory(t *testing.T, p ports.PTY) *portsmocks.MockPTYFactory {
115115 t.Helper()
116116 f := portsmocks.NewMockPTYFactory(t)
117- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p, nil).Maybe()
117+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p, nil).Maybe()
118118 return f
119119 }
120120
@@ -123,8 +123,8 @@ func newFactorySeq(t *testing.T, ptys ...ports.PTY) *portsmocks.MockPTYFactory {
123123 f := portsmocks.NewMockPTYFactory(t)
124124 var mu sync.Mutex
125125 next := 0
126- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
127- func(string, []string, []string, domain.Size) (ports.PTY, error) {
126+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
127+ func(string, []string, []string, string, domain.Size) (ports.PTY, error) {
128128 mu.Lock()
129129 defer mu.Unlock()
130130 if next >= len(ptys) {
@@ -176,6 +176,45 @@ func mustHello(intent uint8, name string, sz domain.Size) ports.Frame {
176176 })}
177177 }
178178
179+func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
180+ sz := domain.Size{Cols: 80, Rows: 24}
181+ first, releaseFirst := newBlockingPTY(t)
182+ defer releaseFirst()
183+ second, releaseSecond := newBlockingPTY(t)
184+ defer releaseSecond()
185+ f := portsmocks.NewMockPTYFactory(t)
186+ var dirs []string
187+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
188+ func(_ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) {
189+ dirs = append(dirs, dir)
190+ if len(dirs) == 1 {
191+ return first, nil
192+ }
193+ return second, nil
194+ },
195+ ).Twice()
196+
197+ d := newTestDaemon(t, f, stubClock{})
198+ tr := portsmocks.NewMockTransport(t)
199+ tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
200+ tr.EXPECT().Close().Return(nil).Maybe()
201+
202+ sess, ac, err := d.route(ports.Hello{
203+ Version: ports.ProtocolVersion,
204+ Intent: ports.IntentNew,
205+ Name: "work",
206+ Size: sz,
207+ TermEnv: "xterm-256color",
208+ Cwd: "/tmp/work",
209+ }, tr)
210+ require.NoError(t, err)
211+ require.NotNil(t, ac)
212+ require.Equal(t, "/tmp/work", sess.cwd)
213+
214+ require.NoError(t, d.createTab(sess, sz))
215+ require.Equal(t, []string{"/tmp/work", "/tmp/work"}, dirs)
216+}
217+
179218 func frameInput(data []byte) ports.Frame {
180219 return ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: data})}
181220 }
@@ -481,14 +520,14 @@ func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {
481520
482521 func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
483522 cases := []struct {
484- name string
485- firstInput []byte
486- secondInput []byte
487- wantActive bool
523+ name string
524+ input [][]byte
525+ wantCursor int
488526 }{
489- {name: "split up arrow", firstInput: []byte("\x1b["), secondInput: []byte("A"), wantActive: true},
490- {name: "split page down", firstInput: []byte("\x1b[6"), secondInput: []byte("~"), wantActive: true},
491- {name: "lone escape exits", firstInput: []byte("\x1b"), wantActive: false},
527+ {name: "escape then up arrow", input: [][]byte{[]byte("\x1b"), []byte("[A")}, wantCursor: 23},
528+ {name: "escape then down arrow", input: [][]byte{[]byte("g"), []byte("\x1b"), []byte("[B")}, wantCursor: 1},
529+ {name: "split up arrow", input: [][]byte{[]byte("\x1b["), []byte("A")}, wantCursor: 23},
530+ {name: "split page down", input: [][]byte{[]byte("g"), []byte("\x1b[6"), []byte("~")}, wantCursor: 23},
492531 }
493532 for _, tc := range cases {
494533 t.Run(tc.name, func(t *testing.T) {
@@ -501,14 +540,12 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
501540
502541 d.enterCopyMode(sess, ac)
503542 awaitFrame(t, sends, ports.MsgOutput)
504- d.handleInput(sess, ac, tc.firstInput)
505- if tc.secondInput != nil {
506- d.handleInput(sess, ac, tc.secondInput)
543+ for _, input := range tc.input {
544+ d.handleInput(sess, ac, input)
507545 }
508546
509- if gotActive := ac.copyMode != nil; gotActive != tc.wantActive {
510- t.Fatalf("copy mode active = %v, want %v", gotActive, tc.wantActive)
511- }
547+ require.NotNil(t, ac.copyMode)
548+ require.Equal(t, tc.wantCursor, ac.copyMode.Cursor)
512549 })
513550 }
514551 }
@@ -536,6 +573,57 @@ func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) {
536573 }
537574 }
538575
576+func TestCopyModeLoneEscapeExitsAfterDelay(t *testing.T) {
577+ p, _ := newBlockingPTY(t)
578+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
579+ clk := &signalClock{timers: make(chan *signalTimer, 1)}
580+ d.clock = clk
581+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
582+ sess.tabs[0].screen.Write([]byte("live"))
583+
584+ d.enterCopyMode(sess, ac)
585+ awaitFrame(t, sends, ports.MsgOutput)
586+ d.handleInput(sess, ac, []byte("\x1b"))
587+ timer := <-clk.timers
588+ ac.copyMu.Lock()
589+ require.NotNil(t, ac.copyMode)
590+ ac.copyMu.Unlock()
591+ timer.ch <- time.Now()
592+ require.Eventually(t, func() bool {
593+ ac.copyMu.Lock()
594+ defer ac.copyMu.Unlock()
595+ return ac.copyMode == nil
596+ }, time.Second, 5*time.Millisecond)
597+}
598+
599+func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) {
600+ p, _ := newBlockingPTY(t)
601+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
602+ clk := &signalClock{timers: make(chan *signalTimer, 1)}
603+ d.clock = clk
604+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
605+ sess.tabs[0].screen.Write([]byte("live"))
606+
607+ d.enterCopyMode(sess, ac)
608+ awaitFrame(t, sends, ports.MsgOutput)
609+ d.handleInput(sess, ac, []byte("\x1b"))
610+ timer := <-clk.timers
611+ d.enterCopyMode(sess, ac)
612+ awaitFrame(t, sends, ports.MsgOutput)
613+
614+ timer.ch <- time.Now()
615+ require.Never(t, func() bool {
616+ ac.copyMu.Lock()
617+ defer ac.copyMu.Unlock()
618+ return ac.copyMode == nil
619+ }, 50*time.Millisecond, 5*time.Millisecond)
620+ ac.copyMu.Lock()
621+ require.Nil(t, ac.copyESC.timer)
622+ require.Nil(t, ac.copyESC.done)
623+ require.Empty(t, ac.copyPending)
624+ ac.copyMu.Unlock()
625+}
626+
539627 func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
540628 cases := []struct {
541629 name string
@@ -676,6 +764,26 @@ func TestHandshakeVersionMismatch(t *testing.T) {
676764 require.Equal(t, 0, sessionCount(d))
677765 }
678766
767+func TestHandshakeOldHelloLayoutReportsVersionMismatch(t *testing.T) {
768+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
769+ oldLayout := []byte{
770+ 0x00, byte(ports.ProtocolVersion - 1),
771+ ports.IntentEphemeral,
772+ 0x00, 0x00,
773+ 0x00, 0x50,
774+ 0x00, 0x18,
775+ 0x00, 0x00,
776+ }
777+ tr, sends, _ := newConn(t, ports.Frame{Type: ports.MsgHello, Payload: oldLayout})
778+ d.handleConn(tr)
779+
780+ e := awaitFrame(t, sends, ports.MsgError)
781+ em, err := ports.UnmarshalErrorMsg(e.Payload)
782+ require.NoError(t, err)
783+ require.Equal(t, ports.ErrVersionMismatch, em.Code)
784+ require.Equal(t, 0, sessionCount(d))
785+}
786+
679787 func TestHandshakeNameTaken(t *testing.T) {
680788 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
681789 // Pre-populate a bare session (no goroutines) to collide with.
@@ -1042,9 +1150,60 @@ func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
10421150 awaitFrame(t, sends, ports.MsgOutput)
10431151 d.handleInput(sess, ac, []byte("\x1bt"))
10441152 awaitFrame(t, sends, ports.MsgOutput)
1153+ clk := &signalClock{timers: make(chan *signalTimer, 1)}
1154+ d.clock = clk
10451155 d.handleInput(sess, ac, []byte("\x1b"))
1046- require.False(t, ac.pickerActive())
1156+ timer := <-clk.timers
1157+ require.True(t, ac.pickerActive())
1158+ timer.ch <- time.Now()
1159+ require.Eventually(t, func() bool { return !ac.pickerActive() }, time.Second, 5*time.Millisecond)
1160+ awaitFrame(t, sends, ports.MsgOutput)
1161+}
1162+
1163+func TestPickerSplitArrowNavigatesWithoutExiting(t *testing.T) {
1164+ cases := []struct {
1165+ name string
1166+ input [][]byte
1167+ wantActive int
1168+ }{
1169+ {name: "escape then down arrow", input: [][]byte{[]byte("\x1b"), []byte("[B")}, wantActive: 1},
1170+ {name: "escape then up arrow", input: [][]byte{[]byte("j"), []byte("\x1b"), []byte("[A")}, wantActive: 0},
1171+ {name: "split down arrow", input: [][]byte{[]byte("\x1b["), []byte("B")}, wantActive: 1},
1172+ }
1173+ for _, tc := range cases {
1174+ t.Run(tc.name, func(t *testing.T) {
1175+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
1176+ defer func() {
1177+ for _, release := range releases {
1178+ release()
1179+ }
1180+ }()
1181+
1182+ d.handleInput(sess, ac, []byte("\x1bt"))
1183+ awaitFrame(t, sends, ports.MsgOutput)
1184+ for _, input := range tc.input {
1185+ d.handleInput(sess, ac, input)
1186+ }
1187+ require.True(t, ac.pickerActive())
1188+ d.handleInput(sess, ac, []byte("\r"))
1189+ require.Equal(t, tc.wantActive, activeTabIndex(sess))
1190+ })
1191+ }
1192+}
1193+
1194+func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) {
1195+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
1196+ defer releases[0]()
1197+
1198+ d.handleInput(sess, ac, []byte("\x1bt"))
10471199 awaitFrame(t, sends, ports.MsgOutput)
1200+ clk := &signalClock{timers: make(chan *signalTimer, 1)}
1201+ d.clock = clk
1202+ d.handleInput(sess, ac, []byte("\x1b"))
1203+ timer := <-clk.timers
1204+ require.True(t, ac.pickerActive())
1205+ timer.ch <- time.Now()
1206+ require.Eventually(t, func() bool { return !ac.pickerActive() }, time.Second, 5*time.Millisecond)
10481207 }
10491208
10501209 func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
@@ -1408,6 +1567,7 @@ func TestSchedulerDebounceCoalesces(t *testing.T) {
14081567 var outputs atomic.Int32
14091568 gotOutput := make(chan struct{}, 1)
14101569 tr := portsmocks.NewMockTransport(t)
1570+ tr.EXPECT().Close().Return(nil).Maybe()
14111571 tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {
14121572 if f.Type == ports.MsgOutput {
14131573 outputs.Add(1)
@@ -1484,6 +1644,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
14841644
14851645 var gotOutput atomic.Bool
14861646 tr := portsmocks.NewMockTransport(t)
1647+ tr.EXPECT().Close().Return(nil).Maybe()
14871648 tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {
14881649 if f.Type == ports.MsgOutput {
14891650 gotOutput.Store(true)
@@ -1635,7 +1796,7 @@ func TestHelloRacingShutdownIsRejected(t *testing.T) {
16351796 f := portsmocks.NewMockPTYFactory(t)
16361797 // Exactly one child may ever be spawned; a second Open call (a leaked
16371798 // child) fails the test via mock expectations.
1638- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p, nil).Once()
1799+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p, nil).Once()
16391800 d := newTestDaemon(t, f, stubClock{})
16401801
16411802 tr1, sends1, release1 := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
@@ -1814,6 +1975,7 @@ func TestSchedulerDefersPendingDirtyTimerDuringSynchronizedUpdate(t *testing.T)
18141975 var outputs atomic.Int32
18151976 gotOutput := make(chan struct{}, 1)
18161977 tr := portsmocks.NewMockTransport(t)
1978+ tr.EXPECT().Close().Return(nil).Maybe()
18171979 tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {
18181980 if f.Type == ports.MsgOutput {
18191981 outputs.Add(1)
@@ -114,7 +114,7 @@ func newBlockingPTYWithWrites(t *testing.T, writes chan<- []byte) (*portsmocks.M
114 func newFactory(t *testing.T, p ports.PTY) *portsmocks.MockPTYFactory {114 func newFactory(t *testing.T, p ports.PTY) *portsmocks.MockPTYFactory {
115 t.Helper()115 t.Helper()
116 f := portsmocks.NewMockPTYFactory(t)116 f := portsmocks.NewMockPTYFactory(t)
117- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p, nil).Maybe()117+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p, nil).Maybe()
118 return f118 return f
119 }119 }
120 120
@@ -123,8 +123,8 @@ func newFactorySeq(t *testing.T, ptys ...ports.PTY) *portsmocks.MockPTYFactory {
123 f := portsmocks.NewMockPTYFactory(t)123 f := portsmocks.NewMockPTYFactory(t)
124 var mu sync.Mutex124 var mu sync.Mutex
125 next := 0125 next := 0
126- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(126+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
127- func(string, []string, []string, domain.Size) (ports.PTY, error) {127+ func(string, []string, []string, string, domain.Size) (ports.PTY, error) {
128 mu.Lock()128 mu.Lock()
129 defer mu.Unlock()129 defer mu.Unlock()
130 if next >= len(ptys) {130 if next >= len(ptys) {
@@ -176,6 +176,45 @@ func mustHello(intent uint8, name string, sz domain.Size) ports.Frame {
176 })}176 })}
177 }177 }
178 178
179+func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
180+ sz := domain.Size{Cols: 80, Rows: 24}
181+ first, releaseFirst := newBlockingPTY(t)
182+ defer releaseFirst()
183+ second, releaseSecond := newBlockingPTY(t)
184+ defer releaseSecond()
185+ f := portsmocks.NewMockPTYFactory(t)
186+ var dirs []string
187+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
188+ func(_ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) {
189+ dirs = append(dirs, dir)
190+ if len(dirs) == 1 {
191+ return first, nil
192+ }
193+ return second, nil
194+ },
195+ ).Twice()
196+
197+ d := newTestDaemon(t, f, stubClock{})
198+ tr := portsmocks.NewMockTransport(t)
199+ tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
200+ tr.EXPECT().Close().Return(nil).Maybe()
201+
202+ sess, ac, err := d.route(ports.Hello{
203+ Version: ports.ProtocolVersion,
204+ Intent: ports.IntentNew,
205+ Name: "work",
206+ Size: sz,
207+ TermEnv: "xterm-256color",
208+ Cwd: "/tmp/work",
209+ }, tr)
210+ require.NoError(t, err)
211+ require.NotNil(t, ac)
212+ require.Equal(t, "/tmp/work", sess.cwd)
213+
214+ require.NoError(t, d.createTab(sess, sz))
215+ require.Equal(t, []string{"/tmp/work", "/tmp/work"}, dirs)
216+}
217+
179 func frameInput(data []byte) ports.Frame {218 func frameInput(data []byte) ports.Frame {
180 return ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: data})}219 return ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: data})}
181 }220 }
@@ -481,14 +520,14 @@ func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {
481 520
482 func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {521 func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
483 cases := []struct {522 cases := []struct {
484- name string523+ name string
485- firstInput []byte524+ input [][]byte
486- secondInput []byte525+ wantCursor int
487- wantActive bool
488 }{526 }{
489- {name: "split up arrow", firstInput: []byte("\x1b["), secondInput: []byte("A"), wantActive: true},527+ {name: "escape then up arrow", input: [][]byte{[]byte("\x1b"), []byte("[A")}, wantCursor: 23},
490- {name: "split page down", firstInput: []byte("\x1b[6"), secondInput: []byte("~"), wantActive: true},528+ {name: "escape then down arrow", input: [][]byte{[]byte("g"), []byte("\x1b"), []byte("[B")}, wantCursor: 1},
491- {name: "lone escape exits", firstInput: []byte("\x1b"), wantActive: false},529+ {name: "split up arrow", input: [][]byte{[]byte("\x1b["), []byte("A")}, wantCursor: 23},
530+ {name: "split page down", input: [][]byte{[]byte("g"), []byte("\x1b[6"), []byte("~")}, wantCursor: 23},
492 }531 }
493 for _, tc := range cases {532 for _, tc := range cases {
494 t.Run(tc.name, func(t *testing.T) {533 t.Run(tc.name, func(t *testing.T) {
@@ -501,14 +540,12 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
501 540
502 d.enterCopyMode(sess, ac)541 d.enterCopyMode(sess, ac)
503 awaitFrame(t, sends, ports.MsgOutput)542 awaitFrame(t, sends, ports.MsgOutput)
504- d.handleInput(sess, ac, tc.firstInput)543+ for _, input := range tc.input {
505- if tc.secondInput != nil {544+ d.handleInput(sess, ac, input)
506- d.handleInput(sess, ac, tc.secondInput)
507 }545 }
508 546
509- if gotActive := ac.copyMode != nil; gotActive != tc.wantActive {547+ require.NotNil(t, ac.copyMode)
510- t.Fatalf("copy mode active = %v, want %v", gotActive, tc.wantActive)548+ require.Equal(t, tc.wantCursor, ac.copyMode.Cursor)
511- }
512 })549 })
513 }550 }
514 }551 }
@@ -536,6 +573,57 @@ func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) {
536 }573 }
537 }574 }
538 575
576+func TestCopyModeLoneEscapeExitsAfterDelay(t *testing.T) {
577+ p, _ := newBlockingPTY(t)
578+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
579+ clk := &signalClock{timers: make(chan *signalTimer, 1)}
580+ d.clock = clk
581+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
582+ sess.tabs[0].screen.Write([]byte("live"))
583+
584+ d.enterCopyMode(sess, ac)
585+ awaitFrame(t, sends, ports.MsgOutput)
586+ d.handleInput(sess, ac, []byte("\x1b"))
587+ timer := <-clk.timers
588+ ac.copyMu.Lock()
589+ require.NotNil(t, ac.copyMode)
590+ ac.copyMu.Unlock()
591+ timer.ch <- time.Now()
592+ require.Eventually(t, func() bool {
593+ ac.copyMu.Lock()
594+ defer ac.copyMu.Unlock()
595+ return ac.copyMode == nil
596+ }, time.Second, 5*time.Millisecond)
597+}
598+
599+func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) {
600+ p, _ := newBlockingPTY(t)
601+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
602+ clk := &signalClock{timers: make(chan *signalTimer, 1)}
603+ d.clock = clk
604+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
605+ sess.tabs[0].screen.Write([]byte("live"))
606+
607+ d.enterCopyMode(sess, ac)
608+ awaitFrame(t, sends, ports.MsgOutput)
609+ d.handleInput(sess, ac, []byte("\x1b"))
610+ timer := <-clk.timers
611+ d.enterCopyMode(sess, ac)
612+ awaitFrame(t, sends, ports.MsgOutput)
613+
614+ timer.ch <- time.Now()
615+ require.Never(t, func() bool {
616+ ac.copyMu.Lock()
617+ defer ac.copyMu.Unlock()
618+ return ac.copyMode == nil
619+ }, 50*time.Millisecond, 5*time.Millisecond)
620+ ac.copyMu.Lock()
621+ require.Nil(t, ac.copyESC.timer)
622+ require.Nil(t, ac.copyESC.done)
623+ require.Empty(t, ac.copyPending)
624+ ac.copyMu.Unlock()
625+}
626+
539 func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {627 func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
540 cases := []struct {628 cases := []struct {
541 name string629 name string
@@ -676,6 +764,26 @@ func TestHandshakeVersionMismatch(t *testing.T) {
676 require.Equal(t, 0, sessionCount(d))764 require.Equal(t, 0, sessionCount(d))
677 }765 }
678 766
767+func TestHandshakeOldHelloLayoutReportsVersionMismatch(t *testing.T) {
768+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
769+ oldLayout := []byte{
770+ 0x00, byte(ports.ProtocolVersion - 1),
771+ ports.IntentEphemeral,
772+ 0x00, 0x00,
773+ 0x00, 0x50,
774+ 0x00, 0x18,
775+ 0x00, 0x00,
776+ }
777+ tr, sends, _ := newConn(t, ports.Frame{Type: ports.MsgHello, Payload: oldLayout})
778+ d.handleConn(tr)
779+
780+ e := awaitFrame(t, sends, ports.MsgError)
781+ em, err := ports.UnmarshalErrorMsg(e.Payload)
782+ require.NoError(t, err)
783+ require.Equal(t, ports.ErrVersionMismatch, em.Code)
784+ require.Equal(t, 0, sessionCount(d))
785+}
786+
679 func TestHandshakeNameTaken(t *testing.T) {787 func TestHandshakeNameTaken(t *testing.T) {
680 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})788 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
681 // Pre-populate a bare session (no goroutines) to collide with.789 // Pre-populate a bare session (no goroutines) to collide with.
@@ -1042,9 +1150,60 @@ func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
1042 awaitFrame(t, sends, ports.MsgOutput)1150 awaitFrame(t, sends, ports.MsgOutput)
1043 d.handleInput(sess, ac, []byte("\x1bt"))1151 d.handleInput(sess, ac, []byte("\x1bt"))
1044 awaitFrame(t, sends, ports.MsgOutput)1152 awaitFrame(t, sends, ports.MsgOutput)
1153+ clk := &signalClock{timers: make(chan *signalTimer, 1)}
1154+ d.clock = clk
1045 d.handleInput(sess, ac, []byte("\x1b"))1155 d.handleInput(sess, ac, []byte("\x1b"))
1046- require.False(t, ac.pickerActive())1156+ timer := <-clk.timers
1157+ require.True(t, ac.pickerActive())
1158+ timer.ch <- time.Now()
1159+ require.Eventually(t, func() bool { return !ac.pickerActive() }, time.Second, 5*time.Millisecond)
1160+ awaitFrame(t, sends, ports.MsgOutput)
1161+}
1162+
1163+func TestPickerSplitArrowNavigatesWithoutExiting(t *testing.T) {
1164+ cases := []struct {
1165+ name string
1166+ input [][]byte
1167+ wantActive int
1168+ }{
1169+ {name: "escape then down arrow", input: [][]byte{[]byte("\x1b"), []byte("[B")}, wantActive: 1},
1170+ {name: "escape then up arrow", input: [][]byte{[]byte("j"), []byte("\x1b"), []byte("[A")}, wantActive: 0},
1171+ {name: "split down arrow", input: [][]byte{[]byte("\x1b["), []byte("B")}, wantActive: 1},
1172+ }
1173+ for _, tc := range cases {
1174+ t.Run(tc.name, func(t *testing.T) {
1175+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
1176+ defer func() {
1177+ for _, release := range releases {
1178+ release()
1179+ }
1180+ }()
1181+
1182+ d.handleInput(sess, ac, []byte("\x1bt"))
1183+ awaitFrame(t, sends, ports.MsgOutput)
1184+ for _, input := range tc.input {
1185+ d.handleInput(sess, ac, input)
1186+ }
1187+ require.True(t, ac.pickerActive())
1188+ d.handleInput(sess, ac, []byte("\r"))
1189+ require.Equal(t, tc.wantActive, activeTabIndex(sess))
1190+ })
1191+ }
1192+}
1193+
1194+func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) {
1195+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
1196+ defer releases[0]()
1197+
1198+ d.handleInput(sess, ac, []byte("\x1bt"))
1047 awaitFrame(t, sends, ports.MsgOutput)1199 awaitFrame(t, sends, ports.MsgOutput)
1200+ clk := &signalClock{timers: make(chan *signalTimer, 1)}
1201+ d.clock = clk
1202+ d.handleInput(sess, ac, []byte("\x1b"))
1203+ timer := <-clk.timers
1204+ require.True(t, ac.pickerActive())
1205+ timer.ch <- time.Now()
1206+ require.Eventually(t, func() bool { return !ac.pickerActive() }, time.Second, 5*time.Millisecond)
1048 }1207 }
1049 1208
1050 func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {1209 func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
@@ -1408,6 +1567,7 @@ func TestSchedulerDebounceCoalesces(t *testing.T) {
1408 var outputs atomic.Int321567 var outputs atomic.Int32
1409 gotOutput := make(chan struct{}, 1)1568 gotOutput := make(chan struct{}, 1)
1410 tr := portsmocks.NewMockTransport(t)1569 tr := portsmocks.NewMockTransport(t)
1570+ tr.EXPECT().Close().Return(nil).Maybe()
1411 tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {1571 tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {
1412 if f.Type == ports.MsgOutput {1572 if f.Type == ports.MsgOutput {
1413 outputs.Add(1)1573 outputs.Add(1)
@@ -1484,6 +1644,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
1484 1644
1485 var gotOutput atomic.Bool1645 var gotOutput atomic.Bool
1486 tr := portsmocks.NewMockTransport(t)1646 tr := portsmocks.NewMockTransport(t)
1647+ tr.EXPECT().Close().Return(nil).Maybe()
1487 tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {1648 tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {
1488 if f.Type == ports.MsgOutput {1649 if f.Type == ports.MsgOutput {
1489 gotOutput.Store(true)1650 gotOutput.Store(true)
@@ -1635,7 +1796,7 @@ func TestHelloRacingShutdownIsRejected(t *testing.T) {
1635 f := portsmocks.NewMockPTYFactory(t)1796 f := portsmocks.NewMockPTYFactory(t)
1636 // Exactly one child may ever be spawned; a second Open call (a leaked1797 // Exactly one child may ever be spawned; a second Open call (a leaked
1637 // child) fails the test via mock expectations.1798 // child) fails the test via mock expectations.
1638- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p, nil).Once()1799+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p, nil).Once()
1639 d := newTestDaemon(t, f, stubClock{})1800 d := newTestDaemon(t, f, stubClock{})
1640 1801
1641 tr1, sends1, release1 := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))1802 tr1, sends1, release1 := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
@@ -1814,6 +1975,7 @@ func TestSchedulerDefersPendingDirtyTimerDuringSynchronizedUpdate(t *testing.T)
1814 var outputs atomic.Int321975 var outputs atomic.Int32
1815 gotOutput := make(chan struct{}, 1)1976 gotOutput := make(chan struct{}, 1)
1816 tr := portsmocks.NewMockTransport(t)1977 tr := portsmocks.NewMockTransport(t)
1978+ tr.EXPECT().Close().Return(nil).Maybe()
1817 tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {1979 tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {
1818 if f.Type == ports.MsgOutput {1980 if f.Type == ports.MsgOutput {
1819 outputs.Add(1)1981 outputs.Add(1)
M internal/usecase/ui/modal.go +2 -41
@@ -117,7 +117,7 @@ func DrawText(f renderer.Frame, x, y, clipX int, text string, style renderer.Sty
117117 clipX = clamp(clipX, 0, f.Width)
118118 pos := x
119119 for _, r := range text {
120- w := runeWidth(r)
120+ w := renderer.RuneWidth(r)
121121 if w == 0 {
122122 continue
123123 }
@@ -165,46 +165,7 @@ func clamp(n, low, high int) int {
165165 func textWidth(text string) int {
166166 width := 0
167167 for _, r := range text {
168- width += runeWidth(r)
168+ width += renderer.RuneWidth(r)
169169 }
170170 return width
171171 }
172-
173-func runeWidth(r rune) int {
174- switch {
175- case r < 0x20 || r == 0x7F || (r >= 0x80 && r <= 0x9F):
176- return 0
177- case r >= 0x1100 && r <= 0x115F:
178- return 2
179- case r == 0x2329 || r == 0x232A:
180- return 2
181- case r >= 0x2E80 && r <= 0xA4CF:
182- return 2
183- case r >= 0xAC00 && r <= 0xD7FF:
184- return 2
185- case r >= 0xF900 && r <= 0xFAFF:
186- return 2
187- case r >= 0xFE10 && r <= 0xFE19:
188- return 2
189- case r >= 0xFE30 && r <= 0xFE6F:
190- return 2
191- case r >= 0xFF01 && r <= 0xFF60:
192- return 2
193- case r >= 0xFFE0 && r <= 0xFFE6:
194- return 2
195- case r >= 0x1B000 && r <= 0x1B12F:
196- return 2
197- case r >= 0x1F000 && r <= 0x1F64F:
198- return 2
199- case r >= 0x1F680 && r <= 0x1F6FF:
200- return 2
201- case r >= 0x1F900 && r <= 0x1F9FF:
202- return 2
203- case r >= 0x1FA70 && r <= 0x1FAFF:
204- return 2
205- case r >= 0x20000 && r <= 0x3FFFF:
206- return 2
207- default:
208- return 1
209- }
210-}
@@ -117,7 +117,7 @@ func DrawText(f renderer.Frame, x, y, clipX int, text string, style renderer.Sty
117 clipX = clamp(clipX, 0, f.Width)117 clipX = clamp(clipX, 0, f.Width)
118 pos := x118 pos := x
119 for _, r := range text {119 for _, r := range text {
120- w := runeWidth(r)120+ w := renderer.RuneWidth(r)
121 if w == 0 {121 if w == 0 {
122 continue122 continue
123 }123 }
@@ -165,46 +165,7 @@ func clamp(n, low, high int) int {
165 func textWidth(text string) int {165 func textWidth(text string) int {
166 width := 0166 width := 0
167 for _, r := range text {167 for _, r := range text {
168- width += runeWidth(r)168+ width += renderer.RuneWidth(r)
169 }169 }
170 return width170 return width
171 }171 }
172-
173-func runeWidth(r rune) int {
174- switch {
175- case r < 0x20 || r == 0x7F || (r >= 0x80 && r <= 0x9F):
176- return 0
177- case r >= 0x1100 && r <= 0x115F:
178- return 2
179- case r == 0x2329 || r == 0x232A:
180- return 2
181- case r >= 0x2E80 && r <= 0xA4CF:
182- return 2
183- case r >= 0xAC00 && r <= 0xD7FF:
184- return 2
185- case r >= 0xF900 && r <= 0xFAFF:
186- return 2
187- case r >= 0xFE10 && r <= 0xFE19:
188- return 2
189- case r >= 0xFE30 && r <= 0xFE6F:
190- return 2
191- case r >= 0xFF01 && r <= 0xFF60:
192- return 2
193- case r >= 0xFFE0 && r <= 0xFFE6:
194- return 2
195- case r >= 0x1B000 && r <= 0x1B12F:
196- return 2
197- case r >= 0x1F000 && r <= 0x1F64F:
198- return 2
199- case r >= 0x1F680 && r <= 0x1F6FF:
200- return 2
201- case r >= 0x1F900 && r <= 0x1F9FF:
202- return 2
203- case r >= 0x1FA70 && r <= 0x1FAFF:
204- return 2
205- case r >= 0x20000 && r <= 0x3FFFF:
206- return 2
207- default:
208- return 1
209- }
210-}
A pkg/renderer/rune_width.go +120 -0
@@ -0,0 +1,120 @@
1+package renderer
2+
3+import "unicode"
4+
5+// RuneWidth returns the terminal cell width of a rune.
6+//
7+// Returns 0 for combining marks, zero-width characters, and control codes.
8+// Returns 2 for CJK, fullwidth, and wide emoji characters.
9+// Returns 1 for all other printable characters.
10+//
11+// Wide runes (width 2) occupy two grid cells: a left cell holding the rune and
12+// a right continuation cell. Combining marks (Mn/Me) and zero-width joiner
13+// sequences are out of scope: combining marks report width 0 (current
14+// behavior), ZWJ sequences are not coalesced (each rune is measured on its own).
15+func RuneWidth(r rune) int {
16+ switch {
17+ case r < 0x20 || r == 0x7F || (r >= 0x80 && r <= 0x9F):
18+ return 0
19+ // Soft hyphen, combining grapheme joiner, Arabic sign, etc.
20+ case r == 0x00AD || r == 0x034F || r == 0x061C:
21+ return 0
22+ // Hangul Jungseong O/E, Hangul Jungseong Yu
23+ case r == 0x115F || r == 0x1160:
24+ return 0
25+ // Balinese musical symbols
26+ case r == 0x17B4 || r == 0x17B5:
27+ return 0
28+ // Mongolian variation selectors, free variation selector
29+ case r == 0x180B || r == 0x180E || r == 0x200B || r == 0x200C || r == 0x200D || r == 0x200E || r == 0x200F:
30+ return 0
31+ // Line/paragraph separator, bidirectional overrides
32+ case r >= 0x2028 && r <= 0x202E:
33+ return 0
34+ // Word joiner, invisible operators, Arabic/Syriac number marks, etc.
35+ case r >= 0x2060 && r <= 0x206F:
36+ return 0
37+ case r == 0xFEFF || r == 0xFFF9 || r == 0xFFFA || r == 0xFFFB:
38+ return 0
39+ // Tags
40+ case r >= 0xE0001 && r <= 0xE007F:
41+ return 0
42+ // Combining diacritical marks (Unicode categories Mn, Me)
43+ case unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Me, r):
44+ return 0
45+ // --- Wide characters (width 2) ---
46+ // Hangul Jamo
47+ case r >= 0x1100 && r <= 0x115F:
48+ return 2
49+ // Left/right-pointing angle bracket (for compatibility)
50+ case r == 0x2329 || r == 0x232A:
51+ return 2
52+ // CJK Radicals Supplement, Kangxi Radicals, Ideographic Description
53+ // CJK Symbols, Hiragana, Katakana, Bopomofo, Hangul Compatibility Jamo
54+ // Kanbun, CJK Strokes, Enclosed CJK Letters
55+ case r >= 0x2E80 && r <= 0x303E:
56+ return 2
57+ // Hiragana, Katakana, Bopomofo, Hangul Compatibility Jamo, Kanbun
58+ case r >= 0x3040 && r <= 0x33FF:
59+ return 2
60+ // CJK Unified Ideographs Extension A
61+ case r >= 0x3400 && r <= 0x4DBF:
62+ return 2
63+ // CJK Unified Ideographs, Yi Syllables, Yi Radicals
64+ case r >= 0x4E00 && r <= 0xA4CF:
65+ return 2
66+ // Hangul Jamo Extended-A
67+ case r >= 0xA960 && r <= 0xA97C:
68+ return 2
69+ // Hangul Syllables
70+ case r >= 0xAC00 && r <= 0xD7AF:
71+ return 2
72+ // Hangul Jamo Extended-B
73+ case r >= 0xD7B0 && r <= 0xD7FF:
74+ return 2
75+ // CJK Compatibility Ideographs
76+ case r >= 0xF900 && r <= 0xFAFF:
77+ return 2
78+ // Vertical Forms
79+ case r >= 0xFE10 && r <= 0xFE19:
80+ return 2
81+ // CJK Compatibility Forms
82+ case r >= 0xFE30 && r <= 0xFE6F:
83+ return 2
84+ // Fullwidth Forms (variation selectors, fullwidth punctuation, etc.)
85+ case r >= 0xFF01 && r <= 0xFF60:
86+ return 2
87+ // Fullwidth Signs (cent, pound, yen, won, etc.)
88+ case r >= 0xFFE0 && r <= 0xFFE6:
89+ return 2
90+ // Kana Supplement
91+ case r >= 0x1B000 && r <= 0x1B0FF:
92+ return 2
93+ // Kana Extended-A
94+ case r >= 0x1B100 && r <= 0x1B12F:
95+ return 2
96+ // Mahjong Tiles, Domino Tiles, Playing Cards
97+ case r >= 0x1F000 && r <= 0x1F09F:
98+ return 2
99+ // Miscellaneous Symbols and Pictographs, Emoticons, Ornamental Dingbats
100+ case r >= 0x1F0A0 && r <= 0x1F64F:
101+ return 2
102+ // Transport and Map Symbols
103+ case r >= 0x1F680 && r <= 0x1F6FF:
104+ return 2
105+ // Supplemental Symbols and Pictographs
106+ case r >= 0x1F900 && r <= 0x1F9FF:
107+ return 2
108+ // Symbols and Pictographs Extended-A (emoji added in recent Unicode)
109+ case r >= 0x1FA70 && r <= 0x1FAFF:
110+ return 2
111+ // CJK Unified Ideographs Extension B and beyond
112+ case r >= 0x20000 && r <= 0x2FFFF:
113+ return 2
114+ // CJK Unified Ideographs Extension G and beyond
115+ case r >= 0x30000 && r <= 0x3FFFF:
116+ return 2
117+ default:
118+ return 1
119+ }
120+}
@@ -0,0 +1,120 @@
1+package renderer
2+
3+import "unicode"
4+
5+// RuneWidth returns the terminal cell width of a rune.
6+//
7+// Returns 0 for combining marks, zero-width characters, and control codes.
8+// Returns 2 for CJK, fullwidth, and wide emoji characters.
9+// Returns 1 for all other printable characters.
10+//
11+// Wide runes (width 2) occupy two grid cells: a left cell holding the rune and
12+// a right continuation cell. Combining marks (Mn/Me) and zero-width joiner
13+// sequences are out of scope: combining marks report width 0 (current
14+// behavior), ZWJ sequences are not coalesced (each rune is measured on its own).
15+func RuneWidth(r rune) int {
16+ switch {
17+ case r < 0x20 || r == 0x7F || (r >= 0x80 && r <= 0x9F):
18+ return 0
19+ // Soft hyphen, combining grapheme joiner, Arabic sign, etc.
20+ case r == 0x00AD || r == 0x034F || r == 0x061C:
21+ return 0
22+ // Hangul Jungseong O/E, Hangul Jungseong Yu
23+ case r == 0x115F || r == 0x1160:
24+ return 0
25+ // Balinese musical symbols
26+ case r == 0x17B4 || r == 0x17B5:
27+ return 0
28+ // Mongolian variation selectors, free variation selector
29+ case r == 0x180B || r == 0x180E || r == 0x200B || r == 0x200C || r == 0x200D || r == 0x200E || r == 0x200F:
30+ return 0
31+ // Line/paragraph separator, bidirectional overrides
32+ case r >= 0x2028 && r <= 0x202E:
33+ return 0
34+ // Word joiner, invisible operators, Arabic/Syriac number marks, etc.
35+ case r >= 0x2060 && r <= 0x206F:
36+ return 0
37+ case r == 0xFEFF || r == 0xFFF9 || r == 0xFFFA || r == 0xFFFB:
38+ return 0
39+ // Tags
40+ case r >= 0xE0001 && r <= 0xE007F:
41+ return 0
42+ // Combining diacritical marks (Unicode categories Mn, Me)
43+ case unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Me, r):
44+ return 0
45+ // --- Wide characters (width 2) ---
46+ // Hangul Jamo
47+ case r >= 0x1100 && r <= 0x115F:
48+ return 2
49+ // Left/right-pointing angle bracket (for compatibility)
50+ case r == 0x2329 || r == 0x232A:
51+ return 2
52+ // CJK Radicals Supplement, Kangxi Radicals, Ideographic Description
53+ // CJK Symbols, Hiragana, Katakana, Bopomofo, Hangul Compatibility Jamo
54+ // Kanbun, CJK Strokes, Enclosed CJK Letters
55+ case r >= 0x2E80 && r <= 0x303E:
56+ return 2
57+ // Hiragana, Katakana, Bopomofo, Hangul Compatibility Jamo, Kanbun
58+ case r >= 0x3040 && r <= 0x33FF:
59+ return 2
60+ // CJK Unified Ideographs Extension A
61+ case r >= 0x3400 && r <= 0x4DBF:
62+ return 2
63+ // CJK Unified Ideographs, Yi Syllables, Yi Radicals
64+ case r >= 0x4E00 && r <= 0xA4CF:
65+ return 2
66+ // Hangul Jamo Extended-A
67+ case r >= 0xA960 && r <= 0xA97C:
68+ return 2
69+ // Hangul Syllables
70+ case r >= 0xAC00 && r <= 0xD7AF:
71+ return 2
72+ // Hangul Jamo Extended-B
73+ case r >= 0xD7B0 && r <= 0xD7FF:
74+ return 2
75+ // CJK Compatibility Ideographs
76+ case r >= 0xF900 && r <= 0xFAFF:
77+ return 2
78+ // Vertical Forms
79+ case r >= 0xFE10 && r <= 0xFE19:
80+ return 2
81+ // CJK Compatibility Forms
82+ case r >= 0xFE30 && r <= 0xFE6F:
83+ return 2
84+ // Fullwidth Forms (variation selectors, fullwidth punctuation, etc.)
85+ case r >= 0xFF01 && r <= 0xFF60:
86+ return 2
87+ // Fullwidth Signs (cent, pound, yen, won, etc.)
88+ case r >= 0xFFE0 && r <= 0xFFE6:
89+ return 2
90+ // Kana Supplement
91+ case r >= 0x1B000 && r <= 0x1B0FF:
92+ return 2
93+ // Kana Extended-A
94+ case r >= 0x1B100 && r <= 0x1B12F:
95+ return 2
96+ // Mahjong Tiles, Domino Tiles, Playing Cards
97+ case r >= 0x1F000 && r <= 0x1F09F:
98+ return 2
99+ // Miscellaneous Symbols and Pictographs, Emoticons, Ornamental Dingbats
100+ case r >= 0x1F0A0 && r <= 0x1F64F:
101+ return 2
102+ // Transport and Map Symbols
103+ case r >= 0x1F680 && r <= 0x1F6FF:
104+ return 2
105+ // Supplemental Symbols and Pictographs
106+ case r >= 0x1F900 && r <= 0x1F9FF:
107+ return 2
108+ // Symbols and Pictographs Extended-A (emoji added in recent Unicode)
109+ case r >= 0x1FA70 && r <= 0x1FAFF:
110+ return 2
111+ // CJK Unified Ideographs Extension B and beyond
112+ case r >= 0x20000 && r <= 0x2FFFF:
113+ return 2
114+ // CJK Unified Ideographs Extension G and beyond
115+ case r >= 0x30000 && r <= 0x3FFFF:
116+ return 2
117+ default:
118+ return 1
119+ }
120+}
M pkg/vt/screen.go +3 -121
@@ -3,7 +3,6 @@ package vt
33 import (
44 "strconv"
55 "strings"
6- "unicode"
76 "unicode/utf8"
87
98 "github.com/bnema/vev/pkg/renderer"
@@ -173,125 +172,8 @@ func (s *Screen) putRune(r rune) {
173172 }
174173 }
175174
176-// runeWidth returns the terminal cell width of a rune.
177-//
178-// Returns 0 for combining marks, zero-width characters, and control codes.
179-// Returns 2 for CJK, fullwidth, and wide emoji characters.
180-// Returns 1 for all other printable characters.
181-//
182-// Wide runes (width 2) occupy two grid cells: a left cell holding the rune and
183-// a right continuation cell. Combining marks (Mn/Me) and zero-width joiner
184-// sequences are out of scope: combining marks report width 0 (current
185-// behavior), ZWJ sequences are not coalesced (each rune is measured on its own).
186-func runeWidth(r rune) int {
187- switch {
188- case r < 0x20 || r == 0x7F || (r >= 0x80 && r <= 0x9F):
189- return 0
190- // Soft hyphen, combining grapheme joiner, Arabic sign, etc.
191- case r == 0x00AD || r == 0x034F || r == 0x061C:
192- return 0
193- // Hangul Jungseong O/E, Hangul Jungseong Yu
194- case r == 0x115F || r == 0x1160:
195- return 0
196- // Balinese musical symbols
197- case r == 0x17B4 || r == 0x17B5:
198- return 0
199- // Mongolian variation selectors, free variation selector
200- case r == 0x180B || r == 0x180E || r == 0x200B || r == 0x200C || r == 0x200D || r == 0x200E || r == 0x200F:
201- return 0
202- // Line/paragraph separator, bidirectional overrides
203- case r >= 0x2028 && r <= 0x202E:
204- return 0
205- // Word joiner, invisible operators, Arabic/Syriac number marks, etc.
206- case r >= 0x2060 && r <= 0x206F:
207- return 0
208- case r == 0xFEFF || r == 0xFFF9 || r == 0xFFFA || r == 0xFFFB:
209- return 0
210- // Tags
211- case r >= 0xE0001 && r <= 0xE007F:
212- return 0
213- // Combining diacritical marks (Unicode categories Mn, Me)
214- case unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Me, r):
215- return 0
216- // --- Wide characters (width 2) ---
217- // Hangul Jamo
218- case r >= 0x1100 && r <= 0x115F:
219- return 2
220- // Left/right-pointing angle bracket (for compatibility)
221- case r == 0x2329 || r == 0x232A:
222- return 2
223- // CJK Radicals Supplement, Kangxi Radicals, Ideographic Description
224- // CJK Symbols, Hiragana, Katakana, Bopomofo, Hangul Compatibility Jamo
225- // Kanbun, CJK Strokes, Enclosed CJK Letters
226- case r >= 0x2E80 && r <= 0x303E:
227- return 2
228- // Hiragana, Katakana, Bopomofo, Hangul Compatibility Jamo, Kanbun
229- case r >= 0x3040 && r <= 0x33FF:
230- return 2
231- // CJK Unified Ideographs Extension A
232- case r >= 0x3400 && r <= 0x4DBF:
233- return 2
234- // CJK Unified Ideographs, Yi Syllables, Yi Radicals
235- case r >= 0x4E00 && r <= 0xA4CF:
236- return 2
237- // Hangul Jamo Extended-A
238- case r >= 0xA960 && r <= 0xA97C:
239- return 2
240- // Hangul Syllables
241- case r >= 0xAC00 && r <= 0xD7AF:
242- return 2
243- // Hangul Jamo Extended-B
244- case r >= 0xD7B0 && r <= 0xD7FF:
245- return 2
246- // CJK Compatibility Ideographs
247- case r >= 0xF900 && r <= 0xFAFF:
248- return 2
249- // Vertical Forms
250- case r >= 0xFE10 && r <= 0xFE19:
251- return 2
252- // CJK Compatibility Forms
253- case r >= 0xFE30 && r <= 0xFE6F:
254- return 2
255- // Fullwidth Forms (variation selectors, fullwidth punctuation, etc.)
256- case r >= 0xFF01 && r <= 0xFF60:
257- return 2
258- // Fullwidth Signs (cent, pound, yen, won, etc.)
259- case r >= 0xFFE0 && r <= 0xFFE6:
260- return 2
261- // Kana Supplement
262- case r >= 0x1B000 && r <= 0x1B0FF:
263- return 2
264- // Kana Extended-A
265- case r >= 0x1B100 && r <= 0x1B12F:
266- return 2
267- // Mahjong Tiles, Domino Tiles, Playing Cards
268- case r >= 0x1F000 && r <= 0x1F09F:
269- return 2
270- // Miscellaneous Symbols and Pictographs, Emoticons, Ornamental Dingbats
271- case r >= 0x1F0A0 && r <= 0x1F64F:
272- return 2
273- // Transport and Map Symbols
274- case r >= 0x1F680 && r <= 0x1F6FF:
275- return 2
276- // Supplemental Symbols and Pictographs
277- case r >= 0x1F900 && r <= 0x1F9FF:
278- return 2
279- // Symbols and Pictographs Extended-A (emoji added in recent Unicode)
280- case r >= 0x1FA70 && r <= 0x1FAFF:
281- return 2
282- // CJK Unified Ideographs Extension B and beyond
283- case r >= 0x20000 && r <= 0x2FFFF:
284- return 2
285- // CJK Unified Ideographs Extension G and beyond
286- case r >= 0x30000 && r <= 0x3FFFF:
287- return 2
288- default:
289- return 1
290- }
291-}
292-
293175 func (s *Screen) putPrintable(r rune) {
294- w := runeWidth(r)
176+ w := renderer.RuneWidth(r)
295177 // Skip combining marks and zero-width characters.
296178 if w == 0 {
297179 return
@@ -423,12 +305,12 @@ func (s *Screen) repairRow(y int) {
423305 continue
424306 }
425307 left := s.Frame.At(x-1, y)
426- if left.Continuation || runeWidth(left.Rune) != 2 {
308+ if left.Continuation || renderer.RuneWidth(left.Rune) != 2 {
427309 s.Frame.Set(x, y, renderer.BlankCell())
428310 }
429311 continue
430312 }
431- if runeWidth(c.Rune) == 2 {
313+ if renderer.RuneWidth(c.Rune) == 2 {
432314 if x+1 >= w || !s.Frame.At(x+1, y).Continuation {
433315 s.Frame.Set(x, y, renderer.BlankCell())
434316 }
@@ -3,7 +3,6 @@ package vt
3 import (3 import (
4 "strconv"4 "strconv"
5 "strings"5 "strings"
6- "unicode"
7 "unicode/utf8"6 "unicode/utf8"
8 7
9 "github.com/bnema/vev/pkg/renderer"8 "github.com/bnema/vev/pkg/renderer"
@@ -173,125 +172,8 @@ func (s *Screen) putRune(r rune) {
173 }172 }
174 }173 }
175 174
176-// runeWidth returns the terminal cell width of a rune.
177-//
178-// Returns 0 for combining marks, zero-width characters, and control codes.
179-// Returns 2 for CJK, fullwidth, and wide emoji characters.
180-// Returns 1 for all other printable characters.
181-//
182-// Wide runes (width 2) occupy two grid cells: a left cell holding the rune and
183-// a right continuation cell. Combining marks (Mn/Me) and zero-width joiner
184-// sequences are out of scope: combining marks report width 0 (current
185-// behavior), ZWJ sequences are not coalesced (each rune is measured on its own).
186-func runeWidth(r rune) int {
187- switch {
188- case r < 0x20 || r == 0x7F || (r >= 0x80 && r <= 0x9F):
189- return 0
190- // Soft hyphen, combining grapheme joiner, Arabic sign, etc.
191- case r == 0x00AD || r == 0x034F || r == 0x061C:
192- return 0
193- // Hangul Jungseong O/E, Hangul Jungseong Yu
194- case r == 0x115F || r == 0x1160:
195- return 0
196- // Balinese musical symbols
197- case r == 0x17B4 || r == 0x17B5:
198- return 0
199- // Mongolian variation selectors, free variation selector
200- case r == 0x180B || r == 0x180E || r == 0x200B || r == 0x200C || r == 0x200D || r == 0x200E || r == 0x200F:
201- return 0
202- // Line/paragraph separator, bidirectional overrides
203- case r >= 0x2028 && r <= 0x202E:
204- return 0
205- // Word joiner, invisible operators, Arabic/Syriac number marks, etc.
206- case r >= 0x2060 && r <= 0x206F:
207- return 0
208- case r == 0xFEFF || r == 0xFFF9 || r == 0xFFFA || r == 0xFFFB:
209- return 0
210- // Tags
211- case r >= 0xE0001 && r <= 0xE007F:
212- return 0
213- // Combining diacritical marks (Unicode categories Mn, Me)
214- case unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Me, r):
215- return 0
216- // --- Wide characters (width 2) ---
217- // Hangul Jamo
218- case r >= 0x1100 && r <= 0x115F:
219- return 2
220- // Left/right-pointing angle bracket (for compatibility)
221- case r == 0x2329 || r == 0x232A:
222- return 2
223- // CJK Radicals Supplement, Kangxi Radicals, Ideographic Description
224- // CJK Symbols, Hiragana, Katakana, Bopomofo, Hangul Compatibility Jamo
225- // Kanbun, CJK Strokes, Enclosed CJK Letters
226- case r >= 0x2E80 && r <= 0x303E:
227- return 2
228- // Hiragana, Katakana, Bopomofo, Hangul Compatibility Jamo, Kanbun
229- case r >= 0x3040 && r <= 0x33FF:
230- return 2
231- // CJK Unified Ideographs Extension A
232- case r >= 0x3400 && r <= 0x4DBF:
233- return 2
234- // CJK Unified Ideographs, Yi Syllables, Yi Radicals
235- case r >= 0x4E00 && r <= 0xA4CF:
236- return 2
237- // Hangul Jamo Extended-A
238- case r >= 0xA960 && r <= 0xA97C:
239- return 2
240- // Hangul Syllables
241- case r >= 0xAC00 && r <= 0xD7AF:
242- return 2
243- // Hangul Jamo Extended-B
244- case r >= 0xD7B0 && r <= 0xD7FF:
245- return 2
246- // CJK Compatibility Ideographs
247- case r >= 0xF900 && r <= 0xFAFF:
248- return 2
249- // Vertical Forms
250- case r >= 0xFE10 && r <= 0xFE19:
251- return 2
252- // CJK Compatibility Forms
253- case r >= 0xFE30 && r <= 0xFE6F:
254- return 2
255- // Fullwidth Forms (variation selectors, fullwidth punctuation, etc.)
256- case r >= 0xFF01 && r <= 0xFF60:
257- return 2
258- // Fullwidth Signs (cent, pound, yen, won, etc.)
259- case r >= 0xFFE0 && r <= 0xFFE6:
260- return 2
261- // Kana Supplement
262- case r >= 0x1B000 && r <= 0x1B0FF:
263- return 2
264- // Kana Extended-A
265- case r >= 0x1B100 && r <= 0x1B12F:
266- return 2
267- // Mahjong Tiles, Domino Tiles, Playing Cards
268- case r >= 0x1F000 && r <= 0x1F09F:
269- return 2
270- // Miscellaneous Symbols and Pictographs, Emoticons, Ornamental Dingbats
271- case r >= 0x1F0A0 && r <= 0x1F64F:
272- return 2
273- // Transport and Map Symbols
274- case r >= 0x1F680 && r <= 0x1F6FF:
275- return 2
276- // Supplemental Symbols and Pictographs
277- case r >= 0x1F900 && r <= 0x1F9FF:
278- return 2
279- // Symbols and Pictographs Extended-A (emoji added in recent Unicode)
280- case r >= 0x1FA70 && r <= 0x1FAFF:
281- return 2
282- // CJK Unified Ideographs Extension B and beyond
283- case r >= 0x20000 && r <= 0x2FFFF:
284- return 2
285- // CJK Unified Ideographs Extension G and beyond
286- case r >= 0x30000 && r <= 0x3FFFF:
287- return 2
288- default:
289- return 1
290- }
291-}
292-
293 func (s *Screen) putPrintable(r rune) {175 func (s *Screen) putPrintable(r rune) {
294- w := runeWidth(r)176+ w := renderer.RuneWidth(r)
295 // Skip combining marks and zero-width characters.177 // Skip combining marks and zero-width characters.
296 if w == 0 {178 if w == 0 {
297 return179 return
@@ -423,12 +305,12 @@ func (s *Screen) repairRow(y int) {
423 continue305 continue
424 }306 }
425 left := s.Frame.At(x-1, y)307 left := s.Frame.At(x-1, y)
426- if left.Continuation || runeWidth(left.Rune) != 2 {308+ if left.Continuation || renderer.RuneWidth(left.Rune) != 2 {
427 s.Frame.Set(x, y, renderer.BlankCell())309 s.Frame.Set(x, y, renderer.BlankCell())
428 }310 }
429 continue311 continue
430 }312 }
431- if runeWidth(c.Rune) == 2 {313+ if renderer.RuneWidth(c.Rune) == 2 {
432 if x+1 >= w || !s.Frame.At(x+1, y).Continuation {314 if x+1 >= w || !s.Frame.At(x+1, y).Continuation {
433 s.Frame.Set(x, y, renderer.BlankCell())315 s.Frame.Set(x, y, renderer.BlankCell())
434 }316 }
M pkg/vt/screen_test.go +2 -2
@@ -1692,9 +1692,9 @@ func TestRuneWidth(t *testing.T) {
16921692
16931693 for _, tt := range tests {
16941694 t.Run(tt.name, func(t *testing.T) {
1695- got := runeWidth(tt.r)
1695+ got := renderer.RuneWidth(tt.r)
16961696 if got != tt.width {
1697- t.Errorf("runeWidth(%U %q) = %d, want %d", tt.r, tt.r, got, tt.width)
1697+ t.Errorf("renderer.RuneWidth(%U %q) = %d, want %d", tt.r, tt.r, got, tt.width)
16981698 }
16991699 })
17001700 }
@@ -1692,9 +1692,9 @@ func TestRuneWidth(t *testing.T) {
1692 1692
1693 for _, tt := range tests {1693 for _, tt := range tests {
1694 t.Run(tt.name, func(t *testing.T) {1694 t.Run(tt.name, func(t *testing.T) {
1695- got := runeWidth(tt.r)1695+ got := renderer.RuneWidth(tt.r)
1696 if got != tt.width {1696 if got != tt.width {
1697- t.Errorf("runeWidth(%U %q) = %d, want %d", tt.r, tt.r, got, tt.width)1697+ t.Errorf("renderer.RuneWidth(%U %q) = %d, want %d", tt.r, tt.r, got, tt.width)
1698 }1698 }
1699 })1699 })
1700 }1700 }

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 2

Tags 13

↑↓ moveenter openesc close