detached 71c1f46

Merge pull request #50 from bnema/improve/003-session-steal-render-guard

Improve/003 session steal render guard
commit
71c1f461913a7e776461ad20ad7346cfd86ca335
author
bnema · · 2026-07-08 23:09 UTC
committer
GitHub · · 2026-07-08 23:09 UTC
merge of
919a1be + fe2c474 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
15 files · +754 -77
M AGENTS.md +4 -1
@@ -16,11 +16,14 @@ go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc -bench=. -benchmem
1616 ```
1717
1818 - Format with **goimports**.
19+- Install dev tools once with `go install golang.org/x/tools/cmd/goimports@latest` and `go install github.com/vektra/mockery/v2@latest`.
1920 - `make test` runs `go test ./... -race`.
2021 - `make lint` checks `goimports -l`, then `go vet`.
2122 - Enable daemon pprof with `VEV_PPROF_ADDR=127.0.0.1:6060 vev --daemon`.
23+- Set `VEV_LOG=debug|warn|error` to change log verbosity (default info).
2224 - Logs live in `$XDG_STATE_HOME/vev`, or `~/.local/state/vev` when unset.
23-- Use feature worktrees under `.worktrees/<branch-name>`.
25+- Use feature worktrees under `.worktrees/<branch-name>`. Remove the worktree once its branch is merged or abandoned (`git worktree remove <path>`).
26+- Create vev-owned private directories with `pkg/safedir.EnsurePrivate`, not `os.MkdirAll`.
2427
2528 ## Architecture rules
2629
@@ -16,11 +16,14 @@ go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc -bench=. -benchmem
16 ```16 ```
17 17
18 - Format with **goimports**.18 - Format with **goimports**.
19+- Install dev tools once with `go install golang.org/x/tools/cmd/goimports@latest` and `go install github.com/vektra/mockery/v2@latest`.
19 - `make test` runs `go test ./... -race`.20 - `make test` runs `go test ./... -race`.
20 - `make lint` checks `goimports -l`, then `go vet`.21 - `make lint` checks `goimports -l`, then `go vet`.
21 - Enable daemon pprof with `VEV_PPROF_ADDR=127.0.0.1:6060 vev --daemon`.22 - Enable daemon pprof with `VEV_PPROF_ADDR=127.0.0.1:6060 vev --daemon`.
23+- Set `VEV_LOG=debug|warn|error` to change log verbosity (default info).
22 - Logs live in `$XDG_STATE_HOME/vev`, or `~/.local/state/vev` when unset.24 - Logs live in `$XDG_STATE_HOME/vev`, or `~/.local/state/vev` when unset.
23-- Use feature worktrees under `.worktrees/<branch-name>`.25+- Use feature worktrees under `.worktrees/<branch-name>`. Remove the worktree once its branch is merged or abandoned (`git worktree remove <path>`).
26+- Create vev-owned private directories with `pkg/safedir.EnsurePrivate`, not `os.MkdirAll`.
24 27
25 ## Architecture rules28 ## Architecture rules
26 29
M README.md +2 -0
@@ -208,6 +208,8 @@ Copy uses OSC 52 clipboard sequences when supported by your terminal.
208208 ## Development
209209
210210 ```sh
211+go install golang.org/x/tools/cmd/goimports@latest
212+go install github.com/vektra/mockery/v2@latest
211213 make test # go test ./... -race
212214 make lint # goimports check, then go vet ./...
213215 make mocks # regenerate mocks
@@ -208,6 +208,8 @@ Copy uses OSC 52 clipboard sequences when supported by your terminal.
208 ## Development208 ## Development
209 209
210 ```sh210 ```sh
211+go install golang.org/x/tools/cmd/goimports@latest
212+go install github.com/vektra/mockery/v2@latest
211 make test # go test ./... -race213 make test # go test ./... -race
212 make lint # goimports check, then go vet ./...214 make lint # goimports check, then go vet ./...
213 make mocks # regenerate mocks215 make mocks # regenerate mocks
A internal/adapters/clock/clock_test.go +56 -0
@@ -0,0 +1,56 @@
1+package clock
2+
3+import (
4+ "testing"
5+ "time"
6+)
7+
8+func TestRealClockNow(t *testing.T) {
9+ c := New()
10+ before := time.Now().Add(-time.Second)
11+ got := c.Now()
12+ after := time.Now().Add(time.Second)
13+
14+ if got.Before(before) || got.After(after) {
15+ t.Fatalf("Now() = %v, want between %v and %v", got, before, after)
16+ }
17+}
18+
19+func TestRealClockTimerFires(t *testing.T) {
20+ c := New()
21+ timer := c.NewTimer(10 * time.Millisecond)
22+ defer timer.Stop()
23+
24+ select {
25+ case <-timer.C():
26+ case <-time.After(5 * time.Second):
27+ t.Fatal("timer did not fire before deadline")
28+ }
29+}
30+
31+func TestRealClockTimerStopKeepsChannelQuiet(t *testing.T) {
32+ c := New()
33+ timer := c.NewTimer(time.Hour)
34+ _ = timer.Stop()
35+
36+ select {
37+ case <-timer.C():
38+ t.Fatal("stopped timer fired")
39+ case <-time.After(25 * time.Millisecond):
40+ }
41+}
42+
43+func TestRealClockTimerResetRearms(t *testing.T) {
44+ c := New()
45+ timer := c.NewTimer(time.Hour)
46+ defer timer.Stop()
47+
48+ _ = timer.Stop()
49+ timer.Reset(10 * time.Millisecond)
50+
51+ select {
52+ case <-timer.C():
53+ case <-time.After(5 * time.Second):
54+ t.Fatal("reset timer did not fire before deadline")
55+ }
56+}
@@ -0,0 +1,56 @@
1+package clock
2+
3+import (
4+ "testing"
5+ "time"
6+)
7+
8+func TestRealClockNow(t *testing.T) {
9+ c := New()
10+ before := time.Now().Add(-time.Second)
11+ got := c.Now()
12+ after := time.Now().Add(time.Second)
13+
14+ if got.Before(before) || got.After(after) {
15+ t.Fatalf("Now() = %v, want between %v and %v", got, before, after)
16+ }
17+}
18+
19+func TestRealClockTimerFires(t *testing.T) {
20+ c := New()
21+ timer := c.NewTimer(10 * time.Millisecond)
22+ defer timer.Stop()
23+
24+ select {
25+ case <-timer.C():
26+ case <-time.After(5 * time.Second):
27+ t.Fatal("timer did not fire before deadline")
28+ }
29+}
30+
31+func TestRealClockTimerStopKeepsChannelQuiet(t *testing.T) {
32+ c := New()
33+ timer := c.NewTimer(time.Hour)
34+ _ = timer.Stop()
35+
36+ select {
37+ case <-timer.C():
38+ t.Fatal("stopped timer fired")
39+ case <-time.After(25 * time.Millisecond):
40+ }
41+}
42+
43+func TestRealClockTimerResetRearms(t *testing.T) {
44+ c := New()
45+ timer := c.NewTimer(time.Hour)
46+ defer timer.Stop()
47+
48+ _ = timer.Stop()
49+ timer.Reset(10 * time.Millisecond)
50+
51+ select {
52+ case <-timer.C():
53+ case <-time.After(5 * time.Second):
54+ t.Fatal("reset timer did not fire before deadline")
55+ }
56+}
M internal/app/run.go +15 -0
@@ -262,6 +262,18 @@ func snapshotDir() string {
262262 return filepath.Join(platform.StateDir(), "snapshots")
263263 }
264264
265+func pprofAddrIsLoopback(addr string) bool {
266+ host, _, err := net.SplitHostPort(addr)
267+ if err != nil || host == "" {
268+ return false
269+ }
270+ if strings.EqualFold(host, "localhost") {
271+ return true
272+ }
273+ ip := net.ParseIP(host)
274+ return ip != nil && ip.IsLoopback()
275+}
276+
265277 // runDaemon runs the daemon in the foreground (the hidden --daemon path,
266278 // entered by an auto-spawned child): it sets up logging, binds the socket,
267279 // constructs the daemon, and serves until the last session exits or a
@@ -284,6 +296,9 @@ func runDaemon() error {
284296 defer stop()
285297
286298 if addr := os.Getenv("VEV_PPROF_ADDR"); addr != "" {
299+ if !pprofAddrIsLoopback(addr) {
300+ log.Warn("pprof bound to non-loopback address; /debug/pprof is unauthenticated", "addr", addr)
301+ }
287302 go func() {
288303 if err := http.ListenAndServe(addr, nil); err != nil && !errors.Is(err, http.ErrServerClosed) {
289304 log.Error("pprof server exited", "err", err)
@@ -262,6 +262,18 @@ func snapshotDir() string {
262 return filepath.Join(platform.StateDir(), "snapshots")262 return filepath.Join(platform.StateDir(), "snapshots")
263 }263 }
264 264
265+func pprofAddrIsLoopback(addr string) bool {
266+ host, _, err := net.SplitHostPort(addr)
267+ if err != nil || host == "" {
268+ return false
269+ }
270+ if strings.EqualFold(host, "localhost") {
271+ return true
272+ }
273+ ip := net.ParseIP(host)
274+ return ip != nil && ip.IsLoopback()
275+}
276+
265 // runDaemon runs the daemon in the foreground (the hidden --daemon path,277 // runDaemon runs the daemon in the foreground (the hidden --daemon path,
266 // entered by an auto-spawned child): it sets up logging, binds the socket,278 // entered by an auto-spawned child): it sets up logging, binds the socket,
267 // constructs the daemon, and serves until the last session exits or a279 // constructs the daemon, and serves until the last session exits or a
@@ -284,6 +296,9 @@ func runDaemon() error {
284 defer stop()296 defer stop()
285 297
286 if addr := os.Getenv("VEV_PPROF_ADDR"); addr != "" {298 if addr := os.Getenv("VEV_PPROF_ADDR"); addr != "" {
299+ if !pprofAddrIsLoopback(addr) {
300+ log.Warn("pprof bound to non-loopback address; /debug/pprof is unauthenticated", "addr", addr)
301+ }
287 go func() {302 go func() {
288 if err := http.ListenAndServe(addr, nil); err != nil && !errors.Is(err, http.ErrServerClosed) {303 if err := http.ListenAndServe(addr, nil); err != nil && !errors.Is(err, http.ErrServerClosed) {
289 log.Error("pprof server exited", "err", err)304 log.Error("pprof server exited", "err", err)
M internal/app/run_test.go +23 -0
@@ -753,3 +753,26 @@ func TestListenUDPInRange(t *testing.T) {
753753 }
754754 })
755755 }
756+
757+func TestPprofAddrIsLoopback(t *testing.T) {
758+ tests := []struct {
759+ addr string
760+ want bool
761+ }{
762+ {addr: "127.0.0.1:6060", want: true},
763+ {addr: "localhost:6060", want: true},
764+ {addr: "[::1]:6060", want: true},
765+ {addr: ":6060", want: false},
766+ {addr: "0.0.0.0:6060", want: false},
767+ {addr: "192.168.1.5:6060", want: false},
768+ {addr: "garbage", want: false},
769+ }
770+
771+ for _, tt := range tests {
772+ t.Run(tt.addr, func(t *testing.T) {
773+ if got := pprofAddrIsLoopback(tt.addr); got != tt.want {
774+ t.Fatalf("pprofAddrIsLoopback(%q) = %v, want %v", tt.addr, got, tt.want)
775+ }
776+ })
777+ }
778+}
@@ -753,3 +753,26 @@ func TestListenUDPInRange(t *testing.T) {
753 }753 }
754 })754 })
755 }755 }
756+
757+func TestPprofAddrIsLoopback(t *testing.T) {
758+ tests := []struct {
759+ addr string
760+ want bool
761+ }{
762+ {addr: "127.0.0.1:6060", want: true},
763+ {addr: "localhost:6060", want: true},
764+ {addr: "[::1]:6060", want: true},
765+ {addr: ":6060", want: false},
766+ {addr: "0.0.0.0:6060", want: false},
767+ {addr: "192.168.1.5:6060", want: false},
768+ {addr: "garbage", want: false},
769+ }
770+
771+ for _, tt := range tests {
772+ t.Run(tt.addr, func(t *testing.T) {
773+ if got := pprofAddrIsLoopback(tt.addr); got != tt.want {
774+ t.Fatalf("pprofAddrIsLoopback(%q) = %v, want %v", tt.addr, got, tt.want)
775+ }
776+ })
777+ }
778+}
M internal/platform/dir_test.go +102 -4
@@ -1,24 +1,91 @@
11 package platform
22
33 import (
4+ "os"
45 "path/filepath"
6+ "strings"
57 "testing"
68 )
79
10+func TestStateDir(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ xdgState string
14+ home string
15+ want func(*testing.T, string, string) string
16+ }{
17+ {
18+ name: "xdg state home",
19+ xdgState: filepath.Join(t.TempDir(), "xdg-state"),
20+ home: filepath.Join(t.TempDir(), "home"),
21+ want: func(_ *testing.T, xdgState, _ string) string {
22+ return filepath.Join(xdgState, "vev")
23+ },
24+ },
25+ {
26+ name: "home fallback",
27+ home: filepath.Join(t.TempDir(), "home"),
28+ want: func(_ *testing.T, _, home string) string {
29+ return filepath.Join(home, ".local", "state", "vev")
30+ },
31+ },
32+ {
33+ name: "temp fallback without home",
34+ want: func(t *testing.T, _, _ string) string {
35+ t.Helper()
36+ return filepath.Join(os.TempDir(), "vev-state-")
37+ },
38+ },
39+ }
40+
41+ for _, tt := range tests {
42+ t.Run(tt.name, func(t *testing.T) {
43+ t.Setenv("XDG_STATE_HOME", tt.xdgState)
44+ t.Setenv("HOME", tt.home)
45+
46+ want := tt.want(t, tt.xdgState, tt.home)
47+ got := StateDir()
48+ if strings.HasSuffix(want, "vev-state-") {
49+ if !strings.HasPrefix(got, want) {
50+ t.Fatalf("StateDir() = %q, want prefix %q", got, want)
51+ }
52+ return
53+ }
54+ if got != want {
55+ t.Fatalf("StateDir() = %q, want %q", got, want)
56+ }
57+ })
58+ }
59+}
60+
861 func TestConfigPath(t *testing.T) {
962 tests := []struct {
1063 name string
1164 xdgConfig string
1265 home string
66+ want func(*testing.T, string, string) string
1367 }{
1468 {
1569 name: "xdg config home",
1670 xdgConfig: filepath.Join(t.TempDir(), "xdg-config"),
1771 home: filepath.Join(t.TempDir(), "home"),
72+ want: func(_ *testing.T, xdgConfig, _ string) string {
73+ return filepath.Join(xdgConfig, "vev", "config")
74+ },
1875 },
1976 {
2077 name: "home fallback",
2178 home: filepath.Join(t.TempDir(), "home"),
79+ want: func(_ *testing.T, _, home string) string {
80+ return filepath.Join(home, ".config", "vev", "config")
81+ },
82+ },
83+ {
84+ name: "temp fallback without home",
85+ want: func(t *testing.T, _, _ string) string {
86+ t.Helper()
87+ return filepath.Join(os.TempDir(), "vev-config-")
88+ },
2289 },
2390 }
2491
@@ -27,13 +94,44 @@ func TestConfigPath(t *testing.T) {
2794 t.Setenv("XDG_CONFIG_HOME", tt.xdgConfig)
2895 t.Setenv("HOME", tt.home)
2996
30- want := filepath.Join(tt.xdgConfig, "vev", "config")
31- if tt.xdgConfig == "" {
32- want = filepath.Join(tt.home, ".config", "vev", "config")
97+ want := tt.want(t, tt.xdgConfig, tt.home)
98+ got := ConfigPath()
99+ if strings.HasSuffix(want, "vev-config-") {
100+ if !strings.HasPrefix(got, want) || filepath.Base(got) != "config" {
101+ t.Fatalf("ConfigPath() = %q, want prefix %q and base config", got, want)
102+ }
103+ return
33104 }
34- if got := ConfigPath(); got != want {
105+ if got != want {
35106 t.Fatalf("ConfigPath() = %q, want %q", got, want)
36107 }
37108 })
38109 }
39110 }
111+
112+func TestDirOrHome(t *testing.T) {
113+ existing := t.TempDir()
114+ home := filepath.Join(t.TempDir(), "home")
115+ nonexistent := filepath.Join(t.TempDir(), "missing")
116+
117+ tests := []struct {
118+ name string
119+ dir string
120+ home string
121+ want string
122+ }{
123+ {name: "existing dir", dir: existing, home: home, want: existing},
124+ {name: "non-existent dir falls back to home", dir: nonexistent, home: home, want: home},
125+ {name: "non-existent dir without home falls back to root", dir: nonexistent, want: "/"},
126+ }
127+
128+ for _, tt := range tests {
129+ t.Run(tt.name, func(t *testing.T) {
130+ t.Setenv("HOME", tt.home)
131+
132+ if got := DirOrHome(tt.dir); got != tt.want {
133+ t.Fatalf("DirOrHome(%q) = %q, want %q", tt.dir, got, tt.want)
134+ }
135+ })
136+ }
137+}
@@ -1,24 +1,91 @@
1 package platform1 package platform
2 2
3 import (3 import (
4+ "os"
4 "path/filepath"5 "path/filepath"
6+ "strings"
5 "testing"7 "testing"
6 )8 )
7 9
10+func TestStateDir(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ xdgState string
14+ home string
15+ want func(*testing.T, string, string) string
16+ }{
17+ {
18+ name: "xdg state home",
19+ xdgState: filepath.Join(t.TempDir(), "xdg-state"),
20+ home: filepath.Join(t.TempDir(), "home"),
21+ want: func(_ *testing.T, xdgState, _ string) string {
22+ return filepath.Join(xdgState, "vev")
23+ },
24+ },
25+ {
26+ name: "home fallback",
27+ home: filepath.Join(t.TempDir(), "home"),
28+ want: func(_ *testing.T, _, home string) string {
29+ return filepath.Join(home, ".local", "state", "vev")
30+ },
31+ },
32+ {
33+ name: "temp fallback without home",
34+ want: func(t *testing.T, _, _ string) string {
35+ t.Helper()
36+ return filepath.Join(os.TempDir(), "vev-state-")
37+ },
38+ },
39+ }
40+
41+ for _, tt := range tests {
42+ t.Run(tt.name, func(t *testing.T) {
43+ t.Setenv("XDG_STATE_HOME", tt.xdgState)
44+ t.Setenv("HOME", tt.home)
45+
46+ want := tt.want(t, tt.xdgState, tt.home)
47+ got := StateDir()
48+ if strings.HasSuffix(want, "vev-state-") {
49+ if !strings.HasPrefix(got, want) {
50+ t.Fatalf("StateDir() = %q, want prefix %q", got, want)
51+ }
52+ return
53+ }
54+ if got != want {
55+ t.Fatalf("StateDir() = %q, want %q", got, want)
56+ }
57+ })
58+ }
59+}
60+
8 func TestConfigPath(t *testing.T) {61 func TestConfigPath(t *testing.T) {
9 tests := []struct {62 tests := []struct {
10 name string63 name string
11 xdgConfig string64 xdgConfig string
12 home string65 home string
66+ want func(*testing.T, string, string) string
13 }{67 }{
14 {68 {
15 name: "xdg config home",69 name: "xdg config home",
16 xdgConfig: filepath.Join(t.TempDir(), "xdg-config"),70 xdgConfig: filepath.Join(t.TempDir(), "xdg-config"),
17 home: filepath.Join(t.TempDir(), "home"),71 home: filepath.Join(t.TempDir(), "home"),
72+ want: func(_ *testing.T, xdgConfig, _ string) string {
73+ return filepath.Join(xdgConfig, "vev", "config")
74+ },
18 },75 },
19 {76 {
20 name: "home fallback",77 name: "home fallback",
21 home: filepath.Join(t.TempDir(), "home"),78 home: filepath.Join(t.TempDir(), "home"),
79+ want: func(_ *testing.T, _, home string) string {
80+ return filepath.Join(home, ".config", "vev", "config")
81+ },
82+ },
83+ {
84+ name: "temp fallback without home",
85+ want: func(t *testing.T, _, _ string) string {
86+ t.Helper()
87+ return filepath.Join(os.TempDir(), "vev-config-")
88+ },
22 },89 },
23 }90 }
24 91
@@ -27,13 +94,44 @@ func TestConfigPath(t *testing.T) {
27 t.Setenv("XDG_CONFIG_HOME", tt.xdgConfig)94 t.Setenv("XDG_CONFIG_HOME", tt.xdgConfig)
28 t.Setenv("HOME", tt.home)95 t.Setenv("HOME", tt.home)
29 96
30- want := filepath.Join(tt.xdgConfig, "vev", "config")97+ want := tt.want(t, tt.xdgConfig, tt.home)
31- if tt.xdgConfig == "" {98+ got := ConfigPath()
32- want = filepath.Join(tt.home, ".config", "vev", "config")99+ if strings.HasSuffix(want, "vev-config-") {
100+ if !strings.HasPrefix(got, want) || filepath.Base(got) != "config" {
101+ t.Fatalf("ConfigPath() = %q, want prefix %q and base config", got, want)
102+ }
103+ return
33 }104 }
34- if got := ConfigPath(); got != want {105+ if got != want {
35 t.Fatalf("ConfigPath() = %q, want %q", got, want)106 t.Fatalf("ConfigPath() = %q, want %q", got, want)
36 }107 }
37 })108 })
38 }109 }
39 }110 }
111+
112+func TestDirOrHome(t *testing.T) {
113+ existing := t.TempDir()
114+ home := filepath.Join(t.TempDir(), "home")
115+ nonexistent := filepath.Join(t.TempDir(), "missing")
116+
117+ tests := []struct {
118+ name string
119+ dir string
120+ home string
121+ want string
122+ }{
123+ {name: "existing dir", dir: existing, home: home, want: existing},
124+ {name: "non-existent dir falls back to home", dir: nonexistent, home: home, want: home},
125+ {name: "non-existent dir without home falls back to root", dir: nonexistent, want: "/"},
126+ }
127+
128+ for _, tt := range tests {
129+ t.Run(tt.name, func(t *testing.T) {
130+ t.Setenv("HOME", tt.home)
131+
132+ if got := DirOrHome(tt.dir); got != tt.want {
133+ t.Fatalf("DirOrHome(%q) = %q, want %q", tt.dir, got, tt.want)
134+ }
135+ })
136+ }
137+}
M internal/usecase/client/client_test.go +5 -2
@@ -617,6 +617,8 @@ func TestAttachForwardsResize(t *testing.T) {
617617 defer in.unblock()
618618
619619 detachAfterResize := make(chan struct{})
620+ firstRecv := make(chan struct{})
621+ var firstRecvOnce sync.Once
620622
621623 tr := portsmocks.NewMockTransport(t)
622624 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
@@ -635,6 +637,7 @@ func TestAttachForwardsResize(t *testing.T) {
635637 recvCh <- recvItem{f: welcome}
636638 done := make(chan struct{})
637639 tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
640+ firstRecvOnce.Do(func() { close(firstRecv) })
638641 select {
639642 case it := <-recvCh:
640643 return it.f, it.err
@@ -653,9 +656,9 @@ func TestAttachForwardsResize(t *testing.T) {
653656 }).Maybe()
654657 tr.EXPECT().Close().Return(nil).Once()
655658
656- // Push a resize event shortly after attach begins.
659+ // Push a resize event once attach begins receiving daemon frames.
657660 go func() {
658- time.Sleep(20 * time.Millisecond)
661+ <-firstRecv
659662 resizeCh <- domain.Size{Cols: 120, Rows: 40}
660663 }()
661664
@@ -617,6 +617,8 @@ func TestAttachForwardsResize(t *testing.T) {
617 defer in.unblock()617 defer in.unblock()
618 618
619 detachAfterResize := make(chan struct{})619 detachAfterResize := make(chan struct{})
620+ firstRecv := make(chan struct{})
621+ var firstRecvOnce sync.Once
620 622
621 tr := portsmocks.NewMockTransport(t)623 tr := portsmocks.NewMockTransport(t)
622 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()624 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
@@ -635,6 +637,7 @@ func TestAttachForwardsResize(t *testing.T) {
635 recvCh <- recvItem{f: welcome}637 recvCh <- recvItem{f: welcome}
636 done := make(chan struct{})638 done := make(chan struct{})
637 tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {639 tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
640+ firstRecvOnce.Do(func() { close(firstRecv) })
638 select {641 select {
639 case it := <-recvCh:642 case it := <-recvCh:
640 return it.f, it.err643 return it.f, it.err
@@ -653,9 +656,9 @@ func TestAttachForwardsResize(t *testing.T) {
653 }).Maybe()656 }).Maybe()
654 tr.EXPECT().Close().Return(nil).Once()657 tr.EXPECT().Close().Return(nil).Once()
655 658
656- // Push a resize event shortly after attach begins.659+ // Push a resize event once attach begins receiving daemon frames.
657 go func() {660 go func() {
658- time.Sleep(20 * time.Millisecond)661+ <-firstRecv
659 resizeCh <- domain.Size{Cols: 120, Rows: 40}662 resizeCh <- domain.Size{Cols: 120, Rows: 40}
660 }()663 }()
661 664
M internal/usecase/daemon/client.go +3 -2
@@ -49,8 +49,9 @@ type attachedClient struct {
4949 nextStateNum uint64
5050 echoAck atomic.Uint64
5151 outputAck atomic.Uint64
52- paintDeferred bool // guarded by sendMu; a diff paint skipped while acks lag
53- bars barCache // only touched while sendMu is held
52+ paintDeferred bool // guarded by sendMu; a diff paint skipped while acks lag
53+ bars barCache // only touched while sendMu is held
54+ composed composedFrameCache // only touched while sendMu is held
5455 size domain.Size
5556 keys *keys.Router
5657 sess Guarded[*session]
@@ -49,8 +49,9 @@ type attachedClient struct {
49 nextStateNum uint6449 nextStateNum uint64
50 echoAck atomic.Uint6450 echoAck atomic.Uint64
51 outputAck atomic.Uint6451 outputAck atomic.Uint64
52- paintDeferred bool // guarded by sendMu; a diff paint skipped while acks lag52+ paintDeferred bool // guarded by sendMu; a diff paint skipped while acks lag
53- bars barCache // only touched while sendMu is held53+ bars barCache // only touched while sendMu is held
54+ composed composedFrameCache // only touched while sendMu is held
54 size domain.Size55 size domain.Size
55 keys *keys.Router56 keys *keys.Router
56 sess Guarded[*session]57 sess Guarded[*session]
M internal/usecase/daemon/render.go +117 -21
@@ -332,14 +332,22 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
332332 return
333333 }
334334 reset = ac.shouldResetOutputState(reset)
335- if reset || overlays.copyActive || overlays.pickerActive || overlays.paletteActive || overlays.promptActive {
335+ overlayActive := overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive
336+ if reset || overlayActive {
336337 ac.rend.Reset()
337338 ac.bars.Reset()
339+ ac.composed.invalidate()
338340 }
339341 if reset || overlays.pickerActive || overlays.paletteActive || overlays.promptActive {
340342 ac.lastCursor.valid = false
341343 }
342- frame, damage := composeClientFrameWithLayout(bars, tb, reset, layoutSnap, &ac.bars)
344+ var frame renderer.Frame
345+ var damage []renderer.Damage
346+ if overlayActive {
347+ frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, nil)
348+ } else {
349+ frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, &ac.composed)
350+ }
343351 if overlays.copyActive {
344352 frame, damage = composeCopyClientFrame(overlays.copyMode, tb, bars)
345353 }
@@ -380,11 +388,6 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
380388 if err == nil {
381389 cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0)
382390 }
383- for _, pane := range tb.panesSnapshot() {
384- pane.mu.Lock()
385- pane.screen.ClearDamage()
386- pane.mu.Unlock()
387- }
388391 tb.mu.Unlock()
389392
390393 var serr error
@@ -500,6 +503,16 @@ func (c *barCache) Reset() {
500503 c.bottom = nil
501504 }
502505
506+type composedFrameCache struct {
507+ frame renderer.Frame
508+ valid bool
509+ layoutSnap tabLayoutSnapshot
510+}
511+
512+func (c *composedFrameCache) invalidate() {
513+ c.valid = false
514+}
515+
503516 func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
504517 return composeClientFrameWithState(barState{status: sess.statusSegments(), copyFeedback: rightStatus}, tb, full, caches...)
505518 }
@@ -513,6 +526,18 @@ func composeClientFrameWithLayout(bars barState, tb *tab, full bool, layoutSnap
513526 if len(caches) > 0 {
514527 cache = caches[0]
515528 }
529+ return composeClientFrameWithLayoutCached(bars, tb, full, layoutSnap, cache, nil)
530+}
531+
532+func composeClientFrameWithLayoutCached(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) {
533+ return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, false)
534+}
535+
536+func composeClientFrameWithLayoutCachedConsumeDamage(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) {
537+ return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, true)
538+}
539+
540+func composeClientFrameWithLayoutCachedOptions(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache, consumeDamage bool) (renderer.Frame, []renderer.Damage) {
516541 p := tb.focusedPane()
517542 styles := newThemeStyles(bars.theme)
518543 if p == nil {
@@ -528,15 +553,34 @@ func composeClientFrameWithLayout(bars barState, tb *tab, full bool, layoutSnap
528553 if tb.size.Valid() {
529554 width, screenRows = tb.size.Cols, tb.size.Rows
530555 }
531- frame := renderer.NewFrame(width, screenRows+2)
556+ cacheValid := composed != nil && composed.valid && composed.frame.Width == width && composed.frame.Height == screenRows+2
557+ layoutSame := composed == nil || (cacheValid && sameTabLayoutSnapshot(composed.layoutSnap, layoutSnap))
558+ var frame renderer.Frame
559+ if cacheValid {
560+ frame = composed.frame
561+ } else {
562+ frame = renderer.NewFrame(width, screenRows+2)
563+ if composed != nil {
564+ full = true
565+ }
566+ }
567+ if !layoutSame {
568+ full = true
569+ }
570+ contentArea := domain.Rect{Y: 1, Width: width, Height: screenRows}
571+ if cacheValid && !layoutSame {
572+ clearFrameRect(frame, contentArea)
573+ }
532574 topBar := frame.Row(0)
533575 drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, bars.topRight, styles)
534- contentFrame, contentDamage := composeTabFrameWithLayout(tb, domain.Rect{Width: width, Height: screenRows}, bars.theme, layoutSnap)
535- for y := range screenRows {
536- copy(frame.Row(y+1), contentFrame.Row(y))
537- }
576+ contentDamage := composeTabFrameIntoWithLayoutOptions(tb, frame, contentArea, bars.theme, layoutSnap, cacheValid && layoutSame, consumeDamage)
538577 bottomBar := frame.Row(screenRows + 1)
539578 drawStatusBarState(bottomBar, bars, styles)
579+ if composed != nil {
580+ composed.frame = frame
581+ composed.valid = true
582+ composed.layoutSnap = layoutSnap
583+ }
540584 if full {
541585 if cache != nil {
542586 cache.capture(topBar, bottomBar)
@@ -562,20 +606,30 @@ func composeTabFrame(tb *tab, area domain.Rect, theme themeui.Theme) (renderer.F
562606
563607 func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot) (renderer.Frame, []renderer.Damage) {
564608 frame := renderer.NewFrame(area.Width, area.Height)
609+ damage := composeTabFrameIntoWithLayout(tb, frame, area, theme, layoutSnap, false)
610+ return frame, damage
611+}
612+
613+func composeTabFrameIntoWithLayout(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool) []renderer.Damage {
614+ return composeTabFrameIntoWithLayoutOptions(tb, frame, area, theme, layoutSnap, cacheValid, false)
615+}
616+
617+func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool, consumeDamage bool) []renderer.Damage {
618+ contentArea := domain.Rect{Width: area.Width, Height: area.Height}
565619 root := tb.tree.Root
566- placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == area
620+ placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == contentArea
567621 if !ok {
568- placements, ok = layout.Solve(root, area)
622+ placements, ok = layout.Solve(root, contentArea)
569623 }
570624 var fallback *pane
571625 if !ok {
572626 fallback = tb.focusedPane()
573627 if fallback == nil {
574- return frame, nil
628+ return nil
575629 }
576- placements = []layout.Placement{{ID: fallback.id, Content: area}}
630+ placements = []layout.Placement{{ID: fallback.id, Content: contentArea}}
577631 }
578- if ok {
632+ if ok && !cacheValid {
579633 drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme))
580634 }
581635 var damage []renderer.Damage
@@ -588,20 +642,62 @@ func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, l
588642 continue
589643 }
590644 focused := tb.tree.Focus == pl.ID
645+ pl = offsetPlacement(pl, area.X, area.Y)
591646 if pl.TitleBar.Height > 0 {
592647 drawPaneTitleBar(frame, pl, p, focused, theme, "")
648+ if cacheValid {
649+ titleDamage := pl.TitleBar
650+ titleDamage.X -= area.X
651+ titleDamage.Y -= area.Y
652+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: titleDamage.X, Y: titleDamage.Y, Width: titleDamage.Width, Height: titleDamage.Height})
653+ }
593654 }
594655 if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {
656+ if consumeDamage {
657+ p.mu.Lock()
658+ p.screen.ClearDamage()
659+ p.mu.Unlock()
660+ }
595661 continue
596662 }
597663 p.mu.Lock()
598- blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme)
599- for _, d := range p.screen.Damage() {
600- damage = append(damage, translatePaneDamage(d, pl.Content, area)...)
664+ paneDamage := p.screen.Damage()
665+ if !cacheValid || len(paneDamage) > 0 {
666+ blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme)
667+ }
668+ for _, d := range paneDamage {
669+ localContent := pl.Content
670+ localContent.X -= area.X
671+ localContent.Y -= area.Y
672+ damage = append(damage, translatePaneDamage(d, localContent, contentArea)...)
673+ }
674+ if consumeDamage {
675+ p.screen.ClearDamage()
601676 }
602677 p.mu.Unlock()
603678 }
604- return frame, damage
679+ return damage
680+}
681+
682+func sameTabLayoutSnapshot(a, b tabLayoutSnapshot) bool {
683+ return a.ok == b.ok && a.root == b.root && a.fingerprint == b.fingerprint && a.area == b.area && a.focus == b.focus
684+}
685+
686+func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement {
687+ pl.Content.X += dx
688+ pl.Content.Y += dy
689+ pl.TitleBar.X += dx
690+ pl.TitleBar.Y += dy
691+ return pl
692+}
693+
694+func clearFrameRect(frame renderer.Frame, r domain.Rect) {
695+ blank := renderer.BlankCell()
696+ for y := r.Y; y < r.Y+r.Height && y < frame.Height; y++ {
697+ for x := r.X; x < r.X+r.Width && x < frame.Width; x++ {
698+ frame.Set(x, y, blank)
699+ }
700+ }
605701 }
606702
607703 func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, theme themeui.Theme) {
@@ -332,14 +332,22 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
332 return332 return
333 }333 }
334 reset = ac.shouldResetOutputState(reset)334 reset = ac.shouldResetOutputState(reset)
335- if reset || overlays.copyActive || overlays.pickerActive || overlays.paletteActive || overlays.promptActive {335+ overlayActive := overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive
336+ if reset || overlayActive {
336 ac.rend.Reset()337 ac.rend.Reset()
337 ac.bars.Reset()338 ac.bars.Reset()
339+ ac.composed.invalidate()
338 }340 }
339 if reset || overlays.pickerActive || overlays.paletteActive || overlays.promptActive {341 if reset || overlays.pickerActive || overlays.paletteActive || overlays.promptActive {
340 ac.lastCursor.valid = false342 ac.lastCursor.valid = false
341 }343 }
342- frame, damage := composeClientFrameWithLayout(bars, tb, reset, layoutSnap, &ac.bars)344+ var frame renderer.Frame
345+ var damage []renderer.Damage
346+ if overlayActive {
347+ frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, nil)
348+ } else {
349+ frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, &ac.composed)
350+ }
343 if overlays.copyActive {351 if overlays.copyActive {
344 frame, damage = composeCopyClientFrame(overlays.copyMode, tb, bars)352 frame, damage = composeCopyClientFrame(overlays.copyMode, tb, bars)
345 }353 }
@@ -380,11 +388,6 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
380 if err == nil {388 if err == nil {
381 cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0)389 cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0)
382 }390 }
383- for _, pane := range tb.panesSnapshot() {
384- pane.mu.Lock()
385- pane.screen.ClearDamage()
386- pane.mu.Unlock()
387- }
388 tb.mu.Unlock()391 tb.mu.Unlock()
389 392
390 var serr error393 var serr error
@@ -500,6 +503,16 @@ func (c *barCache) Reset() {
500 c.bottom = nil503 c.bottom = nil
501 }504 }
502 505
506+type composedFrameCache struct {
507+ frame renderer.Frame
508+ valid bool
509+ layoutSnap tabLayoutSnapshot
510+}
511+
512+func (c *composedFrameCache) invalidate() {
513+ c.valid = false
514+}
515+
503 func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, caches ...*barCache) (renderer.Frame, []renderer.Damage) {516 func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
504 return composeClientFrameWithState(barState{status: sess.statusSegments(), copyFeedback: rightStatus}, tb, full, caches...)517 return composeClientFrameWithState(barState{status: sess.statusSegments(), copyFeedback: rightStatus}, tb, full, caches...)
505 }518 }
@@ -513,6 +526,18 @@ func composeClientFrameWithLayout(bars barState, tb *tab, full bool, layoutSnap
513 if len(caches) > 0 {526 if len(caches) > 0 {
514 cache = caches[0]527 cache = caches[0]
515 }528 }
529+ return composeClientFrameWithLayoutCached(bars, tb, full, layoutSnap, cache, nil)
530+}
531+
532+func composeClientFrameWithLayoutCached(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) {
533+ return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, false)
534+}
535+
536+func composeClientFrameWithLayoutCachedConsumeDamage(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) {
537+ return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, true)
538+}
539+
540+func composeClientFrameWithLayoutCachedOptions(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache, consumeDamage bool) (renderer.Frame, []renderer.Damage) {
516 p := tb.focusedPane()541 p := tb.focusedPane()
517 styles := newThemeStyles(bars.theme)542 styles := newThemeStyles(bars.theme)
518 if p == nil {543 if p == nil {
@@ -528,15 +553,34 @@ func composeClientFrameWithLayout(bars barState, tb *tab, full bool, layoutSnap
528 if tb.size.Valid() {553 if tb.size.Valid() {
529 width, screenRows = tb.size.Cols, tb.size.Rows554 width, screenRows = tb.size.Cols, tb.size.Rows
530 }555 }
531- frame := renderer.NewFrame(width, screenRows+2)556+ cacheValid := composed != nil && composed.valid && composed.frame.Width == width && composed.frame.Height == screenRows+2
557+ layoutSame := composed == nil || (cacheValid && sameTabLayoutSnapshot(composed.layoutSnap, layoutSnap))
558+ var frame renderer.Frame
559+ if cacheValid {
560+ frame = composed.frame
561+ } else {
562+ frame = renderer.NewFrame(width, screenRows+2)
563+ if composed != nil {
564+ full = true
565+ }
566+ }
567+ if !layoutSame {
568+ full = true
569+ }
570+ contentArea := domain.Rect{Y: 1, Width: width, Height: screenRows}
571+ if cacheValid && !layoutSame {
572+ clearFrameRect(frame, contentArea)
573+ }
532 topBar := frame.Row(0)574 topBar := frame.Row(0)
533 drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, bars.topRight, styles)575 drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, bars.topRight, styles)
534- contentFrame, contentDamage := composeTabFrameWithLayout(tb, domain.Rect{Width: width, Height: screenRows}, bars.theme, layoutSnap)576+ contentDamage := composeTabFrameIntoWithLayoutOptions(tb, frame, contentArea, bars.theme, layoutSnap, cacheValid && layoutSame, consumeDamage)
535- for y := range screenRows {
536- copy(frame.Row(y+1), contentFrame.Row(y))
537- }
538 bottomBar := frame.Row(screenRows + 1)577 bottomBar := frame.Row(screenRows + 1)
539 drawStatusBarState(bottomBar, bars, styles)578 drawStatusBarState(bottomBar, bars, styles)
579+ if composed != nil {
580+ composed.frame = frame
581+ composed.valid = true
582+ composed.layoutSnap = layoutSnap
583+ }
540 if full {584 if full {
541 if cache != nil {585 if cache != nil {
542 cache.capture(topBar, bottomBar)586 cache.capture(topBar, bottomBar)
@@ -562,20 +606,30 @@ func composeTabFrame(tb *tab, area domain.Rect, theme themeui.Theme) (renderer.F
562 606
563 func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot) (renderer.Frame, []renderer.Damage) {607 func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot) (renderer.Frame, []renderer.Damage) {
564 frame := renderer.NewFrame(area.Width, area.Height)608 frame := renderer.NewFrame(area.Width, area.Height)
609+ damage := composeTabFrameIntoWithLayout(tb, frame, area, theme, layoutSnap, false)
610+ return frame, damage
611+}
612+
613+func composeTabFrameIntoWithLayout(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool) []renderer.Damage {
614+ return composeTabFrameIntoWithLayoutOptions(tb, frame, area, theme, layoutSnap, cacheValid, false)
615+}
616+
617+func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool, consumeDamage bool) []renderer.Damage {
618+ contentArea := domain.Rect{Width: area.Width, Height: area.Height}
565 root := tb.tree.Root619 root := tb.tree.Root
566- placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == area620+ placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == contentArea
567 if !ok {621 if !ok {
568- placements, ok = layout.Solve(root, area)622+ placements, ok = layout.Solve(root, contentArea)
569 }623 }
570 var fallback *pane624 var fallback *pane
571 if !ok {625 if !ok {
572 fallback = tb.focusedPane()626 fallback = tb.focusedPane()
573 if fallback == nil {627 if fallback == nil {
574- return frame, nil628+ return nil
575 }629 }
576- placements = []layout.Placement{{ID: fallback.id, Content: area}}630+ placements = []layout.Placement{{ID: fallback.id, Content: contentArea}}
577 }631 }
578- if ok {632+ if ok && !cacheValid {
579 drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme))633 drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme))
580 }634 }
581 var damage []renderer.Damage635 var damage []renderer.Damage
@@ -588,20 +642,62 @@ func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, l
588 continue642 continue
589 }643 }
590 focused := tb.tree.Focus == pl.ID644 focused := tb.tree.Focus == pl.ID
645+ pl = offsetPlacement(pl, area.X, area.Y)
591 if pl.TitleBar.Height > 0 {646 if pl.TitleBar.Height > 0 {
592 drawPaneTitleBar(frame, pl, p, focused, theme, "")647 drawPaneTitleBar(frame, pl, p, focused, theme, "")
648+ if cacheValid {
649+ titleDamage := pl.TitleBar
650+ titleDamage.X -= area.X
651+ titleDamage.Y -= area.Y
652+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: titleDamage.X, Y: titleDamage.Y, Width: titleDamage.Width, Height: titleDamage.Height})
653+ }
593 }654 }
594 if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {655 if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {
656+ if consumeDamage {
657+ p.mu.Lock()
658+ p.screen.ClearDamage()
659+ p.mu.Unlock()
660+ }
595 continue661 continue
596 }662 }
597 p.mu.Lock()663 p.mu.Lock()
598- blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme)664+ paneDamage := p.screen.Damage()
599- for _, d := range p.screen.Damage() {665+ if !cacheValid || len(paneDamage) > 0 {
600- damage = append(damage, translatePaneDamage(d, pl.Content, area)...)666+ blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme)
667+ }
668+ for _, d := range paneDamage {
669+ localContent := pl.Content
670+ localContent.X -= area.X
671+ localContent.Y -= area.Y
672+ damage = append(damage, translatePaneDamage(d, localContent, contentArea)...)
673+ }
674+ if consumeDamage {
675+ p.screen.ClearDamage()
601 }676 }
602 p.mu.Unlock()677 p.mu.Unlock()
603 }678 }
604- return frame, damage679+ return damage
680+}
681+
682+func sameTabLayoutSnapshot(a, b tabLayoutSnapshot) bool {
683+ return a.ok == b.ok && a.root == b.root && a.fingerprint == b.fingerprint && a.area == b.area && a.focus == b.focus
684+}
685+
686+func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement {
687+ pl.Content.X += dx
688+ pl.Content.Y += dy
689+ pl.TitleBar.X += dx
690+ pl.TitleBar.Y += dy
691+ return pl
692+}
693+
694+func clearFrameRect(frame renderer.Frame, r domain.Rect) {
695+ blank := renderer.BlankCell()
696+ for y := r.Y; y < r.Y+r.Height && y < frame.Height; y++ {
697+ for x := r.X; x < r.X+r.Width && x < frame.Width; x++ {
698+ frame.Set(x, y, blank)
699+ }
700+ }
605 }701 }
606 702
607 func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, theme themeui.Theme) {703 func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, theme themeui.Theme) {
M internal/usecase/daemon/render_test.go +209 -0
@@ -26,6 +26,12 @@ import (
2626
2727 // --- test doubles -----------------------------------------------------------
2828
29+type discardTransport struct{}
30+
31+func (discardTransport) Send(ports.Frame) error { return nil }
32+func (discardTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
33+func (discardTransport) Close() error { return nil }
34+
2935 // stubClock returns timers whose channel never fires, so a scheduler under it
3036 // blocks in its debounce loop until the session context is cancelled. Used by
3137
@@ -430,6 +436,147 @@ func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *test
430436 require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 22, Y: 0, Width: 1, Height: 1, Count: 1})
431437 }
432438
439+func TestComposeClientFrameCacheSkipsUndamagedPaneBlits(t *testing.T) {
440+ win := newTab(nil, domain.Size{Cols: 41, Rows: 4})
441+ left := win.focusedPane()
442+ right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4})
443+ win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
444+ win.tree.Focus = right.id
445+ win.panes[right.id] = right
446+ left.screen.Write([]byte("L"))
447+ right.screen.Write([]byte("R"))
448+ var bars barCache
449+ var composed composedFrameCache
450+ sess := &session{id: "s", name: "work", tabs: []*tab{win}}
451+ composeClientFrameWithLayoutCached(barState{status: sess.statusSegments()}, win, true, solveTabLayoutLocked(win), &bars, &composed)
452+ left.screen.ClearDamage()
453+ right.screen.ClearDamage()
454+ left.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'Z'})
455+ right.screen.Write([]byte("x"))
456+
457+ frame, damage := composeClientFrameWithLayoutCached(barState{status: sess.statusSegments()}, win, false, solveTabLayoutLocked(win), &bars, &composed)
458+
459+ require.Equal(t, 'L', frame.At(0, 1).Rune, "undamaged left pane should remain cached, not re-blitted")
460+ require.Equal(t, 'x', frame.At(22, 1).Rune)
461+ require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 22, Y: 1, Width: 1, Height: 1, Count: 1})
462+}
463+
464+func TestPaintComposeConsumesOnlyDamageIncludedInFrame(t *testing.T) {
465+ win := newTab(nil, domain.Size{Cols: 20, Rows: 4})
466+ p := win.focusedPane()
467+ p.screen.Write([]byte("old"))
468+ var bars barCache
469+ var composed composedFrameCache
470+ state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()}
471+
472+ _, damage := composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
473+ require.NotEmpty(t, damage)
474+ require.Empty(t, p.screen.Damage(), "paint compose should consume damage it included in the frame")
475+
476+ p.screen.Write([]byte("new"))
477+ require.NotEmpty(t, p.screen.Damage(), "damage produced after paint compose must remain for a later render")
478+
479+ _, _ = composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
480+ require.NotEmpty(t, p.screen.Damage(), "standalone compose helpers should remain non-consuming")
481+}
482+
483+func TestPaintComposeClearsCollapsedPaneDamage(t *testing.T) {
484+ win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
485+ p1 := win.focusedPane()
486+ p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
487+ win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p2.id}
488+ win.tree.Focus = p2.id
489+ win.panes[p2.id] = p2
490+ p1.screen.Write([]byte("hidden"))
491+ p2.screen.Write([]byte("shown"))
492+ var bars barCache
493+ var composed composedFrameCache
494+ state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()}
495+
496+ _, _ = composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
497+
498+ require.Empty(t, p1.screen.Damage(), "collapsed pane damage should not accumulate while paint renders stack title bars")
499+ require.Empty(t, p2.screen.Damage())
500+}
501+
502+func TestComposeClientFrameCacheFocusChangeReblitsDimmedPanes(t *testing.T) {
503+ win := newTab(nil, domain.Size{Cols: 41, Rows: 4})
504+ left := win.focusedPane()
505+ right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4})
506+ win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
507+ win.panes[right.id] = right
508+ left.screen.Write([]byte("L"))
509+ right.screen.Write([]byte("R"))
510+ theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}}
511+ var bars barCache
512+ var composed composedFrameCache
513+ state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(), theme: theme}
514+ win.tree.Focus = left.id
515+ composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)
516+ left.screen.ClearDamage()
517+ right.screen.ClearDamage()
518+ leftWasFocused := composed.frame.At(0, 1).Style
519+ rightWasDimmed := composed.frame.At(21, 1).Style
520+
521+ win.tree.Focus = right.id
522+ frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
523+
524+ require.Equal(t, rightWasDimmed, frame.At(0, 1).Style, "previously focused pane should be re-blitted dimmed")
525+ require.Equal(t, leftWasFocused, frame.At(21, 1).Style, "newly focused pane should be re-blitted undimmed")
526+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
527+}
528+
529+func TestComposeClientFrameCacheLayoutChangeClearsStaleDividers(t *testing.T) {
530+ win := newTab(nil, domain.Size{Cols: 41, Rows: 5})
531+ left := win.focusedPane()
532+ right := newPane("pane-2", nil, domain.Size{Cols: 41, Rows: 5})
533+ win.panes[right.id] = right
534+ left.screen.Write([]byte("L"))
535+ right.screen.Write([]byte("R"))
536+ var bars barCache
537+ var composed composedFrameCache
538+ state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()}
539+ win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
540+ composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)
541+ require.Equal(t, '│', composed.frame.At(20, 1).Rune)
542+ left.screen.ClearDamage()
543+ right.screen.ClearDamage()
544+
545+ win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Vertical, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
546+ frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
547+
548+ require.NotEqual(t, '│', frame.At(20, 1).Rune, "old vertical divider must be cleared when layout changes")
549+ require.Equal(t, '─', frame.At(20, 3).Rune)
550+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
551+}
552+
553+func BenchmarkPaintCachedSinglePaneDamage(b *testing.B) {
554+ win := newTab(nil, domain.Size{Cols: 81, Rows: 24})
555+ left := win.focusedPane()
556+ right := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 24})
557+ win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
558+ win.tree.Focus = right.id
559+ win.panes[right.id] = right
560+ left.screen.Write([]byte("left"))
561+ right.screen.Write([]byte("right"))
562+ sess := &session{id: "s", name: "work", tabs: []*tab{win}, active: 0}
563+ ac := &attachedClient{tr: discardTransport{}, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 81, Rows: 26}}
564+ ac.initOverlays()
565+ sess.client = ac
566+ ac.setSession(sess)
567+ d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
568+ d.paint(sess, ac, true)
569+ left.screen.ClearDamage()
570+ right.screen.ClearDamage()
571+
572+ b.ReportAllocs()
573+ b.ResetTimer()
574+ for i := 0; i < b.N; i++ {
575+ right.screen.Write([]byte("x\b"))
576+ d.paint(sess, ac, false)
577+ }
578+}
579+
433580 func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {
434581 win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
435582 p1 := win.focusedPane()
@@ -454,6 +601,31 @@ func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {
454601 require.True(t, frame.At(0, 1).Style.Inverse || frame.At(0, 1).Style.HasBackgroundRGB, "focused title bar should use accent chrome")
455602 }
456603
604+func TestComposeClientFrameCacheRefreshesStackTitleBars(t *testing.T) {
605+ win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
606+ p1 := win.focusedPane()
607+ p1.title = "one"
608+ p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
609+ p2.title = "two"
610+ p2.screen.Write([]byte("T"))
611+ win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p2.id}
612+ win.tree.Focus = p2.id
613+ win.panes[p2.id] = p2
614+ var bars barCache
615+ var composed composedFrameCache
616+ state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()}
617+ composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)
618+ p1.screen.ClearDamage()
619+ p2.screen.ClearDamage()
620+
621+ p1.title = "renamed"
622+ frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
623+
624+ require.Equal(t, "renamed", rowText(frame.Row(1))[:7])
625+ require.NotEqual(t, []renderer.Damage{renderer.FullRedraw()}, damage, "title-only changes should not force a full layout reset")
626+ require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1})
627+}
628+
457629 func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
458630 tests := []struct {
459631 name string
@@ -524,6 +696,43 @@ func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
524696 }
525697 }
526698
699+func TestOverlayPaintBypassesComposedCache(t *testing.T) {
700+ p, release := newBlockingPTY(t)
701+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
702+ defer release()
703+ pane := sess.tabs[0].focusedPane()
704+ pane.screen.Write([]byte("live"))
705+
706+ d.paint(sess, ac, true)
707+ _ = mustOutputData(t, sends)
708+ require.True(t, ac.composed.valid)
709+ require.NotContains(t, frameText(ac.composed.frame), "Rename session")
710+
711+ d.enterPrompt(sess, ac, " Rename session ", "work", func(string) error { return nil })
712+ shown := string(mustOutputData(t, sends))
713+ require.Contains(t, shown, "Rename session")
714+ require.False(t, ac.composed.valid, "overlay paint must not store the modal-mutated frame in the composed cache")
715+ require.NotContains(t, frameText(ac.composed.frame), "Rename session")
716+
717+ d.closePrompt(ac)
718+ d.paint(sess, ac, false)
719+ restored := string(mustOutputData(t, sends))
720+ require.NotContains(t, restored, "Rename session")
721+ require.True(t, ac.composed.valid)
722+ cached := frameText(ac.composed.frame)
723+ require.Contains(t, cached, "live")
724+ require.NotContains(t, cached, "Rename session")
725+}
726+
727+func frameText(frame renderer.Frame) string {
728+ var b strings.Builder
729+ for y := 0; y < frame.Height; y++ {
730+ b.WriteString(rowText(frame.Row(y)))
731+ b.WriteByte('\n')
732+ }
733+ return b.String()
734+}
735+
527736 func TestPaintEarlyReturnReleasesOverlayRenderLocks(t *testing.T) {
528737 tests := []struct {
529738 name string
@@ -26,6 +26,12 @@ import (
26 26
27 // --- test doubles -----------------------------------------------------------27 // --- test doubles -----------------------------------------------------------
28 28
29+type discardTransport struct{}
30+
31+func (discardTransport) Send(ports.Frame) error { return nil }
32+func (discardTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
33+func (discardTransport) Close() error { return nil }
34+
29 // stubClock returns timers whose channel never fires, so a scheduler under it35 // stubClock returns timers whose channel never fires, so a scheduler under it
30 // blocks in its debounce loop until the session context is cancelled. Used by36 // blocks in its debounce loop until the session context is cancelled. Used by
31 37
@@ -430,6 +436,147 @@ func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *test
430 require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 22, Y: 0, Width: 1, Height: 1, Count: 1})436 require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 22, Y: 0, Width: 1, Height: 1, Count: 1})
431 }437 }
432 438
439+func TestComposeClientFrameCacheSkipsUndamagedPaneBlits(t *testing.T) {
440+ win := newTab(nil, domain.Size{Cols: 41, Rows: 4})
441+ left := win.focusedPane()
442+ right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4})
443+ win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
444+ win.tree.Focus = right.id
445+ win.panes[right.id] = right
446+ left.screen.Write([]byte("L"))
447+ right.screen.Write([]byte("R"))
448+ var bars barCache
449+ var composed composedFrameCache
450+ sess := &session{id: "s", name: "work", tabs: []*tab{win}}
451+ composeClientFrameWithLayoutCached(barState{status: sess.statusSegments()}, win, true, solveTabLayoutLocked(win), &bars, &composed)
452+ left.screen.ClearDamage()
453+ right.screen.ClearDamage()
454+ left.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'Z'})
455+ right.screen.Write([]byte("x"))
456+
457+ frame, damage := composeClientFrameWithLayoutCached(barState{status: sess.statusSegments()}, win, false, solveTabLayoutLocked(win), &bars, &composed)
458+
459+ require.Equal(t, 'L', frame.At(0, 1).Rune, "undamaged left pane should remain cached, not re-blitted")
460+ require.Equal(t, 'x', frame.At(22, 1).Rune)
461+ require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 22, Y: 1, Width: 1, Height: 1, Count: 1})
462+}
463+
464+func TestPaintComposeConsumesOnlyDamageIncludedInFrame(t *testing.T) {
465+ win := newTab(nil, domain.Size{Cols: 20, Rows: 4})
466+ p := win.focusedPane()
467+ p.screen.Write([]byte("old"))
468+ var bars barCache
469+ var composed composedFrameCache
470+ state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()}
471+
472+ _, damage := composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
473+ require.NotEmpty(t, damage)
474+ require.Empty(t, p.screen.Damage(), "paint compose should consume damage it included in the frame")
475+
476+ p.screen.Write([]byte("new"))
477+ require.NotEmpty(t, p.screen.Damage(), "damage produced after paint compose must remain for a later render")
478+
479+ _, _ = composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
480+ require.NotEmpty(t, p.screen.Damage(), "standalone compose helpers should remain non-consuming")
481+}
482+
483+func TestPaintComposeClearsCollapsedPaneDamage(t *testing.T) {
484+ win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
485+ p1 := win.focusedPane()
486+ p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
487+ win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p2.id}
488+ win.tree.Focus = p2.id
489+ win.panes[p2.id] = p2
490+ p1.screen.Write([]byte("hidden"))
491+ p2.screen.Write([]byte("shown"))
492+ var bars barCache
493+ var composed composedFrameCache
494+ state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()}
495+
496+ _, _ = composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
497+
498+ require.Empty(t, p1.screen.Damage(), "collapsed pane damage should not accumulate while paint renders stack title bars")
499+ require.Empty(t, p2.screen.Damage())
500+}
501+
502+func TestComposeClientFrameCacheFocusChangeReblitsDimmedPanes(t *testing.T) {
503+ win := newTab(nil, domain.Size{Cols: 41, Rows: 4})
504+ left := win.focusedPane()
505+ right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4})
506+ win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
507+ win.panes[right.id] = right
508+ left.screen.Write([]byte("L"))
509+ right.screen.Write([]byte("R"))
510+ theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}}
511+ var bars barCache
512+ var composed composedFrameCache
513+ state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(), theme: theme}
514+ win.tree.Focus = left.id
515+ composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)
516+ left.screen.ClearDamage()
517+ right.screen.ClearDamage()
518+ leftWasFocused := composed.frame.At(0, 1).Style
519+ rightWasDimmed := composed.frame.At(21, 1).Style
520+
521+ win.tree.Focus = right.id
522+ frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
523+
524+ require.Equal(t, rightWasDimmed, frame.At(0, 1).Style, "previously focused pane should be re-blitted dimmed")
525+ require.Equal(t, leftWasFocused, frame.At(21, 1).Style, "newly focused pane should be re-blitted undimmed")
526+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
527+}
528+
529+func TestComposeClientFrameCacheLayoutChangeClearsStaleDividers(t *testing.T) {
530+ win := newTab(nil, domain.Size{Cols: 41, Rows: 5})
531+ left := win.focusedPane()
532+ right := newPane("pane-2", nil, domain.Size{Cols: 41, Rows: 5})
533+ win.panes[right.id] = right
534+ left.screen.Write([]byte("L"))
535+ right.screen.Write([]byte("R"))
536+ var bars barCache
537+ var composed composedFrameCache
538+ state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()}
539+ win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
540+ composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)
541+ require.Equal(t, '│', composed.frame.At(20, 1).Rune)
542+ left.screen.ClearDamage()
543+ right.screen.ClearDamage()
544+
545+ win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Vertical, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
546+ frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
547+
548+ require.NotEqual(t, '│', frame.At(20, 1).Rune, "old vertical divider must be cleared when layout changes")
549+ require.Equal(t, '─', frame.At(20, 3).Rune)
550+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
551+}
552+
553+func BenchmarkPaintCachedSinglePaneDamage(b *testing.B) {
554+ win := newTab(nil, domain.Size{Cols: 81, Rows: 24})
555+ left := win.focusedPane()
556+ right := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 24})
557+ win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
558+ win.tree.Focus = right.id
559+ win.panes[right.id] = right
560+ left.screen.Write([]byte("left"))
561+ right.screen.Write([]byte("right"))
562+ sess := &session{id: "s", name: "work", tabs: []*tab{win}, active: 0}
563+ ac := &attachedClient{tr: discardTransport{}, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 81, Rows: 26}}
564+ ac.initOverlays()
565+ sess.client = ac
566+ ac.setSession(sess)
567+ d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
568+ d.paint(sess, ac, true)
569+ left.screen.ClearDamage()
570+ right.screen.ClearDamage()
571+
572+ b.ReportAllocs()
573+ b.ResetTimer()
574+ for i := 0; i < b.N; i++ {
575+ right.screen.Write([]byte("x\b"))
576+ d.paint(sess, ac, false)
577+ }
578+}
579+
433 func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {580 func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {
434 win := newTab(nil, domain.Size{Cols: 20, Rows: 5})581 win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
435 p1 := win.focusedPane()582 p1 := win.focusedPane()
@@ -454,6 +601,31 @@ func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {
454 require.True(t, frame.At(0, 1).Style.Inverse || frame.At(0, 1).Style.HasBackgroundRGB, "focused title bar should use accent chrome")601 require.True(t, frame.At(0, 1).Style.Inverse || frame.At(0, 1).Style.HasBackgroundRGB, "focused title bar should use accent chrome")
455 }602 }
456 603
604+func TestComposeClientFrameCacheRefreshesStackTitleBars(t *testing.T) {
605+ win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
606+ p1 := win.focusedPane()
607+ p1.title = "one"
608+ p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
609+ p2.title = "two"
610+ p2.screen.Write([]byte("T"))
611+ win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p2.id}
612+ win.tree.Focus = p2.id
613+ win.panes[p2.id] = p2
614+ var bars barCache
615+ var composed composedFrameCache
616+ state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()}
617+ composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)
618+ p1.screen.ClearDamage()
619+ p2.screen.ClearDamage()
620+
621+ p1.title = "renamed"
622+ frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
623+
624+ require.Equal(t, "renamed", rowText(frame.Row(1))[:7])
625+ require.NotEqual(t, []renderer.Damage{renderer.FullRedraw()}, damage, "title-only changes should not force a full layout reset")
626+ require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1})
627+}
628+
457 func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {629 func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
458 tests := []struct {630 tests := []struct {
459 name string631 name string
@@ -524,6 +696,43 @@ func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
524 }696 }
525 }697 }
526 698
699+func TestOverlayPaintBypassesComposedCache(t *testing.T) {
700+ p, release := newBlockingPTY(t)
701+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
702+ defer release()
703+ pane := sess.tabs[0].focusedPane()
704+ pane.screen.Write([]byte("live"))
705+
706+ d.paint(sess, ac, true)
707+ _ = mustOutputData(t, sends)
708+ require.True(t, ac.composed.valid)
709+ require.NotContains(t, frameText(ac.composed.frame), "Rename session")
710+
711+ d.enterPrompt(sess, ac, " Rename session ", "work", func(string) error { return nil })
712+ shown := string(mustOutputData(t, sends))
713+ require.Contains(t, shown, "Rename session")
714+ require.False(t, ac.composed.valid, "overlay paint must not store the modal-mutated frame in the composed cache")
715+ require.NotContains(t, frameText(ac.composed.frame), "Rename session")
716+
717+ d.closePrompt(ac)
718+ d.paint(sess, ac, false)
719+ restored := string(mustOutputData(t, sends))
720+ require.NotContains(t, restored, "Rename session")
721+ require.True(t, ac.composed.valid)
722+ cached := frameText(ac.composed.frame)
723+ require.Contains(t, cached, "live")
724+ require.NotContains(t, cached, "Rename session")
725+}
726+
727+func frameText(frame renderer.Frame) string {
728+ var b strings.Builder
729+ for y := 0; y < frame.Height; y++ {
730+ b.WriteString(rowText(frame.Row(y)))
731+ b.WriteByte('\n')
732+ }
733+ return b.String()
734+}
735+
527 func TestPaintEarlyReturnReleasesOverlayRenderLocks(t *testing.T) {736 func TestPaintEarlyReturnReleasesOverlayRenderLocks(t *testing.T) {
528 tests := []struct {737 tests := []struct {
529 name string738 name string
M internal/usecase/daemon/session.go +4 -3
@@ -660,15 +660,16 @@ func (d *Daemon) nameLiveOrStoppedLocked(name string) bool {
660660 }
661661
662662 func (d *Daemon) cwdSampler(ctx context.Context) {
663- t := time.NewTicker(5 * time.Second)
663+ t := d.clock.NewTimer(5 * time.Second)
664664 defer t.Stop()
665665 for {
666666 select {
667667 case <-ctx.Done():
668668 return
669- case <-t.C:
670- d.refreshNamedSessionCwds()
669+ case <-t.C():
671670 }
671+ d.refreshNamedSessionCwds()
672+ t.Reset(5 * time.Second)
672673 }
673674 }
674675
@@ -660,15 +660,16 @@ func (d *Daemon) nameLiveOrStoppedLocked(name string) bool {
660 }660 }
661 661
662 func (d *Daemon) cwdSampler(ctx context.Context) {662 func (d *Daemon) cwdSampler(ctx context.Context) {
663- t := time.NewTicker(5 * time.Second)663+ t := d.clock.NewTimer(5 * time.Second)
664 defer t.Stop()664 defer t.Stop()
665 for {665 for {
666 select {666 select {
667 case <-ctx.Done():667 case <-ctx.Done():
668 return668 return
669- case <-t.C:669+ case <-t.C():
670- d.refreshNamedSessionCwds()
671 }670 }
671+ d.refreshNamedSessionCwds()
672+ t.Reset(5 * time.Second)
672 }673 }
673 }674 }
674 675
M internal/usecase/daemon/status_test.go +47 -0
@@ -12,6 +12,7 @@ import (
1212
1313 "github.com/bnema/vev/internal/domain"
1414 "github.com/bnema/vev/internal/ports"
15+ "github.com/bnema/vev/internal/usecase/layout"
1516 themeui "github.com/bnema/vev/internal/usecase/theme"
1617 "github.com/bnema/vev/pkg/renderer"
1718 )
@@ -21,6 +22,52 @@ import (
2122 // stubClock returns timers whose channel never fires, so a scheduler under it
2223 // blocks in its debounce loop until the session context is cancelled. Used by
2324
25+func TestApplyConfigThemeRepaintInvalidatesComposedFrameCache(t *testing.T) {
26+ p, releasePTY := newBlockingPTY(t)
27+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
28+ defer releasePTY()
29+ d.ApplyConfig(domain.Config{Theme: domain.ThemeLight})
30+
31+ win := sess.activeTab()
32+ left := win.focusedPane()
33+ right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4})
34+ win.mu.Lock()
35+ win.size = domain.Size{Cols: 41, Rows: 4}
36+ win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
37+ win.tree.Focus = left.id
38+ win.panes[right.id] = right
39+ win.mu.Unlock()
40+ left.mu.Lock()
41+ left.screen.Resize(20, 4)
42+ left.screen.Write([]byte("L"))
43+ left.mu.Unlock()
44+ right.mu.Lock()
45+ right.screen.Resize(20, 4)
46+ right.screen.Write([]byte("R"))
47+ right.mu.Unlock()
48+
49+ d.paint(sess, ac, true)
50+ ac.sendMu.Lock()
51+ require.True(t, ac.composed.valid)
52+ lightDimmedPane := ac.composed.frame.At(21, 1).Style
53+ lightDivider := ac.composed.frame.At(20, 1).Style
54+ ac.sendMu.Unlock()
55+ left.mu.Lock()
56+ left.screen.ClearDamage()
57+ left.mu.Unlock()
58+ right.mu.Lock()
59+ right.screen.ClearDamage()
60+ right.mu.Unlock()
61+
62+ d.ApplyConfig(domain.Config{Theme: domain.ThemeDark})
63+
64+ ac.sendMu.Lock()
65+ defer ac.sendMu.Unlock()
66+ require.True(t, ac.composed.valid, "reset=false config repaint should rebuild the composed cache")
67+ require.NotEqual(t, lightDimmedPane, ac.composed.frame.At(21, 1).Style, "dimmed pane style must not stay cached across theme reapply")
68+ require.NotEqual(t, lightDivider, ac.composed.frame.At(20, 1).Style, "divider style must not stay cached across theme reapply")
69+}
70+
2471 func TestStatusCompositionGolden(t *testing.T) {
2572 p1, releasePTY1 := newBlockingPTY(t)
2673 p2, releasePTY2 := newBlockingPTY(t)
@@ -12,6 +12,7 @@ import (
12 12
13 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
14 "github.com/bnema/vev/internal/ports"14 "github.com/bnema/vev/internal/ports"
15+ "github.com/bnema/vev/internal/usecase/layout"
15 themeui "github.com/bnema/vev/internal/usecase/theme"16 themeui "github.com/bnema/vev/internal/usecase/theme"
16 "github.com/bnema/vev/pkg/renderer"17 "github.com/bnema/vev/pkg/renderer"
17 )18 )
@@ -21,6 +22,52 @@ import (
21 // stubClock returns timers whose channel never fires, so a scheduler under it22 // stubClock returns timers whose channel never fires, so a scheduler under it
22 // blocks in its debounce loop until the session context is cancelled. Used by23 // blocks in its debounce loop until the session context is cancelled. Used by
23 24
25+func TestApplyConfigThemeRepaintInvalidatesComposedFrameCache(t *testing.T) {
26+ p, releasePTY := newBlockingPTY(t)
27+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
28+ defer releasePTY()
29+ d.ApplyConfig(domain.Config{Theme: domain.ThemeLight})
30+
31+ win := sess.activeTab()
32+ left := win.focusedPane()
33+ right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4})
34+ win.mu.Lock()
35+ win.size = domain.Size{Cols: 41, Rows: 4}
36+ win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
37+ win.tree.Focus = left.id
38+ win.panes[right.id] = right
39+ win.mu.Unlock()
40+ left.mu.Lock()
41+ left.screen.Resize(20, 4)
42+ left.screen.Write([]byte("L"))
43+ left.mu.Unlock()
44+ right.mu.Lock()
45+ right.screen.Resize(20, 4)
46+ right.screen.Write([]byte("R"))
47+ right.mu.Unlock()
48+
49+ d.paint(sess, ac, true)
50+ ac.sendMu.Lock()
51+ require.True(t, ac.composed.valid)
52+ lightDimmedPane := ac.composed.frame.At(21, 1).Style
53+ lightDivider := ac.composed.frame.At(20, 1).Style
54+ ac.sendMu.Unlock()
55+ left.mu.Lock()
56+ left.screen.ClearDamage()
57+ left.mu.Unlock()
58+ right.mu.Lock()
59+ right.screen.ClearDamage()
60+ right.mu.Unlock()
61+
62+ d.ApplyConfig(domain.Config{Theme: domain.ThemeDark})
63+
64+ ac.sendMu.Lock()
65+ defer ac.sendMu.Unlock()
66+ require.True(t, ac.composed.valid, "reset=false config repaint should rebuild the composed cache")
67+ require.NotEqual(t, lightDimmedPane, ac.composed.frame.At(21, 1).Style, "dimmed pane style must not stay cached across theme reapply")
68+ require.NotEqual(t, lightDivider, ac.composed.frame.At(20, 1).Style, "divider style must not stay cached across theme reapply")
69+}
70+
24 func TestStatusCompositionGolden(t *testing.T) {71 func TestStatusCompositionGolden(t *testing.T) {
25 p1, releasePTY1 := newBlockingPTY(t)72 p1, releasePTY1 := newBlockingPTY(t)
26 p2, releasePTY2 := newBlockingPTY(t)73 p2, releasePTY2 := newBlockingPTY(t)
M internal/usecase/daemon/theme_resolve.go +6 -0
@@ -3,6 +3,11 @@ package daemon
33 import "github.com/bnema/vev/internal/usecase/theme"
44
55 func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {
6+ if ac != nil {
7+ ac.sendMu.Lock()
8+ defer ac.sendMu.Unlock()
9+ }
10+
611 sess.mu.Lock()
712 if ac != nil {
813 if sess.client != ac {
@@ -18,6 +23,7 @@ func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme
1823
1924 if ac != nil {
2025 ac.setTheme(t)
26+ ac.composed.invalidate()
2127 }
2228 for _, tb := range tabs {
2329 tb.mu.Lock()
@@ -3,6 +3,11 @@ package daemon
3 import "github.com/bnema/vev/internal/usecase/theme"3 import "github.com/bnema/vev/internal/usecase/theme"
4 4
5 func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {5 func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {
6+ if ac != nil {
7+ ac.sendMu.Lock()
8+ defer ac.sendMu.Unlock()
9+ }
10+
6 sess.mu.Lock()11 sess.mu.Lock()
7 if ac != nil {12 if ac != nil {
8 if sess.client != ac {13 if sess.client != ac {
@@ -18,6 +23,7 @@ func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme
18 23
19 if ac != nil {24 if ac != nil {
20 ac.setTheme(t)25 ac.setTheme(t)
26+ ac.composed.invalidate()
21 }27 }
22 for _, tb := range tabs {28 for _, tb := range tabs {
23 tb.mu.Lock()29 tb.mu.Lock()
M pkg/vt/screen.go +104 -44
@@ -81,8 +81,10 @@ type Screen struct {
8181 defaultBG renderer.RGB
8282 defaultColorsKnown bool
8383
84- damage []renderer.Damage
85- escapeBuf []byte
84+ damage []renderer.Damage
85+ escapeBuf []byte
86+ csiScratch []int
87+ sgrScratch []int
8688
8789 scrollTop int
8890 scrollBottom int
@@ -784,7 +786,7 @@ func consumeSTString(data []byte) (consumed int, partial bool) {
784786
785787 func (s *Screen) applyCSI(params string, cmd byte) {
786788 private := strings.HasPrefix(params, "?")
787- parts := parseCSIInts(params)
789+ parts := s.parseCSIInts(params)
788790 switch cmd {
789791 case 'c':
790792 switch params {
@@ -952,7 +954,7 @@ func (s *Screen) applySGR(params string) {
952954 s.Style = renderer.DefaultStyle()
953955 return
954956 }
955- parts := parseSGRInts(params)
957+ parts := s.parseSGRInts(params)
956958 for i := 0; i < len(parts); i++ {
957959 switch parts[i] {
958960 case 0:
@@ -1309,66 +1311,124 @@ func (s *Screen) clampCursor() {
13091311 s.Col = clamp(s.Col, 0, s.Frame.Width-1)
13101312 }
13111313
1314+func (s *Screen) parseCSIInts(params string) []int {
1315+ s.csiScratch = parseCSIIntsInto(s.csiScratch[:0], params)
1316+ return s.csiScratch
1317+}
1318+
13121319 func parseCSIInts(params string) []int {
1320+ return parseCSIIntsInto(nil, params)
1321+}
1322+
1323+func parseCSIIntsInto(out []int, params string) []int {
13131324 if params == "" {
1314- return nil
1325+ return out
13151326 }
13161327 params = strings.TrimPrefix(params, "?")
13171328 params = strings.TrimPrefix(params, ">")
1318- parts := strings.Split(params, ";")
1319- out := make([]int, 0, len(parts))
1320- for _, p := range parts {
1321- out = append(out, parseCSIInt(p))
1329+ start := 0
1330+ for i := 0; i <= len(params); i++ {
1331+ if i == len(params) || params[i] == ';' {
1332+ out = append(out, parseCSIInt(params[start:i]))
1333+ start = i + 1
1334+ }
13221335 }
13231336 return out
13241337 }
13251338
1339+func (s *Screen) parseSGRInts(params string) []int {
1340+ s.sgrScratch = parseSGRIntsInto(s.sgrScratch[:0], params)
1341+ return s.sgrScratch
1342+}
1343+
13261344 func parseSGRInts(params string) []int {
1345+ return parseSGRIntsInto(nil, params)
1346+}
1347+
1348+func parseSGRIntsInto(out []int, params string) []int {
13271349 if params == "" {
1328- return nil
1350+ return out
13291351 }
1330- groups := strings.Split(params, ";")
1331- out := make([]int, 0, len(groups))
1332- for _, group := range groups {
1333- if !strings.Contains(group, ":") {
1334- out = append(out, parseCSIInt(group))
1335- continue
1352+ start := 0
1353+ for i := 0; i <= len(params); i++ {
1354+ if i == len(params) || params[i] == ';' {
1355+ out = appendSGRGroup(out, params[start:i])
1356+ start = i + 1
13361357 }
1337- sub := strings.Split(group, ":")
1338- code := parseCSIInt(sub[0])
1339- if code != 38 && code != 48 {
1340- out = append(out, code)
1341- continue
1358+ }
1359+ return out
1360+}
1361+
1362+func appendSGRGroup(out []int, group string) []int {
1363+ colon := false
1364+ for i := 0; i < len(group); i++ {
1365+ if group[i] == ':' {
1366+ colon = true
1367+ break
13421368 }
1343- mode := 0
1344- if len(sub) > 1 {
1345- mode = parseCSIInt(sub[1])
1369+ }
1370+ if !colon {
1371+ return append(out, parseCSIInt(group))
1372+ }
1373+
1374+ parts := countSGRColonFields(group)
1375+ code := parseCSIInt(sgrColonField(group, 0))
1376+ if code != 38 && code != 48 {
1377+ return append(out, code)
1378+ }
1379+ mode := 0
1380+ if parts > 1 {
1381+ mode = parseCSIInt(sgrColonField(group, 1))
1382+ }
1383+ switch mode {
1384+ case 5:
1385+ if parts < 3 {
1386+ return out
13461387 }
1347- switch mode {
1348- case 5:
1349- if len(sub) < 3 {
1350- continue
1351- }
1352- out = append(out, code, mode, parseCSIInt(sub[2]))
1353- case 2:
1354- // code:mode::R:G:B and code:mode:cs:R:G:B both put RGB after
1355- // the colorspace slot; code:mode:R:G:B omits that slot.
1356- start := 2
1357- if len(sub) > 2 && (sub[2] == "" || len(sub) >= 6) {
1358- start = 3
1359- }
1360- if len(sub) < start+3 {
1361- continue
1362- }
1363- out = append(out, code, mode)
1364- for i := 0; i < 3; i++ {
1365- out = append(out, parseCSIInt(sub[start+i]))
1366- }
1388+ out = append(out, code, mode, parseCSIInt(sgrColonField(group, 2)))
1389+ case 2:
1390+ // code:mode::R:G:B and code:mode:cs:R:G:B both put RGB after
1391+ // the colorspace slot; code:mode:R:G:B omits that slot.
1392+ start := 2
1393+ if parts > 2 && (sgrColonField(group, 2) == "" || parts >= 6) {
1394+ start = 3
1395+ }
1396+ if parts < start+3 {
1397+ return out
1398+ }
1399+ out = append(out, code, mode)
1400+ for i := 0; i < 3; i++ {
1401+ out = append(out, parseCSIInt(sgrColonField(group, start+i)))
13671402 }
13681403 }
13691404 return out
13701405 }
13711406
1407+func countSGRColonFields(group string) int {
1408+ count := 1
1409+ for i := 0; i < len(group); i++ {
1410+ if group[i] == ':' {
1411+ count++
1412+ }
1413+ }
1414+ return count
1415+}
1416+
1417+func sgrColonField(group string, index int) string {
1418+ start := 0
1419+ field := 0
1420+ for i := 0; i <= len(group); i++ {
1421+ if i == len(group) || group[i] == ':' {
1422+ if field == index {
1423+ return group[start:i]
1424+ }
1425+ field++
1426+ start = i + 1
1427+ }
1428+ }
1429+ return ""
1430+}
1431+
13721432 func parseCSIInt(param string) int {
13731433 if param == "" {
13741434 return 0
@@ -81,8 +81,10 @@ type Screen struct {
81 defaultBG renderer.RGB81 defaultBG renderer.RGB
82 defaultColorsKnown bool82 defaultColorsKnown bool
83 83
84- damage []renderer.Damage84+ damage []renderer.Damage
85- escapeBuf []byte85+ escapeBuf []byte
86+ csiScratch []int
87+ sgrScratch []int
86 88
87 scrollTop int89 scrollTop int
88 scrollBottom int90 scrollBottom int
@@ -784,7 +786,7 @@ func consumeSTString(data []byte) (consumed int, partial bool) {
784 786
785 func (s *Screen) applyCSI(params string, cmd byte) {787 func (s *Screen) applyCSI(params string, cmd byte) {
786 private := strings.HasPrefix(params, "?")788 private := strings.HasPrefix(params, "?")
787- parts := parseCSIInts(params)789+ parts := s.parseCSIInts(params)
788 switch cmd {790 switch cmd {
789 case 'c':791 case 'c':
790 switch params {792 switch params {
@@ -952,7 +954,7 @@ func (s *Screen) applySGR(params string) {
952 s.Style = renderer.DefaultStyle()954 s.Style = renderer.DefaultStyle()
953 return955 return
954 }956 }
955- parts := parseSGRInts(params)957+ parts := s.parseSGRInts(params)
956 for i := 0; i < len(parts); i++ {958 for i := 0; i < len(parts); i++ {
957 switch parts[i] {959 switch parts[i] {
958 case 0:960 case 0:
@@ -1309,66 +1311,124 @@ func (s *Screen) clampCursor() {
1309 s.Col = clamp(s.Col, 0, s.Frame.Width-1)1311 s.Col = clamp(s.Col, 0, s.Frame.Width-1)
1310 }1312 }
1311 1313
1314+func (s *Screen) parseCSIInts(params string) []int {
1315+ s.csiScratch = parseCSIIntsInto(s.csiScratch[:0], params)
1316+ return s.csiScratch
1317+}
1318+
1312 func parseCSIInts(params string) []int {1319 func parseCSIInts(params string) []int {
1320+ return parseCSIIntsInto(nil, params)
1321+}
1322+
1323+func parseCSIIntsInto(out []int, params string) []int {
1313 if params == "" {1324 if params == "" {
1314- return nil1325+ return out
1315 }1326 }
1316 params = strings.TrimPrefix(params, "?")1327 params = strings.TrimPrefix(params, "?")
1317 params = strings.TrimPrefix(params, ">")1328 params = strings.TrimPrefix(params, ">")
1318- parts := strings.Split(params, ";")1329+ start := 0
1319- out := make([]int, 0, len(parts))1330+ for i := 0; i <= len(params); i++ {
1320- for _, p := range parts {1331+ if i == len(params) || params[i] == ';' {
1321- out = append(out, parseCSIInt(p))1332+ out = append(out, parseCSIInt(params[start:i]))
1333+ start = i + 1
1334+ }
1322 }1335 }
1323 return out1336 return out
1324 }1337 }
1325 1338
1339+func (s *Screen) parseSGRInts(params string) []int {
1340+ s.sgrScratch = parseSGRIntsInto(s.sgrScratch[:0], params)
1341+ return s.sgrScratch
1342+}
1343+
1326 func parseSGRInts(params string) []int {1344 func parseSGRInts(params string) []int {
1345+ return parseSGRIntsInto(nil, params)
1346+}
1347+
1348+func parseSGRIntsInto(out []int, params string) []int {
1327 if params == "" {1349 if params == "" {
1328- return nil1350+ return out
1329 }1351 }
1330- groups := strings.Split(params, ";")1352+ start := 0
1331- out := make([]int, 0, len(groups))1353+ for i := 0; i <= len(params); i++ {
1332- for _, group := range groups {1354+ if i == len(params) || params[i] == ';' {
1333- if !strings.Contains(group, ":") {1355+ out = appendSGRGroup(out, params[start:i])
1334- out = append(out, parseCSIInt(group))1356+ start = i + 1
1335- continue
1336 }1357 }
1337- sub := strings.Split(group, ":")1358+ }
1338- code := parseCSIInt(sub[0])1359+ return out
1339- if code != 38 && code != 48 {1360+}
1340- out = append(out, code)1361+
1341- continue1362+func appendSGRGroup(out []int, group string) []int {
1363+ colon := false
1364+ for i := 0; i < len(group); i++ {
1365+ if group[i] == ':' {
1366+ colon = true
1367+ break
1342 }1368 }
1343- mode := 01369+ }
1344- if len(sub) > 1 {1370+ if !colon {
1345- mode = parseCSIInt(sub[1])1371+ return append(out, parseCSIInt(group))
1372+ }
1373+
1374+ parts := countSGRColonFields(group)
1375+ code := parseCSIInt(sgrColonField(group, 0))
1376+ if code != 38 && code != 48 {
1377+ return append(out, code)
1378+ }
1379+ mode := 0
1380+ if parts > 1 {
1381+ mode = parseCSIInt(sgrColonField(group, 1))
1382+ }
1383+ switch mode {
1384+ case 5:
1385+ if parts < 3 {
1386+ return out
1346 }1387 }
1347- switch mode {1388+ out = append(out, code, mode, parseCSIInt(sgrColonField(group, 2)))
1348- case 5:1389+ case 2:
1349- if len(sub) < 3 {1390+ // code:mode::R:G:B and code:mode:cs:R:G:B both put RGB after
1350- continue1391+ // the colorspace slot; code:mode:R:G:B omits that slot.
1351- }1392+ start := 2
1352- out = append(out, code, mode, parseCSIInt(sub[2]))1393+ if parts > 2 && (sgrColonField(group, 2) == "" || parts >= 6) {
1353- case 2:1394+ start = 3
1354- // code:mode::R:G:B and code:mode:cs:R:G:B both put RGB after1395+ }
1355- // the colorspace slot; code:mode:R:G:B omits that slot.1396+ if parts < start+3 {
1356- start := 21397+ return out
1357- if len(sub) > 2 && (sub[2] == "" || len(sub) >= 6) {1398+ }
1358- start = 31399+ out = append(out, code, mode)
1359- }1400+ for i := 0; i < 3; i++ {
1360- if len(sub) < start+3 {1401+ out = append(out, parseCSIInt(sgrColonField(group, start+i)))
1361- continue
1362- }
1363- out = append(out, code, mode)
1364- for i := 0; i < 3; i++ {
1365- out = append(out, parseCSIInt(sub[start+i]))
1366- }
1367 }1402 }
1368 }1403 }
1369 return out1404 return out
1370 }1405 }
1371 1406
1407+func countSGRColonFields(group string) int {
1408+ count := 1
1409+ for i := 0; i < len(group); i++ {
1410+ if group[i] == ':' {
1411+ count++
1412+ }
1413+ }
1414+ return count
1415+}
1416+
1417+func sgrColonField(group string, index int) string {
1418+ start := 0
1419+ field := 0
1420+ for i := 0; i <= len(group); i++ {
1421+ if i == len(group) || group[i] == ':' {
1422+ if field == index {
1423+ return group[start:i]
1424+ }
1425+ field++
1426+ start = i + 1
1427+ }
1428+ }
1429+ return ""
1430+}
1431+
1372 func parseCSIInt(param string) int {1432 func parseCSIInt(param string) int {
1373 if param == "" {1433 if param == "" {
1374 return 01434 return 0
M pkg/vt/screen_test.go +57 -0
@@ -74,6 +74,63 @@ func lineText(s *Screen, y int) string {
7474 return string(out)
7575 }
7676
77+func TestParseCSIInts(t *testing.T) {
78+ t.Parallel()
79+
80+ tests := []struct {
81+ name string
82+ params string
83+ want []int
84+ }{
85+ {name: "empty", params: "", want: nil},
86+ {name: "single", params: "5", want: []int{5}},
87+ {name: "multiple", params: "1;2;3", want: []int{1, 2, 3}},
88+ {name: "leading empty token", params: ";5", want: []int{0, 5}},
89+ {name: "double empty token", params: "5;;3", want: []int{5, 0, 3}},
90+ {name: "trailing empty token", params: "5;", want: []int{5, 0}},
91+ {name: "question prefix", params: "?25", want: []int{25}},
92+ {name: "greater prefix", params: ">0;1", want: []int{0, 1}},
93+ {name: "prefix order", params: "?>7", want: []int{7}},
94+ {name: "junk token", params: "12x;4", want: []int{0, 4}},
95+ }
96+
97+ for _, tt := range tests {
98+ t.Run(tt.name, func(t *testing.T) {
99+ require.Equal(t, tt.want, parseCSIInts(tt.params))
100+ })
101+ }
102+}
103+
104+func TestParseSGRInts(t *testing.T) {
105+ t.Parallel()
106+
107+ tests := []struct {
108+ name string
109+ params string
110+ want []int
111+ }{
112+ {name: "empty", params: "", want: nil},
113+ {name: "single", params: "1", want: []int{1}},
114+ {name: "multiple", params: "1;31", want: []int{1, 31}},
115+ {name: "leading empty token", params: ";5", want: []int{0, 5}},
116+ {name: "double empty token", params: "5;;3", want: []int{5, 0, 3}},
117+ {name: "trailing empty token", params: "5;", want: []int{5, 0}},
118+ {name: "indexed foreground colon", params: "38:5:196", want: []int{38, 5, 196}},
119+ {name: "rgb foreground colon", params: "38:2:1:2:3", want: []int{38, 2, 1, 2, 3}},
120+ {name: "indexed background colon", params: "48:5:10", want: []int{48, 5, 10}},
121+ {name: "rgb foreground empty colorspace", params: "38:2::1:2:3", want: []int{38, 2, 1, 2, 3}},
122+ {name: "rgb foreground explicit colorspace", params: "38:2:9:1:2:3", want: []int{38, 2, 1, 2, 3}},
123+ {name: "non color colon group", params: "1:2:3", want: []int{1}},
124+ {name: "junk token", params: "abc;31", want: []int{0, 31}},
125+ }
126+
127+ for _, tt := range tests {
128+ t.Run(tt.name, func(t *testing.T) {
129+ require.Equal(t, tt.want, parseSGRInts(tt.params))
130+ })
131+ }
132+}
133+
77134 func rowString(row []renderer.Cell) string {
78135 out := make([]rune, len(row))
79136 for i, cell := range row {
@@ -74,6 +74,63 @@ func lineText(s *Screen, y int) string {
74 return string(out)74 return string(out)
75 }75 }
76 76
77+func TestParseCSIInts(t *testing.T) {
78+ t.Parallel()
79+
80+ tests := []struct {
81+ name string
82+ params string
83+ want []int
84+ }{
85+ {name: "empty", params: "", want: nil},
86+ {name: "single", params: "5", want: []int{5}},
87+ {name: "multiple", params: "1;2;3", want: []int{1, 2, 3}},
88+ {name: "leading empty token", params: ";5", want: []int{0, 5}},
89+ {name: "double empty token", params: "5;;3", want: []int{5, 0, 3}},
90+ {name: "trailing empty token", params: "5;", want: []int{5, 0}},
91+ {name: "question prefix", params: "?25", want: []int{25}},
92+ {name: "greater prefix", params: ">0;1", want: []int{0, 1}},
93+ {name: "prefix order", params: "?>7", want: []int{7}},
94+ {name: "junk token", params: "12x;4", want: []int{0, 4}},
95+ }
96+
97+ for _, tt := range tests {
98+ t.Run(tt.name, func(t *testing.T) {
99+ require.Equal(t, tt.want, parseCSIInts(tt.params))
100+ })
101+ }
102+}
103+
104+func TestParseSGRInts(t *testing.T) {
105+ t.Parallel()
106+
107+ tests := []struct {
108+ name string
109+ params string
110+ want []int
111+ }{
112+ {name: "empty", params: "", want: nil},
113+ {name: "single", params: "1", want: []int{1}},
114+ {name: "multiple", params: "1;31", want: []int{1, 31}},
115+ {name: "leading empty token", params: ";5", want: []int{0, 5}},
116+ {name: "double empty token", params: "5;;3", want: []int{5, 0, 3}},
117+ {name: "trailing empty token", params: "5;", want: []int{5, 0}},
118+ {name: "indexed foreground colon", params: "38:5:196", want: []int{38, 5, 196}},
119+ {name: "rgb foreground colon", params: "38:2:1:2:3", want: []int{38, 2, 1, 2, 3}},
120+ {name: "indexed background colon", params: "48:5:10", want: []int{48, 5, 10}},
121+ {name: "rgb foreground empty colorspace", params: "38:2::1:2:3", want: []int{38, 2, 1, 2, 3}},
122+ {name: "rgb foreground explicit colorspace", params: "38:2:9:1:2:3", want: []int{38, 2, 1, 2, 3}},
123+ {name: "non color colon group", params: "1:2:3", want: []int{1}},
124+ {name: "junk token", params: "abc;31", want: []int{0, 31}},
125+ }
126+
127+ for _, tt := range tests {
128+ t.Run(tt.name, func(t *testing.T) {
129+ require.Equal(t, tt.want, parseSGRInts(tt.params))
130+ })
131+ }
132+}
133+
77 func rowString(row []renderer.Cell) string {134 func rowString(row []renderer.Cell) string {
78 out := make([]rune, len(row))135 out := make([]rune, len(row))
79 for i, cell := range row {136 for i, cell := range row {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close