detached cb489d5

feat(terminal): integrate compact storage and measured history budgets (#254)

* feat(terminal): integrate compact storage and measured history budgets

* fix(copy): handle checked history iteration and pin reviewed storage

* fix(snapshot): reject incompatible dense VT checkpoints

* fix(copy): discard failed searches without crashing daemon

* perf(copy): avoid redundant frame work while scrolling

* test(copy): cover row semantics and cache ownership boundaries

* chore: bump vev-vt to v0.5.0
commit
cb489d5b4ce3e21e27fd516e65f719980aa16ee4
author
bnema · · 2026-09-05 10:19 UTC
committer
GitHub · · 2026-09-05 10:19 UTC
parent
9bf0749
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
76 files · +1366 -399
A docs/compact-storage-benchmarks.md +106 -0
@@ -0,0 +1,106 @@
1+# Compact storage: measured budgets
2+
3+Measured September 5, 2026 (CEST; September 4 UTC) on Linux amd64, Ryzen 5 7535HS, Go 1.27.1,
4+`GOMAXPROCS=2`. The existing manual-test binary was not replaced or restarted.
5+
6+## References and method
7+
8+- Consumer baseline: clean vev `9bf07496` (before this integration).
9+- Measured compact consumer: the integration captured in vev `b59725e3`, using vev-vt `f912b86` (before the final review fixes).
10+- Current integration dependency: vev-vt `f2960699a487`. The figures below were not remeasured at this revision; they describe the earlier `f912b86` dependency state.
11+- Retained-history baseline: vev-vt foundation `a0f7c5a`, before compact pages.
12+- Consumer workloads: 120 × 40 terminals, 10,000 history rows per pane.
13+- Main consumer comparisons: three 100 ms samples, reported as medians.
14+- Additional four/eight-tab cases: one 100 ms sample; directional evidence only.
15+- Retained heap: three one-iteration samples with explicit GC; not process RSS.
16+
17+These are local short-run measurements, not a statistical latency guarantee.
18+
19+## Consumer results
20+
21+| Operation | Dense ms/op | Compact ms/op | Dense bytes/op | Compact bytes/op | Dense allocs/op | Compact allocs/op |
22+| --- | ---: | ---: | ---: | ---: | ---: | ---: |
23+| Live paint, 1 tab / 1 pane | 0.197 | 0.951 | 356,090 | 86,507 | 36 | 40 |
24+| Live paint, 1 tab / 4 panes | 0.129 | 0.444 | 355,884 | 86,041 | 36 | 40 |
25+| Copy entry, 1 tab / 1 pane | 0.708 | 2.180 | 2,100,051 | 449,512 | 64 | 85 |
26+| Copy entry, 1 tab / 4 panes | 0.629 | 1.389 | 1,542,092 | 296,382 | 82 | 114 |
27+| Copy entry, 4 tabs / 1 pane* | 1.165 | 2.144 | 2,096,593 | 433,169 | 68 | 88 |
28+| Copy entry, 4 tabs / 4 panes* | 0.974 | 1.383 | 1,541,684 | 296,647 | 86 | 118 |
29+| Copy entry, 8 tabs / 1 pane* | 1.113 | 2.169 | 2,098,087 | 434,861 | 76 | 96 |
30+
31+`*` Single-sample comparison.
32+
33+Main workloads emitted the same output bytes and one output frame per operation:
34+57/59 bytes for live paint and 524/971 bytes for copy entry.
35+
36+**The trade-off is real:** allocated bytes fall approximately 4–5×, but the
37+main workloads take approximately 2.2–4.8× longer. Do not describe this change as
38+a general throughput improvement. The integration's cell-by-cell grid copies
39+and page-local style bookkeeping remain optimization candidates. No unsafe
40+copying, pooling, or new allocator was introduced to make allocation counts pass.
41+
42+## Retained history
43+
44+Plain ASCII, 10,000 × 120 cells:
45+
46+| Representation | Median retained bytes |
47+| --- | ---: |
48+| Dense foundation | 95,283,872 |
49+| Compact, uncompressed | 22,769,712 |
50+| Compact after two idle compression passes | 4,209,776 |
51+
52+Uncompressed storage meets issue #9's **4× retained-memory reduction** target
53+(approximately 4.18×). Cold storage retains approximately 22.6× less than dense
54+storage, with 38 cold pages and 173,190 compressed bytes.
55+
56+Building this synthetic history allocates **148.5 MB cumulatively**, versus
57+95.8 MB for the dense reference. Lower retained memory does not imply lower
58+allocation traffic during construction. The mutable semantic tail contributes
59+to this cost. Retained-memory sample times include measurement GCs and must not
60+be treated as isolated append throughput.
61+
62+## Regression gates
63+
64+The previous 38/66–90 allocation-count thresholds came from vev's dense-frame
65+benchmarks, not from the vev-vt issues. The floating composition threshold of
66+two allocations explicitly assumed two dense slices.
67+
68+The recalibrated tests retain count checks and add independent byte checks:
69+
70+- Live paint: at most **44 allocations** and **96 KiB/op**.
71+- Copy entry: approximately 10% count headroom over measured compact values;
72+ at most **512 KiB/op** for single-pane tabs and **384 KiB/op** for four-pane tabs.
73+- Cached floating composition: the measured cost of one compact clone plus one
74+ allocation for initial style growth, and at most **40 KiB/op**.
75+- Warm title/bar composition still requires **zero allocations**.
76+- Retained-history and ownership/safety checks remain separate and unchanged.
77+
78+Byte checks use Go benchmark allocation accounting, excluding fixture setup.
79+Instrumented race runs exercise behavior but do not enforce byte budgets.
80+Wall-clock timings are reported rather than asserted as machine-dependent CI
81+thresholds. The CPU regression remains visible above; these gates do not claim
82+that it has been removed.
83+
84+## Reproduce
85+
86+Run the same consumer command in clean vev `9bf07496` and the integration tree.
87+The current integration measures the final dependency, not the exact historical
88+sample above. For the historical comparison, use a separate checkout of vev
89+`b59725e3`, which pins the compatible `f912b86` dependency and pre-review consumer
90+API. Do not downgrade the dependency alone in the current integration.
91+
92+```sh
93+GOMAXPROCS=2 go test ./internal/usecase/daemon -run '^$' \
94+ -bench '^BenchmarkDaemonHistory(LivePaint|CopyEnter)$/^1tab-(1pane-control|4panes)$' \
95+ -benchmem -benchtime=100ms -count=3
96+```
97+
98+Run in the dense foundation and compact vev-vt trees:
99+
100+```sh
101+GOMAXPROCS=2 go test -run '^$' \
102+ -bench '^BenchmarkHistoryRetained10Kx120$/plain-ascii$' \
103+ -benchtime=1x -count=3 -benchmem
104+```
105+
106+The compact tree also provides `BenchmarkColdHistoryRetained10Kx120`.
@@ -0,0 +1,106 @@
1+# Compact storage: measured budgets
2+
3+Measured September 5, 2026 (CEST; September 4 UTC) on Linux amd64, Ryzen 5 7535HS, Go 1.27.1,
4+`GOMAXPROCS=2`. The existing manual-test binary was not replaced or restarted.
5+
6+## References and method
7+
8+- Consumer baseline: clean vev `9bf07496` (before this integration).
9+- Measured compact consumer: the integration captured in vev `b59725e3`, using vev-vt `f912b86` (before the final review fixes).
10+- Current integration dependency: vev-vt `f2960699a487`. The figures below were not remeasured at this revision; they describe the earlier `f912b86` dependency state.
11+- Retained-history baseline: vev-vt foundation `a0f7c5a`, before compact pages.
12+- Consumer workloads: 120 × 40 terminals, 10,000 history rows per pane.
13+- Main consumer comparisons: three 100 ms samples, reported as medians.
14+- Additional four/eight-tab cases: one 100 ms sample; directional evidence only.
15+- Retained heap: three one-iteration samples with explicit GC; not process RSS.
16+
17+These are local short-run measurements, not a statistical latency guarantee.
18+
19+## Consumer results
20+
21+| Operation | Dense ms/op | Compact ms/op | Dense bytes/op | Compact bytes/op | Dense allocs/op | Compact allocs/op |
22+| --- | ---: | ---: | ---: | ---: | ---: | ---: |
23+| Live paint, 1 tab / 1 pane | 0.197 | 0.951 | 356,090 | 86,507 | 36 | 40 |
24+| Live paint, 1 tab / 4 panes | 0.129 | 0.444 | 355,884 | 86,041 | 36 | 40 |
25+| Copy entry, 1 tab / 1 pane | 0.708 | 2.180 | 2,100,051 | 449,512 | 64 | 85 |
26+| Copy entry, 1 tab / 4 panes | 0.629 | 1.389 | 1,542,092 | 296,382 | 82 | 114 |
27+| Copy entry, 4 tabs / 1 pane* | 1.165 | 2.144 | 2,096,593 | 433,169 | 68 | 88 |
28+| Copy entry, 4 tabs / 4 panes* | 0.974 | 1.383 | 1,541,684 | 296,647 | 86 | 118 |
29+| Copy entry, 8 tabs / 1 pane* | 1.113 | 2.169 | 2,098,087 | 434,861 | 76 | 96 |
30+
31+`*` Single-sample comparison.
32+
33+Main workloads emitted the same output bytes and one output frame per operation:
34+57/59 bytes for live paint and 524/971 bytes for copy entry.
35+
36+**The trade-off is real:** allocated bytes fall approximately 4–5×, but the
37+main workloads take approximately 2.2–4.8× longer. Do not describe this change as
38+a general throughput improvement. The integration's cell-by-cell grid copies
39+and page-local style bookkeeping remain optimization candidates. No unsafe
40+copying, pooling, or new allocator was introduced to make allocation counts pass.
41+
42+## Retained history
43+
44+Plain ASCII, 10,000 × 120 cells:
45+
46+| Representation | Median retained bytes |
47+| --- | ---: |
48+| Dense foundation | 95,283,872 |
49+| Compact, uncompressed | 22,769,712 |
50+| Compact after two idle compression passes | 4,209,776 |
51+
52+Uncompressed storage meets issue #9's **4× retained-memory reduction** target
53+(approximately 4.18×). Cold storage retains approximately 22.6× less than dense
54+storage, with 38 cold pages and 173,190 compressed bytes.
55+
56+Building this synthetic history allocates **148.5 MB cumulatively**, versus
57+95.8 MB for the dense reference. Lower retained memory does not imply lower
58+allocation traffic during construction. The mutable semantic tail contributes
59+to this cost. Retained-memory sample times include measurement GCs and must not
60+be treated as isolated append throughput.
61+
62+## Regression gates
63+
64+The previous 38/66–90 allocation-count thresholds came from vev's dense-frame
65+benchmarks, not from the vev-vt issues. The floating composition threshold of
66+two allocations explicitly assumed two dense slices.
67+
68+The recalibrated tests retain count checks and add independent byte checks:
69+
70+- Live paint: at most **44 allocations** and **96 KiB/op**.
71+- Copy entry: approximately 10% count headroom over measured compact values;
72+ at most **512 KiB/op** for single-pane tabs and **384 KiB/op** for four-pane tabs.
73+- Cached floating composition: the measured cost of one compact clone plus one
74+ allocation for initial style growth, and at most **40 KiB/op**.
75+- Warm title/bar composition still requires **zero allocations**.
76+- Retained-history and ownership/safety checks remain separate and unchanged.
77+
78+Byte checks use Go benchmark allocation accounting, excluding fixture setup.
79+Instrumented race runs exercise behavior but do not enforce byte budgets.
80+Wall-clock timings are reported rather than asserted as machine-dependent CI
81+thresholds. The CPU regression remains visible above; these gates do not claim
82+that it has been removed.
83+
84+## Reproduce
85+
86+Run the same consumer command in clean vev `9bf07496` and the integration tree.
87+The current integration measures the final dependency, not the exact historical
88+sample above. For the historical comparison, use a separate checkout of vev
89+`b59725e3`, which pins the compatible `f912b86` dependency and pre-review consumer
90+API. Do not downgrade the dependency alone in the current integration.
91+
92+```sh
93+GOMAXPROCS=2 go test ./internal/usecase/daemon -run '^$' \
94+ -bench '^BenchmarkDaemonHistory(LivePaint|CopyEnter)$/^1tab-(1pane-control|4panes)$' \
95+ -benchmem -benchtime=100ms -count=3
96+```
97+
98+Run in the dense foundation and compact vev-vt trees:
99+
100+```sh
101+GOMAXPROCS=2 go test -run '^$' \
102+ -bench '^BenchmarkHistoryRetained10Kx120$/plain-ascii$' \
103+ -benchtime=1x -count=3 -benchmem
104+```
105+
106+The compact tree also provides `BenchmarkColdHistoryRetained10Kx120`.
M docs/configuration.md +31 -0
@@ -40,6 +40,11 @@ focus-pane-down = alt+j
4040 switch-tab-1 = alt+1
4141 # ... through switch-tab-9 = alt+9
4242
43+# Per-pane scrollback: decimal MB; zero disables history.
44+scrollback.megabytes = 50
45+# Optional additional line ceiling; zero means byte-only retention.
46+scrollback.lines = 10000
47+
4348 # Processes relaunched when a named session is restored. Empty disables relaunch.
4449 snapshot.restore_processes = vi,vim,nvim,emacs,man,less,more,tail,top,htop,btop,claude,codex,pi,opencode
4550
@@ -79,6 +84,32 @@ code.detach = DET
7984
8085 Invalid values log a warning and resolve that setting to its default on both initial load and reload.
8186
87+## Scrollback
88+
89+`vev` chooses the retention policy; `vev-vt` enforces it. The PTY transports
90+bytes and does not own scrollback.
91+
92+- `scrollback.megabytes`: 0–4096 decimal MB, default 50. Zero disables history.
93+- `scrollback.lines`: 0–1,000,000 lines, default 10,000. Zero removes the
94+ additional line ceiling when the byte budget is positive.
95+- Limits apply independently to each pane, including hidden floating panes,
96+ and exclude visible primary/alternate grids. They measure uncompressed
97+ logical history bytes, not total process RSS.
98+- Reload applies to existing panes immediately. Lowering a limit evicts the
99+ oldest rows; disabling history discards its retained contents. Existing copy
100+ documents remain stable. New and restored panes use the current limits.
101+- Idle maintenance visits at most one sealed page per pane every five seconds,
102+ skips busy panes, and compresses only pages that remain cold. Compression does
103+ not grant additional retention or count compressed bytes toward the budget.
104+
105+Persistence has separate per-blob and aggregate safety ceilings; raising the
106+runtime retention budget does not remove them. The compact codec replaces the
107+old VT encoding directly: old VT history blobs are not readable by this alpha
108+build. Test with `VEV_ENV=dev`, not your normal daemon's persisted state.
109+
110+See [compact storage measurements](compact-storage-benchmarks.md) for memory,
111+allocation and CPU trade-offs.
112+
82113 ## Development environments
83114
84115 Set `VEV_ENV` to a name such as `dev` to redirect all vev-owned paths into `.dev/<name>/` under the directory where vev was invoked. The name must be one safe segment of at most 64 ASCII letters, digits, dots, underscores, or hyphens, and must start with a letter or digit.
@@ -40,6 +40,11 @@ focus-pane-down = alt+j
40 switch-tab-1 = alt+140 switch-tab-1 = alt+1
41 # ... through switch-tab-9 = alt+941 # ... through switch-tab-9 = alt+9
42 42
43+# Per-pane scrollback: decimal MB; zero disables history.
44+scrollback.megabytes = 50
45+# Optional additional line ceiling; zero means byte-only retention.
46+scrollback.lines = 10000
47+
43 # Processes relaunched when a named session is restored. Empty disables relaunch.48 # Processes relaunched when a named session is restored. Empty disables relaunch.
44 snapshot.restore_processes = vi,vim,nvim,emacs,man,less,more,tail,top,htop,btop,claude,codex,pi,opencode49 snapshot.restore_processes = vi,vim,nvim,emacs,man,less,more,tail,top,htop,btop,claude,codex,pi,opencode
45 50
@@ -79,6 +84,32 @@ code.detach = DET
79 84
80 Invalid values log a warning and resolve that setting to its default on both initial load and reload.85 Invalid values log a warning and resolve that setting to its default on both initial load and reload.
81 86
87+## Scrollback
88+
89+`vev` chooses the retention policy; `vev-vt` enforces it. The PTY transports
90+bytes and does not own scrollback.
91+
92+- `scrollback.megabytes`: 0–4096 decimal MB, default 50. Zero disables history.
93+- `scrollback.lines`: 0–1,000,000 lines, default 10,000. Zero removes the
94+ additional line ceiling when the byte budget is positive.
95+- Limits apply independently to each pane, including hidden floating panes,
96+ and exclude visible primary/alternate grids. They measure uncompressed
97+ logical history bytes, not total process RSS.
98+- Reload applies to existing panes immediately. Lowering a limit evicts the
99+ oldest rows; disabling history discards its retained contents. Existing copy
100+ documents remain stable. New and restored panes use the current limits.
101+- Idle maintenance visits at most one sealed page per pane every five seconds,
102+ skips busy panes, and compresses only pages that remain cold. Compression does
103+ not grant additional retention or count compressed bytes toward the budget.
104+
105+Persistence has separate per-blob and aggregate safety ceilings; raising the
106+runtime retention budget does not remove them. The compact codec replaces the
107+old VT encoding directly: old VT history blobs are not readable by this alpha
108+build. Test with `VEV_ENV=dev`, not your normal daemon's persisted state.
109+
110+See [compact storage measurements](compact-storage-benchmarks.md) for memory,
111+allocation and CPU trade-offs.
112+
82 ## Development environments113 ## Development environments
83 114
84 Set `VEV_ENV` to a name such as `dev` to redirect all vev-owned paths into `.dev/<name>/` under the directory where vev was invoked. The name must be one safe segment of at most 64 ASCII letters, digits, dots, underscores, or hyphens, and must start with a letter or digit.115 Set `VEV_ENV` to a name such as `dev` to redirect all vev-owned paths into `.dev/<name>/` under the directory where vev was invoked. The name must be one safe segment of at most 64 ASCII letters, digits, dots, underscores, or hyphens, and must start with a letter or digit.
M docs/durable-session-recovery.md +2 -0
@@ -20,6 +20,8 @@ Before rewriting a legacy catalogue, vev creates and syncs a private `sessions.k
2020
2121 ## Incompatible checkpoints
2222
23+Compact VT storage uses VEVM version 4 and VEVS version 5. Dense VT checkpoints are intentionally incompatible: there is no scrollback migration or legacy decoder. On the next daemon startup, healthy older VEVM checkpoints take the reset path below. This clears saved tabs, layout, screen contents, scrollback, and recovery transcripts while preserving session names and working directories. The live client/daemon protocol is unchanged. Installing a binary does not restart an existing daemon.
24+
2325 After verifying a VEVM manifest's digest, vev treats any VEVM version mismatch as an incompatible healthy checkpoint. It atomically replaces only that named session's exact healthy checkpoint with a fresh incarnation. Unlike a protocol reset, this replacement retains the session name and working directory, but has no checkpoint, tabs, terminal history, or recovery transcript.
2426
2527 Digest mismatches, corruption, validation failures, I/O errors, and ambiguous failures are never reset or purged. The session remains degraded for explicit recovery.
@@ -20,6 +20,8 @@ Before rewriting a legacy catalogue, vev creates and syncs a private `sessions.k
20 20
21 ## Incompatible checkpoints21 ## Incompatible checkpoints
22 22
23+Compact VT storage uses VEVM version 4 and VEVS version 5. Dense VT checkpoints are intentionally incompatible: there is no scrollback migration or legacy decoder. On the next daemon startup, healthy older VEVM checkpoints take the reset path below. This clears saved tabs, layout, screen contents, scrollback, and recovery transcripts while preserving session names and working directories. The live client/daemon protocol is unchanged. Installing a binary does not restart an existing daemon.
24+
23 After verifying a VEVM manifest's digest, vev treats any VEVM version mismatch as an incompatible healthy checkpoint. It atomically replaces only that named session's exact healthy checkpoint with a fresh incarnation. Unlike a protocol reset, this replacement retains the session name and working directory, but has no checkpoint, tabs, terminal history, or recovery transcript.25 After verifying a VEVM manifest's digest, vev treats any VEVM version mismatch as an incompatible healthy checkpoint. It atomically replaces only that named session's exact healthy checkpoint with a fresh incarnation. Unlike a protocol reset, this replacement retains the session name and working directory, but has no checkpoint, tabs, terminal history, or recovery transcript.
24 26
25 Digest mismatches, corruption, validation failures, I/O errors, and ambiguous failures are never reset or purged. The session remains degraded for explicit recovery.27 Digest mismatches, corruption, validation failures, I/O errors, and ambiguous failures are never reset or purged. The session remains degraded for explicit recovery.
M go.mod +1 -1
@@ -3,7 +3,7 @@ module github.com/bnema/vev
33 go 1.27
44
55 require (
6- github.com/bnema/vev-vt v0.4.2-0.20260827060951-f53f75f19d7f
6+ github.com/bnema/vev-vt v0.5.0
77 github.com/stretchr/testify v1.11.1
88 )
99
@@ -3,7 +3,7 @@ module github.com/bnema/vev
3 go 1.273 go 1.27
4 4
5 require (5 require (
6- github.com/bnema/vev-vt v0.4.2-0.20260827060951-f53f75f19d7f6+ github.com/bnema/vev-vt v0.5.0
7 github.com/stretchr/testify v1.11.17 github.com/stretchr/testify v1.11.1
8 )8 )
9 9
M go.sum +2 -2 generated file: collapsed
M internal/adapters/config/config.go +17 -0
@@ -35,6 +35,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
3535 seenPaletteKeys := make(map[string]bool)
3636 seenNavKeys := make(map[string]bool)
3737 seenTabsKeys := make(map[string]bool)
38+ seenScrollbackKeys := make(map[string]bool)
3839
3940 scanner := bufio.NewScanner(r)
4041 lineNo := 0
@@ -185,6 +186,22 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
185186 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
186187 }
187188 cfg.Codes[codeKey] = value
189+ case key == "scrollback.megabytes":
190+ warnings = warnDuplicateKey(warnings, seenScrollbackKeys, key, lineNo)
191+ n, err := strconv.ParseUint(value, 10, 64)
192+ if err != nil || n > domain.MaxScrollbackMegabytes {
193+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid %s %q (want 0–%d)", key, value, domain.MaxScrollbackMegabytes)})
194+ continue
195+ }
196+ cfg.Scrollback.Megabytes = n
197+ case key == "scrollback.lines":
198+ warnings = warnDuplicateKey(warnings, seenScrollbackKeys, key, lineNo)
199+ n, err := strconv.ParseUint(value, 10, 64)
200+ if err != nil || n > domain.MaxScrollbackLines {
201+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid %s %q (want 0–%d)", key, value, domain.MaxScrollbackLines)})
202+ continue
203+ }
204+ cfg.Scrollback.Lines = int(n)
188205 case key == "snapshot.restore_processes":
189206 var processWarnings []domain.Warning
190207 cfg.Snapshot.RestoreProcesses, processWarnings = parseProcessList(value, lineNo)
@@ -35,6 +35,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
35 seenPaletteKeys := make(map[string]bool)35 seenPaletteKeys := make(map[string]bool)
36 seenNavKeys := make(map[string]bool)36 seenNavKeys := make(map[string]bool)
37 seenTabsKeys := make(map[string]bool)37 seenTabsKeys := make(map[string]bool)
38+ seenScrollbackKeys := make(map[string]bool)
38 39
39 scanner := bufio.NewScanner(r)40 scanner := bufio.NewScanner(r)
40 lineNo := 041 lineNo := 0
@@ -185,6 +186,22 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
185 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})186 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
186 }187 }
187 cfg.Codes[codeKey] = value188 cfg.Codes[codeKey] = value
189+ case key == "scrollback.megabytes":
190+ warnings = warnDuplicateKey(warnings, seenScrollbackKeys, key, lineNo)
191+ n, err := strconv.ParseUint(value, 10, 64)
192+ if err != nil || n > domain.MaxScrollbackMegabytes {
193+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid %s %q (want 0–%d)", key, value, domain.MaxScrollbackMegabytes)})
194+ continue
195+ }
196+ cfg.Scrollback.Megabytes = n
197+ case key == "scrollback.lines":
198+ warnings = warnDuplicateKey(warnings, seenScrollbackKeys, key, lineNo)
199+ n, err := strconv.ParseUint(value, 10, 64)
200+ if err != nil || n > domain.MaxScrollbackLines {
201+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid %s %q (want 0–%d)", key, value, domain.MaxScrollbackLines)})
202+ continue
203+ }
204+ cfg.Scrollback.Lines = int(n)
188 case key == "snapshot.restore_processes":205 case key == "snapshot.restore_processes":
189 var processWarnings []domain.Warning206 var processWarnings []domain.Warning
190 cfg.Snapshot.RestoreProcesses, processWarnings = parseProcessList(value, lineNo)207 cfg.Snapshot.RestoreProcesses, processWarnings = parseProcessList(value, lineNo)
M internal/adapters/config/config_test.go +1 -0
@@ -358,6 +358,7 @@ func TestParse(t *testing.T) {
358358 if !tt.want.Palette.AnchorSet {
359359 tt.want.Palette = domain.Defaults().Palette
360360 }
361+ tt.want.Scrollback = domain.DefaultScrollbackConfig()
361362 got, warnings, err := Parse(strings.NewReader(tt.input))
362363 if err != nil {
363364 t.Fatalf("Parse() error = %v", err)
@@ -358,6 +358,7 @@ func TestParse(t *testing.T) {
358 if !tt.want.Palette.AnchorSet {358 if !tt.want.Palette.AnchorSet {
359 tt.want.Palette = domain.Defaults().Palette359 tt.want.Palette = domain.Defaults().Palette
360 }360 }
361+ tt.want.Scrollback = domain.DefaultScrollbackConfig()
361 got, warnings, err := Parse(strings.NewReader(tt.input))362 got, warnings, err := Parse(strings.NewReader(tt.input))
362 if err != nil {363 if err != nil {
363 t.Fatalf("Parse() error = %v", err)364 t.Fatalf("Parse() error = %v", err)
A internal/adapters/config/scrollback_test.go +37 -0
@@ -0,0 +1,37 @@
1+package config
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestParseScrollbackPolicy(t *testing.T) {
12+ for _, tt := range []struct {
13+ name, input string
14+ want domain.ScrollbackConfig
15+ warnings int
16+ }{
17+ {"defaults", "", domain.DefaultScrollbackConfig(), 0},
18+ {"bytes only", "scrollback.megabytes = 20\nscrollback.lines = 0", domain.ScrollbackConfig{Megabytes: 20}, 0},
19+ {"disabled", "scrollback.megabytes = 0", domain.ScrollbackConfig{Lines: 10_000}, 0},
20+ {"both limits", "scrollback.megabytes = 10\nscrollback.lines = 100", domain.ScrollbackConfig{Megabytes: 10, Lines: 100}, 0},
21+ {"upper bounds", "scrollback.megabytes = 4096\nscrollback.lines = 1000000", domain.ScrollbackConfig{Megabytes: 4096, Lines: 1_000_000}, 0},
22+ {"negative bytes", "scrollback.megabytes = -1", domain.DefaultScrollbackConfig(), 1},
23+ {"negative lines", "scrollback.lines = -1", domain.DefaultScrollbackConfig(), 1},
24+ {"oversized bytes", "scrollback.megabytes = 4097", domain.DefaultScrollbackConfig(), 1},
25+ {"oversized lines", "scrollback.lines = 1000001", domain.DefaultScrollbackConfig(), 1},
26+ {"overflow", "scrollback.megabytes = 18446744073709551616", domain.DefaultScrollbackConfig(), 1},
27+ {"duplicate invalid keeps valid", "scrollback.megabytes = 20\nscrollback.megabytes = -1", domain.ScrollbackConfig{Megabytes: 20, Lines: 10_000}, 2},
28+ } {
29+ t.Run(tt.name, func(t *testing.T) {
30+ cfg, warnings, err := Parse(strings.NewReader(tt.input))
31+ require.NoError(t, err)
32+ require.Equal(t, tt.want, cfg.Scrollback)
33+ require.Len(t, warnings, tt.warnings)
34+ require.Empty(t, cfg.BindingEntries)
35+ })
36+ }
37+}
@@ -0,0 +1,37 @@
1+package config
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestParseScrollbackPolicy(t *testing.T) {
12+ for _, tt := range []struct {
13+ name, input string
14+ want domain.ScrollbackConfig
15+ warnings int
16+ }{
17+ {"defaults", "", domain.DefaultScrollbackConfig(), 0},
18+ {"bytes only", "scrollback.megabytes = 20\nscrollback.lines = 0", domain.ScrollbackConfig{Megabytes: 20}, 0},
19+ {"disabled", "scrollback.megabytes = 0", domain.ScrollbackConfig{Lines: 10_000}, 0},
20+ {"both limits", "scrollback.megabytes = 10\nscrollback.lines = 100", domain.ScrollbackConfig{Megabytes: 10, Lines: 100}, 0},
21+ {"upper bounds", "scrollback.megabytes = 4096\nscrollback.lines = 1000000", domain.ScrollbackConfig{Megabytes: 4096, Lines: 1_000_000}, 0},
22+ {"negative bytes", "scrollback.megabytes = -1", domain.DefaultScrollbackConfig(), 1},
23+ {"negative lines", "scrollback.lines = -1", domain.DefaultScrollbackConfig(), 1},
24+ {"oversized bytes", "scrollback.megabytes = 4097", domain.DefaultScrollbackConfig(), 1},
25+ {"oversized lines", "scrollback.lines = 1000001", domain.DefaultScrollbackConfig(), 1},
26+ {"overflow", "scrollback.megabytes = 18446744073709551616", domain.DefaultScrollbackConfig(), 1},
27+ {"duplicate invalid keeps valid", "scrollback.megabytes = 20\nscrollback.megabytes = -1", domain.ScrollbackConfig{Megabytes: 20, Lines: 10_000}, 2},
28+ } {
29+ t.Run(tt.name, func(t *testing.T) {
30+ cfg, warnings, err := Parse(strings.NewReader(tt.input))
31+ require.NoError(t, err)
32+ require.Equal(t, tt.want, cfg.Scrollback)
33+ require.Len(t, warnings, tt.warnings)
34+ require.Empty(t, cfg.BindingEntries)
35+ })
36+ }
37+}
M internal/adapters/snapshot/direct_load_test.go +1 -1
@@ -67,7 +67,7 @@ func TestLoadCheckpointPreservesManifestVersionError(t *testing.T) {
6767 name string
6868 version uint16
6969 }{
70- {name: "old", version: codec.ManifestVersion - 1},
70+ {name: "dense VT checkpoint", version: 3},
7171 {name: "future", version: codec.ManifestVersion + 1},
7272 } {
7373 t.Run(tt.name, func(t *testing.T) {
@@ -67,7 +67,7 @@ func TestLoadCheckpointPreservesManifestVersionError(t *testing.T) {
67 name string67 name string
68 version uint1668 version uint16
69 }{69 }{
70- {name: "old", version: codec.ManifestVersion - 1},70+ {name: "dense VT checkpoint", version: 3},
71 {name: "future", version: codec.ManifestVersion + 1},71 {name: "future", version: codec.ManifestVersion + 1},
72 } {72 } {
73 t.Run(tt.name, func(t *testing.T) {73 t.Run(tt.name, func(t *testing.T) {
M internal/app/daemon_integration_test.go +3 -3
@@ -316,9 +316,9 @@ func assertNoTextAfterInput(t *testing.T, p *pump, sz domain.Size, absent string
316316
317317 func screenText(s *vt.Screen) string {
318318 var b strings.Builder
319- for y := range s.Frame.Height {
320- for x := range s.Frame.Width {
321- r := s.Frame.At(x, y).Rune
319+ for y := range s.Rows() {
320+ for x := range s.Columns() {
321+ r := s.Cell(x, y).Rune
322322 if r == 0 {
323323 r = ' '
324324 }
@@ -316,9 +316,9 @@ func assertNoTextAfterInput(t *testing.T, p *pump, sz domain.Size, absent string
316 316
317 func screenText(s *vt.Screen) string {317 func screenText(s *vt.Screen) string {
318 var b strings.Builder318 var b strings.Builder
319- for y := range s.Frame.Height {319+ for y := range s.Rows() {
320- for x := range s.Frame.Width {320+ for x := range s.Columns() {
321- r := s.Frame.At(x, y).Rune321+ r := s.Cell(x, y).Rune
322 if r == 0 {322 if r == 0 {
323 r = ' '323 r = ' '
324 }324 }
M internal/domain/config.go +23 -0
@@ -82,6 +82,27 @@ type TabsConfig struct {
8282 TerminalTitle bool
8383 }
8484
85+// ScrollbackConfig is vev's per-pane retention policy. The terminal library
86+// only enforces these application-selected limits. Zero megabytes disables
87+// history; zero lines removes the additional line ceiling.
88+type ScrollbackConfig struct {
89+ Megabytes uint64
90+ Lines int
91+}
92+
93+const DefaultScrollbackMegabytes uint64 = 50
94+const DefaultScrollbackLines = 10_000
95+const MaxScrollbackMegabytes uint64 = 4096
96+const MaxScrollbackLines = 1_000_000
97+
98+func DefaultScrollbackConfig() ScrollbackConfig {
99+ return ScrollbackConfig{Megabytes: DefaultScrollbackMegabytes, Lines: DefaultScrollbackLines}
100+}
101+
102+func (c ScrollbackConfig) Valid() bool {
103+ return c.Megabytes <= MaxScrollbackMegabytes && c.Lines >= 0 && c.Lines <= MaxScrollbackLines
104+}
105+
85106 // Config is the user-editable vev configuration after parsing. Unknown binding
86107 // keys are preserved here (in BindingEntries, in file order) so the usecase
87108 // layer can decide which actions it understands.
@@ -98,6 +119,7 @@ type Config struct {
98119 Floating FloatingConfig
99120 Nav NavConfig
100121 Tabs TabsConfig
122+ Scrollback ScrollbackConfig
101123 }
102124
103125 // Warning describes a non-fatal config problem. Parsers and reloaders should
@@ -135,5 +157,6 @@ func Defaults() Config {
135157 Tabs: TabsConfig{
136158 TerminalTitle: true,
137159 },
160+ Scrollback: DefaultScrollbackConfig(),
138161 }
139162 }
@@ -82,6 +82,27 @@ type TabsConfig struct {
82 TerminalTitle bool82 TerminalTitle bool
83 }83 }
84 84
85+// ScrollbackConfig is vev's per-pane retention policy. The terminal library
86+// only enforces these application-selected limits. Zero megabytes disables
87+// history; zero lines removes the additional line ceiling.
88+type ScrollbackConfig struct {
89+ Megabytes uint64
90+ Lines int
91+}
92+
93+const DefaultScrollbackMegabytes uint64 = 50
94+const DefaultScrollbackLines = 10_000
95+const MaxScrollbackMegabytes uint64 = 4096
96+const MaxScrollbackLines = 1_000_000
97+
98+func DefaultScrollbackConfig() ScrollbackConfig {
99+ return ScrollbackConfig{Megabytes: DefaultScrollbackMegabytes, Lines: DefaultScrollbackLines}
100+}
101+
102+func (c ScrollbackConfig) Valid() bool {
103+ return c.Megabytes <= MaxScrollbackMegabytes && c.Lines >= 0 && c.Lines <= MaxScrollbackLines
104+}
105+
85 // Config is the user-editable vev configuration after parsing. Unknown binding106 // Config is the user-editable vev configuration after parsing. Unknown binding
86 // keys are preserved here (in BindingEntries, in file order) so the usecase107 // keys are preserved here (in BindingEntries, in file order) so the usecase
87 // layer can decide which actions it understands.108 // layer can decide which actions it understands.
@@ -98,6 +119,7 @@ type Config struct {
98 Floating FloatingConfig119 Floating FloatingConfig
99 Nav NavConfig120 Nav NavConfig
100 Tabs TabsConfig121 Tabs TabsConfig
122+ Scrollback ScrollbackConfig
101 }123 }
102 124
103 // Warning describes a non-fatal config problem. Parsers and reloaders should125 // Warning describes a non-fatal config problem. Parsers and reloaders should
@@ -135,5 +157,6 @@ func Defaults() Config {
135 Tabs: TabsConfig{157 Tabs: TabsConfig{
136 TerminalTitle: true,158 TerminalTitle: true,
137 },159 },
160+ Scrollback: DefaultScrollbackConfig(),
138 }161 }
139 }162 }
M internal/usecase/copy/document.go +19 -19
@@ -41,6 +41,8 @@ func (d *Document) Len() int { return d.snapshot.Len() }
4141 func (d *Document) Width() int { return d.snapshot.Width }
4242 func (d *Document) Height() int { return d.snapshot.Height }
4343 func (d *Document) Row(row int) []renderer.Cell { return d.snapshot.Row(row) }
44+func (d *Document) RowWidth(row int) int { return d.snapshot.RowWidth(row) }
45+func (d *Document) Cell(x, y int) renderer.Cell { return d.snapshot.Cell(x, y) }
4446 func (d *Document) RowID(row int) vt.RowID { return d.snapshot.RowID(row) }
4547 func (d *Document) FindRowID(id vt.RowID) int { return d.snapshot.FindRowID(id) }
4648
@@ -50,20 +52,20 @@ func (d *Document) Normalize(pos Pos) (Pos, bool) {
5052 if pos.Row < 0 || pos.Row >= d.Len() || pos.Col < 0 {
5153 return Pos{}, false
5254 }
53- row := d.Row(pos.Row)
54- if len(row) == 0 {
55+ width := d.RowWidth(pos.Row)
56+ if width == 0 {
5557 if pos.Col == 0 {
5658 return pos, true
5759 }
5860 return Pos{}, false
5961 }
60- if pos.Col >= len(row) {
62+ if pos.Col >= width {
6163 return Pos{}, false
6264 }
63- for pos.Col > 0 && row[pos.Col].Continuation {
65+ for pos.Col > 0 && d.Cell(pos.Col, pos.Row).Continuation {
6466 pos.Col--
6567 }
66- if row[pos.Col].Continuation {
68+ if d.Cell(pos.Col, pos.Row).Continuation {
6769 return Pos{}, false
6870 }
6971 return pos, true
@@ -72,11 +74,11 @@ func (d *Document) Normalize(pos Pos) (Pos, bool) {
7274 // PrevGlyph returns the previous glyph head on pos's physical row.
7375 func (d *Document) PrevGlyph(pos Pos) (Pos, bool) {
7476 pos, ok := d.Normalize(pos)
75- if !ok || len(d.Row(pos.Row)) == 0 {
77+ if !ok || d.RowWidth(pos.Row) == 0 {
7678 return Pos{}, false
7779 }
7880 for col := pos.Col - 1; col >= 0; col-- {
79- if !d.Row(pos.Row)[col].Continuation {
81+ if !d.Cell(col, pos.Row).Continuation {
8082 return Pos{Row: pos.Row, Col: col}, true
8183 }
8284 }
@@ -89,9 +91,9 @@ func (d *Document) NextGlyph(pos Pos) (Pos, bool) {
8991 if !ok {
9092 return Pos{}, false
9193 }
92- row := d.Row(pos.Row)
93- for col := pos.Col + 1; col < len(row); col++ {
94- if !row[col].Continuation {
94+ width := d.RowWidth(pos.Row)
95+ for col := pos.Col + 1; col < width; col++ {
96+ if !d.Cell(col, pos.Row).Continuation {
9597 return Pos{Row: pos.Row, Col: col}, true
9698 }
9799 }
@@ -357,12 +359,11 @@ func (d *Document) isSeparator(r rune) bool {
357359 }
358360
359361 func (d *Document) isGlyph(pos Pos) bool {
360- row := d.Row(pos.Row)
361- return pos.Col >= 0 && pos.Col < len(row) && !row[pos.Col].Continuation
362+ return pos.Col >= 0 && pos.Col < d.RowWidth(pos.Row) && !d.Cell(pos.Col, pos.Row).Continuation
362363 }
363364
364365 func (d *Document) runeAt(pos Pos) rune {
365- cell := d.Row(pos.Row)[pos.Col]
366+ cell := d.Cell(pos.Col, pos.Row)
366367 if cell.Rune == 0 {
367368 return ' '
368369 }
@@ -373,13 +374,13 @@ func (d *Document) runeAt(pos Pos) rune {
373374 // an index for unrelated scrollback rows.
374375 func (d *Document) nextDocumentGlyph(pos Pos) (Pos, bool) {
375376 for row := pos.Row; row < d.Len(); row++ {
376- cells := d.Row(row)
377+ width := d.RowWidth(row)
377378 start := 0
378379 if row == pos.Row {
379380 start = pos.Col + 1
380381 }
381- for col := start; col < len(cells); col++ {
382- if !cells[col].Continuation {
382+ for col := start; col < width; col++ {
383+ if !d.Cell(col, row).Continuation {
383384 return Pos{Row: row, Col: col}, true
384385 }
385386 }
@@ -391,13 +392,12 @@ func (d *Document) nextDocumentGlyph(pos Pos) (Pos, bool) {
391392 // building an index for unrelated scrollback rows.
392393 func (d *Document) previousDocumentGlyph(pos Pos) (Pos, bool) {
393394 for row := pos.Row; row >= 0; row-- {
394- cells := d.Row(row)
395- start := len(cells) - 1
395+ start := d.RowWidth(row) - 1
396396 if row == pos.Row {
397397 start = min(pos.Col-1, start)
398398 }
399399 for col := start; col >= 0; col-- {
400- if !cells[col].Continuation {
400+ if !d.Cell(col, row).Continuation {
401401 return Pos{Row: row, Col: col}, true
402402 }
403403 }
@@ -41,6 +41,8 @@ func (d *Document) Len() int { return d.snapshot.Len() }
41 func (d *Document) Width() int { return d.snapshot.Width }41 func (d *Document) Width() int { return d.snapshot.Width }
42 func (d *Document) Height() int { return d.snapshot.Height }42 func (d *Document) Height() int { return d.snapshot.Height }
43 func (d *Document) Row(row int) []renderer.Cell { return d.snapshot.Row(row) }43 func (d *Document) Row(row int) []renderer.Cell { return d.snapshot.Row(row) }
44+func (d *Document) RowWidth(row int) int { return d.snapshot.RowWidth(row) }
45+func (d *Document) Cell(x, y int) renderer.Cell { return d.snapshot.Cell(x, y) }
44 func (d *Document) RowID(row int) vt.RowID { return d.snapshot.RowID(row) }46 func (d *Document) RowID(row int) vt.RowID { return d.snapshot.RowID(row) }
45 func (d *Document) FindRowID(id vt.RowID) int { return d.snapshot.FindRowID(id) }47 func (d *Document) FindRowID(id vt.RowID) int { return d.snapshot.FindRowID(id) }
46 48
@@ -50,20 +52,20 @@ func (d *Document) Normalize(pos Pos) (Pos, bool) {
50 if pos.Row < 0 || pos.Row >= d.Len() || pos.Col < 0 {52 if pos.Row < 0 || pos.Row >= d.Len() || pos.Col < 0 {
51 return Pos{}, false53 return Pos{}, false
52 }54 }
53- row := d.Row(pos.Row)55+ width := d.RowWidth(pos.Row)
54- if len(row) == 0 {56+ if width == 0 {
55 if pos.Col == 0 {57 if pos.Col == 0 {
56 return pos, true58 return pos, true
57 }59 }
58 return Pos{}, false60 return Pos{}, false
59 }61 }
60- if pos.Col >= len(row) {62+ if pos.Col >= width {
61 return Pos{}, false63 return Pos{}, false
62 }64 }
63- for pos.Col > 0 && row[pos.Col].Continuation {65+ for pos.Col > 0 && d.Cell(pos.Col, pos.Row).Continuation {
64 pos.Col--66 pos.Col--
65 }67 }
66- if row[pos.Col].Continuation {68+ if d.Cell(pos.Col, pos.Row).Continuation {
67 return Pos{}, false69 return Pos{}, false
68 }70 }
69 return pos, true71 return pos, true
@@ -72,11 +74,11 @@ func (d *Document) Normalize(pos Pos) (Pos, bool) {
72 // PrevGlyph returns the previous glyph head on pos's physical row.74 // PrevGlyph returns the previous glyph head on pos's physical row.
73 func (d *Document) PrevGlyph(pos Pos) (Pos, bool) {75 func (d *Document) PrevGlyph(pos Pos) (Pos, bool) {
74 pos, ok := d.Normalize(pos)76 pos, ok := d.Normalize(pos)
75- if !ok || len(d.Row(pos.Row)) == 0 {77+ if !ok || d.RowWidth(pos.Row) == 0 {
76 return Pos{}, false78 return Pos{}, false
77 }79 }
78 for col := pos.Col - 1; col >= 0; col-- {80 for col := pos.Col - 1; col >= 0; col-- {
79- if !d.Row(pos.Row)[col].Continuation {81+ if !d.Cell(col, pos.Row).Continuation {
80 return Pos{Row: pos.Row, Col: col}, true82 return Pos{Row: pos.Row, Col: col}, true
81 }83 }
82 }84 }
@@ -89,9 +91,9 @@ func (d *Document) NextGlyph(pos Pos) (Pos, bool) {
89 if !ok {91 if !ok {
90 return Pos{}, false92 return Pos{}, false
91 }93 }
92- row := d.Row(pos.Row)94+ width := d.RowWidth(pos.Row)
93- for col := pos.Col + 1; col < len(row); col++ {95+ for col := pos.Col + 1; col < width; col++ {
94- if !row[col].Continuation {96+ if !d.Cell(col, pos.Row).Continuation {
95 return Pos{Row: pos.Row, Col: col}, true97 return Pos{Row: pos.Row, Col: col}, true
96 }98 }
97 }99 }
@@ -357,12 +359,11 @@ func (d *Document) isSeparator(r rune) bool {
357 }359 }
358 360
359 func (d *Document) isGlyph(pos Pos) bool {361 func (d *Document) isGlyph(pos Pos) bool {
360- row := d.Row(pos.Row)362+ return pos.Col >= 0 && pos.Col < d.RowWidth(pos.Row) && !d.Cell(pos.Col, pos.Row).Continuation
361- return pos.Col >= 0 && pos.Col < len(row) && !row[pos.Col].Continuation
362 }363 }
363 364
364 func (d *Document) runeAt(pos Pos) rune {365 func (d *Document) runeAt(pos Pos) rune {
365- cell := d.Row(pos.Row)[pos.Col]366+ cell := d.Cell(pos.Col, pos.Row)
366 if cell.Rune == 0 {367 if cell.Rune == 0 {
367 return ' '368 return ' '
368 }369 }
@@ -373,13 +374,13 @@ func (d *Document) runeAt(pos Pos) rune {
373 // an index for unrelated scrollback rows.374 // an index for unrelated scrollback rows.
374 func (d *Document) nextDocumentGlyph(pos Pos) (Pos, bool) {375 func (d *Document) nextDocumentGlyph(pos Pos) (Pos, bool) {
375 for row := pos.Row; row < d.Len(); row++ {376 for row := pos.Row; row < d.Len(); row++ {
376- cells := d.Row(row)377+ width := d.RowWidth(row)
377 start := 0378 start := 0
378 if row == pos.Row {379 if row == pos.Row {
379 start = pos.Col + 1380 start = pos.Col + 1
380 }381 }
381- for col := start; col < len(cells); col++ {382+ for col := start; col < width; col++ {
382- if !cells[col].Continuation {383+ if !d.Cell(col, row).Continuation {
383 return Pos{Row: row, Col: col}, true384 return Pos{Row: row, Col: col}, true
384 }385 }
385 }386 }
@@ -391,13 +392,12 @@ func (d *Document) nextDocumentGlyph(pos Pos) (Pos, bool) {
391 // building an index for unrelated scrollback rows.392 // building an index for unrelated scrollback rows.
392 func (d *Document) previousDocumentGlyph(pos Pos) (Pos, bool) {393 func (d *Document) previousDocumentGlyph(pos Pos) (Pos, bool) {
393 for row := pos.Row; row >= 0; row-- {394 for row := pos.Row; row >= 0; row-- {
394- cells := d.Row(row)395+ start := d.RowWidth(row) - 1
395- start := len(cells) - 1
396 if row == pos.Row {396 if row == pos.Row {
397 start = min(pos.Col-1, start)397 start = min(pos.Col-1, start)
398 }398 }
399 for col := start; col >= 0; col-- {399 for col := start; col >= 0; col-- {
400- if !cells[col].Continuation {400+ if !d.Cell(col, row).Continuation {
401 return Pos{Row: row, Col: col}, true401 return Pos{Row: row, Col: col}, true
402 }402 }
403 }403 }
M internal/usecase/copy/mode.go +99 -31
@@ -24,7 +24,7 @@ type Snapshot struct {
2424 Width, Height int
2525 }
2626
27-func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound, rowIDs []vt.RowID) Snapshot {
27+func NewSnapshot(historySource *vt.History, screen vt.CellSource, bounds []vt.LineBound, rowIDs []vt.RowID) Snapshot {
2828 return newSnapshot(historySource, screen, bounds, append([]vt.RowID(nil), rowIDs...))
2929 }
3030
@@ -37,21 +37,27 @@ func NewSnapshotFromScreen(historySource *vt.History, screen *vt.Screen) Snapsho
3737 if screen == nil {
3838 return NewSnapshot(historySource, renderer.Frame{}, nil, nil)
3939 }
40- return newSnapshot(historySource, screen.Frame, screen.LineBounds(), screen.RowIDs())
40+ return newSnapshot(historySource, screen, screen.LineBounds(), screen.RowIDs())
4141 }
4242
43-func newSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound, screenRowIDs []vt.RowID) Snapshot {
43+func newSnapshot(historySource *vt.History, screen vt.CellSource, bounds []vt.LineBound, screenRowIDs []vt.RowID) Snapshot {
44+ owned := renderer.NewFrame(screen.Columns(), screen.Rows())
45+ for y := range screen.Rows() {
46+ for x := range screen.Columns() {
47+ owned.Set(x, y, screen.Cell(x, y))
48+ }
49+ }
4450 var history vt.HistoryView
4551 if historySource != nil {
4652 history = historySource.SealAndView()
4753 }
4854 return Snapshot{
4955 history: history,
50- screen: screen.Clone(),
56+ screen: owned,
5157 screenBounds: append([]vt.LineBound(nil), bounds...),
5258 screenRowIDs: screenRowIDs,
53- Width: screen.Width,
54- Height: screen.Height,
59+ Width: screen.Columns(),
60+ Height: screen.Rows(),
5561 }
5662 }
5763
@@ -60,7 +66,7 @@ func newSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.L
6066 func NewSnapshotFromLines(rows [][]renderer.Cell, bounds []vt.LineBound, width, height int) Snapshot {
6167 history := vt.NewHistory(vt.HistoryConfig{
6268 MaxRows: max(len(rows), 1),
63- MaxCells: snapshotRowsCellBudget(rows),
69+ MaxBytes: math.MaxUint64, // Input is already owned and bounded by the caller.
6470 ChunkRows: 256,
6571 })
6672 for i, row := range rows {
@@ -80,25 +86,13 @@ func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot {
8086 return NewSnapshotFromLines(rows, nil, width, height)
8187 }
8288
83-// snapshotRowsCellBudget returns a capacity that retains every supplied row.
84-// Saturation is safe because a larger representable capacity does not exist.
85-func snapshotRowsCellBudget(rows [][]renderer.Cell) int {
86- cells := 0
87- for _, row := range rows {
88- if len(row) > math.MaxInt-cells {
89- return math.MaxInt
90- }
91- cells += len(row)
92- }
93- return cells
94-}
9589 func (s Snapshot) Len() int { return s.history.Len() + s.screen.Height }
9690 func (s Snapshot) Row(i int) []renderer.Cell {
9791 if i < 0 {
9892 return nil
9993 }
10094 if i < s.history.Len() {
101- return s.history.BorrowedRow(i)
95+ return s.history.Row(i)
10296 }
10397 i -= s.history.Len()
10498 if i >= s.screen.Height {
@@ -107,6 +101,52 @@ func (s Snapshot) Row(i int) []renderer.Cell {
107101 return s.screen.Row(i)
108102 }
109103
104+func (s Snapshot) RowWidth(y int) int {
105+ if y < 0 {
106+ return 0
107+ }
108+ if y < s.history.Len() {
109+ return s.history.RowWidth(y)
110+ }
111+ y -= s.history.Len()
112+ if y >= s.screen.Height {
113+ return 0
114+ }
115+ return s.screen.Width
116+}
117+
118+func (s Snapshot) Cell(x, y int) renderer.Cell {
119+ if y < 0 || x < 0 {
120+ return renderer.BlankCell()
121+ }
122+ if y < s.history.Len() {
123+ return s.history.Cell(x, y)
124+ }
125+ y -= s.history.Len()
126+ if y >= s.screen.Height || x >= s.screen.Width {
127+ return renderer.BlankCell()
128+ }
129+ return s.screen.Cell(x, y)
130+}
131+
132+func (s Snapshot) CopyRow(y int, dst []renderer.Cell) int {
133+ if y < 0 {
134+ return 0
135+ }
136+ if y < s.history.Len() {
137+ return s.history.CopyRow(y, dst)
138+ }
139+ y -= s.history.Len()
140+ if y >= s.screen.Height {
141+ return 0
142+ }
143+ n := min(len(dst), s.screen.Width)
144+ for x := range n {
145+ dst[x] = s.screen.Cell(x, y)
146+ }
147+ return n
148+}
149+
110150 // Bound returns the logical extent of row i, dispatching between sealed history
111151 // and the live screen exactly as Row does.
112152 func (s Snapshot) Bound(i int) vt.LineBound {
@@ -167,10 +207,10 @@ func (s Snapshot) FindRowID(id vt.RowID) int {
167207 return -1
168208 }
169209
170-func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) {
210+func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) error {
171211 i := 0
172212 stopped := false
173- s.history.Range(func(r []renderer.Cell) bool {
213+ err := s.history.Range(func(r []renderer.Cell) bool {
174214 if !yield(i, r) {
175215 stopped = true
176216 return false
@@ -178,14 +218,18 @@ func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) {
178218 i++
179219 return true
180220 })
221+ if err != nil {
222+ return err
223+ }
181224 if stopped {
182- return
225+ return nil
183226 }
184227 for y := range s.screen.Height {
185228 if !yield(i+y, s.screen.Row(y)) {
186- return
229+ return nil
187230 }
188231 }
232+ return nil
189233 }
190234
191235 type SearchMatch struct {
@@ -425,9 +469,13 @@ func FindMatches(doc *Document, query string) []SearchMatch {
425469 if doc == nil || query == "" {
426470 return nil
427471 }
472+ return findMatches(doc, query, doc.Snapshot().rangeRows)
473+}
474+
475+func findMatches(doc *Document, query string, rangeRows func(func(int, []renderer.Cell) bool) error) []SearchMatch {
428476 needle := lowerRunes(query)
429477 matches := []SearchMatch{}
430- doc.Snapshot().rangeRows(func(row int, cells []renderer.Cell) bool {
478+ err := rangeRows(func(row int, cells []renderer.Cell) bool {
431479 hay, indexes := searchableCells(cells)
432480 text := ""
433481 for start := 0; start+len(needle) <= len(hay); {
@@ -448,6 +496,10 @@ func FindMatches(doc *Document, query string) []SearchMatch {
448496 }
449497 return true
450498 })
499+ if err != nil {
500+ // Discard partial results without taking down the daemon on corrupt history.
501+ return nil
502+ }
451503 return matches
452504 }
453505 func searchableCells(cells []renderer.Cell) ([]rune, []int) {
@@ -478,18 +530,33 @@ func (m *Mode) Render(styles ...renderer.Style) renderer.Frame {
478530 if m == nil || m.document == nil {
479531 return renderer.NewFrame(0, 0)
480532 }
533+ frame := renderer.NewFrame(m.document.Width(), m.document.Height()+1)
534+ m.RenderRows(func(y int, row []renderer.Cell) { frame.WriteRow(y, 0, row) }, styles...)
535+ return frame
536+}
537+
538+// RenderRows paints viewport rows followed by the status row at Document.Height().
539+// The callback borrows a reusable semantic row only until it returns. Rendering
540+// directly into a compositor avoids building and then decoding a compact frame.
541+func (m *Mode) RenderRows(paint func(int, []renderer.Cell), styles ...renderer.Style) {
542+ if m == nil || m.document == nil {
543+ return
544+ }
481545 d := m.document
482- frame := renderer.NewFrame(d.Width(), d.Height()+1)
483546 selectionBounds, hasSelectionBounds := m.selection.bounds(d)
484547 selection, hasSelection := optionalStyle(styles, 1)
485548 cursor, cursorValid := d.Normalize(m.navigator.Pos)
549+ row := make([]renderer.Cell, d.Width())
486550 for y := range d.Height() {
487551 src := m.ViewportTop + y
552+ for x := range row {
553+ row[x] = renderer.BlankCell()
554+ }
488555 if src >= d.Len() {
489- break
556+ paint(y, row)
557+ continue
490558 }
491- row := frame.Row(y)
492- copy(row, d.Row(src))
559+ d.snapshot.CopyRow(src, row)
493560 if match, ok := m.currentSearchMatchForRow(src); ok {
494561 for x := max(match.Start, 0); x < min(match.End, len(row)); x++ {
495562 applySelectionStyle(&row[x].Style, selection, hasSelection)
@@ -510,13 +577,14 @@ func (m *Mode) Render(styles ...renderer.Style) renderer.Frame {
510577 cursorCol := min(cursor.Col, len(row)-1)
511578 applySelectionStyle(&row[cursorCol].Style, selection, hasSelection)
512579 }
580+ paint(y, row)
513581 }
514582 status := inverseStyle()
515583 if len(styles) > 0 {
516584 status = styles[0]
517585 }
518- drawCopyStatus(frame.Row(d.Height()), m, d.Len(), status)
519- return frame
586+ drawCopyStatus(row, m, d.Len(), status)
587+ paint(d.Height(), row)
520588 }
521589 func (m *Mode) currentSearchMatchForRow(row int) (SearchMatch, bool) {
522590 if m == nil || m.SearchIndex < 0 || m.SearchIndex >= len(m.Searches) {
@@ -24,7 +24,7 @@ type Snapshot struct {
24 Width, Height int24 Width, Height int
25 }25 }
26 26
27-func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound, rowIDs []vt.RowID) Snapshot {27+func NewSnapshot(historySource *vt.History, screen vt.CellSource, bounds []vt.LineBound, rowIDs []vt.RowID) Snapshot {
28 return newSnapshot(historySource, screen, bounds, append([]vt.RowID(nil), rowIDs...))28 return newSnapshot(historySource, screen, bounds, append([]vt.RowID(nil), rowIDs...))
29 }29 }
30 30
@@ -37,21 +37,27 @@ func NewSnapshotFromScreen(historySource *vt.History, screen *vt.Screen) Snapsho
37 if screen == nil {37 if screen == nil {
38 return NewSnapshot(historySource, renderer.Frame{}, nil, nil)38 return NewSnapshot(historySource, renderer.Frame{}, nil, nil)
39 }39 }
40- return newSnapshot(historySource, screen.Frame, screen.LineBounds(), screen.RowIDs())40+ return newSnapshot(historySource, screen, screen.LineBounds(), screen.RowIDs())
41 }41 }
42 42
43-func newSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound, screenRowIDs []vt.RowID) Snapshot {43+func newSnapshot(historySource *vt.History, screen vt.CellSource, bounds []vt.LineBound, screenRowIDs []vt.RowID) Snapshot {
44+ owned := renderer.NewFrame(screen.Columns(), screen.Rows())
45+ for y := range screen.Rows() {
46+ for x := range screen.Columns() {
47+ owned.Set(x, y, screen.Cell(x, y))
48+ }
49+ }
44 var history vt.HistoryView50 var history vt.HistoryView
45 if historySource != nil {51 if historySource != nil {
46 history = historySource.SealAndView()52 history = historySource.SealAndView()
47 }53 }
48 return Snapshot{54 return Snapshot{
49 history: history,55 history: history,
50- screen: screen.Clone(),56+ screen: owned,
51 screenBounds: append([]vt.LineBound(nil), bounds...),57 screenBounds: append([]vt.LineBound(nil), bounds...),
52 screenRowIDs: screenRowIDs,58 screenRowIDs: screenRowIDs,
53- Width: screen.Width,59+ Width: screen.Columns(),
54- Height: screen.Height,60+ Height: screen.Rows(),
55 }61 }
56 }62 }
57 63
@@ -60,7 +66,7 @@ func newSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.L
60 func NewSnapshotFromLines(rows [][]renderer.Cell, bounds []vt.LineBound, width, height int) Snapshot {66 func NewSnapshotFromLines(rows [][]renderer.Cell, bounds []vt.LineBound, width, height int) Snapshot {
61 history := vt.NewHistory(vt.HistoryConfig{67 history := vt.NewHistory(vt.HistoryConfig{
62 MaxRows: max(len(rows), 1),68 MaxRows: max(len(rows), 1),
63- MaxCells: snapshotRowsCellBudget(rows),69+ MaxBytes: math.MaxUint64, // Input is already owned and bounded by the caller.
64 ChunkRows: 256,70 ChunkRows: 256,
65 })71 })
66 for i, row := range rows {72 for i, row := range rows {
@@ -80,25 +86,13 @@ func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot {
80 return NewSnapshotFromLines(rows, nil, width, height)86 return NewSnapshotFromLines(rows, nil, width, height)
81 }87 }
82 88
83-// snapshotRowsCellBudget returns a capacity that retains every supplied row.
84-// Saturation is safe because a larger representable capacity does not exist.
85-func snapshotRowsCellBudget(rows [][]renderer.Cell) int {
86- cells := 0
87- for _, row := range rows {
88- if len(row) > math.MaxInt-cells {
89- return math.MaxInt
90- }
91- cells += len(row)
92- }
93- return cells
94-}
95 func (s Snapshot) Len() int { return s.history.Len() + s.screen.Height }89 func (s Snapshot) Len() int { return s.history.Len() + s.screen.Height }
96 func (s Snapshot) Row(i int) []renderer.Cell {90 func (s Snapshot) Row(i int) []renderer.Cell {
97 if i < 0 {91 if i < 0 {
98 return nil92 return nil
99 }93 }
100 if i < s.history.Len() {94 if i < s.history.Len() {
101- return s.history.BorrowedRow(i)95+ return s.history.Row(i)
102 }96 }
103 i -= s.history.Len()97 i -= s.history.Len()
104 if i >= s.screen.Height {98 if i >= s.screen.Height {
@@ -107,6 +101,52 @@ func (s Snapshot) Row(i int) []renderer.Cell {
107 return s.screen.Row(i)101 return s.screen.Row(i)
108 }102 }
109 103
104+func (s Snapshot) RowWidth(y int) int {
105+ if y < 0 {
106+ return 0
107+ }
108+ if y < s.history.Len() {
109+ return s.history.RowWidth(y)
110+ }
111+ y -= s.history.Len()
112+ if y >= s.screen.Height {
113+ return 0
114+ }
115+ return s.screen.Width
116+}
117+
118+func (s Snapshot) Cell(x, y int) renderer.Cell {
119+ if y < 0 || x < 0 {
120+ return renderer.BlankCell()
121+ }
122+ if y < s.history.Len() {
123+ return s.history.Cell(x, y)
124+ }
125+ y -= s.history.Len()
126+ if y >= s.screen.Height || x >= s.screen.Width {
127+ return renderer.BlankCell()
128+ }
129+ return s.screen.Cell(x, y)
130+}
131+
132+func (s Snapshot) CopyRow(y int, dst []renderer.Cell) int {
133+ if y < 0 {
134+ return 0
135+ }
136+ if y < s.history.Len() {
137+ return s.history.CopyRow(y, dst)
138+ }
139+ y -= s.history.Len()
140+ if y >= s.screen.Height {
141+ return 0
142+ }
143+ n := min(len(dst), s.screen.Width)
144+ for x := range n {
145+ dst[x] = s.screen.Cell(x, y)
146+ }
147+ return n
148+}
149+
110 // Bound returns the logical extent of row i, dispatching between sealed history150 // Bound returns the logical extent of row i, dispatching between sealed history
111 // and the live screen exactly as Row does.151 // and the live screen exactly as Row does.
112 func (s Snapshot) Bound(i int) vt.LineBound {152 func (s Snapshot) Bound(i int) vt.LineBound {
@@ -167,10 +207,10 @@ func (s Snapshot) FindRowID(id vt.RowID) int {
167 return -1207 return -1
168 }208 }
169 209
170-func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) {210+func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) error {
171 i := 0211 i := 0
172 stopped := false212 stopped := false
173- s.history.Range(func(r []renderer.Cell) bool {213+ err := s.history.Range(func(r []renderer.Cell) bool {
174 if !yield(i, r) {214 if !yield(i, r) {
175 stopped = true215 stopped = true
176 return false216 return false
@@ -178,14 +218,18 @@ func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) {
178 i++218 i++
179 return true219 return true
180 })220 })
221+ if err != nil {
222+ return err
223+ }
181 if stopped {224 if stopped {
182- return225+ return nil
183 }226 }
184 for y := range s.screen.Height {227 for y := range s.screen.Height {
185 if !yield(i+y, s.screen.Row(y)) {228 if !yield(i+y, s.screen.Row(y)) {
186- return229+ return nil
187 }230 }
188 }231 }
232+ return nil
189 }233 }
190 234
191 type SearchMatch struct {235 type SearchMatch struct {
@@ -425,9 +469,13 @@ func FindMatches(doc *Document, query string) []SearchMatch {
425 if doc == nil || query == "" {469 if doc == nil || query == "" {
426 return nil470 return nil
427 }471 }
472+ return findMatches(doc, query, doc.Snapshot().rangeRows)
473+}
474+
475+func findMatches(doc *Document, query string, rangeRows func(func(int, []renderer.Cell) bool) error) []SearchMatch {
428 needle := lowerRunes(query)476 needle := lowerRunes(query)
429 matches := []SearchMatch{}477 matches := []SearchMatch{}
430- doc.Snapshot().rangeRows(func(row int, cells []renderer.Cell) bool {478+ err := rangeRows(func(row int, cells []renderer.Cell) bool {
431 hay, indexes := searchableCells(cells)479 hay, indexes := searchableCells(cells)
432 text := ""480 text := ""
433 for start := 0; start+len(needle) <= len(hay); {481 for start := 0; start+len(needle) <= len(hay); {
@@ -448,6 +496,10 @@ func FindMatches(doc *Document, query string) []SearchMatch {
448 }496 }
449 return true497 return true
450 })498 })
499+ if err != nil {
500+ // Discard partial results without taking down the daemon on corrupt history.
501+ return nil
502+ }
451 return matches503 return matches
452 }504 }
453 func searchableCells(cells []renderer.Cell) ([]rune, []int) {505 func searchableCells(cells []renderer.Cell) ([]rune, []int) {
@@ -478,18 +530,33 @@ func (m *Mode) Render(styles ...renderer.Style) renderer.Frame {
478 if m == nil || m.document == nil {530 if m == nil || m.document == nil {
479 return renderer.NewFrame(0, 0)531 return renderer.NewFrame(0, 0)
480 }532 }
533+ frame := renderer.NewFrame(m.document.Width(), m.document.Height()+1)
534+ m.RenderRows(func(y int, row []renderer.Cell) { frame.WriteRow(y, 0, row) }, styles...)
535+ return frame
536+}
537+
538+// RenderRows paints viewport rows followed by the status row at Document.Height().
539+// The callback borrows a reusable semantic row only until it returns. Rendering
540+// directly into a compositor avoids building and then decoding a compact frame.
541+func (m *Mode) RenderRows(paint func(int, []renderer.Cell), styles ...renderer.Style) {
542+ if m == nil || m.document == nil {
543+ return
544+ }
481 d := m.document545 d := m.document
482- frame := renderer.NewFrame(d.Width(), d.Height()+1)
483 selectionBounds, hasSelectionBounds := m.selection.bounds(d)546 selectionBounds, hasSelectionBounds := m.selection.bounds(d)
484 selection, hasSelection := optionalStyle(styles, 1)547 selection, hasSelection := optionalStyle(styles, 1)
485 cursor, cursorValid := d.Normalize(m.navigator.Pos)548 cursor, cursorValid := d.Normalize(m.navigator.Pos)
549+ row := make([]renderer.Cell, d.Width())
486 for y := range d.Height() {550 for y := range d.Height() {
487 src := m.ViewportTop + y551 src := m.ViewportTop + y
552+ for x := range row {
553+ row[x] = renderer.BlankCell()
554+ }
488 if src >= d.Len() {555 if src >= d.Len() {
489- break556+ paint(y, row)
557+ continue
490 }558 }
491- row := frame.Row(y)559+ d.snapshot.CopyRow(src, row)
492- copy(row, d.Row(src))
493 if match, ok := m.currentSearchMatchForRow(src); ok {560 if match, ok := m.currentSearchMatchForRow(src); ok {
494 for x := max(match.Start, 0); x < min(match.End, len(row)); x++ {561 for x := max(match.Start, 0); x < min(match.End, len(row)); x++ {
495 applySelectionStyle(&row[x].Style, selection, hasSelection)562 applySelectionStyle(&row[x].Style, selection, hasSelection)
@@ -510,13 +577,14 @@ func (m *Mode) Render(styles ...renderer.Style) renderer.Frame {
510 cursorCol := min(cursor.Col, len(row)-1)577 cursorCol := min(cursor.Col, len(row)-1)
511 applySelectionStyle(&row[cursorCol].Style, selection, hasSelection)578 applySelectionStyle(&row[cursorCol].Style, selection, hasSelection)
512 }579 }
580+ paint(y, row)
513 }581 }
514 status := inverseStyle()582 status := inverseStyle()
515 if len(styles) > 0 {583 if len(styles) > 0 {
516 status = styles[0]584 status = styles[0]
517 }585 }
518- drawCopyStatus(frame.Row(d.Height()), m, d.Len(), status)586+ drawCopyStatus(row, m, d.Len(), status)
519- return frame587+ paint(d.Height(), row)
520 }588 }
521 func (m *Mode) currentSearchMatchForRow(row int) (SearchMatch, bool) {589 func (m *Mode) currentSearchMatchForRow(row int) (SearchMatch, bool) {
522 if m == nil || m.SearchIndex < 0 || m.SearchIndex >= len(m.Searches) {590 if m == nil || m.SearchIndex < 0 || m.SearchIndex >= len(m.Searches) {
M internal/usecase/copy/mode_test.go +10 -10
@@ -43,12 +43,12 @@ func TestNewSnapshotFromRowsPreservesWideRows(t *testing.T) {
4343 }
4444
4545 func TestSnapshotCarriesImmutableRowIDsAndDocumentsResolveBookmarks(t *testing.T) {
46- history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 1024})
46+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8})
4747 require.NoError(t, history.AppendWithID(row("hist"), vt.LineBound{End: 4}, vt.RowID(11)))
4848 require.NoError(t, history.AppendWithID(row("tail"), vt.LineBound{End: 4}, vt.RowID(12)))
4949 screen := renderer.NewFrame(4, 2)
50- copy(screen.Row(0), row("live"))
51- copy(screen.Row(1), row("more"))
50+ screen.WriteRow(0, 0, row("live"))
51+ screen.WriteRow(1, 0, row("more"))
5252 rowIDs := []vt.RowID{21, 22}
5353
5454 snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 4}}, rowIDs)
@@ -74,12 +74,12 @@ func TestSnapshotBoundDispatchesLikeRow(t *testing.T) {
7474 return cells
7575 }
7676
77- history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 1024})
77+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8})
7878 if err := history.Append(row("hist"), vt.LineBound{End: 4, Soft: true}); err != nil {
7979 t.Fatalf("append: %v", err)
8080 }
8181 screen := renderer.NewFrame(4, 2)
82- copy(screen.Row(0), row("live"))
82+ screen.WriteRow(0, 0, row("live"))
8383
8484 snap := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 0, Soft: true}}, nil)
8585
@@ -359,14 +359,14 @@ func TestSelectedTextJoinsAcrossTheHistoryScreenBoundary(t *testing.T) {
359359 }
360360
361361 const width = 4
362- history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 1024})
362+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8})
363363 // The last history row wrapped into what is now the first live screen row.
364364 if err := history.Append(row("abcd", width), vt.LineBound{End: width, Soft: true}); err != nil {
365365 t.Fatalf("append: %v", err)
366366 }
367367 screen := renderer.NewFrame(width, 2)
368- copy(screen.Row(0), row("ef", width))
369- copy(screen.Row(1), row("gh", width))
368+ screen.WriteRow(0, 0, row("ef", width))
369+ screen.WriteRow(1, 0, row("gh", width))
370370
371371 snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 2}, {End: 2}}, nil)
372372 doc := NewDocument(snapshot, "")
@@ -434,10 +434,10 @@ func TestSelectedTextJoinsRowsWrappedByTheVT(t *testing.T) {
434434 }
435435 for _, tc := range cases {
436436 t.Run(tc.name, func(t *testing.T) {
437- screen := vt.NewScreenWithHistory(width, height, vt.HistoryConfig{MaxRows: 64, MaxCells: 4096})
437+ screen := vt.NewScreenWithHistory(width, height, vt.HistoryConfig{MaxRows: 64})
438438 screen.Write([]byte(tc.input))
439439
440- snapshot := NewSnapshot(screen.History(), screen.Frame, screen.LineBounds(), nil)
440+ snapshot := NewSnapshot(screen.History(), screen, screen.LineBounds(), nil)
441441 require.Equal(t, tc.wantHistoryLen, snapshot.history.Len(), "history/screen seam moved")
442442 doc := NewDocument(snapshot, domain.DefaultWordSeparators)
443443
@@ -43,12 +43,12 @@ func TestNewSnapshotFromRowsPreservesWideRows(t *testing.T) {
43 }43 }
44 44
45 func TestSnapshotCarriesImmutableRowIDsAndDocumentsResolveBookmarks(t *testing.T) {45 func TestSnapshotCarriesImmutableRowIDsAndDocumentsResolveBookmarks(t *testing.T) {
46- history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 1024})46+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8})
47 require.NoError(t, history.AppendWithID(row("hist"), vt.LineBound{End: 4}, vt.RowID(11)))47 require.NoError(t, history.AppendWithID(row("hist"), vt.LineBound{End: 4}, vt.RowID(11)))
48 require.NoError(t, history.AppendWithID(row("tail"), vt.LineBound{End: 4}, vt.RowID(12)))48 require.NoError(t, history.AppendWithID(row("tail"), vt.LineBound{End: 4}, vt.RowID(12)))
49 screen := renderer.NewFrame(4, 2)49 screen := renderer.NewFrame(4, 2)
50- copy(screen.Row(0), row("live"))50+ screen.WriteRow(0, 0, row("live"))
51- copy(screen.Row(1), row("more"))51+ screen.WriteRow(1, 0, row("more"))
52 rowIDs := []vt.RowID{21, 22}52 rowIDs := []vt.RowID{21, 22}
53 53
54 snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 4}}, rowIDs)54 snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 4}}, rowIDs)
@@ -74,12 +74,12 @@ func TestSnapshotBoundDispatchesLikeRow(t *testing.T) {
74 return cells74 return cells
75 }75 }
76 76
77- history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 1024})77+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8})
78 if err := history.Append(row("hist"), vt.LineBound{End: 4, Soft: true}); err != nil {78 if err := history.Append(row("hist"), vt.LineBound{End: 4, Soft: true}); err != nil {
79 t.Fatalf("append: %v", err)79 t.Fatalf("append: %v", err)
80 }80 }
81 screen := renderer.NewFrame(4, 2)81 screen := renderer.NewFrame(4, 2)
82- copy(screen.Row(0), row("live"))82+ screen.WriteRow(0, 0, row("live"))
83 83
84 snap := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 0, Soft: true}}, nil)84 snap := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 0, Soft: true}}, nil)
85 85
@@ -359,14 +359,14 @@ func TestSelectedTextJoinsAcrossTheHistoryScreenBoundary(t *testing.T) {
359 }359 }
360 360
361 const width = 4361 const width = 4
362- history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 1024})362+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8})
363 // The last history row wrapped into what is now the first live screen row.363 // The last history row wrapped into what is now the first live screen row.
364 if err := history.Append(row("abcd", width), vt.LineBound{End: width, Soft: true}); err != nil {364 if err := history.Append(row("abcd", width), vt.LineBound{End: width, Soft: true}); err != nil {
365 t.Fatalf("append: %v", err)365 t.Fatalf("append: %v", err)
366 }366 }
367 screen := renderer.NewFrame(width, 2)367 screen := renderer.NewFrame(width, 2)
368- copy(screen.Row(0), row("ef", width))368+ screen.WriteRow(0, 0, row("ef", width))
369- copy(screen.Row(1), row("gh", width))369+ screen.WriteRow(1, 0, row("gh", width))
370 370
371 snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 2}, {End: 2}}, nil)371 snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 2}, {End: 2}}, nil)
372 doc := NewDocument(snapshot, "")372 doc := NewDocument(snapshot, "")
@@ -434,10 +434,10 @@ func TestSelectedTextJoinsRowsWrappedByTheVT(t *testing.T) {
434 }434 }
435 for _, tc := range cases {435 for _, tc := range cases {
436 t.Run(tc.name, func(t *testing.T) {436 t.Run(tc.name, func(t *testing.T) {
437- screen := vt.NewScreenWithHistory(width, height, vt.HistoryConfig{MaxRows: 64, MaxCells: 4096})437+ screen := vt.NewScreenWithHistory(width, height, vt.HistoryConfig{MaxRows: 64})
438 screen.Write([]byte(tc.input))438 screen.Write([]byte(tc.input))
439 439
440- snapshot := NewSnapshot(screen.History(), screen.Frame, screen.LineBounds(), nil)440+ snapshot := NewSnapshot(screen.History(), screen, screen.LineBounds(), nil)
441 require.Equal(t, tc.wantHistoryLen, snapshot.history.Len(), "history/screen seam moved")441 require.Equal(t, tc.wantHistoryLen, snapshot.history.Len(), "history/screen seam moved")
442 doc := NewDocument(snapshot, domain.DefaultWordSeparators)442 doc := NewDocument(snapshot, domain.DefaultWordSeparators)
443 443
A internal/usecase/copy/render_rows_test.go +56 -0
@@ -0,0 +1,56 @@
1+package copy
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ renderer "github.com/bnema/vev-vt/ansi"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestRenderRowsFixedSemanticCells(t *testing.T) {
12+ base := renderer.DefaultStyle()
13+ base.Bold = true
14+ selected := renderer.DefaultStyle()
15+ selected.Inverse = true
16+ status := renderer.DefaultStyle()
17+ status.Inverse = true
18+ input := []renderer.Cell{
19+ {Rune: 'A', Style: base},
20+ {Rune: '界', Style: base},
21+ {Continuation: true, Style: base},
22+ {Rune: 'Z', Style: base},
23+ }
24+ m := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{input}, 32, 3), ""))
25+ require.True(t, m.Search("A"))
26+ require.True(t, m.StartCharacterSelection(Pos{Col: 1}))
27+ require.True(t, m.ExtendCharacterSelection(Pos{Col: 2}))
28+
29+ want := make([][]renderer.Cell, 4)
30+ for y := range want {
31+ want[y] = make([]renderer.Cell, 32)
32+ for x := range want[y] {
33+ want[y][x] = renderer.BlankCell()
34+ }
35+ }
36+ copy(want[0], input)
37+ // Search highlights A; selection covers both cells of the wide glyph.
38+ // Inverse highlighting preserves the source's bold style, including the
39+ // cursor already covered by selection (it must not toggle inverse twice).
40+ for x := 0; x < 3; x++ {
41+ want[0][x].Style.Inverse = true
42+ }
43+ text := " [SELECT] 1/1 1/1 /A "
44+ for x, r := range text + strings.Repeat(" ", 32-len(text)) {
45+ want[3][x] = renderer.Cell{Rune: r, Style: status}
46+ }
47+
48+ var got [][]renderer.Cell
49+ m.RenderRows(func(y int, cells []renderer.Cell) {
50+ require.Equal(t, len(got), y, "body rows precede the final status row")
51+ // Own the callback data; later callbacks reuse the borrowed row.
52+ got = append(got, append([]renderer.Cell(nil), cells...))
53+ }, status, selected)
54+ require.Equal(t, want, got)
55+ require.Equal(t, input, m.Document().Row(0), "rendering must not style the immutable document")
56+}
@@ -0,0 +1,56 @@
1+package copy
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ renderer "github.com/bnema/vev-vt/ansi"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestRenderRowsFixedSemanticCells(t *testing.T) {
12+ base := renderer.DefaultStyle()
13+ base.Bold = true
14+ selected := renderer.DefaultStyle()
15+ selected.Inverse = true
16+ status := renderer.DefaultStyle()
17+ status.Inverse = true
18+ input := []renderer.Cell{
19+ {Rune: 'A', Style: base},
20+ {Rune: '界', Style: base},
21+ {Continuation: true, Style: base},
22+ {Rune: 'Z', Style: base},
23+ }
24+ m := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{input}, 32, 3), ""))
25+ require.True(t, m.Search("A"))
26+ require.True(t, m.StartCharacterSelection(Pos{Col: 1}))
27+ require.True(t, m.ExtendCharacterSelection(Pos{Col: 2}))
28+
29+ want := make([][]renderer.Cell, 4)
30+ for y := range want {
31+ want[y] = make([]renderer.Cell, 32)
32+ for x := range want[y] {
33+ want[y][x] = renderer.BlankCell()
34+ }
35+ }
36+ copy(want[0], input)
37+ // Search highlights A; selection covers both cells of the wide glyph.
38+ // Inverse highlighting preserves the source's bold style, including the
39+ // cursor already covered by selection (it must not toggle inverse twice).
40+ for x := 0; x < 3; x++ {
41+ want[0][x].Style.Inverse = true
42+ }
43+ text := " [SELECT] 1/1 1/1 /A "
44+ for x, r := range text + strings.Repeat(" ", 32-len(text)) {
45+ want[3][x] = renderer.Cell{Rune: r, Style: status}
46+ }
47+
48+ var got [][]renderer.Cell
49+ m.RenderRows(func(y int, cells []renderer.Cell) {
50+ require.Equal(t, len(got), y, "body rows precede the final status row")
51+ // Own the callback data; later callbacks reuse the borrowed row.
52+ got = append(got, append([]renderer.Cell(nil), cells...))
53+ }, status, selected)
54+ require.Equal(t, want, got)
55+ require.Equal(t, input, m.Document().Row(0), "rendering must not style the immutable document")
56+}
A internal/usecase/copy/search_error_test.go +34 -0
@@ -0,0 +1,34 @@
1+package copy
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ renderer "github.com/bnema/vev-vt/ansi"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestFindMatchesDiscardsPartialResultsOnHistoryError(t *testing.T) {
12+ for _, tc := range []struct {
13+ name string
14+ yielded bool
15+ }{
16+ {name: "before rows"},
17+ {name: "after match", yielded: true},
18+ } {
19+ t.Run(tc.name, func(t *testing.T) {
20+ m := modeFor([]string{"match"}, 1)
21+ require.True(t, m.Search("match"))
22+ matches := findMatches(m.document, "match", func(yield func(int, []renderer.Cell) bool) error {
23+ if tc.yielded {
24+ require.True(t, yield(0, row("match")))
25+ }
26+ return errors.New("corrupt cold history")
27+ })
28+ require.Nil(t, matches)
29+ require.False(t, m.SetSearchMatches("match", matches, 0))
30+ require.Empty(t, m.Searches)
31+ require.Equal(t, -1, m.SearchIndex)
32+ })
33+ }
34+}
@@ -0,0 +1,34 @@
1+package copy
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ renderer "github.com/bnema/vev-vt/ansi"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestFindMatchesDiscardsPartialResultsOnHistoryError(t *testing.T) {
12+ for _, tc := range []struct {
13+ name string
14+ yielded bool
15+ }{
16+ {name: "before rows"},
17+ {name: "after match", yielded: true},
18+ } {
19+ t.Run(tc.name, func(t *testing.T) {
20+ m := modeFor([]string{"match"}, 1)
21+ require.True(t, m.Search("match"))
22+ matches := findMatches(m.document, "match", func(yield func(int, []renderer.Cell) bool) error {
23+ if tc.yielded {
24+ require.True(t, yield(0, row("match")))
25+ }
26+ return errors.New("corrupt cold history")
27+ })
28+ require.Nil(t, matches)
29+ require.False(t, m.SetSearchMatches("match", matches, 0))
30+ require.Empty(t, m.Searches)
31+ require.Equal(t, -1, m.SearchIndex)
32+ })
33+ }
34+}
M internal/usecase/daemon/attachment_output_test.go +5 -5
@@ -350,7 +350,7 @@ func TestAttachmentOutputRepeatedUnackedScrollRemainsClientCorrect(t *testing.T)
350350 require.True(t, ok)
351351 mustApplyOutput(t, client, third)
352352 for y, want := range []string{"BBBB", "CCCC", "N "} {
353- require.Equal(t, want, outputStateRow(client.Frame.Row(y)))
353+ require.Equal(t, want, outputStateRow(client.RowCells(y)))
354354 }
355355 }
356356
@@ -416,7 +416,7 @@ func TestAttachmentOutputResizeFrameThenNoopAndDamageAreDifferential(t *testing.
416416 screen.Resize(6, 2)
417417
418418 stream := newOutputStateStream()
419- resized, ok, err := drawOutputState(t, stream, screen.Frame, screen.Damage(), true, 0)
419+ resized, ok, err := drawOutputState(t, stream, captureTestFrame(screen), screen.Damage(), true, 0)
420420 require.NoError(t, err)
421421 require.True(t, ok)
422422 resizeOutput, err := wire.UnmarshalOutput(resized.Payload)
@@ -424,12 +424,12 @@ func TestAttachmentOutputResizeFrameThenNoopAndDamageAreDifferential(t *testing.
424424 require.Zero(t, resizeOutput.Base, "resize must emit the one reset frame")
425425 screen.ClearDamage()
426426
427- _, ok, err = drawOutputState(t, stream, screen.Frame, screen.Damage(), false, 0)
427+ _, ok, err = drawOutputState(t, stream, captureTestFrame(screen), screen.Damage(), false, 0)
428428 require.NoError(t, err)
429429 require.False(t, ok, "an unchanged render after resize must emit nothing")
430430
431- screen.Frame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
432- damaged, ok, err := drawOutputState(t, stream, screen.Frame, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false, 0)
431+ screen.Write([]byte("\x1b[1;1Hx"))
432+ damaged, ok, err := drawOutputState(t, stream, captureTestFrame(screen), []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false, 0)
433433 require.NoError(t, err)
434434 require.True(t, ok)
435435 damageOutput, err := wire.UnmarshalOutput(damaged.Payload)
@@ -350,7 +350,7 @@ func TestAttachmentOutputRepeatedUnackedScrollRemainsClientCorrect(t *testing.T)
350 require.True(t, ok)350 require.True(t, ok)
351 mustApplyOutput(t, client, third)351 mustApplyOutput(t, client, third)
352 for y, want := range []string{"BBBB", "CCCC", "N "} {352 for y, want := range []string{"BBBB", "CCCC", "N "} {
353- require.Equal(t, want, outputStateRow(client.Frame.Row(y)))353+ require.Equal(t, want, outputStateRow(client.RowCells(y)))
354 }354 }
355 }355 }
356 356
@@ -416,7 +416,7 @@ func TestAttachmentOutputResizeFrameThenNoopAndDamageAreDifferential(t *testing.
416 screen.Resize(6, 2)416 screen.Resize(6, 2)
417 417
418 stream := newOutputStateStream()418 stream := newOutputStateStream()
419- resized, ok, err := drawOutputState(t, stream, screen.Frame, screen.Damage(), true, 0)419+ resized, ok, err := drawOutputState(t, stream, captureTestFrame(screen), screen.Damage(), true, 0)
420 require.NoError(t, err)420 require.NoError(t, err)
421 require.True(t, ok)421 require.True(t, ok)
422 resizeOutput, err := wire.UnmarshalOutput(resized.Payload)422 resizeOutput, err := wire.UnmarshalOutput(resized.Payload)
@@ -424,12 +424,12 @@ func TestAttachmentOutputResizeFrameThenNoopAndDamageAreDifferential(t *testing.
424 require.Zero(t, resizeOutput.Base, "resize must emit the one reset frame")424 require.Zero(t, resizeOutput.Base, "resize must emit the one reset frame")
425 screen.ClearDamage()425 screen.ClearDamage()
426 426
427- _, ok, err = drawOutputState(t, stream, screen.Frame, screen.Damage(), false, 0)427+ _, ok, err = drawOutputState(t, stream, captureTestFrame(screen), screen.Damage(), false, 0)
428 require.NoError(t, err)428 require.NoError(t, err)
429 require.False(t, ok, "an unchanged render after resize must emit nothing")429 require.False(t, ok, "an unchanged render after resize must emit nothing")
430 430
431- screen.Frame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})431+ screen.Write([]byte("\x1b[1;1Hx"))
432- damaged, ok, err := drawOutputState(t, stream, screen.Frame, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false, 0)432+ damaged, ok, err := drawOutputState(t, stream, captureTestFrame(screen), []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false, 0)
433 require.NoError(t, err)433 require.NoError(t, err)
434 require.True(t, ok)434 require.True(t, ok)
435 damageOutput, err := wire.UnmarshalOutput(damaged.Payload)435 damageOutput, err := wire.UnmarshalOutput(damaged.Payload)
M internal/usecase/daemon/attention_test.go +3 -3
@@ -384,13 +384,13 @@ func TestComposeFrameAttentionFrameChangeDamagesOnlyBars(t *testing.T) {
384384 pane := tb.focusedPane()
385385 pane.screen.Write([]byte("ATTENTION-PANE-CONTENT"))
386386 bars := d.barStateFor(sess, "")
387- area := domain.Rect{Width: pane.screen.Frame.Width, Height: pane.screen.Frame.Height}
387+ area := domain.Rect{Width: pane.screen.Columns(), Height: pane.screen.Rows()}
388388 placement := layout.Placement{ID: pane.id, Content: area}
389389 state := capturedRenderState{
390390 reset: true,
391391 layout: capturedTabLayout{area: area, focus: pane.id, placements: []layout.Placement{placement}, valid: true},
392392 panes: []capturedPaneRenderState{{
393- id: pane.id, frame: pane.screen.Frame.Clone(), placement: placement, focused: true,
393+ id: pane.id, frame: captureTestFrame(pane.screen), placement: placement, focused: true,
394394 damage: []renderer.Damage{renderer.FullRedraw()},
395395 }},
396396 bars: bars,
@@ -413,7 +413,7 @@ func TestComposeFrameAttentionFrameChangeDamagesOnlyBars(t *testing.T) {
413413 require.Equal(t, firstContent, rowText(out.frame.Row(1)), "unchanged pane content must remain in the composed frame")
414414 require.Contains(t, rowText(out.frame.Row(1)), "ATTENTION-PANE-CONTENT")
415415 require.NotEmpty(t, damage)
416- lastRow := pane.screen.Frame.Height + 1
416+ lastRow := pane.screen.Rows() + 1
417417 for _, dmg := range damage {
418418 require.Contains(t, []int{0, lastRow}, dmg.Y, "expected damage confined to bar rows, got y=%d", dmg.Y)
419419 }
@@ -384,13 +384,13 @@ func TestComposeFrameAttentionFrameChangeDamagesOnlyBars(t *testing.T) {
384 pane := tb.focusedPane()384 pane := tb.focusedPane()
385 pane.screen.Write([]byte("ATTENTION-PANE-CONTENT"))385 pane.screen.Write([]byte("ATTENTION-PANE-CONTENT"))
386 bars := d.barStateFor(sess, "")386 bars := d.barStateFor(sess, "")
387- area := domain.Rect{Width: pane.screen.Frame.Width, Height: pane.screen.Frame.Height}387+ area := domain.Rect{Width: pane.screen.Columns(), Height: pane.screen.Rows()}
388 placement := layout.Placement{ID: pane.id, Content: area}388 placement := layout.Placement{ID: pane.id, Content: area}
389 state := capturedRenderState{389 state := capturedRenderState{
390 reset: true,390 reset: true,
391 layout: capturedTabLayout{area: area, focus: pane.id, placements: []layout.Placement{placement}, valid: true},391 layout: capturedTabLayout{area: area, focus: pane.id, placements: []layout.Placement{placement}, valid: true},
392 panes: []capturedPaneRenderState{{392 panes: []capturedPaneRenderState{{
393- id: pane.id, frame: pane.screen.Frame.Clone(), placement: placement, focused: true,393+ id: pane.id, frame: captureTestFrame(pane.screen), placement: placement, focused: true,
394 damage: []renderer.Damage{renderer.FullRedraw()},394 damage: []renderer.Damage{renderer.FullRedraw()},
395 }},395 }},
396 bars: bars,396 bars: bars,
@@ -413,7 +413,7 @@ func TestComposeFrameAttentionFrameChangeDamagesOnlyBars(t *testing.T) {
413 require.Equal(t, firstContent, rowText(out.frame.Row(1)), "unchanged pane content must remain in the composed frame")413 require.Equal(t, firstContent, rowText(out.frame.Row(1)), "unchanged pane content must remain in the composed frame")
414 require.Contains(t, rowText(out.frame.Row(1)), "ATTENTION-PANE-CONTENT")414 require.Contains(t, rowText(out.frame.Row(1)), "ATTENTION-PANE-CONTENT")
415 require.NotEmpty(t, damage)415 require.NotEmpty(t, damage)
416- lastRow := pane.screen.Frame.Height + 1416+ lastRow := pane.screen.Rows() + 1
417 for _, dmg := range damage {417 for _, dmg := range damage {
418 require.Contains(t, []int{0, lastRow}, dmg.Y, "expected damage confined to bar rows, got y=%d", dmg.Y)418 require.Contains(t, []int{0, lastRow}, dmg.Y, "expected damage confined to bar rows, got y=%d", dmg.Y)
419 }419 }
M internal/usecase/daemon/config.go +7 -0
@@ -43,6 +43,12 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
4343 bindings, warnings := keys.BuildBindingEntries(cfg.BindingEntries)
4444 overrides, codeWarnings := d.buildCodeOverrides(cfg.Codes)
4545 allWarnings := append(append([]domain.Warning{}, warnings...), codeWarnings...)
46+ scrollback := cfg.Scrollback
47+ if !scrollback.Valid() {
48+ scrollback = domain.DefaultScrollbackConfig()
49+ allWarnings = append(allWarnings, domain.Warning{Msg: "invalid scrollback limits; using vev defaults"})
50+ }
51+ d.scrollbackConfig.Store(&scrollback)
4652 for _, warning := range allWarnings {
4753 d.logConfigWarning(warning)
4854 }
@@ -61,6 +67,7 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
6167 tabs := cfg.Tabs
6268 d.tabsConfig.Store(&tabs)
6369 d.storeThemeConfig(cfg)
70+ d.applyHistoryLimits()
6471 barChanged := false
6572 if d.barScripts != nil {
6673 d.barScripts.mu.Lock()
@@ -43,6 +43,12 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
43 bindings, warnings := keys.BuildBindingEntries(cfg.BindingEntries)43 bindings, warnings := keys.BuildBindingEntries(cfg.BindingEntries)
44 overrides, codeWarnings := d.buildCodeOverrides(cfg.Codes)44 overrides, codeWarnings := d.buildCodeOverrides(cfg.Codes)
45 allWarnings := append(append([]domain.Warning{}, warnings...), codeWarnings...)45 allWarnings := append(append([]domain.Warning{}, warnings...), codeWarnings...)
46+ scrollback := cfg.Scrollback
47+ if !scrollback.Valid() {
48+ scrollback = domain.DefaultScrollbackConfig()
49+ allWarnings = append(allWarnings, domain.Warning{Msg: "invalid scrollback limits; using vev defaults"})
50+ }
51+ d.scrollbackConfig.Store(&scrollback)
46 for _, warning := range allWarnings {52 for _, warning := range allWarnings {
47 d.logConfigWarning(warning)53 d.logConfigWarning(warning)
48 }54 }
@@ -61,6 +67,7 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
61 tabs := cfg.Tabs67 tabs := cfg.Tabs
62 d.tabsConfig.Store(&tabs)68 d.tabsConfig.Store(&tabs)
63 d.storeThemeConfig(cfg)69 d.storeThemeConfig(cfg)
70+ d.applyHistoryLimits()
64 barChanged := false71 barChanged := false
65 if d.barScripts != nil {72 if d.barScripts != nil {
66 d.barScripts.mu.Lock()73 d.barScripts.mu.Lock()
A internal/usecase/daemon/copy_scroll_ownership_test.go +121 -0
@@ -0,0 +1,121 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ renderer "github.com/bnema/vev-vt"
7+ "github.com/bnema/vev/internal/protocol/wire"
8+ scopy "github.com/bnema/vev/internal/usecase/copy"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestCopyCacheFailedPublicationAndRetry(t *testing.T) {
13+ for _, failure := range []string{"prepare", "send"} {
14+ t.Run(failure, func(t *testing.T) {
15+ d, sess, ac, sends := newManualSessionWithPTYs(t)
16+ healthy := ac.transport()
17+ state := cacheState("live", 1)
18+ state.floating = capturedFloatingRenderState{}
19+ state.overlays = capturedOverlayRenderState{
20+ copyActive: true, copyPaneID: "pane",
21+ copyMode: scopy.NewMode(scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("COPY")}, 6, 1), "")),
22+ }
23+ committed := composeFrame(state, composeCacheInput{})
24+ require.True(t, committed.cache.valid)
25+ ac.pipelineCache = committed.cache
26+ before := cloneComposeCache(ac.pipelineCache)
27+ state.bars.topRight = "retry"
28+ state.attachment = ac
29+ pending := composeFrame(state, ac.pipelineCache, ac.pipelineScratch)
30+ if failure == "prepare" {
31+ pending.frame = renderer.Frame{Width: 1}
32+ } else {
33+ ac.replaceTransport(cacheFailTransport{})
34+ }
35+ ac.sendMu.Lock()
36+ require.True(t, d.emitFrame(sess, ac, &state, pending))
37+ require.Equal(t, before, cloneComposeCache(ac.pipelineCache))
38+ require.Zero(t, ac.output.next)
39+ if failure == "send" {
40+ sess.mu.Lock()
41+ sess.registerAttachmentLocked(ac)
42+ sess.mu.Unlock()
43+ ac.setSession(sess)
44+ ac.replaceTransport(healthy)
45+ }
46+ pending = composeFrame(state, ac.pipelineCache, ac.pipelineScratch)
47+ ac.sendMu.Lock()
48+ require.True(t, d.emitFrame(sess, ac, &state, pending))
49+ require.Equal(t, pending.cache, ac.pipelineCache)
50+ require.NotContains(t, frameText(ac.pipelineCache.frame), "COPY")
51+ output, err := wire.UnmarshalOutput((<-sends).Payload)
52+ require.NoError(t, err)
53+ terminal := renderer.NewScreen(pending.frame.Width, pending.frame.Height)
54+ terminal.Write(output.Data)
55+ require.Contains(t, frameText(captureTestFrame(terminal)), "COPY", "retry must render copy cells despite interleaved ANSI styling")
56+ require.Equal(t, uint64(1), ac.output.next)
57+ })
58+ }
59+}
60+
61+func TestCopyCacheSearchTransition(t *testing.T) {
62+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
63+ sess.tabs[0].focusedPane().screen.Write([]byte("alpha"))
64+ ack := func() {
65+ mustOutputData(t, sends)
66+ ac.ackOutputState(ac.output.currentEpoch(), ac.output.next)
67+ }
68+ d.enterCopyMode(sess, ac)
69+ ack()
70+ require.True(t, ac.pipelineCache.valid)
71+ base := captureTestFrame(ac.pipelineCache.frame)
72+ d.handleInput(sess, ac, []byte("/"))
73+ ack()
74+ require.NotNil(t, ac.overlays.copySearch)
75+ require.False(t, ac.pipelineCache.valid)
76+ require.Equal(t, base, captureTestFrame(ac.pipelineCache.frame), "search modal must not contaminate the live base")
77+ d.handleInput(sess, ac, []byte("\x1b"))
78+ ack()
79+ require.Nil(t, ac.overlays.copySearch)
80+ require.NotNil(t, ac.overlays.copyMode)
81+ require.True(t, ac.pipelineCache.valid)
82+ require.Equal(t, base, captureTestFrame(ac.pipelineCache.frame))
83+}
84+
85+func TestCopyExitRefreshesAfterOtherAttachmentConsumesDamage(t *testing.T) {
86+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
87+ p := sess.tabs[0].focusedPane()
88+ p.screen.Write([]byte("before"))
89+ d.enterCopyMode(sess, ac)
90+ mustOutputData(t, sends)
91+ ac.ackOutputState(ac.output.currentEpoch(), ac.output.next)
92+ copyBefore := captureTestFrame(ac.pipelineCache.frame)
93+
94+ tr, peerSends := newCapturingTransport(t)
95+ peer := &attachedClient{tr: tr, output: newOutputStateStream(), size: ac.size}
96+ peer.output.attachment = peer
97+ peer.initOverlays()
98+ peer.setSession(sess)
99+ sess.mu.Lock()
100+ require.True(t, sess.registerAttachmentLocked(peer))
101+ sess.mu.Unlock()
102+ p.mu.Lock()
103+ p.screen.Write([]byte("\x1b[1;1Hafter-peer"))
104+ p.mu.Unlock()
105+ // A real peer capture/emission consumes the shared pane damage first.
106+ d.paint(sess, peer, true, nil)
107+ mustOutputData(t, peerSends)
108+ p.mu.Lock()
109+ require.Empty(t, p.screen.Damage())
110+ p.mu.Unlock()
111+ require.Contains(t, frameText(peer.pipelineCache.frame), "after-peer")
112+ require.Equal(t, copyBefore, captureTestFrame(ac.pipelineCache.frame), "peer publication cannot mutate another attachment's cache")
113+
114+ // The copy document is still frozen, but leaving it must recapture live
115+ // cells even though the other attachment already acknowledged the damage.
116+ d.copyWheel(sess, ac, 1)
117+ mustOutputData(t, sends)
118+ require.Nil(t, ac.overlays.copyMode)
119+ require.Contains(t, frameText(ac.pipelineCache.frame), "after-peer")
120+ require.NoError(t, ac.pipelineCache.frame.CheckInvariants())
121+}
@@ -0,0 +1,121 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ renderer "github.com/bnema/vev-vt"
7+ "github.com/bnema/vev/internal/protocol/wire"
8+ scopy "github.com/bnema/vev/internal/usecase/copy"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestCopyCacheFailedPublicationAndRetry(t *testing.T) {
13+ for _, failure := range []string{"prepare", "send"} {
14+ t.Run(failure, func(t *testing.T) {
15+ d, sess, ac, sends := newManualSessionWithPTYs(t)
16+ healthy := ac.transport()
17+ state := cacheState("live", 1)
18+ state.floating = capturedFloatingRenderState{}
19+ state.overlays = capturedOverlayRenderState{
20+ copyActive: true, copyPaneID: "pane",
21+ copyMode: scopy.NewMode(scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("COPY")}, 6, 1), "")),
22+ }
23+ committed := composeFrame(state, composeCacheInput{})
24+ require.True(t, committed.cache.valid)
25+ ac.pipelineCache = committed.cache
26+ before := cloneComposeCache(ac.pipelineCache)
27+ state.bars.topRight = "retry"
28+ state.attachment = ac
29+ pending := composeFrame(state, ac.pipelineCache, ac.pipelineScratch)
30+ if failure == "prepare" {
31+ pending.frame = renderer.Frame{Width: 1}
32+ } else {
33+ ac.replaceTransport(cacheFailTransport{})
34+ }
35+ ac.sendMu.Lock()
36+ require.True(t, d.emitFrame(sess, ac, &state, pending))
37+ require.Equal(t, before, cloneComposeCache(ac.pipelineCache))
38+ require.Zero(t, ac.output.next)
39+ if failure == "send" {
40+ sess.mu.Lock()
41+ sess.registerAttachmentLocked(ac)
42+ sess.mu.Unlock()
43+ ac.setSession(sess)
44+ ac.replaceTransport(healthy)
45+ }
46+ pending = composeFrame(state, ac.pipelineCache, ac.pipelineScratch)
47+ ac.sendMu.Lock()
48+ require.True(t, d.emitFrame(sess, ac, &state, pending))
49+ require.Equal(t, pending.cache, ac.pipelineCache)
50+ require.NotContains(t, frameText(ac.pipelineCache.frame), "COPY")
51+ output, err := wire.UnmarshalOutput((<-sends).Payload)
52+ require.NoError(t, err)
53+ terminal := renderer.NewScreen(pending.frame.Width, pending.frame.Height)
54+ terminal.Write(output.Data)
55+ require.Contains(t, frameText(captureTestFrame(terminal)), "COPY", "retry must render copy cells despite interleaved ANSI styling")
56+ require.Equal(t, uint64(1), ac.output.next)
57+ })
58+ }
59+}
60+
61+func TestCopyCacheSearchTransition(t *testing.T) {
62+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
63+ sess.tabs[0].focusedPane().screen.Write([]byte("alpha"))
64+ ack := func() {
65+ mustOutputData(t, sends)
66+ ac.ackOutputState(ac.output.currentEpoch(), ac.output.next)
67+ }
68+ d.enterCopyMode(sess, ac)
69+ ack()
70+ require.True(t, ac.pipelineCache.valid)
71+ base := captureTestFrame(ac.pipelineCache.frame)
72+ d.handleInput(sess, ac, []byte("/"))
73+ ack()
74+ require.NotNil(t, ac.overlays.copySearch)
75+ require.False(t, ac.pipelineCache.valid)
76+ require.Equal(t, base, captureTestFrame(ac.pipelineCache.frame), "search modal must not contaminate the live base")
77+ d.handleInput(sess, ac, []byte("\x1b"))
78+ ack()
79+ require.Nil(t, ac.overlays.copySearch)
80+ require.NotNil(t, ac.overlays.copyMode)
81+ require.True(t, ac.pipelineCache.valid)
82+ require.Equal(t, base, captureTestFrame(ac.pipelineCache.frame))
83+}
84+
85+func TestCopyExitRefreshesAfterOtherAttachmentConsumesDamage(t *testing.T) {
86+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
87+ p := sess.tabs[0].focusedPane()
88+ p.screen.Write([]byte("before"))
89+ d.enterCopyMode(sess, ac)
90+ mustOutputData(t, sends)
91+ ac.ackOutputState(ac.output.currentEpoch(), ac.output.next)
92+ copyBefore := captureTestFrame(ac.pipelineCache.frame)
93+
94+ tr, peerSends := newCapturingTransport(t)
95+ peer := &attachedClient{tr: tr, output: newOutputStateStream(), size: ac.size}
96+ peer.output.attachment = peer
97+ peer.initOverlays()
98+ peer.setSession(sess)
99+ sess.mu.Lock()
100+ require.True(t, sess.registerAttachmentLocked(peer))
101+ sess.mu.Unlock()
102+ p.mu.Lock()
103+ p.screen.Write([]byte("\x1b[1;1Hafter-peer"))
104+ p.mu.Unlock()
105+ // A real peer capture/emission consumes the shared pane damage first.
106+ d.paint(sess, peer, true, nil)
107+ mustOutputData(t, peerSends)
108+ p.mu.Lock()
109+ require.Empty(t, p.screen.Damage())
110+ p.mu.Unlock()
111+ require.Contains(t, frameText(peer.pipelineCache.frame), "after-peer")
112+ require.Equal(t, copyBefore, captureTestFrame(ac.pipelineCache.frame), "peer publication cannot mutate another attachment's cache")
113+
114+ // The copy document is still frozen, but leaving it must recapture live
115+ // cells even though the other attachment already acknowledged the damage.
116+ d.copyWheel(sess, ac, 1)
117+ mustOutputData(t, sends)
118+ require.Nil(t, ac.overlays.copyMode)
119+ require.Contains(t, frameText(ac.pipelineCache.frame), "after-peer")
120+ require.NoError(t, ac.pipelineCache.frame.CheckInvariants())
121+}
A internal/usecase/daemon/copy_scroll_performance_test.go +57 -0
@@ -0,0 +1,57 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ renderer "github.com/bnema/vev-vt"
7+ "github.com/bnema/vev/internal/domain"
8+ scopy "github.com/bnema/vev/internal/usecase/copy"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestCopyCompositionDoesNotAllocateIntermediateFrame(t *testing.T) {
13+ rows := make([][]renderer.Cell, 53)
14+ for y := range rows {
15+ rows[y] = testRow("scrollback")
16+ }
17+ mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshotFromRows(rows, 182, 53), ""))
18+ frame := renderer.NewFrame(182, 55)
19+ target := domain.Rect{Y: 1, Width: 182, Height: 53}
20+ styles := resolveStyles(nil)
21+ result := testing.Benchmark(func(b *testing.B) {
22+ for b.Loop() {
23+ composeCopyClientFrame(mode, target, frame, styles)
24+ }
25+ })
26+ // A reusable semantic row and status text fit in 32 KiB. A second compact
27+ // viewport alone needs over 150 KiB and must not be built just to copy it.
28+ require.LessOrEqual(t, result.AllocedBytesPerOp(), int64(32<<10))
29+}
30+
31+// Each operation renders two wheel movements, up three rows then down three.
32+// Keep history setup and copy entry outside the measured steady-state scroll.
33+func BenchmarkDaemonHistoryCopyScroll(b *testing.B) {
34+ for _, size := range []struct {
35+ name string
36+ size domain.Size
37+ }{
38+ {"120x40", domain.Size{Cols: 120, Rows: 40}},
39+ {"182x53", domain.Size{Cols: 182, Rows: 53}},
40+ {"240x70", domain.Size{Cols: 240, Rows: 70}},
41+ } {
42+ b.Run(size.name, func(b *testing.B) {
43+ f := newPerformanceFixture(b, performanceConfig{size: size.size, tabs: 1, panes: 1, historyRows: 10000})
44+ require.True(b, f.hasHistoryTopology(1, 1, 10000))
45+ f.d.enterCopyMode(f.sess, f.ac)
46+ f.d.copyWheel(f.sess, f.ac, -5000)
47+ f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next)
48+ b.ReportAllocs()
49+ for b.Loop() {
50+ f.d.copyWheel(f.sess, f.ac, -3)
51+ f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next)
52+ f.d.copyWheel(f.sess, f.ac, 3)
53+ f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next)
54+ }
55+ })
56+ }
57+}
@@ -0,0 +1,57 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ renderer "github.com/bnema/vev-vt"
7+ "github.com/bnema/vev/internal/domain"
8+ scopy "github.com/bnema/vev/internal/usecase/copy"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestCopyCompositionDoesNotAllocateIntermediateFrame(t *testing.T) {
13+ rows := make([][]renderer.Cell, 53)
14+ for y := range rows {
15+ rows[y] = testRow("scrollback")
16+ }
17+ mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshotFromRows(rows, 182, 53), ""))
18+ frame := renderer.NewFrame(182, 55)
19+ target := domain.Rect{Y: 1, Width: 182, Height: 53}
20+ styles := resolveStyles(nil)
21+ result := testing.Benchmark(func(b *testing.B) {
22+ for b.Loop() {
23+ composeCopyClientFrame(mode, target, frame, styles)
24+ }
25+ })
26+ // A reusable semantic row and status text fit in 32 KiB. A second compact
27+ // viewport alone needs over 150 KiB and must not be built just to copy it.
28+ require.LessOrEqual(t, result.AllocedBytesPerOp(), int64(32<<10))
29+}
30+
31+// Each operation renders two wheel movements, up three rows then down three.
32+// Keep history setup and copy entry outside the measured steady-state scroll.
33+func BenchmarkDaemonHistoryCopyScroll(b *testing.B) {
34+ for _, size := range []struct {
35+ name string
36+ size domain.Size
37+ }{
38+ {"120x40", domain.Size{Cols: 120, Rows: 40}},
39+ {"182x53", domain.Size{Cols: 182, Rows: 53}},
40+ {"240x70", domain.Size{Cols: 240, Rows: 70}},
41+ } {
42+ b.Run(size.name, func(b *testing.B) {
43+ f := newPerformanceFixture(b, performanceConfig{size: size.size, tabs: 1, panes: 1, historyRows: 10000})
44+ require.True(b, f.hasHistoryTopology(1, 1, 10000))
45+ f.d.enterCopyMode(f.sess, f.ac)
46+ f.d.copyWheel(f.sess, f.ac, -5000)
47+ f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next)
48+ b.ReportAllocs()
49+ for b.Loop() {
50+ f.d.copyWheel(f.sess, f.ac, -3)
51+ f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next)
52+ f.d.copyWheel(f.sess, f.ac, 3)
53+ f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next)
54+ }
55+ })
56+ }
57+}
A internal/usecase/daemon/copy_scroll_render_test.go +87 -0
@@ -0,0 +1,87 @@
1+package daemon
2+
3+import (
4+ "fmt"
5+ "testing"
6+
7+ renderer "github.com/bnema/vev-vt"
8+ "github.com/bnema/vev/internal/domain"
9+ scopy "github.com/bnema/vev/internal/usecase/copy"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestCopyRowCompositionMatchesFrameReference(t *testing.T) {
14+ for _, target := range []domain.Rect{
15+ {X: 2, Y: 2, Width: 12, Height: 4},
16+ {X: -3, Y: -1, Width: 12, Height: 4},
17+ {X: 15, Y: 5, Width: 12, Height: 4},
18+ {X: -20, Y: 2, Width: 12, Height: 4},
19+ {X: 25, Y: 2, Width: 12, Height: 4},
20+ {X: 2, Y: 20, Width: 12, Height: 4},
21+ {X: 2, Y: 2, Width: 3, Height: 1},
22+ } {
23+ t.Run(fmt.Sprint(target), func(t *testing.T) {
24+ // The viewport is taller than the document: blank rows must erase the
25+ // old pane rather than leak its contents. Selection/search share the
26+ // same row renderer as the owned-frame API.
27+ rows := [][]renderer.Cell{testRow("alpha beta"), testRow("second row")}
28+ mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshotFromRows(rows, 12, 4), ""))
29+ mode.Search("alpha")
30+ require.True(t, mode.StartCharacterSelection(scopy.Pos{Col: 1}))
31+ require.True(t, mode.ExtendCharacterSelection(scopy.Pos{Row: 1, Col: 3}))
32+ styles := resolveStyles(nil)
33+ base := renderer.NewFrame(20, 7)
34+ for y := range base.Height {
35+ base.FillRow(y, 0, base.Width, renderer.Cell{Rune: '#'})
36+ }
37+ want := base.Clone()
38+ src := mode.Render(styles.CopyStatus, styles.Selection)
39+ // Reference placement deliberately uses independent per-cell clipping.
40+ for y := 0; y < src.Height-1 && y < target.Height; y++ {
41+ for x := 0; x < src.Width && x < target.Width; x++ {
42+ dx, dy := target.X+x, target.Y+y
43+ if dx >= 0 && dx < want.Width && dy >= 0 && dy < want.Height-1 {
44+ want.Set(dx, dy, src.Cell(x, y))
45+ }
46+ }
47+ }
48+ want.FillRow(want.Height-1, 0, want.Width, renderer.Cell{Rune: ' ', Style: styles.SurfaceBar})
49+ for x := range min(want.Width, src.Width) {
50+ want.Set(x, want.Height-1, src.Cell(x, src.Height-1))
51+ }
52+ got, _ := composeCopyClientFrame(mode, target, base, styles)
53+ require.Equal(t, captureTestFrame(want), captureTestFrame(got))
54+ require.NoError(t, got.CheckInvariants())
55+ })
56+ }
57+}
58+
59+func TestCopyScrollKeepsBaseUnadornedAndExitRefreshesLiveState(t *testing.T) {
60+ f := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}, panes: 1, historyRows: 100})
61+ ack := func() { f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) }
62+ f.d.enterCopyMode(f.sess, f.ac)
63+ ack()
64+ require.True(t, f.ac.pipelineCache.valid)
65+ committed := f.ac.pipelineCache.frame
66+ before := captureTestFrame(committed)
67+ f.d.copyWheel(f.sess, f.ac, -30)
68+ ack()
69+ require.True(t, f.ac.pipelineCache.valid)
70+ require.Equal(t, before, captureTestFrame(committed), "scroll must not mutate the committed buffer")
71+ require.NotContains(t, frameText(f.ac.pipelineCache.frame), "[SCROLL]")
72+
73+ // Live damage still updates the base behind the immutable history viewport.
74+ f.activePane.mu.Lock()
75+ f.activePane.screen.Write([]byte("\x1b[1;1HLIVE-UPDATED"))
76+ f.activePane.mu.Unlock()
77+ f.d.copyWheel(f.sess, f.ac, -3)
78+ ack()
79+ require.Contains(t, frameText(f.ac.pipelineCache.frame), "LIVE-UPDATED")
80+ require.NotContains(t, frameText(f.ac.pipelineCache.frame), "[SCROLL]")
81+
82+ f.d.copyWheel(f.sess, f.ac, 1000)
83+ ack()
84+ require.Nil(t, f.ac.overlays.copyMode)
85+ require.Contains(t, frameText(f.ac.pipelineCache.frame), "LIVE-UPDATED")
86+ require.NoError(t, f.ac.pipelineCache.frame.CheckInvariants())
87+}
@@ -0,0 +1,87 @@
1+package daemon
2+
3+import (
4+ "fmt"
5+ "testing"
6+
7+ renderer "github.com/bnema/vev-vt"
8+ "github.com/bnema/vev/internal/domain"
9+ scopy "github.com/bnema/vev/internal/usecase/copy"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestCopyRowCompositionMatchesFrameReference(t *testing.T) {
14+ for _, target := range []domain.Rect{
15+ {X: 2, Y: 2, Width: 12, Height: 4},
16+ {X: -3, Y: -1, Width: 12, Height: 4},
17+ {X: 15, Y: 5, Width: 12, Height: 4},
18+ {X: -20, Y: 2, Width: 12, Height: 4},
19+ {X: 25, Y: 2, Width: 12, Height: 4},
20+ {X: 2, Y: 20, Width: 12, Height: 4},
21+ {X: 2, Y: 2, Width: 3, Height: 1},
22+ } {
23+ t.Run(fmt.Sprint(target), func(t *testing.T) {
24+ // The viewport is taller than the document: blank rows must erase the
25+ // old pane rather than leak its contents. Selection/search share the
26+ // same row renderer as the owned-frame API.
27+ rows := [][]renderer.Cell{testRow("alpha beta"), testRow("second row")}
28+ mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshotFromRows(rows, 12, 4), ""))
29+ mode.Search("alpha")
30+ require.True(t, mode.StartCharacterSelection(scopy.Pos{Col: 1}))
31+ require.True(t, mode.ExtendCharacterSelection(scopy.Pos{Row: 1, Col: 3}))
32+ styles := resolveStyles(nil)
33+ base := renderer.NewFrame(20, 7)
34+ for y := range base.Height {
35+ base.FillRow(y, 0, base.Width, renderer.Cell{Rune: '#'})
36+ }
37+ want := base.Clone()
38+ src := mode.Render(styles.CopyStatus, styles.Selection)
39+ // Reference placement deliberately uses independent per-cell clipping.
40+ for y := 0; y < src.Height-1 && y < target.Height; y++ {
41+ for x := 0; x < src.Width && x < target.Width; x++ {
42+ dx, dy := target.X+x, target.Y+y
43+ if dx >= 0 && dx < want.Width && dy >= 0 && dy < want.Height-1 {
44+ want.Set(dx, dy, src.Cell(x, y))
45+ }
46+ }
47+ }
48+ want.FillRow(want.Height-1, 0, want.Width, renderer.Cell{Rune: ' ', Style: styles.SurfaceBar})
49+ for x := range min(want.Width, src.Width) {
50+ want.Set(x, want.Height-1, src.Cell(x, src.Height-1))
51+ }
52+ got, _ := composeCopyClientFrame(mode, target, base, styles)
53+ require.Equal(t, captureTestFrame(want), captureTestFrame(got))
54+ require.NoError(t, got.CheckInvariants())
55+ })
56+ }
57+}
58+
59+func TestCopyScrollKeepsBaseUnadornedAndExitRefreshesLiveState(t *testing.T) {
60+ f := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}, panes: 1, historyRows: 100})
61+ ack := func() { f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) }
62+ f.d.enterCopyMode(f.sess, f.ac)
63+ ack()
64+ require.True(t, f.ac.pipelineCache.valid)
65+ committed := f.ac.pipelineCache.frame
66+ before := captureTestFrame(committed)
67+ f.d.copyWheel(f.sess, f.ac, -30)
68+ ack()
69+ require.True(t, f.ac.pipelineCache.valid)
70+ require.Equal(t, before, captureTestFrame(committed), "scroll must not mutate the committed buffer")
71+ require.NotContains(t, frameText(f.ac.pipelineCache.frame), "[SCROLL]")
72+
73+ // Live damage still updates the base behind the immutable history viewport.
74+ f.activePane.mu.Lock()
75+ f.activePane.screen.Write([]byte("\x1b[1;1HLIVE-UPDATED"))
76+ f.activePane.mu.Unlock()
77+ f.d.copyWheel(f.sess, f.ac, -3)
78+ ack()
79+ require.Contains(t, frameText(f.ac.pipelineCache.frame), "LIVE-UPDATED")
80+ require.NotContains(t, frameText(f.ac.pipelineCache.frame), "[SCROLL]")
81+
82+ f.d.copyWheel(f.sess, f.ac, 1000)
83+ ack()
84+ require.Nil(t, f.ac.overlays.copyMode)
85+ require.Contains(t, frameText(f.ac.pipelineCache.frame), "LIVE-UPDATED")
86+ require.NoError(t, f.ac.pipelineCache.frame.CheckInvariants())
87+}
M internal/usecase/daemon/copymode.go +18 -14
@@ -60,7 +60,10 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
6060 rt.clearCopyModeLocked()
6161 }
6262 rt.copyMu.Unlock()
63- d.invalidateRender(sess, ac, true, "copymode.go")
63+ // Moving within the immutable copy document does not invalidate live pane
64+ // captures. The compositor still redraws the overlay; leaving it resets live
65+ // state so attachment-local snapshots cannot survive the return to the PTY.
66+ d.invalidateRender(sess, ac, exit, "copymode.go")
6467 }
6568 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
6669 tb := sess.tabForAttachment(ac)
@@ -416,21 +419,22 @@ func composeCopyClientFrame(mode *scopy.Mode, target domain.Rect, frame renderer
416419 if mode == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 {
417420 return frame, nil
418421 }
419- copyFrame := mode.Render(styles.CopyStatus, styles.Selection)
420- bodyRows := max(copyFrame.Height-1, 0)
421- for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ {
422+ statusY := frame.Height - 1
423+ frame.FillRow(statusY, 0, frame.Width, renderer.Cell{Rune: ' ', Style: styles.SurfaceBar})
424+ mode.RenderRows(func(y int, row []renderer.Cell) {
425+ if y == mode.Document().Height() {
426+ frame.WriteRow(statusY, 0, row[:min(frame.Width, len(row))])
427+ return
428+ }
429+ if y >= target.Height || target.Y+y < 0 || target.Y+y >= statusY {
430+ return
431+ }
422432 dstX := max(target.X, 0)
423433 srcX := max(-target.X, 0)
424- width := min(target.Width-srcX, copyFrame.Width-srcX)
425- width = min(width, frame.Width-dstX)
426- if width > 0 && target.Y+y >= 0 {
427- copy(frame.Row(target.Y + y)[dstX:dstX+width], copyFrame.Row(y)[srcX:srcX+width])
434+ width := min(target.Width-srcX, len(row)-srcX, frame.Width-dstX)
435+ if width > 0 {
436+ frame.WriteRow(target.Y+y, dstX, row[srcX:srcX+width])
428437 }
429- }
430- statusY := frame.Height - 1
431- for x := range frame.Row(statusY) {
432- frame.Row(statusY)[x] = renderer.Cell{Rune: ' ', Style: styles.SurfaceBar}
433- }
434- copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(frame.Width, copyFrame.Width)])
438+ }, styles.CopyStatus, styles.Selection)
435439 return frame, []renderer.Damage{renderer.FullRedraw()}
436440 }
@@ -60,7 +60,10 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
60 rt.clearCopyModeLocked()60 rt.clearCopyModeLocked()
61 }61 }
62 rt.copyMu.Unlock()62 rt.copyMu.Unlock()
63- d.invalidateRender(sess, ac, true, "copymode.go")63+ // Moving within the immutable copy document does not invalidate live pane
64+ // captures. The compositor still redraws the overlay; leaving it resets live
65+ // state so attachment-local snapshots cannot survive the return to the PTY.
66+ d.invalidateRender(sess, ac, exit, "copymode.go")
64 }67 }
65 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {68 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
66 tb := sess.tabForAttachment(ac)69 tb := sess.tabForAttachment(ac)
@@ -416,21 +419,22 @@ func composeCopyClientFrame(mode *scopy.Mode, target domain.Rect, frame renderer
416 if mode == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 {419 if mode == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 {
417 return frame, nil420 return frame, nil
418 }421 }
419- copyFrame := mode.Render(styles.CopyStatus, styles.Selection)422+ statusY := frame.Height - 1
420- bodyRows := max(copyFrame.Height-1, 0)423+ frame.FillRow(statusY, 0, frame.Width, renderer.Cell{Rune: ' ', Style: styles.SurfaceBar})
421- for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ {424+ mode.RenderRows(func(y int, row []renderer.Cell) {
425+ if y == mode.Document().Height() {
426+ frame.WriteRow(statusY, 0, row[:min(frame.Width, len(row))])
427+ return
428+ }
429+ if y >= target.Height || target.Y+y < 0 || target.Y+y >= statusY {
430+ return
431+ }
422 dstX := max(target.X, 0)432 dstX := max(target.X, 0)
423 srcX := max(-target.X, 0)433 srcX := max(-target.X, 0)
424- width := min(target.Width-srcX, copyFrame.Width-srcX)434+ width := min(target.Width-srcX, len(row)-srcX, frame.Width-dstX)
425- width = min(width, frame.Width-dstX)435+ if width > 0 {
426- if width > 0 && target.Y+y >= 0 {436+ frame.WriteRow(target.Y+y, dstX, row[srcX:srcX+width])
427- copy(frame.Row(target.Y + y)[dstX:dstX+width], copyFrame.Row(y)[srcX:srcX+width])
428 }437 }
429- }438+ }, styles.CopyStatus, styles.Selection)
430- statusY := frame.Height - 1
431- for x := range frame.Row(statusY) {
432- frame.Row(statusY)[x] = renderer.Cell{Rune: ' ', Style: styles.SurfaceBar}
433- }
434- copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(frame.Width, copyFrame.Width)])
435 return frame, []renderer.Damage{renderer.FullRedraw()}439 return frame, []renderer.Damage{renderer.FullRedraw()}
436 }440 }
M internal/usecase/daemon/copymode_lifecycle_test.go +3 -3
@@ -101,7 +101,7 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
101101 sess.registerAttachment(ac)
102102 ac.setSession(sess)
103103 closing.mu.Lock()
104- document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame, closing.screen.LineBounds(), nil), domain.DefaultWordSeparators)
104+ document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen, closing.screen.LineBounds(), nil), domain.DefaultWordSeparators)
105105 closing.mu.Unlock()
106106 ac.overlays.copyMu.Lock()
107107 ac.overlays.copyPane = closing
@@ -146,7 +146,7 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) {
146146 tb := testAttachmentTab(sess)
147147 p := tb.focusedPane()
148148 p.mu.Lock()
149- document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)
149+ document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)
150150 p.mu.Unlock()
151151
152152 // Pause after candidate publication, then make the captured target stale.
@@ -370,7 +370,7 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.
370370 tb := testAttachmentTab(sess)
371371 p := tb.focusedPane()
372372 p.mu.Lock()
373- document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)
373+ document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)
374374 p.mu.Unlock()
375375 tc.invalidate(sess, tb, p)
376376
@@ -101,7 +101,7 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
101 sess.registerAttachment(ac)101 sess.registerAttachment(ac)
102 ac.setSession(sess)102 ac.setSession(sess)
103 closing.mu.Lock()103 closing.mu.Lock()
104- document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame, closing.screen.LineBounds(), nil), domain.DefaultWordSeparators)104+ document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen, closing.screen.LineBounds(), nil), domain.DefaultWordSeparators)
105 closing.mu.Unlock()105 closing.mu.Unlock()
106 ac.overlays.copyMu.Lock()106 ac.overlays.copyMu.Lock()
107 ac.overlays.copyPane = closing107 ac.overlays.copyPane = closing
@@ -146,7 +146,7 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) {
146 tb := testAttachmentTab(sess)146 tb := testAttachmentTab(sess)
147 p := tb.focusedPane()147 p := tb.focusedPane()
148 p.mu.Lock()148 p.mu.Lock()
149- document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)149+ document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)
150 p.mu.Unlock()150 p.mu.Unlock()
151 151
152 // Pause after candidate publication, then make the captured target stale.152 // Pause after candidate publication, then make the captured target stale.
@@ -370,7 +370,7 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.
370 tb := testAttachmentTab(sess)370 tb := testAttachmentTab(sess)
371 p := tb.focusedPane()371 p := tb.focusedPane()
372 p.mu.Lock()372 p.mu.Lock()
373- document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)373+ document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)
374 p.mu.Unlock()374 p.mu.Unlock()
375 tc.invalidate(sess, tb, p)375 tc.invalidate(sess, tb, p)
376 376
M internal/usecase/daemon/copymode_mouse_test.go +4 -5
@@ -28,7 +28,7 @@ func mouseCopyHarness(t *testing.T, rows ...string) (*Daemon, *session, *attache
2828 d.clock = clock
2929 pane := testAttachmentTab(sess).focusedPane()
3030 for i, row := range rows {
31- copy(pane.screen.Frame.Row(i), testRow(row))
31+ writeTestRow(pane.screen, i, row)
3232 }
3333 d.enterCopyMode(sess, ac)
3434 return d, sess, ac, clock
@@ -44,8 +44,7 @@ func TestCopyDoubleClick(t *testing.T) {
4444 pane := testAttachmentTab(sess).focusedPane()
4545 d.exitCopyMode(ac)
4646 pane.mu.Lock()
47- row := pane.screen.Frame.Row(0)
48- row[2] = renderer.Cell{Continuation: true}
47+ require.True(t, pane.screen.Cell(2, 0).Continuation)
4948 pane.mu.Unlock()
5049 d.enterCopyMode(sess, ac)
5150
@@ -324,7 +323,7 @@ func TestCopyWordSelectionYanksExactOSC52(t *testing.T) {
324323 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
325324 clock := &manualMouseClock{now: time.Unix(1, 0)}
326325 d.clock = clock
327- copy(testAttachmentTab(sess).focusedPane().screen.Frame.Row(0), testRow("alpha beta"))
326+ writeTestRow(testAttachmentTab(sess).focusedPane().screen, 0, "alpha beta")
328327 d.enterCopyMode(sess, ac)
329328 mustOutputData(t, sends)
330329
@@ -350,7 +349,7 @@ func TestCopyPointerAndClickResetOnCopyExitAndReplacement(t *testing.T) {
350349 {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) {
351350 p := testAttachmentTab(sess).focusedPane()
352351 p.mu.Lock()
353- doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)
352+ doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)
354353 p.mu.Unlock()
355354 require.True(t, d.publishCopyMode(sess, ac, testAttachmentTab(sess), p, doc, nil, nil))
356355 }},
@@ -28,7 +28,7 @@ func mouseCopyHarness(t *testing.T, rows ...string) (*Daemon, *session, *attache
28 d.clock = clock28 d.clock = clock
29 pane := testAttachmentTab(sess).focusedPane()29 pane := testAttachmentTab(sess).focusedPane()
30 for i, row := range rows {30 for i, row := range rows {
31- copy(pane.screen.Frame.Row(i), testRow(row))31+ writeTestRow(pane.screen, i, row)
32 }32 }
33 d.enterCopyMode(sess, ac)33 d.enterCopyMode(sess, ac)
34 return d, sess, ac, clock34 return d, sess, ac, clock
@@ -44,8 +44,7 @@ func TestCopyDoubleClick(t *testing.T) {
44 pane := testAttachmentTab(sess).focusedPane()44 pane := testAttachmentTab(sess).focusedPane()
45 d.exitCopyMode(ac)45 d.exitCopyMode(ac)
46 pane.mu.Lock()46 pane.mu.Lock()
47- row := pane.screen.Frame.Row(0)47+ require.True(t, pane.screen.Cell(2, 0).Continuation)
48- row[2] = renderer.Cell{Continuation: true}
49 pane.mu.Unlock()48 pane.mu.Unlock()
50 d.enterCopyMode(sess, ac)49 d.enterCopyMode(sess, ac)
51 50
@@ -324,7 +323,7 @@ func TestCopyWordSelectionYanksExactOSC52(t *testing.T) {
324 d, sess, ac, sends := newManualSessionWithPTYs(t, p)323 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
325 clock := &manualMouseClock{now: time.Unix(1, 0)}324 clock := &manualMouseClock{now: time.Unix(1, 0)}
326 d.clock = clock325 d.clock = clock
327- copy(testAttachmentTab(sess).focusedPane().screen.Frame.Row(0), testRow("alpha beta"))326+ writeTestRow(testAttachmentTab(sess).focusedPane().screen, 0, "alpha beta")
328 d.enterCopyMode(sess, ac)327 d.enterCopyMode(sess, ac)
329 mustOutputData(t, sends)328 mustOutputData(t, sends)
330 329
@@ -350,7 +349,7 @@ func TestCopyPointerAndClickResetOnCopyExitAndReplacement(t *testing.T) {
350 {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) {349 {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) {
351 p := testAttachmentTab(sess).focusedPane()350 p := testAttachmentTab(sess).focusedPane()
352 p.mu.Lock()351 p.mu.Lock()
353- doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)352+ doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)
354 p.mu.Unlock()353 p.mu.Unlock()
355 require.True(t, d.publishCopyMode(sess, ac, testAttachmentTab(sess), p, doc, nil, nil))354 require.True(t, d.publishCopyMode(sess, ac, testAttachmentTab(sess), p, doc, nil, nil))
356 }},355 }},
M internal/usecase/daemon/copymode_test.go +19 -19
@@ -133,7 +133,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
133133 tb := testAttachmentTab(sess)
134134 pane := tb.focusedPane()
135135 pane.mu.Lock()
136- snap := scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds(), nil)
136+ snap := scopy.NewSnapshot(pane.history, pane.screen, pane.screen.LineBounds(), nil)
137137 pane.mu.Unlock()
138138 mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))
139139
@@ -167,7 +167,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
167167 tb := testAttachmentTab(sess)
168168 tb.focusedPane().screen = vt.NewScreen(12, 3)
169169 tb.focusedPane().screen.Write([]byte("live"))
170- snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame, tb.focusedPane().screen.LineBounds(), nil)
170+ snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen, tb.focusedPane().screen.LineBounds(), nil)
171171 mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))
172172
173173 bars := barState{status: sess.statusSegments(true)}
@@ -176,7 +176,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
176176 reset: true,
177177 layout: capturedTabLayout{area: content, focus: tb.focusedPane().id, valid: true},
178178 panes: []capturedPaneRenderState{{
179- id: tb.focusedPane().id, frame: tb.focusedPane().screen.Frame.Clone(), placement: layout.Placement{ID: tb.focusedPane().id, Content: content}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()},
179+ id: tb.focusedPane().id, frame: captureTestFrame(tb.focusedPane().screen), placement: layout.Placement{ID: tb.focusedPane().id, Content: content}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()},
180180 }},
181181 bars: bars,
182182 }, composeCacheInput{}).frame
@@ -230,8 +230,8 @@ func TestCopyModeSearchModalRoutesBatchedInputAfterSlash(t *testing.T) {
230230 p, _ := newBlockingPTYWithWrites(t, writes)
231231 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
232232 pane := sess.tabs[0].focusedPane()
233- copy(pane.screen.Frame.Row(0), testRow("alpha"))
234- copy(pane.screen.Frame.Row(1), testRow("beta alpha"))
233+ writeTestRow(pane.screen, 0, "alpha")
234+ writeTestRow(pane.screen, 1, "beta alpha")
235235
236236 d.enterCopyMode(sess, ac)
237237 awaitFrame(t, sends, wire.MsgOutput)
@@ -253,9 +253,9 @@ func TestCopyModeSearchModalJumpsAndKeepsNavigation(t *testing.T) {
253253 p, _ := newBlockingPTYWithWrites(t, writes)
254254 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
255255 pane := sess.tabs[0].focusedPane()
256- copy(pane.screen.Frame.Row(0), testRow("alpha"))
257- copy(pane.screen.Frame.Row(1), testRow("beta alpha"))
258- copy(pane.screen.Frame.Row(2), testRow("gamma"))
256+ writeTestRow(pane.screen, 0, "alpha")
257+ writeTestRow(pane.screen, 1, "beta alpha")
258+ writeTestRow(pane.screen, 2, "gamma")
259259
260260 d.enterCopyMode(sess, ac)
261261 awaitFrame(t, sends, wire.MsgOutput)
@@ -297,9 +297,9 @@ func TestCopyModeSearchModalSelectionPreviewsBehindModal(t *testing.T) {
297297 p, _ := newBlockingPTY(t)
298298 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
299299 pane := sess.tabs[0].focusedPane()
300- copy(pane.screen.Frame.Row(0), testRow("alpha"))
301- copy(pane.screen.Frame.Row(1), testRow("beta alpha"))
302- copy(pane.screen.Frame.Row(2), testRow("gamma"))
300+ writeTestRow(pane.screen, 0, "alpha")
301+ writeTestRow(pane.screen, 1, "beta alpha")
302+ writeTestRow(pane.screen, 2, "gamma")
303303
304304 d.enterCopyMode(sess, ac)
305305 awaitFrame(t, sends, wire.MsgOutput)
@@ -323,7 +323,7 @@ func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) {
323323 pane := sess.tabs[0].focusedPane()
324324 installTestHistory(pane, vt.HistoryConfig{MaxRows: 4})
325325 appendHistoryRow(t, pane.history, testRow("old alpha"))
326- copy(pane.screen.Frame.Row(0), testRow("live alpha"))
326+ writeTestRow(pane.screen, 0, "live alpha")
327327
328328 d.enterCopyMode(sess, ac)
329329 awaitFrame(t, sends, wire.MsgOutput)
@@ -455,7 +455,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
455455 }
456456 p.mu.Lock()
457457 defer p.mu.Unlock()
458- return scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil).Len() >= 12
458+ return scopy.NewSnapshot(p.history, p.screen, p.screen.LineBounds(), nil).Len() >= 12
459459 }, 2*time.Second, 5*time.Millisecond)
460460
461461 sess := firstSession(d)
@@ -548,8 +548,8 @@ func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) {
548548 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
549549 installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 1})
550550 longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1)
551- sess.tabs[0].focusedPane().screen.Frame = renderer.NewFrame(len(longLine), 1)
552- copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow(longLine))
551+ sess.tabs[0].focusedPane().screen.Resize(len(longLine), 1)
552+ writeTestRow(sess.tabs[0].focusedPane().screen, 0, longLine)
553553
554554 d.enterCopyMode(sess, ac)
555555 awaitFrame(t, sends, wire.MsgOutput)
@@ -806,7 +806,7 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) {
806806 }
807807 fp.screen.Write([]byte("live"))
808808 fp.mu.Lock()
809- total := scopy.NewSnapshot(fp.history, fp.screen.Frame, fp.screen.LineBounds(), nil).Len()
809+ total := scopy.NewSnapshot(fp.history, fp.screen, fp.screen.LineBounds(), nil).Len()
810810 fp.mu.Unlock()
811811
812812 d.enterCopyMode(sess, ac)
@@ -885,8 +885,8 @@ func TestMouseDragCopyEntryCapturesSourceForYank(t *testing.T) {
885885 pty, _ := newBlockingPTY(t)
886886 d, sess, ac, sends := newManualSessionWithPTYs(t, pty)
887887 pane := sess.tabs[0].focusedPane()
888- copy(pane.screen.Frame.Row(0), testRow("alpha"))
889- copy(pane.screen.Frame.Row(1), testRow("bravo"))
888+ writeTestRow(pane.screen, 0, "alpha")
889+ writeTestRow(pane.screen, 1, "bravo")
890890
891891 d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<32;1;3M"))
892892 mustOutputData(t, sends)
@@ -917,7 +917,7 @@ func TestComposeCopyClientFrameStylesStatusContentAndSurround(t *testing.T) {
917917 base := renderer.NewFrame(20, 8)
918918 pane := newPane("split", nil, domain.Size{Cols: 12, Rows: 2})
919919 pane.screen.Write([]byte("copy"))
920- mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds(), nil), domain.DefaultWordSeparators))
920+ mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen, pane.screen.LineBounds(), nil), domain.DefaultWordSeparators))
921921 if tt.selectMode {
922922 mode.ToggleLineSelection()
923923 }
@@ -133,7 +133,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
133 tb := testAttachmentTab(sess)133 tb := testAttachmentTab(sess)
134 pane := tb.focusedPane()134 pane := tb.focusedPane()
135 pane.mu.Lock()135 pane.mu.Lock()
136- snap := scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds(), nil)136+ snap := scopy.NewSnapshot(pane.history, pane.screen, pane.screen.LineBounds(), nil)
137 pane.mu.Unlock()137 pane.mu.Unlock()
138 mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))138 mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))
139 139
@@ -167,7 +167,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
167 tb := testAttachmentTab(sess)167 tb := testAttachmentTab(sess)
168 tb.focusedPane().screen = vt.NewScreen(12, 3)168 tb.focusedPane().screen = vt.NewScreen(12, 3)
169 tb.focusedPane().screen.Write([]byte("live"))169 tb.focusedPane().screen.Write([]byte("live"))
170- snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame, tb.focusedPane().screen.LineBounds(), nil)170+ snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen, tb.focusedPane().screen.LineBounds(), nil)
171 mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))171 mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))
172 172
173 bars := barState{status: sess.statusSegments(true)}173 bars := barState{status: sess.statusSegments(true)}
@@ -176,7 +176,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
176 reset: true,176 reset: true,
177 layout: capturedTabLayout{area: content, focus: tb.focusedPane().id, valid: true},177 layout: capturedTabLayout{area: content, focus: tb.focusedPane().id, valid: true},
178 panes: []capturedPaneRenderState{{178 panes: []capturedPaneRenderState{{
179- id: tb.focusedPane().id, frame: tb.focusedPane().screen.Frame.Clone(), placement: layout.Placement{ID: tb.focusedPane().id, Content: content}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()},179+ id: tb.focusedPane().id, frame: captureTestFrame(tb.focusedPane().screen), placement: layout.Placement{ID: tb.focusedPane().id, Content: content}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()},
180 }},180 }},
181 bars: bars,181 bars: bars,
182 }, composeCacheInput{}).frame182 }, composeCacheInput{}).frame
@@ -230,8 +230,8 @@ func TestCopyModeSearchModalRoutesBatchedInputAfterSlash(t *testing.T) {
230 p, _ := newBlockingPTYWithWrites(t, writes)230 p, _ := newBlockingPTYWithWrites(t, writes)
231 d, sess, ac, sends := newManualSessionWithPTYs(t, p)231 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
232 pane := sess.tabs[0].focusedPane()232 pane := sess.tabs[0].focusedPane()
233- copy(pane.screen.Frame.Row(0), testRow("alpha"))233+ writeTestRow(pane.screen, 0, "alpha")
234- copy(pane.screen.Frame.Row(1), testRow("beta alpha"))234+ writeTestRow(pane.screen, 1, "beta alpha")
235 235
236 d.enterCopyMode(sess, ac)236 d.enterCopyMode(sess, ac)
237 awaitFrame(t, sends, wire.MsgOutput)237 awaitFrame(t, sends, wire.MsgOutput)
@@ -253,9 +253,9 @@ func TestCopyModeSearchModalJumpsAndKeepsNavigation(t *testing.T) {
253 p, _ := newBlockingPTYWithWrites(t, writes)253 p, _ := newBlockingPTYWithWrites(t, writes)
254 d, sess, ac, sends := newManualSessionWithPTYs(t, p)254 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
255 pane := sess.tabs[0].focusedPane()255 pane := sess.tabs[0].focusedPane()
256- copy(pane.screen.Frame.Row(0), testRow("alpha"))256+ writeTestRow(pane.screen, 0, "alpha")
257- copy(pane.screen.Frame.Row(1), testRow("beta alpha"))257+ writeTestRow(pane.screen, 1, "beta alpha")
258- copy(pane.screen.Frame.Row(2), testRow("gamma"))258+ writeTestRow(pane.screen, 2, "gamma")
259 259
260 d.enterCopyMode(sess, ac)260 d.enterCopyMode(sess, ac)
261 awaitFrame(t, sends, wire.MsgOutput)261 awaitFrame(t, sends, wire.MsgOutput)
@@ -297,9 +297,9 @@ func TestCopyModeSearchModalSelectionPreviewsBehindModal(t *testing.T) {
297 p, _ := newBlockingPTY(t)297 p, _ := newBlockingPTY(t)
298 d, sess, ac, sends := newManualSessionWithPTYs(t, p)298 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
299 pane := sess.tabs[0].focusedPane()299 pane := sess.tabs[0].focusedPane()
300- copy(pane.screen.Frame.Row(0), testRow("alpha"))300+ writeTestRow(pane.screen, 0, "alpha")
301- copy(pane.screen.Frame.Row(1), testRow("beta alpha"))301+ writeTestRow(pane.screen, 1, "beta alpha")
302- copy(pane.screen.Frame.Row(2), testRow("gamma"))302+ writeTestRow(pane.screen, 2, "gamma")
303 303
304 d.enterCopyMode(sess, ac)304 d.enterCopyMode(sess, ac)
305 awaitFrame(t, sends, wire.MsgOutput)305 awaitFrame(t, sends, wire.MsgOutput)
@@ -323,7 +323,7 @@ func TestCopyModeSearchModalCapturesMouseAndClearsOnExit(t *testing.T) {
323 pane := sess.tabs[0].focusedPane()323 pane := sess.tabs[0].focusedPane()
324 installTestHistory(pane, vt.HistoryConfig{MaxRows: 4})324 installTestHistory(pane, vt.HistoryConfig{MaxRows: 4})
325 appendHistoryRow(t, pane.history, testRow("old alpha"))325 appendHistoryRow(t, pane.history, testRow("old alpha"))
326- copy(pane.screen.Frame.Row(0), testRow("live alpha"))326+ writeTestRow(pane.screen, 0, "live alpha")
327 327
328 d.enterCopyMode(sess, ac)328 d.enterCopyMode(sess, ac)
329 awaitFrame(t, sends, wire.MsgOutput)329 awaitFrame(t, sends, wire.MsgOutput)
@@ -455,7 +455,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
455 }455 }
456 p.mu.Lock()456 p.mu.Lock()
457 defer p.mu.Unlock()457 defer p.mu.Unlock()
458- return scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil).Len() >= 12458+ return scopy.NewSnapshot(p.history, p.screen, p.screen.LineBounds(), nil).Len() >= 12
459 }, 2*time.Second, 5*time.Millisecond)459 }, 2*time.Second, 5*time.Millisecond)
460 460
461 sess := firstSession(d)461 sess := firstSession(d)
@@ -548,8 +548,8 @@ func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) {
548 d, sess, ac, sends := newManualSessionWithPTYs(t, p)548 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
549 installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 1})549 installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 1})
550 longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1)550 longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1)
551- sess.tabs[0].focusedPane().screen.Frame = renderer.NewFrame(len(longLine), 1)551+ sess.tabs[0].focusedPane().screen.Resize(len(longLine), 1)
552- copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow(longLine))552+ writeTestRow(sess.tabs[0].focusedPane().screen, 0, longLine)
553 553
554 d.enterCopyMode(sess, ac)554 d.enterCopyMode(sess, ac)
555 awaitFrame(t, sends, wire.MsgOutput)555 awaitFrame(t, sends, wire.MsgOutput)
@@ -806,7 +806,7 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) {
806 }806 }
807 fp.screen.Write([]byte("live"))807 fp.screen.Write([]byte("live"))
808 fp.mu.Lock()808 fp.mu.Lock()
809- total := scopy.NewSnapshot(fp.history, fp.screen.Frame, fp.screen.LineBounds(), nil).Len()809+ total := scopy.NewSnapshot(fp.history, fp.screen, fp.screen.LineBounds(), nil).Len()
810 fp.mu.Unlock()810 fp.mu.Unlock()
811 811
812 d.enterCopyMode(sess, ac)812 d.enterCopyMode(sess, ac)
@@ -885,8 +885,8 @@ func TestMouseDragCopyEntryCapturesSourceForYank(t *testing.T) {
885 pty, _ := newBlockingPTY(t)885 pty, _ := newBlockingPTY(t)
886 d, sess, ac, sends := newManualSessionWithPTYs(t, pty)886 d, sess, ac, sends := newManualSessionWithPTYs(t, pty)
887 pane := sess.tabs[0].focusedPane()887 pane := sess.tabs[0].focusedPane()
888- copy(pane.screen.Frame.Row(0), testRow("alpha"))888+ writeTestRow(pane.screen, 0, "alpha")
889- copy(pane.screen.Frame.Row(1), testRow("bravo"))889+ writeTestRow(pane.screen, 1, "bravo")
890 890
891 d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<32;1;3M"))891 d.handleInput(sess, ac, []byte("\x1b[<0;1;2M\x1b[<32;1;3M"))
892 mustOutputData(t, sends)892 mustOutputData(t, sends)
@@ -917,7 +917,7 @@ func TestComposeCopyClientFrameStylesStatusContentAndSurround(t *testing.T) {
917 base := renderer.NewFrame(20, 8)917 base := renderer.NewFrame(20, 8)
918 pane := newPane("split", nil, domain.Size{Cols: 12, Rows: 2})918 pane := newPane("split", nil, domain.Size{Cols: 12, Rows: 2})
919 pane.screen.Write([]byte("copy"))919 pane.screen.Write([]byte("copy"))
920- mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds(), nil), domain.DefaultWordSeparators))920+ mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen, pane.screen.LineBounds(), nil), domain.DefaultWordSeparators))
921 if tt.selectMode {921 if tt.selectMode {
922 mode.ToggleLineSelection()922 mode.ToggleLineSelection()
923 }923 }
M internal/usecase/daemon/daemon.go +10 -5
@@ -35,12 +35,11 @@ const debounceInterval = minDebounceInterval
3535 // ptyReadBufSize is the PTY reader's read buffer.
3636 const ptyReadBufSize = 32 * 1024
3737
38-// New and restored panes are bounded by both history rows and cells. The
39-// cell budget keeps a very wide terminal from retaining disproportionately
40-// more scrollback than the normal 160-column case.
38+// New and restored panes have independent line and uncompressed logical-byte
39+// ceilings. Visible primary and alternate screens are outside this budget.
4140 const (
42- defaultScrollbackRows = 10_000
43- defaultScrollbackCells = 12_000 * 160
41+ defaultScrollbackRows = domain.DefaultScrollbackLines
42+ defaultScrollbackBytes = domain.DefaultScrollbackMegabytes * 1_000_000
4443 )
4544
4645 // detachNotifyTimeout bounds the best-effort Detached notification on the
@@ -294,6 +293,7 @@ type Daemon struct {
294293 paletteConfig atomic.Pointer[domain.PaletteConfig]
295294 navConfig atomic.Pointer[domain.NavConfig]
296295 tabsConfig atomic.Pointer[domain.TabsConfig]
296+ scrollbackConfig atomic.Pointer[domain.ScrollbackConfig]
297297 themeConfig atomic.Pointer[themeConfigSnapshot]
298298 barScripts *barScriptState
299299 notices *noticeCenter
@@ -688,6 +688,8 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
688688 d.copyConfig.Store(&defaultCopy)
689689 defaultPalette := defaults.Palette
690690 d.paletteConfig.Store(&defaultPalette)
691+ defaultTabs := defaults.Tabs
692+ d.tabsConfig.Store(&defaultTabs)
691693 for _, o := range opts {
692694 o(d)
693695 }
@@ -747,6 +749,9 @@ func (d *Daemon) Serve(ctx context.Context, l ports.ServerListener) error {
747749 d.sessWg.Go(func() {
748750 d.barScriptPoller(d.serveCtx)
749751 })
752+ d.sessWg.Go(func() {
753+ d.historyMaintenance(d.serveCtx)
754+ })
750755 if d.persistEnabled {
751756 d.sessWg.Go(func() {
752757 d.cwdSampler(d.serveCtx)
@@ -35,12 +35,11 @@ const debounceInterval = minDebounceInterval
35 // ptyReadBufSize is the PTY reader's read buffer.35 // ptyReadBufSize is the PTY reader's read buffer.
36 const ptyReadBufSize = 32 * 102436 const ptyReadBufSize = 32 * 1024
37 37
38-// New and restored panes are bounded by both history rows and cells. The38+// New and restored panes have independent line and uncompressed logical-byte
39-// cell budget keeps a very wide terminal from retaining disproportionately39+// ceilings. Visible primary and alternate screens are outside this budget.
40-// more scrollback than the normal 160-column case.
41 const (40 const (
42- defaultScrollbackRows = 10_00041+ defaultScrollbackRows = domain.DefaultScrollbackLines
43- defaultScrollbackCells = 12_000 * 16042+ defaultScrollbackBytes = domain.DefaultScrollbackMegabytes * 1_000_000
44 )43 )
45 44
46 // detachNotifyTimeout bounds the best-effort Detached notification on the45 // detachNotifyTimeout bounds the best-effort Detached notification on the
@@ -294,6 +293,7 @@ type Daemon struct {
294 paletteConfig atomic.Pointer[domain.PaletteConfig]293 paletteConfig atomic.Pointer[domain.PaletteConfig]
295 navConfig atomic.Pointer[domain.NavConfig]294 navConfig atomic.Pointer[domain.NavConfig]
296 tabsConfig atomic.Pointer[domain.TabsConfig]295 tabsConfig atomic.Pointer[domain.TabsConfig]
296+ scrollbackConfig atomic.Pointer[domain.ScrollbackConfig]
297 themeConfig atomic.Pointer[themeConfigSnapshot]297 themeConfig atomic.Pointer[themeConfigSnapshot]
298 barScripts *barScriptState298 barScripts *barScriptState
299 notices *noticeCenter299 notices *noticeCenter
@@ -688,6 +688,8 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
688 d.copyConfig.Store(&defaultCopy)688 d.copyConfig.Store(&defaultCopy)
689 defaultPalette := defaults.Palette689 defaultPalette := defaults.Palette
690 d.paletteConfig.Store(&defaultPalette)690 d.paletteConfig.Store(&defaultPalette)
691+ defaultTabs := defaults.Tabs
692+ d.tabsConfig.Store(&defaultTabs)
691 for _, o := range opts {693 for _, o := range opts {
692 o(d)694 o(d)
693 }695 }
@@ -747,6 +749,9 @@ func (d *Daemon) Serve(ctx context.Context, l ports.ServerListener) error {
747 d.sessWg.Go(func() {749 d.sessWg.Go(func() {
748 d.barScriptPoller(d.serveCtx)750 d.barScriptPoller(d.serveCtx)
749 })751 })
752+ d.sessWg.Go(func() {
753+ d.historyMaintenance(d.serveCtx)
754+ })
750 if d.persistEnabled {755 if d.persistEnabled {
751 d.sessWg.Go(func() {756 d.sessWg.Go(func() {
752 d.cwdSampler(d.serveCtx)757 d.cwdSampler(d.serveCtx)
M internal/usecase/daemon/daemon_test.go +3 -3
@@ -277,9 +277,9 @@ func frameInput(data []byte) wire.Frame {
277277 }
278278
279279 func screenLineText(s *vt.Screen, y int) string {
280- out := make([]rune, s.Frame.Width)
281- for x := range s.Frame.Width {
282- out[x] = s.Frame.At(x, y).Rune
280+ out := make([]rune, s.Columns())
281+ for x := range s.Columns() {
282+ out[x] = s.Cell(x, y).Rune
283283 }
284284 return string(out)
285285 }
@@ -277,9 +277,9 @@ func frameInput(data []byte) wire.Frame {
277 }277 }
278 278
279 func screenLineText(s *vt.Screen, y int) string {279 func screenLineText(s *vt.Screen, y int) string {
280- out := make([]rune, s.Frame.Width)280+ out := make([]rune, s.Columns())
281- for x := range s.Frame.Width {281+ for x := range s.Columns() {
282- out[x] = s.Frame.At(x, y).Rune282+ out[x] = s.Cell(x, y).Rune
283 }283 }
284 return string(out)284 return string(out)
285 }285 }
M internal/usecase/daemon/final_acceptance_test.go +1 -1
@@ -73,7 +73,7 @@ func TestAcceptancePaletteNewTabUsesSessionGeometryAfterAttachmentResize(t *test
7373 gotTabSize := tb.size
7474 pane := tb.focusedPane()
7575 pane.mu.Lock()
76- gotPaneSize := domain.Size{Cols: pane.screen.Frame.Width, Rows: pane.screen.Frame.Height}
76+ gotPaneSize := domain.Size{Cols: pane.screen.Columns(), Rows: pane.screen.Rows()}
7777 pane.mu.Unlock()
7878 tb.mu.Unlock()
7979 require.Equal(t, wantContent, gotTabSize)
@@ -73,7 +73,7 @@ func TestAcceptancePaletteNewTabUsesSessionGeometryAfterAttachmentResize(t *test
73 gotTabSize := tb.size73 gotTabSize := tb.size
74 pane := tb.focusedPane()74 pane := tb.focusedPane()
75 pane.mu.Lock()75 pane.mu.Lock()
76- gotPaneSize := domain.Size{Cols: pane.screen.Frame.Width, Rows: pane.screen.Frame.Height}76+ gotPaneSize := domain.Size{Cols: pane.screen.Columns(), Rows: pane.screen.Rows()}
77 pane.mu.Unlock()77 pane.mu.Unlock()
78 tb.mu.Unlock()78 tb.mu.Unlock()
79 require.Equal(t, wantContent, gotTabSize)79 require.Equal(t, wantContent, gotTabSize)
M internal/usecase/daemon/floating.go +1 -1
@@ -427,7 +427,7 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau
427427 return
428428 }
429429 ptyGeometry := spec.ptyGeometry
430- p := newPaneWithStableIDAndTitle(layout.PaneID("floating"), spec.paneStableID, pty, spec.size, spec.fallback)
430+ p := newPaneWithStableIDAndTitle(layout.PaneID("floating"), spec.paneStableID, pty, spec.size, spec.fallback, d.currentHistoryConfig())
431431 p.geometry = ptyGeometry
432432 setScreenGeometry(p.screen, ptyGeometry)
433433 p.rect = spec.geometry.ptyRect()
@@ -427,7 +427,7 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau
427 return427 return
428 }428 }
429 ptyGeometry := spec.ptyGeometry429 ptyGeometry := spec.ptyGeometry
430- p := newPaneWithStableIDAndTitle(layout.PaneID("floating"), spec.paneStableID, pty, spec.size, spec.fallback)430+ p := newPaneWithStableIDAndTitle(layout.PaneID("floating"), spec.paneStableID, pty, spec.size, spec.fallback, d.currentHistoryConfig())
431 p.geometry = ptyGeometry431 p.geometry = ptyGeometry
432 setScreenGeometry(p.screen, ptyGeometry)432 setScreenGeometry(p.screen, ptyGeometry)
433 p.rect = spec.geometry.ptyRect()433 p.rect = spec.geometry.ptyRect()
M internal/usecase/daemon/floating_render_test.go +27 -22
@@ -208,8 +208,8 @@ func TestResponsiveFloatingResizeCommitsZeroContentDrawerWithoutPhysicalResize(t
208208 require.Empty(t, pty.sizes(), "a zero-inner drawer must not issue a synthetic PTY resize")
209209 require.Equal(t, requested, floating.popupGeometry)
210210 require.Equal(t, initial.Inner, floating.rect, "physical geometry remains at the last usable size")
211- require.Equal(t, initial.Inner.Width, floating.screen.Frame.Width)
212- require.Equal(t, initial.Inner.Height, floating.screen.Frame.Height)
211+ require.Equal(t, initial.Inner.Width, floating.screen.Columns())
212+ require.Equal(t, initial.Inner.Height, floating.screen.Rows())
213213 }
214214
215215 func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) {
@@ -296,11 +296,13 @@ func TestComposeCapturedFloatingFrameClipsRetainedGeometryAfterFailedShrink(t *t
296296 floatingPane := newPane("floating", pty, rectSize(retained.Inner))
297297 floatingPane.rect = retained.Inner
298298 floatingPane.popupGeometry = retained
299- for y := range floatingPane.screen.Frame.Height {
300- for x := range floatingPane.screen.Frame.Width {
301- floatingPane.screen.Frame.Set(x, y, renderer.Cell{Rune: rune('a' + y*10 + x)})
299+ seed := renderer.NewFrame(floatingPane.screen.Columns(), floatingPane.screen.Rows())
300+ for y := range seed.Height {
301+ for x := range seed.Width {
302+ seed.Set(x, y, renderer.Cell{Rune: rune('a' + y*10 + x)})
302303 }
303304 }
305+ writeTestFrame(t, floatingPane.screen, seed)
304306 requested := calculateContentFloatingGeometry(rectSize(content), domain.FloatingConfig{Width: 80, Height: 80})
305307 d := newTestDaemon(t, nil, stubClock{})
306308 require.False(t, applyFloatingResizePlanForTest(d, floatingPane, requested))
@@ -557,8 +559,8 @@ func TestResizeFloatingPaneCommitsSameSizeGeometryWithoutPTYResize(t *testing.T)
557559 require.Empty(t, pty.sizes(), "same-size geometry must not resize the PTY")
558560 require.Equal(t, tt.next.Inner, p.rect)
559561 require.Equal(t, tt.next, p.popupGeometry)
560- require.Equal(t, 6, p.screen.Frame.Width)
561- require.Equal(t, 3, p.screen.Frame.Height)
562+ require.Equal(t, 6, p.screen.Columns())
563+ require.Equal(t, 3, p.screen.Rows())
562564 })
563565 }
564566 }
@@ -633,7 +635,7 @@ func TestCaptureAndComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) {
633635 func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) {
634636 frame := renderer.NewFrame(2, 1)
635637 drawFloatingBorder(frame, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 2, Height: 1}}, "title", renderer.Style{})
636- for _, cell := range frame.Cells {
638+ for _, cell := range frame.Row(0) {
637639 require.Equal(t, renderer.BlankCell().Rune, cell.Rune)
638640 }
639641 }
@@ -698,10 +700,13 @@ func TestComposeCapturedFloatingFrameCachedAllocationsAreOnlyFrameClone(t *testi
698700 }
699701
700702 allocs := testing.AllocsPerRun(100, func() { composeCapturedFloatingFrame(input) })
701- // The production entry point deliberately clones Frame.Cells and its row
702- // offsets to keep the cached base immutable; those are its two unavoidable
703- // allocations. A higher count would reintroduce avoidable cache churn.
704- require.LessOrEqual(t, allocs, float64(2))
703+ // Compare with the actual compact clone rather than assuming the old two
704+ // dense slices. One additional allocation permits the first style insertion.
705+ cloneAllocs := testing.AllocsPerRun(100, func() { benchmarkComposeSink.frame = base.Clone() })
706+ require.LessOrEqual(t, allocs, cloneAllocs+1)
707+ if copyEnterAllocationBudgetEnabled {
708+ assertRenderByteBudget(t, func() { benchmarkComposeSink.frame, _ = composeCapturedFloatingFrame(input) }, 40<<10)
709+ }
705710 }
706711
707712 var (
@@ -851,16 +856,16 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) {
851856 require.NoError(t, d.toggleFloating(sess, nil))
852857 require.Equal(t, []domain.Size{rectSize(current)}, pty.sizes())
853858 require.Equal(t, initial, floating.rect)
854- require.Equal(t, initial.Width, floating.screen.Frame.Width)
855- require.Equal(t, initial.Height, floating.screen.Frame.Height)
859+ require.Equal(t, initial.Width, floating.screen.Columns())
860+ require.Equal(t, initial.Height, floating.screen.Rows())
856861 require.Equal(t, initialGeometry, floating.popupGeometry)
857862
858863 require.NoError(t, d.toggleFloating(sess, nil)) // hide
859864 require.NoError(t, d.toggleFloating(sess, nil)) // retry show
860865 require.Equal(t, []domain.Size{rectSize(current), rectSize(current)}, pty.sizes())
861866 require.Equal(t, current, floating.rect)
862- require.Equal(t, current.Width, floating.screen.Frame.Width)
863- require.Equal(t, current.Height, floating.screen.Frame.Height)
867+ require.Equal(t, current.Width, floating.screen.Columns())
868+ require.Equal(t, current.Height, floating.screen.Rows())
864869 require.Equal(t, currentGeometry, floating.popupGeometry)
865870 }
866871
@@ -945,8 +950,8 @@ func TestFloatingLayoutRejectsStaleResizeBeforePublishing(t *testing.T) {
945950 p.mu.Lock()
946951 require.Equal(t, previousGeometry.Inner, p.rect, "a stale floating apply must not publish its rectangle")
947952 require.Equal(t, previousGeometry, p.popupGeometry, "a stale floating apply must not publish its geometry")
948- require.Equal(t, previousGeometry.Inner.Width, p.screen.Frame.Width, "a stale floating apply must not resize the screen")
949- require.Equal(t, previousGeometry.Inner.Height, p.screen.Frame.Height, "a stale floating apply must not resize the screen")
953+ require.Equal(t, previousGeometry.Inner.Width, p.screen.Columns(), "a stale floating apply must not resize the screen")
954+ require.Equal(t, previousGeometry.Inner.Height, p.screen.Rows(), "a stale floating apply must not resize the screen")
950955 require.False(t, p.resizeApplying, "a stale floating apply must release its parser gate")
951956 p.mu.Unlock()
952957
@@ -955,8 +960,8 @@ func TestFloatingLayoutRejectsStaleResizeBeforePublishing(t *testing.T) {
955960 require.Equal(t, []domain.Size{rectSize(oldGeometry.Inner), rectSize(newGeometry.Inner)}, pty.sizes())
956961 require.Equal(t, newGeometry.Inner, p.rect)
957962 require.Equal(t, newGeometry, p.popupGeometry)
958- require.Equal(t, newGeometry.Inner.Width, p.screen.Frame.Width)
959- require.Equal(t, newGeometry.Inner.Height, p.screen.Frame.Height)
963+ require.Equal(t, newGeometry.Inner.Width, p.screen.Columns())
964+ require.Equal(t, newGeometry.Inner.Height, p.screen.Rows())
960965 }
961966
962967 func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) {
@@ -969,8 +974,8 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) {
969974 require.False(t, applyFloatingResizePlanForTest(d, p, requested))
970975 require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect)
971976 require.Equal(t, floatingGeometry{}, p.popupGeometry)
972- require.Equal(t, 5, p.screen.Frame.Width)
973- require.Equal(t, 4, p.screen.Frame.Height)
977+ require.Equal(t, 5, p.screen.Columns())
978+ require.Equal(t, 4, p.screen.Rows())
974979 })
975980
976981 t.Run("competing resizes serialize", func(t *testing.T) {
@@ -208,8 +208,8 @@ func TestResponsiveFloatingResizeCommitsZeroContentDrawerWithoutPhysicalResize(t
208 require.Empty(t, pty.sizes(), "a zero-inner drawer must not issue a synthetic PTY resize")208 require.Empty(t, pty.sizes(), "a zero-inner drawer must not issue a synthetic PTY resize")
209 require.Equal(t, requested, floating.popupGeometry)209 require.Equal(t, requested, floating.popupGeometry)
210 require.Equal(t, initial.Inner, floating.rect, "physical geometry remains at the last usable size")210 require.Equal(t, initial.Inner, floating.rect, "physical geometry remains at the last usable size")
211- require.Equal(t, initial.Inner.Width, floating.screen.Frame.Width)211+ require.Equal(t, initial.Inner.Width, floating.screen.Columns())
212- require.Equal(t, initial.Inner.Height, floating.screen.Frame.Height)212+ require.Equal(t, initial.Inner.Height, floating.screen.Rows())
213 }213 }
214 214
215 func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) {215 func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) {
@@ -296,11 +296,13 @@ func TestComposeCapturedFloatingFrameClipsRetainedGeometryAfterFailedShrink(t *t
296 floatingPane := newPane("floating", pty, rectSize(retained.Inner))296 floatingPane := newPane("floating", pty, rectSize(retained.Inner))
297 floatingPane.rect = retained.Inner297 floatingPane.rect = retained.Inner
298 floatingPane.popupGeometry = retained298 floatingPane.popupGeometry = retained
299- for y := range floatingPane.screen.Frame.Height {299+ seed := renderer.NewFrame(floatingPane.screen.Columns(), floatingPane.screen.Rows())
300- for x := range floatingPane.screen.Frame.Width {300+ for y := range seed.Height {
301- floatingPane.screen.Frame.Set(x, y, renderer.Cell{Rune: rune('a' + y*10 + x)})301+ for x := range seed.Width {
302+ seed.Set(x, y, renderer.Cell{Rune: rune('a' + y*10 + x)})
302 }303 }
303 }304 }
305+ writeTestFrame(t, floatingPane.screen, seed)
304 requested := calculateContentFloatingGeometry(rectSize(content), domain.FloatingConfig{Width: 80, Height: 80})306 requested := calculateContentFloatingGeometry(rectSize(content), domain.FloatingConfig{Width: 80, Height: 80})
305 d := newTestDaemon(t, nil, stubClock{})307 d := newTestDaemon(t, nil, stubClock{})
306 require.False(t, applyFloatingResizePlanForTest(d, floatingPane, requested))308 require.False(t, applyFloatingResizePlanForTest(d, floatingPane, requested))
@@ -557,8 +559,8 @@ func TestResizeFloatingPaneCommitsSameSizeGeometryWithoutPTYResize(t *testing.T)
557 require.Empty(t, pty.sizes(), "same-size geometry must not resize the PTY")559 require.Empty(t, pty.sizes(), "same-size geometry must not resize the PTY")
558 require.Equal(t, tt.next.Inner, p.rect)560 require.Equal(t, tt.next.Inner, p.rect)
559 require.Equal(t, tt.next, p.popupGeometry)561 require.Equal(t, tt.next, p.popupGeometry)
560- require.Equal(t, 6, p.screen.Frame.Width)562+ require.Equal(t, 6, p.screen.Columns())
561- require.Equal(t, 3, p.screen.Frame.Height)563+ require.Equal(t, 3, p.screen.Rows())
562 })564 })
563 }565 }
564 }566 }
@@ -633,7 +635,7 @@ func TestCaptureAndComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) {
633 func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) {635 func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) {
634 frame := renderer.NewFrame(2, 1)636 frame := renderer.NewFrame(2, 1)
635 drawFloatingBorder(frame, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 2, Height: 1}}, "title", renderer.Style{})637 drawFloatingBorder(frame, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 2, Height: 1}}, "title", renderer.Style{})
636- for _, cell := range frame.Cells {638+ for _, cell := range frame.Row(0) {
637 require.Equal(t, renderer.BlankCell().Rune, cell.Rune)639 require.Equal(t, renderer.BlankCell().Rune, cell.Rune)
638 }640 }
639 }641 }
@@ -698,10 +700,13 @@ func TestComposeCapturedFloatingFrameCachedAllocationsAreOnlyFrameClone(t *testi
698 }700 }
699 701
700 allocs := testing.AllocsPerRun(100, func() { composeCapturedFloatingFrame(input) })702 allocs := testing.AllocsPerRun(100, func() { composeCapturedFloatingFrame(input) })
701- // The production entry point deliberately clones Frame.Cells and its row703+ // Compare with the actual compact clone rather than assuming the old two
702- // offsets to keep the cached base immutable; those are its two unavoidable704+ // dense slices. One additional allocation permits the first style insertion.
703- // allocations. A higher count would reintroduce avoidable cache churn.705+ cloneAllocs := testing.AllocsPerRun(100, func() { benchmarkComposeSink.frame = base.Clone() })
704- require.LessOrEqual(t, allocs, float64(2))706+ require.LessOrEqual(t, allocs, cloneAllocs+1)
707+ if copyEnterAllocationBudgetEnabled {
708+ assertRenderByteBudget(t, func() { benchmarkComposeSink.frame, _ = composeCapturedFloatingFrame(input) }, 40<<10)
709+ }
705 }710 }
706 711
707 var (712 var (
@@ -851,16 +856,16 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) {
851 require.NoError(t, d.toggleFloating(sess, nil))856 require.NoError(t, d.toggleFloating(sess, nil))
852 require.Equal(t, []domain.Size{rectSize(current)}, pty.sizes())857 require.Equal(t, []domain.Size{rectSize(current)}, pty.sizes())
853 require.Equal(t, initial, floating.rect)858 require.Equal(t, initial, floating.rect)
854- require.Equal(t, initial.Width, floating.screen.Frame.Width)859+ require.Equal(t, initial.Width, floating.screen.Columns())
855- require.Equal(t, initial.Height, floating.screen.Frame.Height)860+ require.Equal(t, initial.Height, floating.screen.Rows())
856 require.Equal(t, initialGeometry, floating.popupGeometry)861 require.Equal(t, initialGeometry, floating.popupGeometry)
857 862
858 require.NoError(t, d.toggleFloating(sess, nil)) // hide863 require.NoError(t, d.toggleFloating(sess, nil)) // hide
859 require.NoError(t, d.toggleFloating(sess, nil)) // retry show864 require.NoError(t, d.toggleFloating(sess, nil)) // retry show
860 require.Equal(t, []domain.Size{rectSize(current), rectSize(current)}, pty.sizes())865 require.Equal(t, []domain.Size{rectSize(current), rectSize(current)}, pty.sizes())
861 require.Equal(t, current, floating.rect)866 require.Equal(t, current, floating.rect)
862- require.Equal(t, current.Width, floating.screen.Frame.Width)867+ require.Equal(t, current.Width, floating.screen.Columns())
863- require.Equal(t, current.Height, floating.screen.Frame.Height)868+ require.Equal(t, current.Height, floating.screen.Rows())
864 require.Equal(t, currentGeometry, floating.popupGeometry)869 require.Equal(t, currentGeometry, floating.popupGeometry)
865 }870 }
866 871
@@ -945,8 +950,8 @@ func TestFloatingLayoutRejectsStaleResizeBeforePublishing(t *testing.T) {
945 p.mu.Lock()950 p.mu.Lock()
946 require.Equal(t, previousGeometry.Inner, p.rect, "a stale floating apply must not publish its rectangle")951 require.Equal(t, previousGeometry.Inner, p.rect, "a stale floating apply must not publish its rectangle")
947 require.Equal(t, previousGeometry, p.popupGeometry, "a stale floating apply must not publish its geometry")952 require.Equal(t, previousGeometry, p.popupGeometry, "a stale floating apply must not publish its geometry")
948- require.Equal(t, previousGeometry.Inner.Width, p.screen.Frame.Width, "a stale floating apply must not resize the screen")953+ require.Equal(t, previousGeometry.Inner.Width, p.screen.Columns(), "a stale floating apply must not resize the screen")
949- require.Equal(t, previousGeometry.Inner.Height, p.screen.Frame.Height, "a stale floating apply must not resize the screen")954+ require.Equal(t, previousGeometry.Inner.Height, p.screen.Rows(), "a stale floating apply must not resize the screen")
950 require.False(t, p.resizeApplying, "a stale floating apply must release its parser gate")955 require.False(t, p.resizeApplying, "a stale floating apply must release its parser gate")
951 p.mu.Unlock()956 p.mu.Unlock()
952 957
@@ -955,8 +960,8 @@ func TestFloatingLayoutRejectsStaleResizeBeforePublishing(t *testing.T) {
955 require.Equal(t, []domain.Size{rectSize(oldGeometry.Inner), rectSize(newGeometry.Inner)}, pty.sizes())960 require.Equal(t, []domain.Size{rectSize(oldGeometry.Inner), rectSize(newGeometry.Inner)}, pty.sizes())
956 require.Equal(t, newGeometry.Inner, p.rect)961 require.Equal(t, newGeometry.Inner, p.rect)
957 require.Equal(t, newGeometry, p.popupGeometry)962 require.Equal(t, newGeometry, p.popupGeometry)
958- require.Equal(t, newGeometry.Inner.Width, p.screen.Frame.Width)963+ require.Equal(t, newGeometry.Inner.Width, p.screen.Columns())
959- require.Equal(t, newGeometry.Inner.Height, p.screen.Frame.Height)964+ require.Equal(t, newGeometry.Inner.Height, p.screen.Rows())
960 }965 }
961 966
962 func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) {967 func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) {
@@ -969,8 +974,8 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) {
969 require.False(t, applyFloatingResizePlanForTest(d, p, requested))974 require.False(t, applyFloatingResizePlanForTest(d, p, requested))
970 require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect)975 require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect)
971 require.Equal(t, floatingGeometry{}, p.popupGeometry)976 require.Equal(t, floatingGeometry{}, p.popupGeometry)
972- require.Equal(t, 5, p.screen.Frame.Width)977+ require.Equal(t, 5, p.screen.Columns())
973- require.Equal(t, 4, p.screen.Frame.Height)978+ require.Equal(t, 4, p.screen.Rows())
974 })979 })
975 980
976 t.Run("competing resizes serialize", func(t *testing.T) {981 t.Run("competing resizes serialize", func(t *testing.T) {
A internal/usecase/daemon/history_policy.go +115 -0
@@ -0,0 +1,115 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "time"
6+
7+ vt "github.com/bnema/vev-vt"
8+ "github.com/bnema/vev/internal/domain"
9+)
10+
11+func historyConfigFor(policy domain.ScrollbackConfig) vt.HistoryConfig {
12+ if policy.Megabytes == 0 {
13+ return vt.HistoryConfig{}
14+ }
15+ return vt.HistoryConfig{MaxBytes: policy.Megabytes * 1_000_000, MaxRows: policy.Lines}
16+}
17+
18+func (d *Daemon) currentHistoryConfig() vt.HistoryConfig {
19+ policy := d.scrollbackConfig.Load()
20+ if policy == nil {
21+ return historyConfigFor(domain.DefaultScrollbackConfig())
22+ }
23+ return historyConfigFor(*policy)
24+}
25+
26+// historyPanes gathers references without holding a session/tab lock while
27+// touching history. Include hidden floating panes; deduplicate a pane that moves
28+// between tabs while the separate session snapshots are being collected.
29+func (d *Daemon) historyPanes() []*pane {
30+ var panes []*pane
31+ seen := make(map[*pane]struct{})
32+ for _, sess := range d.sessionsSnapshot() {
33+ sess.mu.Lock()
34+ tabs := append([]*tab(nil), sess.tabs...)
35+ sess.mu.Unlock()
36+ for _, tb := range tabs {
37+ if tb == nil {
38+ continue
39+ }
40+ tb.mu.Lock()
41+ current := tb.panesSnapshot()
42+ if tb.floating.pane != nil {
43+ current = append(current, tb.floating.pane)
44+ }
45+ tb.mu.Unlock()
46+ for _, p := range current {
47+ if p == nil {
48+ continue
49+ }
50+ if _, exists := seen[p]; exists {
51+ continue
52+ }
53+ seen[p] = struct{}{}
54+ panes = append(panes, p)
55+ }
56+ }
57+ }
58+ return panes
59+}
60+
61+func (d *Daemon) applyHistoryLimitsToPane(p *pane) {
62+ p.mu.Lock()
63+ var err error
64+ if p.history != nil {
65+ // Load inside the pane lock so an older concurrent reload cannot install
66+ // a stale policy after a newer reload has already published its value.
67+ err = p.history.SetLimits(d.currentHistoryConfig())
68+ }
69+ p.mu.Unlock()
70+ if err != nil {
71+ d.log.Warn("cannot apply scrollback limits", "err", err)
72+ }
73+}
74+
75+func (d *Daemon) applyHistoryLimits() {
76+ for _, p := range d.historyPanes() {
77+ d.applyHistoryLimitsToPane(p)
78+ }
79+}
80+
81+// Keep background compression infrequent and bounded; it must not compete
82+// with interactive output when many panes retain history.
83+const historyMaintenanceInterval = 5 * time.Second
84+
85+// historyMaintenance is application scheduling, not a VT-owned worker. It
86+// visits one history page per pane per interval and skips busy/resizing panes.
87+func (d *Daemon) historyMaintenance(ctx context.Context) {
88+ timer := d.clock.NewTimer(historyMaintenanceInterval)
89+ defer timer.Stop()
90+ for {
91+ select {
92+ case <-ctx.Done():
93+ return
94+ case <-timer.C():
95+ d.compressIdleHistories()
96+ timer.Reset(historyMaintenanceInterval)
97+ }
98+ }
99+}
100+
101+func (d *Daemon) compressIdleHistories() {
102+ for _, p := range d.historyPanes() {
103+ if !p.mu.TryLock() {
104+ continue
105+ }
106+ var err error
107+ if p.history != nil && !p.resizeApplying {
108+ _, err = p.history.CompressIdle(1)
109+ }
110+ p.mu.Unlock()
111+ if err != nil {
112+ d.log.Warn("cannot compress idle scrollback", "err", err)
113+ }
114+ }
115+}
@@ -0,0 +1,115 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "time"
6+
7+ vt "github.com/bnema/vev-vt"
8+ "github.com/bnema/vev/internal/domain"
9+)
10+
11+func historyConfigFor(policy domain.ScrollbackConfig) vt.HistoryConfig {
12+ if policy.Megabytes == 0 {
13+ return vt.HistoryConfig{}
14+ }
15+ return vt.HistoryConfig{MaxBytes: policy.Megabytes * 1_000_000, MaxRows: policy.Lines}
16+}
17+
18+func (d *Daemon) currentHistoryConfig() vt.HistoryConfig {
19+ policy := d.scrollbackConfig.Load()
20+ if policy == nil {
21+ return historyConfigFor(domain.DefaultScrollbackConfig())
22+ }
23+ return historyConfigFor(*policy)
24+}
25+
26+// historyPanes gathers references without holding a session/tab lock while
27+// touching history. Include hidden floating panes; deduplicate a pane that moves
28+// between tabs while the separate session snapshots are being collected.
29+func (d *Daemon) historyPanes() []*pane {
30+ var panes []*pane
31+ seen := make(map[*pane]struct{})
32+ for _, sess := range d.sessionsSnapshot() {
33+ sess.mu.Lock()
34+ tabs := append([]*tab(nil), sess.tabs...)
35+ sess.mu.Unlock()
36+ for _, tb := range tabs {
37+ if tb == nil {
38+ continue
39+ }
40+ tb.mu.Lock()
41+ current := tb.panesSnapshot()
42+ if tb.floating.pane != nil {
43+ current = append(current, tb.floating.pane)
44+ }
45+ tb.mu.Unlock()
46+ for _, p := range current {
47+ if p == nil {
48+ continue
49+ }
50+ if _, exists := seen[p]; exists {
51+ continue
52+ }
53+ seen[p] = struct{}{}
54+ panes = append(panes, p)
55+ }
56+ }
57+ }
58+ return panes
59+}
60+
61+func (d *Daemon) applyHistoryLimitsToPane(p *pane) {
62+ p.mu.Lock()
63+ var err error
64+ if p.history != nil {
65+ // Load inside the pane lock so an older concurrent reload cannot install
66+ // a stale policy after a newer reload has already published its value.
67+ err = p.history.SetLimits(d.currentHistoryConfig())
68+ }
69+ p.mu.Unlock()
70+ if err != nil {
71+ d.log.Warn("cannot apply scrollback limits", "err", err)
72+ }
73+}
74+
75+func (d *Daemon) applyHistoryLimits() {
76+ for _, p := range d.historyPanes() {
77+ d.applyHistoryLimitsToPane(p)
78+ }
79+}
80+
81+// Keep background compression infrequent and bounded; it must not compete
82+// with interactive output when many panes retain history.
83+const historyMaintenanceInterval = 5 * time.Second
84+
85+// historyMaintenance is application scheduling, not a VT-owned worker. It
86+// visits one history page per pane per interval and skips busy/resizing panes.
87+func (d *Daemon) historyMaintenance(ctx context.Context) {
88+ timer := d.clock.NewTimer(historyMaintenanceInterval)
89+ defer timer.Stop()
90+ for {
91+ select {
92+ case <-ctx.Done():
93+ return
94+ case <-timer.C():
95+ d.compressIdleHistories()
96+ timer.Reset(historyMaintenanceInterval)
97+ }
98+ }
99+}
100+
101+func (d *Daemon) compressIdleHistories() {
102+ for _, p := range d.historyPanes() {
103+ if !p.mu.TryLock() {
104+ continue
105+ }
106+ var err error
107+ if p.history != nil && !p.resizeApplying {
108+ _, err = p.history.CompressIdle(1)
109+ }
110+ p.mu.Unlock()
111+ if err != nil {
112+ d.log.Warn("cannot compress idle scrollback", "err", err)
113+ }
114+ }
115+}
A internal/usecase/daemon/history_policy_test.go +83 -0
@@ -0,0 +1,83 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ vt "github.com/bnema/vev-vt"
10+ "github.com/bnema/vev/internal/domain"
11+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestHistoryPolicyReloadIncludesHiddenFloatingPanes(t *testing.T) {
16+ d := newTestDaemon(t, nil, stubClock{})
17+ sess := addControlSession(d, "history", "tab", "pane")
18+ tb := sess.tabs[0]
19+ primary := tb.focusedPane()
20+ floating := newPane("floating", nil, domain.Size{Cols: 80, Rows: 24})
21+ installTestFloating(tb, floating, false)
22+ panes := []*pane{primary, floating}
23+ views := make([]vt.HistoryView, len(panes))
24+ for i, p := range panes {
25+ for _, text := range []string{"a", "b", "c"} {
26+ appendHistoryRow(t, p.history, testRow(text))
27+ }
28+ views[i] = p.history.View()
29+ }
30+ cfg := domain.Defaults()
31+ cfg.Scrollback = domain.ScrollbackConfig{Megabytes: 1, Lines: 2}
32+ d.ApplyConfig(cfg)
33+ for i, p := range panes {
34+ require.Equal(t, 2, p.history.Len())
35+ require.Equal(t, uint64(1_000_000), p.history.ByteCap())
36+ require.Equal(t, "b", cellsString(p.history.View().Row(0)))
37+ require.Equal(t, 3, views[i].Len(), "reload must preserve captured documents")
38+ }
39+ cfg.Scrollback.Megabytes = 0
40+ d.ApplyConfig(cfg)
41+ for _, p := range panes {
42+ require.Zero(t, p.history.Len())
43+ require.Zero(t, p.history.Cap())
44+ }
45+ cfg.Scrollback = domain.ScrollbackConfig{Megabytes: 1}
46+ d.ApplyConfig(cfg)
47+ created := newPaneWithStableIDAndTitle("new", "stable-new", nil, domain.Size{Cols: 80, Rows: 24}, "shell", d.currentHistoryConfig())
48+ require.Zero(t, created.history.Cap())
49+ require.Equal(t, uint64(1_000_000), created.history.ByteCap())
50+}
51+
52+func TestHistoryCompressionSkipsBusyPanesAndRemainsBounded(t *testing.T) {
53+ d := newTestDaemon(t, nil, stubClock{})
54+ sess := addControlSession(d, "history", "tab", "pane")
55+ p := sess.tabs[0].focusedPane()
56+ installTestHistory(p, vt.HistoryConfig{MaxRows: 64, MaxBytes: 1 << 20, ChunkRows: 16})
57+ for range 48 {
58+ appendHistoryRow(t, p.history, testRow(strings.Repeat("a", 80)))
59+ }
60+ before := p.history.View()
61+ p.mu.Lock()
62+ d.compressIdleHistories() // TryLock must not wait for the reader's lock.
63+ p.mu.Unlock()
64+ require.Zero(t, p.history.CompressionStats().ColdPages)
65+ for range 4 {
66+ d.compressIdleHistories()
67+ }
68+ require.Equal(t, 2, p.history.CompressionStats().ColdPages)
69+ require.Equal(t, 'a', before.Cell(0, 0).Rune)
70+ require.Equal(t, 48, p.history.Len())
71+}
72+
73+func TestHistoryMaintenanceStopsItsTimerOnCancellation(t *testing.T) {
74+ clock := portsmocks.NewMockClock(t)
75+ timer := portsmocks.NewMockTimer(t)
76+ clock.EXPECT().NewTimer(historyMaintenanceInterval).Return(timer).Once()
77+ timer.EXPECT().C().Return(make(<-chan time.Time)).Once()
78+ timer.EXPECT().Stop().Return(true).Once()
79+ ctx, cancel := context.WithCancel(t.Context())
80+ cancel()
81+ d := &Daemon{clock: clock}
82+ d.historyMaintenance(ctx)
83+}
@@ -0,0 +1,83 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ vt "github.com/bnema/vev-vt"
10+ "github.com/bnema/vev/internal/domain"
11+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestHistoryPolicyReloadIncludesHiddenFloatingPanes(t *testing.T) {
16+ d := newTestDaemon(t, nil, stubClock{})
17+ sess := addControlSession(d, "history", "tab", "pane")
18+ tb := sess.tabs[0]
19+ primary := tb.focusedPane()
20+ floating := newPane("floating", nil, domain.Size{Cols: 80, Rows: 24})
21+ installTestFloating(tb, floating, false)
22+ panes := []*pane{primary, floating}
23+ views := make([]vt.HistoryView, len(panes))
24+ for i, p := range panes {
25+ for _, text := range []string{"a", "b", "c"} {
26+ appendHistoryRow(t, p.history, testRow(text))
27+ }
28+ views[i] = p.history.View()
29+ }
30+ cfg := domain.Defaults()
31+ cfg.Scrollback = domain.ScrollbackConfig{Megabytes: 1, Lines: 2}
32+ d.ApplyConfig(cfg)
33+ for i, p := range panes {
34+ require.Equal(t, 2, p.history.Len())
35+ require.Equal(t, uint64(1_000_000), p.history.ByteCap())
36+ require.Equal(t, "b", cellsString(p.history.View().Row(0)))
37+ require.Equal(t, 3, views[i].Len(), "reload must preserve captured documents")
38+ }
39+ cfg.Scrollback.Megabytes = 0
40+ d.ApplyConfig(cfg)
41+ for _, p := range panes {
42+ require.Zero(t, p.history.Len())
43+ require.Zero(t, p.history.Cap())
44+ }
45+ cfg.Scrollback = domain.ScrollbackConfig{Megabytes: 1}
46+ d.ApplyConfig(cfg)
47+ created := newPaneWithStableIDAndTitle("new", "stable-new", nil, domain.Size{Cols: 80, Rows: 24}, "shell", d.currentHistoryConfig())
48+ require.Zero(t, created.history.Cap())
49+ require.Equal(t, uint64(1_000_000), created.history.ByteCap())
50+}
51+
52+func TestHistoryCompressionSkipsBusyPanesAndRemainsBounded(t *testing.T) {
53+ d := newTestDaemon(t, nil, stubClock{})
54+ sess := addControlSession(d, "history", "tab", "pane")
55+ p := sess.tabs[0].focusedPane()
56+ installTestHistory(p, vt.HistoryConfig{MaxRows: 64, MaxBytes: 1 << 20, ChunkRows: 16})
57+ for range 48 {
58+ appendHistoryRow(t, p.history, testRow(strings.Repeat("a", 80)))
59+ }
60+ before := p.history.View()
61+ p.mu.Lock()
62+ d.compressIdleHistories() // TryLock must not wait for the reader's lock.
63+ p.mu.Unlock()
64+ require.Zero(t, p.history.CompressionStats().ColdPages)
65+ for range 4 {
66+ d.compressIdleHistories()
67+ }
68+ require.Equal(t, 2, p.history.CompressionStats().ColdPages)
69+ require.Equal(t, 'a', before.Cell(0, 0).Rune)
70+ require.Equal(t, 48, p.history.Len())
71+}
72+
73+func TestHistoryMaintenanceStopsItsTimerOnCancellation(t *testing.T) {
74+ clock := portsmocks.NewMockClock(t)
75+ timer := portsmocks.NewMockTimer(t)
76+ clock.EXPECT().NewTimer(historyMaintenanceInterval).Return(timer).Once()
77+ timer.EXPECT().C().Return(make(<-chan time.Time)).Once()
78+ timer.EXPECT().Stop().Return(true).Once()
79+ ctx, cancel := context.WithCancel(t.Context())
80+ cancel()
81+ d := &Daemon{clock: clock}
82+ d.historyMaintenance(ctx)
83+}
M internal/usecase/daemon/history_test_helper_test.go +30 -2
@@ -1,10 +1,38 @@
11 package daemon
22
3-import vt "github.com/bnema/vev-vt"
3+import (
4+ "fmt"
5+ "testing"
6+
7+ vt "github.com/bnema/vev-vt"
8+ "github.com/bnema/vev-vt/ansi"
9+ "github.com/stretchr/testify/require"
10+)
411
512 func newTestHistory(rows int) *vt.History { return vt.NewHistory(vt.HistoryConfig{MaxRows: rows}) }
613
14+func captureTestFrame(source vt.CellSource) vt.Frame {
15+ frame := vt.NewFrame(source.Columns(), source.Rows())
16+ for y := range source.Rows() {
17+ for x := range source.Columns() {
18+ frame.Set(x, y, source.Cell(x, y))
19+ }
20+ }
21+ return frame
22+}
23+
24+func writeTestRow(screen *vt.Screen, row int, text string) {
25+ screen.Write([]byte(fmt.Sprintf("\x1b7\x1b[%d;1H%s\x1b8", row+1, text)))
26+}
27+
28+func writeTestFrame(t testing.TB, screen *vt.Screen, frame vt.Frame) {
29+ t.Helper()
30+ data, err := ansi.New(ansi.Capabilities{}).Draw(frame, []ansi.Damage{ansi.FullRedraw()})
31+ require.NoError(t, err)
32+ screen.Write(data)
33+}
34+
735 func installTestHistory(p *pane, config vt.HistoryConfig) {
8- p.screen = vt.NewScreenWithHistory(p.screen.Frame.Width, p.screen.Frame.Height, config)
36+ p.screen = vt.NewScreenWithHistory(p.screen.Columns(), p.screen.Rows(), config)
937 p.history = p.screen.History()
1038 }
@@ -1,10 +1,38 @@
1 package daemon1 package daemon
2 2
3-import vt "github.com/bnema/vev-vt"3+import (
4+ "fmt"
5+ "testing"
6+
7+ vt "github.com/bnema/vev-vt"
8+ "github.com/bnema/vev-vt/ansi"
9+ "github.com/stretchr/testify/require"
10+)
4 11
5 func newTestHistory(rows int) *vt.History { return vt.NewHistory(vt.HistoryConfig{MaxRows: rows}) }12 func newTestHistory(rows int) *vt.History { return vt.NewHistory(vt.HistoryConfig{MaxRows: rows}) }
6 13
14+func captureTestFrame(source vt.CellSource) vt.Frame {
15+ frame := vt.NewFrame(source.Columns(), source.Rows())
16+ for y := range source.Rows() {
17+ for x := range source.Columns() {
18+ frame.Set(x, y, source.Cell(x, y))
19+ }
20+ }
21+ return frame
22+}
23+
24+func writeTestRow(screen *vt.Screen, row int, text string) {
25+ screen.Write([]byte(fmt.Sprintf("\x1b7\x1b[%d;1H%s\x1b8", row+1, text)))
26+}
27+
28+func writeTestFrame(t testing.TB, screen *vt.Screen, frame vt.Frame) {
29+ t.Helper()
30+ data, err := ansi.New(ansi.Capabilities{}).Draw(frame, []ansi.Damage{ansi.FullRedraw()})
31+ require.NoError(t, err)
32+ screen.Write(data)
33+}
34+
7 func installTestHistory(p *pane, config vt.HistoryConfig) {35 func installTestHistory(p *pane, config vt.HistoryConfig) {
8- p.screen = vt.NewScreenWithHistory(p.screen.Frame.Width, p.screen.Frame.Height, config)36+ p.screen = vt.NewScreenWithHistory(p.screen.Columns(), p.screen.Rows(), config)
9 p.history = p.screen.History()37 p.history = p.screen.History()
10 }38 }
M internal/usecase/daemon/input.go +1 -1
@@ -253,7 +253,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
253253 return
254254 }
255255 p.mu.Lock()
256- childRows := p.screen.Frame.Height
256+ childRows := p.screen.Rows()
257257 mouseMode, mouseSGR := p.screen.MouseMode()
258258 altScreen := p.screen.AltScreenActive()
259259 p.mu.Unlock()
@@ -253,7 +253,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
253 return253 return
254 }254 }
255 p.mu.Lock()255 p.mu.Lock()
256- childRows := p.screen.Frame.Height256+ childRows := p.screen.Rows()
257 mouseMode, mouseSGR := p.screen.MouseMode()257 mouseMode, mouseSGR := p.screen.MouseMode()
258 altScreen := p.screen.AltScreenActive()258 altScreen := p.screen.AltScreenActive()
259 p.mu.Unlock()259 p.mu.Unlock()
M internal/usecase/daemon/input_test.go +17 -17
@@ -217,7 +217,7 @@ func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) {
217217 require.NoError(t, err)
218218 require.Zero(t, secondOutput.Base, "tab switch must emit the complete target frame first")
219219 terminal.Write(secondOutput.Data)
220- require.NotContains(t, strings.Join(frameRows(terminal.Frame), "\n"), "source", "the first target-tab frame must not retain source pane cells")
220+ require.NotContains(t, strings.Join(frameRows(terminal), "\n"), "source", "the first target-tab frame must not retain source pane cells")
221221 }
222222
223223 func TestClosePanePrunesAttachedCaptureFrameAndKeepsSurvivor(t *testing.T) {
@@ -314,8 +314,8 @@ func TestAltFToggleRetainedFloatingPaneRepaintsImmediately(t *testing.T) {
314314 // Route the real Alt+F binding. Showing a retained pane must paint once.
315315 d.handleInput(sess, ac, []byte("\x1bf"))
316316 mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput))
317- require.Contains(t, strings.Join(frameRows(client.Frame), "\n"), "popup-content")
318- require.Contains(t, strings.Join(frameRows(client.Frame), "\n"), "┌")
317+ require.Contains(t, strings.Join(frameRows(client), "\n"), "popup-content")
318+ require.Contains(t, strings.Join(frameRows(client), "\n"), "┌")
319319 select {
320320 case extra := <-sends:
321321 t.Fatalf("show emitted duplicate output: %#v", extra)
@@ -326,7 +326,7 @@ func TestAltFToggleRetainedFloatingPaneRepaintsImmediately(t *testing.T) {
326326 // unrelated output, while retaining the existing PTY and context.
327327 d.handleInput(sess, ac, []byte("\x1bf"))
328328 mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput))
329- require.NotContains(t, strings.Join(frameRows(client.Frame), "\n"), "popup-content")
329+ require.NotContains(t, strings.Join(frameRows(client), "\n"), "popup-content")
330330 tb := testAttachmentTab(sess)
331331 tb.mu.Lock()
332332 require.Equal(t, floatingHidden, tb.floating.state)
@@ -344,7 +344,7 @@ func TestAltFToggleRetainedFloatingPaneRepaintsImmediately(t *testing.T) {
344344 // resize/start frame.
345345 d.handleInput(sess, ac, []byte("\x1bf"))
346346 mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput))
347- require.Contains(t, strings.Join(frameRows(client.Frame), "\n"), "popup-content")
347+ require.Contains(t, strings.Join(frameRows(client), "\n"), "popup-content")
348348 tb.mu.Lock()
349349 require.Equal(t, floatingVisible, tb.floating.state)
350350 require.Same(t, floating, tb.floating.pane)
@@ -1063,7 +1063,7 @@ func TestCopyModeMousePressReleaseWithoutMotionEmitsNoOSC52(t *testing.T) {
10631063 p, release := newBlockingPTY(t)
10641064 defer release()
10651065 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1066- copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha"))
1066+ writeTestRow(sess.tabs[0].focusedPane().screen, 0, "alpha")
10671067 d.enterCopyMode(sess, ac)
10681068 mustOutputData(t, sends)
10691069
@@ -1079,7 +1079,7 @@ func TestCopyModeMousePressReleaseWithoutMotionEmitsNoOSC52(t *testing.T) {
10791079 func TestCopyModeMouseHorizontalReverseDragUsesExactOSC52(t *testing.T) {
10801080 p, _ := newBlockingPTY(t)
10811081 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1082- copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha"))
1082+ writeTestRow(sess.tabs[0].focusedPane().screen, 0, "alpha")
10831083 d.enterCopyMode(sess, ac)
10841084 mustOutputData(t, sends)
10851085
@@ -1103,9 +1103,9 @@ func TestCopyModeMouseHorizontalReverseDragUsesExactOSC52(t *testing.T) {
11031103 func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {
11041104 p, _ := newBlockingPTY(t)
11051105 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1106- copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha"))
1107- copy(sess.tabs[0].focusedPane().screen.Frame.Row(1), testRow("bravo"))
1108- copy(sess.tabs[0].focusedPane().screen.Frame.Row(2), testRow("charlie"))
1106+ writeTestRow(sess.tabs[0].focusedPane().screen, 0, "alpha")
1107+ writeTestRow(sess.tabs[0].focusedPane().screen, 1, "bravo")
1108+ writeTestRow(sess.tabs[0].focusedPane().screen, 2, "charlie")
11091109
11101110 d.enterCopyMode(sess, ac)
11111111 mustOutputData(t, sends)
@@ -1146,7 +1146,7 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {
11461146 func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) {
11471147 p, _ := newBlockingPTY(t)
11481148 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
1149- require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")
1149+ require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Rows(), "fixture assumption: status row is wire row 24")
11501150
11511151 // Press on a content row establishes a (soon to be stale) anchor.
11521152 d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
@@ -1197,10 +1197,10 @@ func TestCopyModeRejectedReleaseClearsPointerAndClickBeforeMotion(t *testing.T)
11971197 func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
11981198 p, _ := newBlockingPTY(t)
11991199 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1200- copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha"))
1201- copy(sess.tabs[0].focusedPane().screen.Frame.Row(1), testRow("bravo"))
1202- copy(sess.tabs[0].focusedPane().screen.Frame.Row(2), testRow("charlie"))
1203- require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")
1200+ writeTestRow(sess.tabs[0].focusedPane().screen, 0, "alpha")
1201+ writeTestRow(sess.tabs[0].focusedPane().screen, 1, "bravo")
1202+ writeTestRow(sess.tabs[0].focusedPane().screen, 2, "charlie")
1203+ require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Rows(), "fixture assumption: status row is wire row 24")
12041204
12051205 d.enterCopyMode(sess, ac)
12061206 mustOutputData(t, sends)
@@ -1244,7 +1244,7 @@ func TestMouseNormalScreenDragUsesPressOwnedDocumentAfterOutputEviction(t *testi
12441244 p, _ := newBlockingPTY(t)
12451245 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
12461246 installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 50})
1247- require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")
1247+ require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Rows(), "fixture assumption: status row is wire row 24")
12481248
12491249 // Press anchors document row 0 before output eviction.
12501250 d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
@@ -1502,7 +1502,7 @@ func TestCopyModeDragOutsideSplitPaneClampsToPaneContent(t *testing.T) {
15021502 d.procComm = nil
15031503 tb := testAttachmentTab(sess)
15041504 for i := range 10 {
1505- copy(tb.focusedPane().screen.Frame.Row(i), testRow(string(rune('a'+i))))
1505+ writeTestRow(tb.focusedPane().screen, i, string(rune('a'+i)))
15061506 }
15071507 tb.mu.Lock()
15081508 tb.size = domain.Size{Cols: 41, Rows: 10}
@@ -217,7 +217,7 @@ func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) {
217 require.NoError(t, err)217 require.NoError(t, err)
218 require.Zero(t, secondOutput.Base, "tab switch must emit the complete target frame first")218 require.Zero(t, secondOutput.Base, "tab switch must emit the complete target frame first")
219 terminal.Write(secondOutput.Data)219 terminal.Write(secondOutput.Data)
220- require.NotContains(t, strings.Join(frameRows(terminal.Frame), "\n"), "source", "the first target-tab frame must not retain source pane cells")220+ require.NotContains(t, strings.Join(frameRows(terminal), "\n"), "source", "the first target-tab frame must not retain source pane cells")
221 }221 }
222 222
223 func TestClosePanePrunesAttachedCaptureFrameAndKeepsSurvivor(t *testing.T) {223 func TestClosePanePrunesAttachedCaptureFrameAndKeepsSurvivor(t *testing.T) {
@@ -314,8 +314,8 @@ func TestAltFToggleRetainedFloatingPaneRepaintsImmediately(t *testing.T) {
314 // Route the real Alt+F binding. Showing a retained pane must paint once.314 // Route the real Alt+F binding. Showing a retained pane must paint once.
315 d.handleInput(sess, ac, []byte("\x1bf"))315 d.handleInput(sess, ac, []byte("\x1bf"))
316 mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput))316 mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput))
317- require.Contains(t, strings.Join(frameRows(client.Frame), "\n"), "popup-content")317+ require.Contains(t, strings.Join(frameRows(client), "\n"), "popup-content")
318- require.Contains(t, strings.Join(frameRows(client.Frame), "\n"), "┌")318+ require.Contains(t, strings.Join(frameRows(client), "\n"), "┌")
319 select {319 select {
320 case extra := <-sends:320 case extra := <-sends:
321 t.Fatalf("show emitted duplicate output: %#v", extra)321 t.Fatalf("show emitted duplicate output: %#v", extra)
@@ -326,7 +326,7 @@ func TestAltFToggleRetainedFloatingPaneRepaintsImmediately(t *testing.T) {
326 // unrelated output, while retaining the existing PTY and context.326 // unrelated output, while retaining the existing PTY and context.
327 d.handleInput(sess, ac, []byte("\x1bf"))327 d.handleInput(sess, ac, []byte("\x1bf"))
328 mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput))328 mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput))
329- require.NotContains(t, strings.Join(frameRows(client.Frame), "\n"), "popup-content")329+ require.NotContains(t, strings.Join(frameRows(client), "\n"), "popup-content")
330 tb := testAttachmentTab(sess)330 tb := testAttachmentTab(sess)
331 tb.mu.Lock()331 tb.mu.Lock()
332 require.Equal(t, floatingHidden, tb.floating.state)332 require.Equal(t, floatingHidden, tb.floating.state)
@@ -344,7 +344,7 @@ func TestAltFToggleRetainedFloatingPaneRepaintsImmediately(t *testing.T) {
344 // resize/start frame.344 // resize/start frame.
345 d.handleInput(sess, ac, []byte("\x1bf"))345 d.handleInput(sess, ac, []byte("\x1bf"))
346 mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput))346 mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput))
347- require.Contains(t, strings.Join(frameRows(client.Frame), "\n"), "popup-content")347+ require.Contains(t, strings.Join(frameRows(client), "\n"), "popup-content")
348 tb.mu.Lock()348 tb.mu.Lock()
349 require.Equal(t, floatingVisible, tb.floating.state)349 require.Equal(t, floatingVisible, tb.floating.state)
350 require.Same(t, floating, tb.floating.pane)350 require.Same(t, floating, tb.floating.pane)
@@ -1063,7 +1063,7 @@ func TestCopyModeMousePressReleaseWithoutMotionEmitsNoOSC52(t *testing.T) {
1063 p, release := newBlockingPTY(t)1063 p, release := newBlockingPTY(t)
1064 defer release()1064 defer release()
1065 d, sess, ac, sends := newManualSessionWithPTYs(t, p)1065 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1066- copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha"))1066+ writeTestRow(sess.tabs[0].focusedPane().screen, 0, "alpha")
1067 d.enterCopyMode(sess, ac)1067 d.enterCopyMode(sess, ac)
1068 mustOutputData(t, sends)1068 mustOutputData(t, sends)
1069 1069
@@ -1079,7 +1079,7 @@ func TestCopyModeMousePressReleaseWithoutMotionEmitsNoOSC52(t *testing.T) {
1079 func TestCopyModeMouseHorizontalReverseDragUsesExactOSC52(t *testing.T) {1079 func TestCopyModeMouseHorizontalReverseDragUsesExactOSC52(t *testing.T) {
1080 p, _ := newBlockingPTY(t)1080 p, _ := newBlockingPTY(t)
1081 d, sess, ac, sends := newManualSessionWithPTYs(t, p)1081 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1082- copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha"))1082+ writeTestRow(sess.tabs[0].focusedPane().screen, 0, "alpha")
1083 d.enterCopyMode(sess, ac)1083 d.enterCopyMode(sess, ac)
1084 mustOutputData(t, sends)1084 mustOutputData(t, sends)
1085 1085
@@ -1103,9 +1103,9 @@ func TestCopyModeMouseHorizontalReverseDragUsesExactOSC52(t *testing.T) {
1103 func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {1103 func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {
1104 p, _ := newBlockingPTY(t)1104 p, _ := newBlockingPTY(t)
1105 d, sess, ac, sends := newManualSessionWithPTYs(t, p)1105 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1106- copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha"))1106+ writeTestRow(sess.tabs[0].focusedPane().screen, 0, "alpha")
1107- copy(sess.tabs[0].focusedPane().screen.Frame.Row(1), testRow("bravo"))1107+ writeTestRow(sess.tabs[0].focusedPane().screen, 1, "bravo")
1108- copy(sess.tabs[0].focusedPane().screen.Frame.Row(2), testRow("charlie"))1108+ writeTestRow(sess.tabs[0].focusedPane().screen, 2, "charlie")
1109 1109
1110 d.enterCopyMode(sess, ac)1110 d.enterCopyMode(sess, ac)
1111 mustOutputData(t, sends)1111 mustOutputData(t, sends)
@@ -1146,7 +1146,7 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {
1146 func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) {1146 func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) {
1147 p, _ := newBlockingPTY(t)1147 p, _ := newBlockingPTY(t)
1148 d, sess, ac, _ := newManualSessionWithPTYs(t, p)1148 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
1149- require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")1149+ require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Rows(), "fixture assumption: status row is wire row 24")
1150 1150
1151 // Press on a content row establishes a (soon to be stale) anchor.1151 // Press on a content row establishes a (soon to be stale) anchor.
1152 d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))1152 d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
@@ -1197,10 +1197,10 @@ func TestCopyModeRejectedReleaseClearsPointerAndClickBeforeMotion(t *testing.T)
1197 func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {1197 func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
1198 p, _ := newBlockingPTY(t)1198 p, _ := newBlockingPTY(t)
1199 d, sess, ac, sends := newManualSessionWithPTYs(t, p)1199 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1200- copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha"))1200+ writeTestRow(sess.tabs[0].focusedPane().screen, 0, "alpha")
1201- copy(sess.tabs[0].focusedPane().screen.Frame.Row(1), testRow("bravo"))1201+ writeTestRow(sess.tabs[0].focusedPane().screen, 1, "bravo")
1202- copy(sess.tabs[0].focusedPane().screen.Frame.Row(2), testRow("charlie"))1202+ writeTestRow(sess.tabs[0].focusedPane().screen, 2, "charlie")
1203- require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")1203+ require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Rows(), "fixture assumption: status row is wire row 24")
1204 1204
1205 d.enterCopyMode(sess, ac)1205 d.enterCopyMode(sess, ac)
1206 mustOutputData(t, sends)1206 mustOutputData(t, sends)
@@ -1244,7 +1244,7 @@ func TestMouseNormalScreenDragUsesPressOwnedDocumentAfterOutputEviction(t *testi
1244 p, _ := newBlockingPTY(t)1244 p, _ := newBlockingPTY(t)
1245 d, sess, ac, _ := newManualSessionWithPTYs(t, p)1245 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
1246 installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 50})1246 installTestHistory(sess.tabs[0].focusedPane(), vt.HistoryConfig{MaxRows: 50})
1247- require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")1247+ require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Rows(), "fixture assumption: status row is wire row 24")
1248 1248
1249 // Press anchors document row 0 before output eviction.1249 // Press anchors document row 0 before output eviction.
1250 d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))1250 d.handleInput(sess, ac, []byte("\x1b[<0;1;2M"))
@@ -1502,7 +1502,7 @@ func TestCopyModeDragOutsideSplitPaneClampsToPaneContent(t *testing.T) {
1502 d.procComm = nil1502 d.procComm = nil
1503 tb := testAttachmentTab(sess)1503 tb := testAttachmentTab(sess)
1504 for i := range 10 {1504 for i := range 10 {
1505- copy(tb.focusedPane().screen.Frame.Row(i), testRow(string(rune('a'+i))))1505+ writeTestRow(tb.focusedPane().screen, i, string(rune('a'+i)))
1506 }1506 }
1507 tb.mu.Lock()1507 tb.mu.Lock()
1508 tb.size = domain.Size{Cols: 41, Rows: 10}1508 tb.size = domain.Size{Cols: 41, Rows: 10}
M internal/usecase/daemon/nil_pty_test.go +2 -2
@@ -30,8 +30,8 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) {
3030 require.NotPanics(t, func() {
3131 sess.geometry.applyTabLayout(d, sess, first)
3232 })
33- require.Equal(t, 20, first.focusedPane().screen.Frame.Width)
34- require.Equal(t, 5, first.focusedPane().screen.Frame.Height)
33+ require.Equal(t, 20, first.focusedPane().screen.Columns())
34+ require.Equal(t, 5, first.focusedPane().screen.Rows())
3535
3636 first.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
3737 first.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
@@ -30,8 +30,8 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) {
30 require.NotPanics(t, func() {30 require.NotPanics(t, func() {
31 sess.geometry.applyTabLayout(d, sess, first)31 sess.geometry.applyTabLayout(d, sess, first)
32 })32 })
33- require.Equal(t, 20, first.focusedPane().screen.Frame.Width)33+ require.Equal(t, 20, first.focusedPane().screen.Columns())
34- require.Equal(t, 5, first.focusedPane().screen.Frame.Height)34+ require.Equal(t, 5, first.focusedPane().screen.Rows())
35 35
36 first.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}36 first.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
37 first.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})37 first.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
M internal/usecase/daemon/notice_render_test.go +2 -2
@@ -87,7 +87,7 @@ func TestPaintComposesNoticeToastTopRightAndExpiresOnTTL(t *testing.T) {
8787 awaitToastCount(t, ac, 1)
8888 replayOutputFrames(t, screen, drainAllFrames(sends))
8989
90- rows := frameRows(screen.Frame)
90+ rows := frameRows(captureTestFrame(screen))
9191 if !containsTopRight(rows, "couldn't open pane", ac.size.Cols/2) {
9292 t.Fatalf("composed frame missing toast message top-right:\n%s", strings.Join(rows, "\n"))
9393 }
@@ -101,7 +101,7 @@ func TestPaintComposesNoticeToastTopRightAndExpiresOnTTL(t *testing.T) {
101101 t.Fatal("expiry did not invalidate the render")
102102 }
103103
104- rows = frameRows(screen.Frame)
104+ rows = frameRows(captureTestFrame(screen))
105105 if containsTopRight(rows, "couldn't open pane", ac.size.Cols/2) {
106106 t.Fatalf("toast message still present after TTL expired:\n%s", strings.Join(rows, "\n"))
107107 }
@@ -87,7 +87,7 @@ func TestPaintComposesNoticeToastTopRightAndExpiresOnTTL(t *testing.T) {
87 awaitToastCount(t, ac, 1)87 awaitToastCount(t, ac, 1)
88 replayOutputFrames(t, screen, drainAllFrames(sends))88 replayOutputFrames(t, screen, drainAllFrames(sends))
89 89
90- rows := frameRows(screen.Frame)90+ rows := frameRows(captureTestFrame(screen))
91 if !containsTopRight(rows, "couldn't open pane", ac.size.Cols/2) {91 if !containsTopRight(rows, "couldn't open pane", ac.size.Cols/2) {
92 t.Fatalf("composed frame missing toast message top-right:\n%s", strings.Join(rows, "\n"))92 t.Fatalf("composed frame missing toast message top-right:\n%s", strings.Join(rows, "\n"))
93 }93 }
@@ -101,7 +101,7 @@ func TestPaintComposesNoticeToastTopRightAndExpiresOnTTL(t *testing.T) {
101 t.Fatal("expiry did not invalidate the render")101 t.Fatal("expiry did not invalidate the render")
102 }102 }
103 103
104- rows = frameRows(screen.Frame)104+ rows = frameRows(captureTestFrame(screen))
105 if containsTopRight(rows, "couldn't open pane", ac.size.Cols/2) {105 if containsTopRight(rows, "couldn't open pane", ac.size.Cols/2) {
106 t.Fatalf("toast message still present after TTL expired:\n%s", strings.Join(rows, "\n"))106 t.Fatalf("toast message still present after TTL expired:\n%s", strings.Join(rows, "\n"))
107 }107 }
M internal/usecase/daemon/notify_test.go +1 -1
@@ -893,7 +893,7 @@ func TestToastExpiresOnFakeClock(t *testing.T) {
893893 ac.sendMu.Lock()
894894 base := ac.pipelineCache.frame.Clone()
895895 ac.sendMu.Unlock()
896- require.Equal(t, frameRows(base), frameRows(terminal.Frame), "fake-clock expiry must restore every old toast cell")
896+ require.Equal(t, frameRows(base), frameRows(terminal), "fake-clock expiry must restore every old toast cell")
897897 }
898898
899899 // TestToastExpiryOnlyRemovesItsOwnEntry proves a fired timer cannot dismiss a
@@ -893,7 +893,7 @@ func TestToastExpiresOnFakeClock(t *testing.T) {
893 ac.sendMu.Lock()893 ac.sendMu.Lock()
894 base := ac.pipelineCache.frame.Clone()894 base := ac.pipelineCache.frame.Clone()
895 ac.sendMu.Unlock()895 ac.sendMu.Unlock()
896- require.Equal(t, frameRows(base), frameRows(terminal.Frame), "fake-clock expiry must restore every old toast cell")896+ require.Equal(t, frameRows(base), frameRows(terminal), "fake-clock expiry must restore every old toast cell")
897 }897 }
898 898
899 // TestToastExpiryOnlyRemovesItsOwnEntry proves a fired timer cannot dismiss a899 // TestToastExpiryOnlyRemovesItsOwnEntry proves a fired timer cannot dismiss a
M internal/usecase/daemon/output_state_sync_test.go +12 -12
@@ -147,12 +147,12 @@ func TestDatagramMultipleUnackedScrollPaintsMatchLatestFrame(t *testing.T) {
147147 require.NoError(t, err)
148148
149149 pane := sess.tabs[0].focusedPane()
150- for y := range pane.screen.Frame.Height {
150+ initial := renderer.NewFrame(pane.screen.Columns(), pane.screen.Rows())
151+ for y := range initial.Height {
151152 cell := renderer.Cell{Rune: rune('A' + y), Style: renderer.DefaultStyle()}
152- for x := range pane.screen.Frame.Width {
153- pane.screen.Frame.Set(x, y, cell)
154- }
153+ initial.FillRow(y, 0, initial.Width, cell)
155154 }
155+ writeTestFrame(t, pane.screen, initial)
156156 pane.screen.ClearDamage()
157157 client := vt.NewScreen(80, 25)
158158 d.paint(sess, ac, true, nil)
@@ -164,22 +164,22 @@ func TestDatagramMultipleUnackedScrollPaintsMatchLatestFrame(t *testing.T) {
164164 // paints are generated from the ACKed frame and apply the scroll twice to
165165 // the client. The ordered stream instead renders the second paint from the
166166 // preceding emitted frame and overwrites the incompatible repeated damage.
167- desired := pane.screen.Frame.Clone()
167+ desired := captureTestFrame(pane.screen)
168168 desired.ScrollUp(0, desired.Height-1, 1)
169169 for x := range desired.Width {
170170 desired.Set(x, desired.Height-1, renderer.Cell{Rune: 'z', Style: renderer.DefaultStyle()})
171171 }
172172 for range 2 {
173- pane.screen.Frame = desired.Clone()
174- pane.screen.Row = pane.screen.Frame.Height - 1
173+ writeTestFrame(t, pane.screen, desired)
174+ pane.screen.Row = pane.screen.Rows() - 1
175175 pane.screen.Col = 0
176176 pane.screen.Write([]byte("\nq"))
177- pane.screen.Frame = desired.Clone()
177+ writeTestFrame(t, pane.screen, desired)
178178 d.paint(sess, ac, false, nil)
179179 mustApplyOutput(t, client, awaitFrame(t, tr.sends, wire.MsgOutput))
180180 }
181181
182- require.Equal(t, 'B', client.Frame.At(0, 1).Rune,
182+ require.Equal(t, 'B', client.Cell(0, 1).Rune,
183183 "client-visible content must equal the latest daemon-composed frame after pipelined scroll paints")
184184 }
185185
@@ -323,11 +323,11 @@ func TestResizeGrowthFirstFrameIncludesConcurrentPTYRedraw(t *testing.T) {
323323 timer.ch <- time.Time{}
324324 awaitTestCompletion(t, done, "resize callback did not complete")
325325 require.True(t, p.deliveredWhileApplying(), "redraw must arrive while PTY.Resize owns the apply gate")
326- require.Equal(t, 'B', pane.screen.Frame.At(100, 0).Rune, "replay must parse the redraw before commit")
326+ require.Equal(t, 'B', pane.screen.Cell(100, 0).Rune, "replay must parse the redraw before commit")
327327
328328 client.Resize(120, 24)
329329 mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput))
330- require.Equal(t, 'B', client.Frame.At(100, 1).Rune,
330+ require.Equal(t, 'B', client.Cell(100, 1).Rune,
331331 "first grown frame exposed stale pre-SIGWINCH pane content")
332332 select {
333333 case extra := <-sends:
@@ -398,6 +398,6 @@ func TestResizeBurstFlushesOnlyLatestGeometry(t *testing.T) {
398398 awaitTestCompletion(t, latestDone, "latest resize callback did not complete")
399399 awaitFrame(t, sends, wire.MsgOutput)
400400 require.Equal(t, domain.Size{Cols: 120, Rows: 24}, ac.size)
401- require.Equal(t, 120, testAttachmentTab(sess).focusedPane().screen.Frame.Width)
401+ require.Equal(t, 120, testAttachmentTab(sess).focusedPane().screen.Columns())
402402 requireNoOutputFrame(t, sends)
403403 }
@@ -147,12 +147,12 @@ func TestDatagramMultipleUnackedScrollPaintsMatchLatestFrame(t *testing.T) {
147 require.NoError(t, err)147 require.NoError(t, err)
148 148
149 pane := sess.tabs[0].focusedPane()149 pane := sess.tabs[0].focusedPane()
150- for y := range pane.screen.Frame.Height {150+ initial := renderer.NewFrame(pane.screen.Columns(), pane.screen.Rows())
151+ for y := range initial.Height {
151 cell := renderer.Cell{Rune: rune('A' + y), Style: renderer.DefaultStyle()}152 cell := renderer.Cell{Rune: rune('A' + y), Style: renderer.DefaultStyle()}
152- for x := range pane.screen.Frame.Width {153+ initial.FillRow(y, 0, initial.Width, cell)
153- pane.screen.Frame.Set(x, y, cell)
154- }
155 }154 }
155+ writeTestFrame(t, pane.screen, initial)
156 pane.screen.ClearDamage()156 pane.screen.ClearDamage()
157 client := vt.NewScreen(80, 25)157 client := vt.NewScreen(80, 25)
158 d.paint(sess, ac, true, nil)158 d.paint(sess, ac, true, nil)
@@ -164,22 +164,22 @@ func TestDatagramMultipleUnackedScrollPaintsMatchLatestFrame(t *testing.T) {
164 // paints are generated from the ACKed frame and apply the scroll twice to164 // paints are generated from the ACKed frame and apply the scroll twice to
165 // the client. The ordered stream instead renders the second paint from the165 // the client. The ordered stream instead renders the second paint from the
166 // preceding emitted frame and overwrites the incompatible repeated damage.166 // preceding emitted frame and overwrites the incompatible repeated damage.
167- desired := pane.screen.Frame.Clone()167+ desired := captureTestFrame(pane.screen)
168 desired.ScrollUp(0, desired.Height-1, 1)168 desired.ScrollUp(0, desired.Height-1, 1)
169 for x := range desired.Width {169 for x := range desired.Width {
170 desired.Set(x, desired.Height-1, renderer.Cell{Rune: 'z', Style: renderer.DefaultStyle()})170 desired.Set(x, desired.Height-1, renderer.Cell{Rune: 'z', Style: renderer.DefaultStyle()})
171 }171 }
172 for range 2 {172 for range 2 {
173- pane.screen.Frame = desired.Clone()173+ writeTestFrame(t, pane.screen, desired)
174- pane.screen.Row = pane.screen.Frame.Height - 1174+ pane.screen.Row = pane.screen.Rows() - 1
175 pane.screen.Col = 0175 pane.screen.Col = 0
176 pane.screen.Write([]byte("\nq"))176 pane.screen.Write([]byte("\nq"))
177- pane.screen.Frame = desired.Clone()177+ writeTestFrame(t, pane.screen, desired)
178 d.paint(sess, ac, false, nil)178 d.paint(sess, ac, false, nil)
179 mustApplyOutput(t, client, awaitFrame(t, tr.sends, wire.MsgOutput))179 mustApplyOutput(t, client, awaitFrame(t, tr.sends, wire.MsgOutput))
180 }180 }
181 181
182- require.Equal(t, 'B', client.Frame.At(0, 1).Rune,182+ require.Equal(t, 'B', client.Cell(0, 1).Rune,
183 "client-visible content must equal the latest daemon-composed frame after pipelined scroll paints")183 "client-visible content must equal the latest daemon-composed frame after pipelined scroll paints")
184 }184 }
185 185
@@ -323,11 +323,11 @@ func TestResizeGrowthFirstFrameIncludesConcurrentPTYRedraw(t *testing.T) {
323 timer.ch <- time.Time{}323 timer.ch <- time.Time{}
324 awaitTestCompletion(t, done, "resize callback did not complete")324 awaitTestCompletion(t, done, "resize callback did not complete")
325 require.True(t, p.deliveredWhileApplying(), "redraw must arrive while PTY.Resize owns the apply gate")325 require.True(t, p.deliveredWhileApplying(), "redraw must arrive while PTY.Resize owns the apply gate")
326- require.Equal(t, 'B', pane.screen.Frame.At(100, 0).Rune, "replay must parse the redraw before commit")326+ require.Equal(t, 'B', pane.screen.Cell(100, 0).Rune, "replay must parse the redraw before commit")
327 327
328 client.Resize(120, 24)328 client.Resize(120, 24)
329 mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput))329 mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput))
330- require.Equal(t, 'B', client.Frame.At(100, 1).Rune,330+ require.Equal(t, 'B', client.Cell(100, 1).Rune,
331 "first grown frame exposed stale pre-SIGWINCH pane content")331 "first grown frame exposed stale pre-SIGWINCH pane content")
332 select {332 select {
333 case extra := <-sends:333 case extra := <-sends:
@@ -398,6 +398,6 @@ func TestResizeBurstFlushesOnlyLatestGeometry(t *testing.T) {
398 awaitTestCompletion(t, latestDone, "latest resize callback did not complete")398 awaitTestCompletion(t, latestDone, "latest resize callback did not complete")
399 awaitFrame(t, sends, wire.MsgOutput)399 awaitFrame(t, sends, wire.MsgOutput)
400 require.Equal(t, domain.Size{Cols: 120, Rows: 24}, ac.size)400 require.Equal(t, domain.Size{Cols: 120, Rows: 24}, ac.size)
401- require.Equal(t, 120, testAttachmentTab(sess).focusedPane().screen.Frame.Width)401+ require.Equal(t, 120, testAttachmentTab(sess).focusedPane().screen.Columns())
402 requireNoOutputFrame(t, sends)402 requireNoOutputFrame(t, sends)
403 }403 }
M internal/usecase/daemon/overlay_backdrop_test.go +2 -2
@@ -29,7 +29,7 @@ func TestApplyOverlayBackdropDimsCompleteFrame(t *testing.T) {
2929 dimmed := themeui.NewDimmer(backdropTheme()).Dim(renderer.DefaultStyle())
3030 for y := range frame.Height {
3131 for x := range frame.Width {
32- require.Equal(t, dimmed, frame.At(x, y).Style, "cell (%d,%d)", x, y)
32+ require.Equal(t, dimmed.Canonical(), frame.At(x, y).Style.Canonical(), "cell (%d,%d)", x, y)
3333 }
3434 }
3535 require.Equal(t, '界', frame.At(1, 1).Rune)
@@ -44,7 +44,7 @@ func TestDimFrameRectClipsToFrame(t *testing.T) {
4444 dimmed := themeui.NewDimmer(backdropTheme()).Dim(renderer.DefaultStyle())
4545
4646 for _, point := range [][2]int{{2, 2}, {3, 2}, {2, 3}, {3, 3}} {
47- require.Equal(t, dimmed, frame.At(point[0], point[1]).Style)
47+ require.Equal(t, dimmed.Canonical(), frame.At(point[0], point[1]).Style.Canonical())
4848 }
4949 for _, point := range [][2]int{{0, 0}, {1, 1}, {3, 1}, {1, 3}} {
5050 require.Equal(t, before.At(point[0], point[1]).Style, frame.At(point[0], point[1]).Style)
@@ -29,7 +29,7 @@ func TestApplyOverlayBackdropDimsCompleteFrame(t *testing.T) {
29 dimmed := themeui.NewDimmer(backdropTheme()).Dim(renderer.DefaultStyle())29 dimmed := themeui.NewDimmer(backdropTheme()).Dim(renderer.DefaultStyle())
30 for y := range frame.Height {30 for y := range frame.Height {
31 for x := range frame.Width {31 for x := range frame.Width {
32- require.Equal(t, dimmed, frame.At(x, y).Style, "cell (%d,%d)", x, y)32+ require.Equal(t, dimmed.Canonical(), frame.At(x, y).Style.Canonical(), "cell (%d,%d)", x, y)
33 }33 }
34 }34 }
35 require.Equal(t, '界', frame.At(1, 1).Rune)35 require.Equal(t, '界', frame.At(1, 1).Rune)
@@ -44,7 +44,7 @@ func TestDimFrameRectClipsToFrame(t *testing.T) {
44 dimmed := themeui.NewDimmer(backdropTheme()).Dim(renderer.DefaultStyle())44 dimmed := themeui.NewDimmer(backdropTheme()).Dim(renderer.DefaultStyle())
45 45
46 for _, point := range [][2]int{{2, 2}, {3, 2}, {2, 3}, {3, 3}} {46 for _, point := range [][2]int{{2, 2}, {3, 2}, {2, 3}, {3, 3}} {
47- require.Equal(t, dimmed, frame.At(point[0], point[1]).Style)47+ require.Equal(t, dimmed.Canonical(), frame.At(point[0], point[1]).Style.Canonical())
48 }48 }
49 for _, point := range [][2]int{{0, 0}, {1, 1}, {3, 1}, {1, 3}} {49 for _, point := range [][2]int{{0, 0}, {1, 1}, {3, 1}, {1, 3}} {
50 require.Equal(t, before.At(point[0], point[1]).Style, frame.At(point[0], point[1]).Style)50 require.Equal(t, before.At(point[0], point[1]).Style, frame.At(point[0], point[1]).Style)
M internal/usecase/daemon/palette_test.go +1 -1
@@ -173,7 +173,7 @@ func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t
173173 captureOverlayLayers(&state, snap, domain.PaletteConfig{})
174174
175175 inactive := state.overlays.palette.inner.At(4, 1).Style
176- require.Equal(t, state.styles.PickerDescription.Foreground, inactive.Foreground)
176+ require.Equal(t, state.styles.PickerDescription.Canonical().Foreground, inactive.Canonical().Foreground)
177177 require.Equal(t, state.styles.PickerDescription.HasForegroundRGB, inactive.HasForegroundRGB)
178178 require.Equal(t, state.styles.PickerDescription.ForegroundRGB, inactive.ForegroundRGB)
179179 require.False(t, inactive.HasBackgroundRGB)
@@ -173,7 +173,7 @@ func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t
173 captureOverlayLayers(&state, snap, domain.PaletteConfig{})173 captureOverlayLayers(&state, snap, domain.PaletteConfig{})
174 174
175 inactive := state.overlays.palette.inner.At(4, 1).Style175 inactive := state.overlays.palette.inner.At(4, 1).Style
176- require.Equal(t, state.styles.PickerDescription.Foreground, inactive.Foreground)176+ require.Equal(t, state.styles.PickerDescription.Canonical().Foreground, inactive.Canonical().Foreground)
177 require.Equal(t, state.styles.PickerDescription.HasForegroundRGB, inactive.HasForegroundRGB)177 require.Equal(t, state.styles.PickerDescription.HasForegroundRGB, inactive.HasForegroundRGB)
178 require.Equal(t, state.styles.PickerDescription.ForegroundRGB, inactive.ForegroundRGB)178 require.Equal(t, state.styles.PickerDescription.ForegroundRGB, inactive.ForegroundRGB)
179 require.False(t, inactive.HasBackgroundRGB)179 require.False(t, inactive.HasBackgroundRGB)
M internal/usecase/daemon/pane.go +3 -3
@@ -171,11 +171,11 @@ func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane {
171171 }
172172
173173 func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane {
174- return newPaneWithStableIDAndTitle(id, stableID, pty, sz, defaultShellTitle)
174+ return newPaneWithStableIDAndTitle(id, stableID, pty, sz, defaultShellTitle, historyConfigFor(domain.DefaultScrollbackConfig()))
175175 }
176176
177-func newPaneWithStableIDAndTitle(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size, title string) *pane {
178- screen := vt.NewScreenWithHistory(sz.Cols, sz.Rows, vt.HistoryConfig{MaxRows: defaultScrollbackRows, MaxCells: defaultScrollbackCells})
177+func newPaneWithStableIDAndTitle(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size, title string, historyConfig vt.HistoryConfig) *pane {
178+ screen := vt.NewScreenWithHistory(sz.Cols, sz.Rows, historyConfig)
179179 return &pane{
180180 id: id,
181181 stableID: stableID,
@@ -171,11 +171,11 @@ func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane {
171 }171 }
172 172
173 func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane {173 func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane {
174- return newPaneWithStableIDAndTitle(id, stableID, pty, sz, defaultShellTitle)174+ return newPaneWithStableIDAndTitle(id, stableID, pty, sz, defaultShellTitle, historyConfigFor(domain.DefaultScrollbackConfig()))
175 }175 }
176 176
177-func newPaneWithStableIDAndTitle(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size, title string) *pane {177+func newPaneWithStableIDAndTitle(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size, title string, historyConfig vt.HistoryConfig) *pane {
178- screen := vt.NewScreenWithHistory(sz.Cols, sz.Rows, vt.HistoryConfig{MaxRows: defaultScrollbackRows, MaxCells: defaultScrollbackCells})178+ screen := vt.NewScreenWithHistory(sz.Cols, sz.Rows, historyConfig)
179 return &pane{179 return &pane{
180 id: id,180 id: id,
181 stableID: stableID,181 stableID: stableID,
M internal/usecase/daemon/pane_actions.go +1 -1
@@ -98,7 +98,7 @@ func (d *Daemon) spawnPaneOpAt(
9898 d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")
9999 return paneFocusChange{}, domain.UserErr(domain.NoticePaneSpawn, "couldn't open pane: shell failed to start", err)
100100 }
101- p := newPaneWithStableIDAndTitle(newID, paneStableID, pty, rectSize(newRect), launch.title)
101+ p := newPaneWithStableIDAndTitle(newID, paneStableID, pty, rectSize(newRect), launch.title, d.currentHistoryConfig())
102102 p.geometry = initialGeometry
103103 setScreenGeometry(p.screen, initialGeometry)
104104 p.rect = newRect
@@ -98,7 +98,7 @@ func (d *Daemon) spawnPaneOpAt(
98 d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")98 d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")
99 return paneFocusChange{}, domain.UserErr(domain.NoticePaneSpawn, "couldn't open pane: shell failed to start", err)99 return paneFocusChange{}, domain.UserErr(domain.NoticePaneSpawn, "couldn't open pane: shell failed to start", err)
100 }100 }
101- p := newPaneWithStableIDAndTitle(newID, paneStableID, pty, rectSize(newRect), launch.title)101+ p := newPaneWithStableIDAndTitle(newID, paneStableID, pty, rectSize(newRect), launch.title, d.currentHistoryConfig())
102 p.geometry = initialGeometry102 p.geometry = initialGeometry
103 setScreenGeometry(p.screen, initialGeometry)103 setScreenGeometry(p.screen, initialGeometry)
104 p.rect = newRect104 p.rect = newRect
M internal/usecase/daemon/pane_actions_test.go +13 -13
@@ -188,14 +188,14 @@ func TestLayoutApplicationRetriesOnlyAcceptedFailedGeometry(t *testing.T) {
188188 }
189189 }
190190 require.Equal(t, []domain.Size{{Cols: 100, Rows: 20}}, pty.requested())
191- require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}, "failed apply must not publish a VT resize")
191+ require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()}, "failed apply must not publish a VT resize")
192192 timer.fire()
193193 awaitSignal(t, secondResize, "accepted failure was not retried")
194194 require.Equal(t, []domain.Size{{Cols: 100, Rows: 20}, {Cols: 100, Rows: 20}}, pty.requested())
195195 require.Eventually(t, func() bool {
196196 p.mu.Lock()
197197 defer p.mu.Unlock()
198- return p.screen.Frame.Width == 100 && p.screen.Frame.Height == 20
198+ return p.screen.Columns() == 100 && p.screen.Rows() == 20
199199 }, time.Second, time.Millisecond, "successful retry did not publish its VT size")
200200 }
201201
@@ -245,7 +245,7 @@ func TestDelayedRetryUsesFreshLayoutAfterPaneResize(t *testing.T) {
245245 require.True(t, ok)
246246 require.Equal(t, currentFirst, first.rect)
247247 require.Equal(t, currentSecond, second.rect)
248- require.Equal(t, rectSize(currentFirst), domain.Size{Cols: first.screen.Frame.Width, Rows: first.screen.Frame.Height})
248+ require.Equal(t, rectSize(currentFirst), domain.Size{Cols: first.screen.Columns(), Rows: first.screen.Rows()})
249249 require.Equal(t, []domain.Size{{Cols: 40, Rows: 22}, rectSize(currentFirst)}, failed.requested())
250250
251251 rc.mu.Lock()
@@ -256,7 +256,7 @@ func TestDelayedRetryUsesFreshLayoutAfterPaneResize(t *testing.T) {
256256
257257 require.Equal(t, []domain.Size{{Cols: 40, Rows: 22}, rectSize(currentFirst)}, failed.requested(), "delayed retry must not apply stale PTY geometry")
258258 require.Equal(t, currentFirst, first.rect, "delayed retry must not publish a stale rectangle")
259- require.Equal(t, rectSize(currentFirst), domain.Size{Cols: first.screen.Frame.Width, Rows: first.screen.Frame.Height}, "delayed retry must not publish a stale VT size")
259+ require.Equal(t, rectSize(currentFirst), domain.Size{Cols: first.screen.Columns(), Rows: first.screen.Rows()}, "delayed retry must not publish a stale VT size")
260260 }
261261
262262 func TestLayoutApplicationDoesNotHoldTabLock(t *testing.T) {
@@ -320,9 +320,9 @@ func TestLayoutApplicationRejectsStalePaneIdentity(t *testing.T) {
320320 awaitSignal(t, done, "stale layout did not retry")
321321
322322 require.Equal(t, domain.Rect{Width: 20, Height: 10}, oldPane.rect, "stale pane rectangle was published")
323- require.Equal(t, 20, oldPane.screen.Frame.Width, "stale pane screen was published")
323+ require.Equal(t, 20, oldPane.screen.Columns(), "stale pane screen was published")
324324 require.Equal(t, domain.Rect{Width: 41, Height: 10}, replacement.rect)
325- require.Equal(t, 41, replacement.screen.Frame.Width)
325+ require.Equal(t, 41, replacement.screen.Columns())
326326 }
327327
328328 func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) {
@@ -371,7 +371,7 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) {
371371 tb.mu.Unlock()
372372 require.False(t, exists)
373373 require.Equal(t, domain.Rect{Width: 80, Height: 23}, p2.rect, "removed pane received a stale rectangle publication")
374- require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p2.screen.Frame.Width, Rows: p2.screen.Frame.Height}, "removed pane received a stale screen publication")
374+ require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p2.screen.Columns(), Rows: p2.screen.Rows()}, "removed pane received a stale screen publication")
375375 p2.mu.Lock()
376376 require.False(t, p2.resizeApplying, "removed pane gate was not cancelled")
377377 p2.mu.Unlock()
@@ -440,7 +440,7 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) {
440440 awaitSignal(t, oldDone, "old size apply did not finish")
441441 require.True(t, <-resizeDone)
442442 require.Equal(t, domain.Rect{Width: 100, Height: 28}, p.rect)
443- require.Equal(t, domain.Size{Cols: 100, Rows: 28}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height})
443+ require.Equal(t, domain.Size{Cols: 100, Rows: 28}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()})
444444 require.Equal(t, []domain.Size{{Cols: 80, Rows: 23}, {Cols: 100, Rows: 28}}, pty.requested())
445445 })
446446 }
@@ -596,8 +596,8 @@ func TestApplyLayoutResizesPTYsAndScreens(t *testing.T) {
596596
597597 sess.geometry.applyTabLayout(d, sess, tb)
598598
599- require.Equal(t, 20, tb.panes["pane-1"].screen.Frame.Width)
600- require.Equal(t, 20, tb.panes["pane-2"].screen.Frame.Width)
599+ require.Equal(t, 20, tb.panes["pane-1"].screen.Columns())
600+ require.Equal(t, 20, tb.panes["pane-2"].screen.Columns())
601601 require.Equal(t, domain.Rect{X: 21, Width: 20, Height: 10}, tb.panes["pane-2"].rect)
602602 }
603603
@@ -617,8 +617,8 @@ func TestResizeReflowsAllPanes(t *testing.T) {
617617 require.Equal(t, domain.Size{Cols: 60, Rows: 18}, tb.size)
618618 require.Equal(t, domain.Rect{Width: 30, Height: 18}, tb.panes["pane-1"].rect)
619619 require.Equal(t, domain.Rect{X: 31, Width: 29, Height: 18}, tb.panes["pane-2"].rect)
620- require.Equal(t, 30, tb.panes["pane-1"].screen.Frame.Width)
621- require.Equal(t, 29, tb.panes["pane-2"].screen.Frame.Width)
620+ require.Equal(t, 30, tb.panes["pane-1"].screen.Columns())
621+ require.Equal(t, 29, tb.panes["pane-2"].screen.Columns())
622622 }
623623
624624 func TestFocusDirAtReturnsDepartingSpanAndMapsNoNeighbor(t *testing.T) {
@@ -945,7 +945,7 @@ func TestCloseOriginalPaneLeavesSurvivorFunctional(t *testing.T) {
945945 reset: true,
946946 layout: capturedTabLayout{area: content, focus: tb.focusedPane().id, valid: true},
947947 panes: []capturedPaneRenderState{{
948- id: tb.focusedPane().id, frame: tb.focusedPane().screen.Frame.Clone(), placement: layout.Placement{ID: tb.focusedPane().id, Content: content}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()},
948+ id: tb.focusedPane().id, frame: captureTestFrame(tb.focusedPane().screen), placement: layout.Placement{ID: tb.focusedPane().id, Content: content}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()},
949949 }},
950950 bars: barState{status: sess.statusSegments(true)},
951951 }, composeCacheInput{}).frame
@@ -188,14 +188,14 @@ func TestLayoutApplicationRetriesOnlyAcceptedFailedGeometry(t *testing.T) {
188 }188 }
189 }189 }
190 require.Equal(t, []domain.Size{{Cols: 100, Rows: 20}}, pty.requested())190 require.Equal(t, []domain.Size{{Cols: 100, Rows: 20}}, pty.requested())
191- require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}, "failed apply must not publish a VT resize")191+ require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()}, "failed apply must not publish a VT resize")
192 timer.fire()192 timer.fire()
193 awaitSignal(t, secondResize, "accepted failure was not retried")193 awaitSignal(t, secondResize, "accepted failure was not retried")
194 require.Equal(t, []domain.Size{{Cols: 100, Rows: 20}, {Cols: 100, Rows: 20}}, pty.requested())194 require.Equal(t, []domain.Size{{Cols: 100, Rows: 20}, {Cols: 100, Rows: 20}}, pty.requested())
195 require.Eventually(t, func() bool {195 require.Eventually(t, func() bool {
196 p.mu.Lock()196 p.mu.Lock()
197 defer p.mu.Unlock()197 defer p.mu.Unlock()
198- return p.screen.Frame.Width == 100 && p.screen.Frame.Height == 20198+ return p.screen.Columns() == 100 && p.screen.Rows() == 20
199 }, time.Second, time.Millisecond, "successful retry did not publish its VT size")199 }, time.Second, time.Millisecond, "successful retry did not publish its VT size")
200 }200 }
201 201
@@ -245,7 +245,7 @@ func TestDelayedRetryUsesFreshLayoutAfterPaneResize(t *testing.T) {
245 require.True(t, ok)245 require.True(t, ok)
246 require.Equal(t, currentFirst, first.rect)246 require.Equal(t, currentFirst, first.rect)
247 require.Equal(t, currentSecond, second.rect)247 require.Equal(t, currentSecond, second.rect)
248- require.Equal(t, rectSize(currentFirst), domain.Size{Cols: first.screen.Frame.Width, Rows: first.screen.Frame.Height})248+ require.Equal(t, rectSize(currentFirst), domain.Size{Cols: first.screen.Columns(), Rows: first.screen.Rows()})
249 require.Equal(t, []domain.Size{{Cols: 40, Rows: 22}, rectSize(currentFirst)}, failed.requested())249 require.Equal(t, []domain.Size{{Cols: 40, Rows: 22}, rectSize(currentFirst)}, failed.requested())
250 250
251 rc.mu.Lock()251 rc.mu.Lock()
@@ -256,7 +256,7 @@ func TestDelayedRetryUsesFreshLayoutAfterPaneResize(t *testing.T) {
256 256
257 require.Equal(t, []domain.Size{{Cols: 40, Rows: 22}, rectSize(currentFirst)}, failed.requested(), "delayed retry must not apply stale PTY geometry")257 require.Equal(t, []domain.Size{{Cols: 40, Rows: 22}, rectSize(currentFirst)}, failed.requested(), "delayed retry must not apply stale PTY geometry")
258 require.Equal(t, currentFirst, first.rect, "delayed retry must not publish a stale rectangle")258 require.Equal(t, currentFirst, first.rect, "delayed retry must not publish a stale rectangle")
259- require.Equal(t, rectSize(currentFirst), domain.Size{Cols: first.screen.Frame.Width, Rows: first.screen.Frame.Height}, "delayed retry must not publish a stale VT size")259+ require.Equal(t, rectSize(currentFirst), domain.Size{Cols: first.screen.Columns(), Rows: first.screen.Rows()}, "delayed retry must not publish a stale VT size")
260 }260 }
261 261
262 func TestLayoutApplicationDoesNotHoldTabLock(t *testing.T) {262 func TestLayoutApplicationDoesNotHoldTabLock(t *testing.T) {
@@ -320,9 +320,9 @@ func TestLayoutApplicationRejectsStalePaneIdentity(t *testing.T) {
320 awaitSignal(t, done, "stale layout did not retry")320 awaitSignal(t, done, "stale layout did not retry")
321 321
322 require.Equal(t, domain.Rect{Width: 20, Height: 10}, oldPane.rect, "stale pane rectangle was published")322 require.Equal(t, domain.Rect{Width: 20, Height: 10}, oldPane.rect, "stale pane rectangle was published")
323- require.Equal(t, 20, oldPane.screen.Frame.Width, "stale pane screen was published")323+ require.Equal(t, 20, oldPane.screen.Columns(), "stale pane screen was published")
324 require.Equal(t, domain.Rect{Width: 41, Height: 10}, replacement.rect)324 require.Equal(t, domain.Rect{Width: 41, Height: 10}, replacement.rect)
325- require.Equal(t, 41, replacement.screen.Frame.Width)325+ require.Equal(t, 41, replacement.screen.Columns())
326 }326 }
327 327
328 func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) {328 func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) {
@@ -371,7 +371,7 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) {
371 tb.mu.Unlock()371 tb.mu.Unlock()
372 require.False(t, exists)372 require.False(t, exists)
373 require.Equal(t, domain.Rect{Width: 80, Height: 23}, p2.rect, "removed pane received a stale rectangle publication")373 require.Equal(t, domain.Rect{Width: 80, Height: 23}, p2.rect, "removed pane received a stale rectangle publication")
374- require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p2.screen.Frame.Width, Rows: p2.screen.Frame.Height}, "removed pane received a stale screen publication")374+ require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p2.screen.Columns(), Rows: p2.screen.Rows()}, "removed pane received a stale screen publication")
375 p2.mu.Lock()375 p2.mu.Lock()
376 require.False(t, p2.resizeApplying, "removed pane gate was not cancelled")376 require.False(t, p2.resizeApplying, "removed pane gate was not cancelled")
377 p2.mu.Unlock()377 p2.mu.Unlock()
@@ -440,7 +440,7 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) {
440 awaitSignal(t, oldDone, "old size apply did not finish")440 awaitSignal(t, oldDone, "old size apply did not finish")
441 require.True(t, <-resizeDone)441 require.True(t, <-resizeDone)
442 require.Equal(t, domain.Rect{Width: 100, Height: 28}, p.rect)442 require.Equal(t, domain.Rect{Width: 100, Height: 28}, p.rect)
443- require.Equal(t, domain.Size{Cols: 100, Rows: 28}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height})443+ require.Equal(t, domain.Size{Cols: 100, Rows: 28}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()})
444 require.Equal(t, []domain.Size{{Cols: 80, Rows: 23}, {Cols: 100, Rows: 28}}, pty.requested())444 require.Equal(t, []domain.Size{{Cols: 80, Rows: 23}, {Cols: 100, Rows: 28}}, pty.requested())
445 })445 })
446 }446 }
@@ -596,8 +596,8 @@ func TestApplyLayoutResizesPTYsAndScreens(t *testing.T) {
596 596
597 sess.geometry.applyTabLayout(d, sess, tb)597 sess.geometry.applyTabLayout(d, sess, tb)
598 598
599- require.Equal(t, 20, tb.panes["pane-1"].screen.Frame.Width)599+ require.Equal(t, 20, tb.panes["pane-1"].screen.Columns())
600- require.Equal(t, 20, tb.panes["pane-2"].screen.Frame.Width)600+ require.Equal(t, 20, tb.panes["pane-2"].screen.Columns())
601 require.Equal(t, domain.Rect{X: 21, Width: 20, Height: 10}, tb.panes["pane-2"].rect)601 require.Equal(t, domain.Rect{X: 21, Width: 20, Height: 10}, tb.panes["pane-2"].rect)
602 }602 }
603 603
@@ -617,8 +617,8 @@ func TestResizeReflowsAllPanes(t *testing.T) {
617 require.Equal(t, domain.Size{Cols: 60, Rows: 18}, tb.size)617 require.Equal(t, domain.Size{Cols: 60, Rows: 18}, tb.size)
618 require.Equal(t, domain.Rect{Width: 30, Height: 18}, tb.panes["pane-1"].rect)618 require.Equal(t, domain.Rect{Width: 30, Height: 18}, tb.panes["pane-1"].rect)
619 require.Equal(t, domain.Rect{X: 31, Width: 29, Height: 18}, tb.panes["pane-2"].rect)619 require.Equal(t, domain.Rect{X: 31, Width: 29, Height: 18}, tb.panes["pane-2"].rect)
620- require.Equal(t, 30, tb.panes["pane-1"].screen.Frame.Width)620+ require.Equal(t, 30, tb.panes["pane-1"].screen.Columns())
621- require.Equal(t, 29, tb.panes["pane-2"].screen.Frame.Width)621+ require.Equal(t, 29, tb.panes["pane-2"].screen.Columns())
622 }622 }
623 623
624 func TestFocusDirAtReturnsDepartingSpanAndMapsNoNeighbor(t *testing.T) {624 func TestFocusDirAtReturnsDepartingSpanAndMapsNoNeighbor(t *testing.T) {
@@ -945,7 +945,7 @@ func TestCloseOriginalPaneLeavesSurvivorFunctional(t *testing.T) {
945 reset: true,945 reset: true,
946 layout: capturedTabLayout{area: content, focus: tb.focusedPane().id, valid: true},946 layout: capturedTabLayout{area: content, focus: tb.focusedPane().id, valid: true},
947 panes: []capturedPaneRenderState{{947 panes: []capturedPaneRenderState{{
948- id: tb.focusedPane().id, frame: tb.focusedPane().screen.Frame.Clone(), placement: layout.Placement{ID: tb.focusedPane().id, Content: content}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()},948+ id: tb.focusedPane().id, frame: captureTestFrame(tb.focusedPane().screen), placement: layout.Placement{ID: tb.focusedPane().id, Content: content}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()},
949 }},949 }},
950 bars: barState{status: sess.statusSegments(true)},950 bars: barState{status: sess.statusSegments(true)},
951 }, composeCacheInput{}).frame951 }, composeCacheInput{}).frame
M internal/usecase/daemon/pane_rearrange_test.go +1 -1
@@ -411,7 +411,7 @@ func (h *paneRearrangeHarness) snapshot() paneRearrangeSnapshot {
411411 snapshot.panes[id] = p
412412 p.mu.Lock()
413413 snapshot.rects[id] = p.rect
414- snapshot.screens[id] = domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}
414+ snapshot.screens[id] = domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()}
415415 p.mu.Unlock()
416416 }
417417 h.tab.mu.Unlock()
@@ -411,7 +411,7 @@ func (h *paneRearrangeHarness) snapshot() paneRearrangeSnapshot {
411 snapshot.panes[id] = p411 snapshot.panes[id] = p
412 p.mu.Lock()412 p.mu.Lock()
413 snapshot.rects[id] = p.rect413 snapshot.rects[id] = p.rect
414- snapshot.screens[id] = domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}414+ snapshot.screens[id] = domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()}
415 p.mu.Unlock()415 p.mu.Unlock()
416 }416 }
417 h.tab.mu.Unlock()417 h.tab.mu.Unlock()
M internal/usecase/daemon/performance_benchmark_test.go +41 -32
@@ -80,7 +80,7 @@ func TestDaemonSnapshotDoesNotResupplyUnchangedTenThousandChunkHistory(t *testin
8080 fixture.snapshots.reset()
8181 fixture.activePane.mu.Lock()
8282 fixture.activePane.screen.Write([]byte("\x1b[1;1Hchanged"))
83- frame := fixture.activePane.screen.Frame.Clone()
83+ frame := captureTestFrame(fixture.activePane.screen)
8484 fixture.activePane.mu.Unlock()
8585 for i, r := range "changed" {
8686 require.Equalf(t, r, frame.At(i, 0).Rune, "fixture must overwrite at column %d", i)
@@ -141,13 +141,14 @@ func TestIncrementalSnapshotMetricScenarios(t *testing.T) {
141141 }
142142 }
143143
144-func TestPerformanceFixtureCellLimitedHistory(t *testing.T) {
145- fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 293, Rows: 40}, panes: 1, historyRows: 10_000})
144+func TestPerformanceFixtureByteLimitedHistory(t *testing.T) {
145+ fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 400, Rows: 40}, panes: 1, historyRows: 10_000})
146146 fixture.activePane.mu.Lock()
147- rows, cells, capCells := fixture.activePane.history.Len(), fixture.activePane.history.Cells(), fixture.activePane.history.CellCap()
147+ rows, bytes, budget := fixture.activePane.history.Len(), fixture.activePane.history.LogicalBytes(), fixture.activePane.history.ByteCap()
148148 fixture.activePane.mu.Unlock()
149- require.Less(t, rows, 10_000)
150- require.LessOrEqual(t, cells, capCells)
149+ require.Less(t, rows, 10_000, "byte eviction must happen before the line ceiling")
150+ require.Positive(t, bytes)
151+ require.LessOrEqual(t, bytes, budget)
151152 }
152153
153154 func TestPerformanceFixtureExplicitCloseReleasesIterationState(t *testing.T) {
@@ -517,7 +518,7 @@ type snapshotBenchmarkScenario struct {
517518 baseline bool
518519 mutate func(*performanceFixture, int)
519520 unchanged bool
520- cellLimited bool
521+ byteLimited bool
521522 }
522523
523524 var snapshotBenchmarkScenarios = []snapshotBenchmarkScenario{
@@ -528,7 +529,7 @@ var snapshotBenchmarkScenarios = []snapshotBenchmarkScenario{
528529 // mutable tail and cannot evict a sealed chunk.
529530 {name: "tail-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 9_999, baseline: true, mutate: mutateBenchmarkTail},
530531 {name: "new-sealed-chunk", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, mutate: mutateBenchmarkSealedChunk},
531- {name: "cell-limited-293-columns", size: domain.Size{Cols: 293, Rows: 40}, historyRows: 10_000, cellLimited: true},
532+ {name: "byte-limited-400-columns", size: domain.Size{Cols: 400, Rows: 40}, historyRows: 10_000, byteLimited: true},
532533 }
533534
534535 func benchmarkIncrementalSnapshotRepository(b *testing.B, scenario snapshotBenchmarkScenario) {
@@ -588,17 +589,17 @@ func benchmarkIncrementalSnapshotRepository(b *testing.B, scenario snapshotBench
588589
589590 func validateSnapshotBenchmarkFixture(b *testing.B, fixture *performanceFixture, scenario snapshotBenchmarkScenario) {
590591 b.Helper()
591- if !fixture.hasHistoryTopology(1, 1, scenario.historyRows) && !scenario.cellLimited {
592+ if !fixture.hasHistoryTopology(1, 1, scenario.historyRows) && !scenario.byteLimited {
592593 b.Fatal("invalid incremental snapshot fixture")
593594 }
594- if !scenario.cellLimited {
595+ if !scenario.byteLimited {
595596 return
596597 }
597598 fixture.activePane.mu.Lock()
598- rows, cells, capCells := fixture.activePane.history.Len(), fixture.activePane.history.Cells(), fixture.activePane.history.CellCap()
599+ rows, bytes, budget := fixture.activePane.history.Len(), fixture.activePane.history.LogicalBytes(), fixture.activePane.history.ByteCap()
599600 fixture.activePane.mu.Unlock()
600- if rows >= scenario.historyRows || cells > capCells {
601- b.Fatalf("293-column fixture did not enforce cell limit: rows=%d cells=%d cap=%d", rows, cells, capCells)
601+ if rows >= scenario.historyRows || bytes > budget {
602+ b.Fatalf("400-column fixture did not enforce byte retention: rows=%d bytes=%d budget=%d", rows, bytes, budget)
602603 }
603604 }
604605
@@ -645,7 +646,7 @@ func mutateBenchmarkTranscript(fixture *performanceFixture, operation int) {
645646 func mutateBenchmarkTail(fixture *performanceFixture, operation int) {
646647 fixture.activePane.mu.Lock()
647648 defer fixture.activePane.mu.Unlock()
648- row := make([]renderer.Cell, fixture.activePane.screen.Frame.Width)
649+ row := make([]renderer.Cell, fixture.activePane.screen.Columns())
649650 for column := range row {
650651 row[column] = renderer.Cell{Rune: rune('a' + (operation+column)%26)}
651652 }
@@ -821,10 +822,9 @@ func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations
821822 b.ReportMetric(float64(historyRows), "historyrows/pane")
822823 }
823824
824-// Root-cause hypothesis (verified by this guard before the fix): S2 allocates
825-// one pane-frame copy during capture plus a new composed frame and its
826-// base-frame clone for every live paint. Those client-sized copies, rather
827-// than history, account for the ~1 MiB/op regression in the pinned benchmark.
825+// Compact-page budgets are measured against the dense baseline in
826+// docs/compact-storage-benchmarks.md. Count and byte limits are separate:
827+// small page dictionaries cost allocations but replace much larger cell copies.
828828 func TestLivePaintAllocationBudget(t *testing.T) {
829829 for _, tt := range []struct {
830830 name string
@@ -835,28 +835,32 @@ func TestLivePaintAllocationBudget(t *testing.T) {
835835 } {
836836 t.Run(tt.name, func(t *testing.T) {
837837 fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: tt.panes, historyRows: 10_000})
838- allocs := testing.AllocsPerRun(20, func() {
838+ run := func() {
839839 fixture.paintLive()
840840 fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next)
841- })
842- require.LessOrEqual(t, allocs, float64(38), "live paint must reuse attachment-owned render scratch across warm paints")
841+ }
842+ allocs := testing.AllocsPerRun(20, run)
843+ require.LessOrEqual(t, allocs, float64(44), "warm paint must not regain per-row allocations")
844+ if copyEnterAllocationBudgetEnabled {
845+ assertRenderByteBudget(t, run, 96<<10)
846+ }
843847 })
844848 }
845849 }
846850
847-// TestCopyEnterAllocationBudget protects the byte-stream attachment baseline
848-// plus 10%. Copy rendering borrows sealed VT history rows; allocating a copy
849-// per viewport row is a production regression even though capture is immutable.
851+// TestCopyEnterAllocationBudget protects the measured compact baseline plus
852+// roughly 10% in allocation count, and separately bounds bytes per operation.
853+// It must still reject per-row copies or a return to dense frame snapshots.
850854 func TestCopyEnterAllocationBudget(t *testing.T) {
851855 for _, tt := range []struct {
852856 name string
853857 tabs, panes, max int
854858 }{
855- {name: "1tab-1pane", tabs: 1, panes: 1, max: 66},
856- {name: "1tab-4panes", tabs: 1, panes: 4, max: 85},
857- {name: "4tabs-1pane", tabs: 4, panes: 1, max: 71},
858- {name: "4tabs-4panes", tabs: 4, panes: 4, max: 90},
859- {name: "8tabs-1pane", tabs: 8, panes: 1, max: 79},
859+ {name: "1tab-1pane", tabs: 1, panes: 1, max: 94},
860+ {name: "1tab-4panes", tabs: 1, panes: 4, max: 126},
861+ {name: "4tabs-1pane", tabs: 4, panes: 1, max: 97},
862+ {name: "4tabs-4panes", tabs: 4, panes: 4, max: 130},
863+ {name: "8tabs-1pane", tabs: 8, panes: 1, max: 106},
860864 } {
861865 t.Run(tt.name, func(t *testing.T) {
862866 fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: tt.tabs, panes: tt.panes, historyRows: 10_000})
@@ -875,7 +879,12 @@ func TestCopyEnterAllocationBudget(t *testing.T) {
875879 }
876880
877881 allocs := testing.AllocsPerRun(20, run)
878- require.LessOrEqual(t, allocs, float64(tt.max), "copy enter must stay within 10% of the byte-stream attachment allocation baseline")
882+ require.LessOrEqual(t, allocs, float64(tt.max), "copy entry exceeds its measured compact allocation budget")
883+ budget := int64(512 << 10)
884+ if tt.panes == 4 {
885+ budget = 384 << 10
886+ }
887+ assertRenderByteBudget(t, run, budget)
879888 })
880889 }
881890 }
@@ -1140,8 +1149,8 @@ func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyR
11401149 tb.mu.Unlock()
11411150 for _, p := range panes {
11421151 p.mu.Lock()
1143- width := p.screen.Frame.Width
1144- for row := 0; p.history.Len() < historyRows && row < historyRows+p.screen.Frame.Height; row++ {
1152+ width := p.screen.Columns()
1153+ for row := 0; p.history.Len() < historyRows && row < historyRows+p.screen.Rows(); row++ {
11451154 p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n"))
11461155 }
11471156 p.mu.Unlock()
@@ -80,7 +80,7 @@ func TestDaemonSnapshotDoesNotResupplyUnchangedTenThousandChunkHistory(t *testin
80 fixture.snapshots.reset()80 fixture.snapshots.reset()
81 fixture.activePane.mu.Lock()81 fixture.activePane.mu.Lock()
82 fixture.activePane.screen.Write([]byte("\x1b[1;1Hchanged"))82 fixture.activePane.screen.Write([]byte("\x1b[1;1Hchanged"))
83- frame := fixture.activePane.screen.Frame.Clone()83+ frame := captureTestFrame(fixture.activePane.screen)
84 fixture.activePane.mu.Unlock()84 fixture.activePane.mu.Unlock()
85 for i, r := range "changed" {85 for i, r := range "changed" {
86 require.Equalf(t, r, frame.At(i, 0).Rune, "fixture must overwrite at column %d", i)86 require.Equalf(t, r, frame.At(i, 0).Rune, "fixture must overwrite at column %d", i)
@@ -141,13 +141,14 @@ func TestIncrementalSnapshotMetricScenarios(t *testing.T) {
141 }141 }
142 }142 }
143 143
144-func TestPerformanceFixtureCellLimitedHistory(t *testing.T) {144+func TestPerformanceFixtureByteLimitedHistory(t *testing.T) {
145- fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 293, Rows: 40}, panes: 1, historyRows: 10_000})145+ fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 400, Rows: 40}, panes: 1, historyRows: 10_000})
146 fixture.activePane.mu.Lock()146 fixture.activePane.mu.Lock()
147- rows, cells, capCells := fixture.activePane.history.Len(), fixture.activePane.history.Cells(), fixture.activePane.history.CellCap()147+ rows, bytes, budget := fixture.activePane.history.Len(), fixture.activePane.history.LogicalBytes(), fixture.activePane.history.ByteCap()
148 fixture.activePane.mu.Unlock()148 fixture.activePane.mu.Unlock()
149- require.Less(t, rows, 10_000)149+ require.Less(t, rows, 10_000, "byte eviction must happen before the line ceiling")
150- require.LessOrEqual(t, cells, capCells)150+ require.Positive(t, bytes)
151+ require.LessOrEqual(t, bytes, budget)
151 }152 }
152 153
153 func TestPerformanceFixtureExplicitCloseReleasesIterationState(t *testing.T) {154 func TestPerformanceFixtureExplicitCloseReleasesIterationState(t *testing.T) {
@@ -517,7 +518,7 @@ type snapshotBenchmarkScenario struct {
517 baseline bool518 baseline bool
518 mutate func(*performanceFixture, int)519 mutate func(*performanceFixture, int)
519 unchanged bool520 unchanged bool
520- cellLimited bool521+ byteLimited bool
521 }522 }
522 523
523 var snapshotBenchmarkScenarios = []snapshotBenchmarkScenario{524 var snapshotBenchmarkScenarios = []snapshotBenchmarkScenario{
@@ -528,7 +529,7 @@ var snapshotBenchmarkScenarios = []snapshotBenchmarkScenario{
528 // mutable tail and cannot evict a sealed chunk.529 // mutable tail and cannot evict a sealed chunk.
529 {name: "tail-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 9_999, baseline: true, mutate: mutateBenchmarkTail},530 {name: "tail-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 9_999, baseline: true, mutate: mutateBenchmarkTail},
530 {name: "new-sealed-chunk", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, mutate: mutateBenchmarkSealedChunk},531 {name: "new-sealed-chunk", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, mutate: mutateBenchmarkSealedChunk},
531- {name: "cell-limited-293-columns", size: domain.Size{Cols: 293, Rows: 40}, historyRows: 10_000, cellLimited: true},532+ {name: "byte-limited-400-columns", size: domain.Size{Cols: 400, Rows: 40}, historyRows: 10_000, byteLimited: true},
532 }533 }
533 534
534 func benchmarkIncrementalSnapshotRepository(b *testing.B, scenario snapshotBenchmarkScenario) {535 func benchmarkIncrementalSnapshotRepository(b *testing.B, scenario snapshotBenchmarkScenario) {
@@ -588,17 +589,17 @@ func benchmarkIncrementalSnapshotRepository(b *testing.B, scenario snapshotBench
588 589
589 func validateSnapshotBenchmarkFixture(b *testing.B, fixture *performanceFixture, scenario snapshotBenchmarkScenario) {590 func validateSnapshotBenchmarkFixture(b *testing.B, fixture *performanceFixture, scenario snapshotBenchmarkScenario) {
590 b.Helper()591 b.Helper()
591- if !fixture.hasHistoryTopology(1, 1, scenario.historyRows) && !scenario.cellLimited {592+ if !fixture.hasHistoryTopology(1, 1, scenario.historyRows) && !scenario.byteLimited {
592 b.Fatal("invalid incremental snapshot fixture")593 b.Fatal("invalid incremental snapshot fixture")
593 }594 }
594- if !scenario.cellLimited {595+ if !scenario.byteLimited {
595 return596 return
596 }597 }
597 fixture.activePane.mu.Lock()598 fixture.activePane.mu.Lock()
598- rows, cells, capCells := fixture.activePane.history.Len(), fixture.activePane.history.Cells(), fixture.activePane.history.CellCap()599+ rows, bytes, budget := fixture.activePane.history.Len(), fixture.activePane.history.LogicalBytes(), fixture.activePane.history.ByteCap()
599 fixture.activePane.mu.Unlock()600 fixture.activePane.mu.Unlock()
600- if rows >= scenario.historyRows || cells > capCells {601+ if rows >= scenario.historyRows || bytes > budget {
601- b.Fatalf("293-column fixture did not enforce cell limit: rows=%d cells=%d cap=%d", rows, cells, capCells)602+ b.Fatalf("400-column fixture did not enforce byte retention: rows=%d bytes=%d budget=%d", rows, bytes, budget)
602 }603 }
603 }604 }
604 605
@@ -645,7 +646,7 @@ func mutateBenchmarkTranscript(fixture *performanceFixture, operation int) {
645 func mutateBenchmarkTail(fixture *performanceFixture, operation int) {646 func mutateBenchmarkTail(fixture *performanceFixture, operation int) {
646 fixture.activePane.mu.Lock()647 fixture.activePane.mu.Lock()
647 defer fixture.activePane.mu.Unlock()648 defer fixture.activePane.mu.Unlock()
648- row := make([]renderer.Cell, fixture.activePane.screen.Frame.Width)649+ row := make([]renderer.Cell, fixture.activePane.screen.Columns())
649 for column := range row {650 for column := range row {
650 row[column] = renderer.Cell{Rune: rune('a' + (operation+column)%26)}651 row[column] = renderer.Cell{Rune: rune('a' + (operation+column)%26)}
651 }652 }
@@ -821,10 +822,9 @@ func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations
821 b.ReportMetric(float64(historyRows), "historyrows/pane")822 b.ReportMetric(float64(historyRows), "historyrows/pane")
822 }823 }
823 824
824-// Root-cause hypothesis (verified by this guard before the fix): S2 allocates825+// Compact-page budgets are measured against the dense baseline in
825-// one pane-frame copy during capture plus a new composed frame and its826+// docs/compact-storage-benchmarks.md. Count and byte limits are separate:
826-// base-frame clone for every live paint. Those client-sized copies, rather827+// small page dictionaries cost allocations but replace much larger cell copies.
827-// than history, account for the ~1 MiB/op regression in the pinned benchmark.
828 func TestLivePaintAllocationBudget(t *testing.T) {828 func TestLivePaintAllocationBudget(t *testing.T) {
829 for _, tt := range []struct {829 for _, tt := range []struct {
830 name string830 name string
@@ -835,28 +835,32 @@ func TestLivePaintAllocationBudget(t *testing.T) {
835 } {835 } {
836 t.Run(tt.name, func(t *testing.T) {836 t.Run(tt.name, func(t *testing.T) {
837 fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: tt.panes, historyRows: 10_000})837 fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: tt.panes, historyRows: 10_000})
838- allocs := testing.AllocsPerRun(20, func() {838+ run := func() {
839 fixture.paintLive()839 fixture.paintLive()
840 fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next)840 fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next)
841- })841+ }
842- require.LessOrEqual(t, allocs, float64(38), "live paint must reuse attachment-owned render scratch across warm paints")842+ allocs := testing.AllocsPerRun(20, run)
843+ require.LessOrEqual(t, allocs, float64(44), "warm paint must not regain per-row allocations")
844+ if copyEnterAllocationBudgetEnabled {
845+ assertRenderByteBudget(t, run, 96<<10)
846+ }
843 })847 })
844 }848 }
845 }849 }
846 850
847-// TestCopyEnterAllocationBudget protects the byte-stream attachment baseline851+// TestCopyEnterAllocationBudget protects the measured compact baseline plus
848-// plus 10%. Copy rendering borrows sealed VT history rows; allocating a copy852+// roughly 10% in allocation count, and separately bounds bytes per operation.
849-// per viewport row is a production regression even though capture is immutable.853+// It must still reject per-row copies or a return to dense frame snapshots.
850 func TestCopyEnterAllocationBudget(t *testing.T) {854 func TestCopyEnterAllocationBudget(t *testing.T) {
851 for _, tt := range []struct {855 for _, tt := range []struct {
852 name string856 name string
853 tabs, panes, max int857 tabs, panes, max int
854 }{858 }{
855- {name: "1tab-1pane", tabs: 1, panes: 1, max: 66},859+ {name: "1tab-1pane", tabs: 1, panes: 1, max: 94},
856- {name: "1tab-4panes", tabs: 1, panes: 4, max: 85},860+ {name: "1tab-4panes", tabs: 1, panes: 4, max: 126},
857- {name: "4tabs-1pane", tabs: 4, panes: 1, max: 71},861+ {name: "4tabs-1pane", tabs: 4, panes: 1, max: 97},
858- {name: "4tabs-4panes", tabs: 4, panes: 4, max: 90},862+ {name: "4tabs-4panes", tabs: 4, panes: 4, max: 130},
859- {name: "8tabs-1pane", tabs: 8, panes: 1, max: 79},863+ {name: "8tabs-1pane", tabs: 8, panes: 1, max: 106},
860 } {864 } {
861 t.Run(tt.name, func(t *testing.T) {865 t.Run(tt.name, func(t *testing.T) {
862 fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: tt.tabs, panes: tt.panes, historyRows: 10_000})866 fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: tt.tabs, panes: tt.panes, historyRows: 10_000})
@@ -875,7 +879,12 @@ func TestCopyEnterAllocationBudget(t *testing.T) {
875 }879 }
876 880
877 allocs := testing.AllocsPerRun(20, run)881 allocs := testing.AllocsPerRun(20, run)
878- require.LessOrEqual(t, allocs, float64(tt.max), "copy enter must stay within 10% of the byte-stream attachment allocation baseline")882+ require.LessOrEqual(t, allocs, float64(tt.max), "copy entry exceeds its measured compact allocation budget")
883+ budget := int64(512 << 10)
884+ if tt.panes == 4 {
885+ budget = 384 << 10
886+ }
887+ assertRenderByteBudget(t, run, budget)
879 })888 })
880 }889 }
881 }890 }
@@ -1140,8 +1149,8 @@ func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyR
1140 tb.mu.Unlock()1149 tb.mu.Unlock()
1141 for _, p := range panes {1150 for _, p := range panes {
1142 p.mu.Lock()1151 p.mu.Lock()
1143- width := p.screen.Frame.Width1152+ width := p.screen.Columns()
1144- for row := 0; p.history.Len() < historyRows && row < historyRows+p.screen.Frame.Height; row++ {1153+ for row := 0; p.history.Len() < historyRows && row < historyRows+p.screen.Rows(); row++ {
1145 p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n"))1154 p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n"))
1146 }1155 }
1147 p.mu.Unlock()1156 p.mu.Unlock()
M internal/usecase/daemon/picker.go +8 -5
@@ -1570,13 +1570,16 @@ func pickerPreviewFromCapturedRender(state capturedRenderState) picker.Preview {
15701570 }
15711571
15721572 func pickerPreviewFromLockedPane(p *pane) picker.Preview {
1573- return pickerPreviewFromFrame(p.screen.Frame)
1573+ return pickerPreviewFromFrame(p.screen)
15741574 }
15751575
1576-func pickerPreviewFromFrame(frame renderer.Frame) picker.Preview {
1577- rows := make([][]renderer.Cell, frame.Height)
1576+func pickerPreviewFromFrame(frame renderer.CellSource) picker.Preview {
1577+ rows := make([][]renderer.Cell, frame.Rows())
15781578 for y := range rows {
1579- rows[y] = append([]renderer.Cell(nil), frame.Row(y)...)
1579+ rows[y] = make([]renderer.Cell, frame.Columns())
1580+ for x := range rows[y] {
1581+ rows[y][x] = frame.Cell(x, y)
1582+ }
15801583 }
1581- return picker.Preview{Rows: rows, Width: frame.Width, Height: frame.Height}
1584+ return picker.Preview{Rows: rows, Width: frame.Columns(), Height: frame.Rows()}
15821585 }
@@ -1570,13 +1570,16 @@ func pickerPreviewFromCapturedRender(state capturedRenderState) picker.Preview {
1570 }1570 }
1571 1571
1572 func pickerPreviewFromLockedPane(p *pane) picker.Preview {1572 func pickerPreviewFromLockedPane(p *pane) picker.Preview {
1573- return pickerPreviewFromFrame(p.screen.Frame)1573+ return pickerPreviewFromFrame(p.screen)
1574 }1574 }
1575 1575
1576-func pickerPreviewFromFrame(frame renderer.Frame) picker.Preview {1576+func pickerPreviewFromFrame(frame renderer.CellSource) picker.Preview {
1577- rows := make([][]renderer.Cell, frame.Height)1577+ rows := make([][]renderer.Cell, frame.Rows())
1578 for y := range rows {1578 for y := range rows {
1579- rows[y] = append([]renderer.Cell(nil), frame.Row(y)...)1579+ rows[y] = make([]renderer.Cell, frame.Columns())
1580+ for x := range rows[y] {
1581+ rows[y][x] = frame.Cell(x, y)
1582+ }
1580 }1583 }
1581- return picker.Preview{Rows: rows, Width: frame.Width, Height: frame.Height}1584+ return picker.Preview{Rows: rows, Width: frame.Columns(), Height: frame.Rows()}
1582 }1585 }
M internal/usecase/daemon/remote_preview.go +7 -7
@@ -68,7 +68,7 @@ func (d *Daemon) captureRemotePreview(request protocol.RemotePreviewRequest) (pr
6868 tab.mu.Unlock()
6969 return protocol.RemotePreview{}, errRemotePreviewNoSuchTarget
7070 }
71- source := pane.screen.Frame.Clone()
71+ source := pane.screen.Snapshot()
7272 revision := pane.syncGen
7373 pane.mu.Unlock()
7474 tab.mu.Unlock()
@@ -76,12 +76,12 @@ func (d *Daemon) captureRemotePreview(request protocol.RemotePreviewRequest) (pr
7676 revision = 1
7777 }
7878
79- width := min(int(request.Width), source.Width)
80- height := min(int(request.Height), source.Height)
79+ width := min(int(request.Width), source.Columns())
80+ height := min(int(request.Height), source.Rows())
8181 if width <= 0 || height <= 0 {
8282 return protocol.RemotePreview{}, errRemotePreviewNoSuchTarget
8383 }
84- startY, endY := source.Height-height, source.Height
84+ startY, endY := source.Rows()-height, source.Rows()
8585 if last := remotePreviewLastContentRow(source, width, startY, endY); last >= startY {
8686 endY = last + 1
8787 } else if last := remotePreviewLastContentRow(source, width, 0, startY); last >= 0 {
@@ -119,10 +119,10 @@ func (d *Daemon) captureRemotePreview(request protocol.RemotePreviewRequest) (pr
119119 // the bounded viewport crop. Remote terminal frames retain their full PTY
120120 // height even when the only output is near the top; compacting the blank tail lets the
121121 // picker bottom-anchor short output instead of clipping it away.
122-func remotePreviewLastContentRow(frame renderer.Frame, width, start, end int) int {
122+func remotePreviewLastContentRow(frame renderer.CellSource, width, start, end int) int {
123123 for y := end - 1; y >= start; y-- {
124- for x, cell := range frame.Row(y)[:width] {
125- cell, splitWideRune := cropRemotePreviewCell(cell, x, width)
124+ for x := range width {
125+ cell, splitWideRune := cropRemotePreviewCell(frame.Cell(x, y), x, width)
126126 if !splitWideRune && remotePreviewCellVisible(cell) {
127127 return y
128128 }
@@ -68,7 +68,7 @@ func (d *Daemon) captureRemotePreview(request protocol.RemotePreviewRequest) (pr
68 tab.mu.Unlock()68 tab.mu.Unlock()
69 return protocol.RemotePreview{}, errRemotePreviewNoSuchTarget69 return protocol.RemotePreview{}, errRemotePreviewNoSuchTarget
70 }70 }
71- source := pane.screen.Frame.Clone()71+ source := pane.screen.Snapshot()
72 revision := pane.syncGen72 revision := pane.syncGen
73 pane.mu.Unlock()73 pane.mu.Unlock()
74 tab.mu.Unlock()74 tab.mu.Unlock()
@@ -76,12 +76,12 @@ func (d *Daemon) captureRemotePreview(request protocol.RemotePreviewRequest) (pr
76 revision = 176 revision = 1
77 }77 }
78 78
79- width := min(int(request.Width), source.Width)79+ width := min(int(request.Width), source.Columns())
80- height := min(int(request.Height), source.Height)80+ height := min(int(request.Height), source.Rows())
81 if width <= 0 || height <= 0 {81 if width <= 0 || height <= 0 {
82 return protocol.RemotePreview{}, errRemotePreviewNoSuchTarget82 return protocol.RemotePreview{}, errRemotePreviewNoSuchTarget
83 }83 }
84- startY, endY := source.Height-height, source.Height84+ startY, endY := source.Rows()-height, source.Rows()
85 if last := remotePreviewLastContentRow(source, width, startY, endY); last >= startY {85 if last := remotePreviewLastContentRow(source, width, startY, endY); last >= startY {
86 endY = last + 186 endY = last + 1
87 } else if last := remotePreviewLastContentRow(source, width, 0, startY); last >= 0 {87 } else if last := remotePreviewLastContentRow(source, width, 0, startY); last >= 0 {
@@ -119,10 +119,10 @@ func (d *Daemon) captureRemotePreview(request protocol.RemotePreviewRequest) (pr
119 // the bounded viewport crop. Remote terminal frames retain their full PTY119 // the bounded viewport crop. Remote terminal frames retain their full PTY
120 // height even when the only output is near the top; compacting the blank tail lets the120 // height even when the only output is near the top; compacting the blank tail lets the
121 // picker bottom-anchor short output instead of clipping it away.121 // picker bottom-anchor short output instead of clipping it away.
122-func remotePreviewLastContentRow(frame renderer.Frame, width, start, end int) int {122+func remotePreviewLastContentRow(frame renderer.CellSource, width, start, end int) int {
123 for y := end - 1; y >= start; y-- {123 for y := end - 1; y >= start; y-- {
124- for x, cell := range frame.Row(y)[:width] {124+ for x := range width {
125- cell, splitWideRune := cropRemotePreviewCell(cell, x, width)125+ cell, splitWideRune := cropRemotePreviewCell(frame.Cell(x, y), x, width)
126 if !splitWideRune && remotePreviewCellVisible(cell) {126 if !splitWideRune && remotePreviewCellVisible(cell) {
127 return y127 return y
128 }128 }
M internal/usecase/daemon/remote_preview_capture_test.go +7 -6
@@ -19,9 +19,9 @@ func TestCaptureRemotePreviewUsesBottomRowsForShorterPreview(t *testing.T) {
1919 pane := sess.tabs[0].focusedPane()
2020 pane.mu.Lock()
2121 pane.screen = vt.NewScreen(2, 3)
22- pane.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'a'})
23- pane.screen.Frame.Set(0, 1, renderer.Cell{Rune: 'b'})
24- pane.screen.Frame.Set(0, 2, renderer.Cell{Rune: 'c'})
22+ writeTestRow(pane.screen, 0, "a")
23+ writeTestRow(pane.screen, 1, "b")
24+ writeTestRow(pane.screen, 2, "c")
2525 pane.mu.Unlock()
2626
2727 target := domain.RemoteSessionTarget{
@@ -103,9 +103,11 @@ func TestCaptureRemotePreviewCompactsBlankTail(t *testing.T) {
103103 pane := sess.tabs[0].focusedPane()
104104 pane.mu.Lock()
105105 pane.screen = vt.NewScreen(test.screenWidth, test.screenHeight)
106+ frame := renderer.NewFrame(test.screenWidth, test.screenHeight)
106107 for _, cell := range test.cells {
107- pane.screen.Frame.Set(cell.x, cell.y, cell.cell)
108+ frame.Set(cell.x, cell.y, cell.cell)
108109 }
110+ writeTestFrame(t, pane.screen, frame)
109111 pane.mu.Unlock()
110112
111113 target := domain.RemoteSessionTarget{
@@ -148,8 +150,7 @@ func TestCaptureRemotePreviewDoesNotSplitWideRuneAtCropBoundary(t *testing.T) {
148150 pane.screen = vt.NewScreen(3, 1)
149151 style := renderer.DefaultStyle()
150152 style.Bold = true
151- pane.screen.Frame.Set(1, 0, renderer.Cell{Rune: '界', Style: style})
152- pane.screen.Frame.Set(2, 0, renderer.Cell{Continuation: true, Style: style})
153+ pane.screen.Write([]byte("\x1b[1;2H\x1b[1m界"))
153154 pane.mu.Unlock()
154155
155156 target := domain.RemoteSessionTarget{
@@ -19,9 +19,9 @@ func TestCaptureRemotePreviewUsesBottomRowsForShorterPreview(t *testing.T) {
19 pane := sess.tabs[0].focusedPane()19 pane := sess.tabs[0].focusedPane()
20 pane.mu.Lock()20 pane.mu.Lock()
21 pane.screen = vt.NewScreen(2, 3)21 pane.screen = vt.NewScreen(2, 3)
22- pane.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'a'})22+ writeTestRow(pane.screen, 0, "a")
23- pane.screen.Frame.Set(0, 1, renderer.Cell{Rune: 'b'})23+ writeTestRow(pane.screen, 1, "b")
24- pane.screen.Frame.Set(0, 2, renderer.Cell{Rune: 'c'})24+ writeTestRow(pane.screen, 2, "c")
25 pane.mu.Unlock()25 pane.mu.Unlock()
26 26
27 target := domain.RemoteSessionTarget{27 target := domain.RemoteSessionTarget{
@@ -103,9 +103,11 @@ func TestCaptureRemotePreviewCompactsBlankTail(t *testing.T) {
103 pane := sess.tabs[0].focusedPane()103 pane := sess.tabs[0].focusedPane()
104 pane.mu.Lock()104 pane.mu.Lock()
105 pane.screen = vt.NewScreen(test.screenWidth, test.screenHeight)105 pane.screen = vt.NewScreen(test.screenWidth, test.screenHeight)
106+ frame := renderer.NewFrame(test.screenWidth, test.screenHeight)
106 for _, cell := range test.cells {107 for _, cell := range test.cells {
107- pane.screen.Frame.Set(cell.x, cell.y, cell.cell)108+ frame.Set(cell.x, cell.y, cell.cell)
108 }109 }
110+ writeTestFrame(t, pane.screen, frame)
109 pane.mu.Unlock()111 pane.mu.Unlock()
110 112
111 target := domain.RemoteSessionTarget{113 target := domain.RemoteSessionTarget{
@@ -148,8 +150,7 @@ func TestCaptureRemotePreviewDoesNotSplitWideRuneAtCropBoundary(t *testing.T) {
148 pane.screen = vt.NewScreen(3, 1)150 pane.screen = vt.NewScreen(3, 1)
149 style := renderer.DefaultStyle()151 style := renderer.DefaultStyle()
150 style.Bold = true152 style.Bold = true
151- pane.screen.Frame.Set(1, 0, renderer.Cell{Rune: '界', Style: style})153+ pane.screen.Write([]byte("\x1b[1;2H\x1b[1m界"))
152- pane.screen.Frame.Set(2, 0, renderer.Cell{Continuation: true, Style: style})
153 pane.mu.Unlock()154 pane.mu.Unlock()
154 155
155 target := domain.RemoteSessionTarget{156 target := domain.RemoteSessionTarget{
A internal/usecase/daemon/render_allocation_test.go +19 -0
@@ -0,0 +1,19 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+// Benchmark accounting measures allocated bytes, not retained heap. Setup and
10+// warm-up belong to the calling fixture, outside this measured operation.
11+func assertRenderByteBudget(t *testing.T, run func(), maximum int64) {
12+ t.Helper()
13+ result := testing.Benchmark(func(b *testing.B) {
14+ for b.Loop() {
15+ run()
16+ }
17+ })
18+ require.LessOrEqual(t, result.AllocedBytesPerOp(), maximum, "rendering must preserve the compact byte-allocation benefit")
19+}
@@ -0,0 +1,19 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+// Benchmark accounting measures allocated bytes, not retained heap. Setup and
10+// warm-up belong to the calling fixture, outside this measured operation.
11+func assertRenderByteBudget(t *testing.T, run func(), maximum int64) {
12+ t.Helper()
13+ result := testing.Benchmark(func(b *testing.B) {
14+ for b.Loop() {
15+ run()
16+ }
17+ })
18+ require.LessOrEqual(t, result.AllocedBytesPerOp(), maximum, "rendering must preserve the compact byte-allocation benefit")
19+}
M internal/usecase/daemon/render_capture.go +6 -4
@@ -197,8 +197,8 @@ func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, out captured
197197 out.damageGeneration = damage.Generation
198198 out.rawDamage = append(out.rawDamage[:0], damage.Damage...)
199199
200- width := min(max(visible.Width, 0), p.screen.Frame.Width)
201- height := min(max(visible.Height, 0), p.screen.Frame.Height)
200+ width := min(max(visible.Width, 0), p.screen.Columns())
201+ height := min(max(visible.Height, 0), p.screen.Rows())
202202 needsFrame := len(damage.Damage) > 0
203203 frameChanged := out.frame.Width != width || out.frame.Height != height
204204 if frameChanged {
@@ -209,11 +209,13 @@ func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, out captured
209209 // pane. With no VT damage, retain it rather than copying inactive panes.
210210 if needsFrame {
211211 for y := range height {
212- copy(out.frame.Row(y), p.screen.Frame.Row(y)[:width])
212+ for x := range width {
213+ out.frame.Set(x, y, p.screen.Cell(x, y))
214+ }
213215 }
214216 }
215217
216- if frameChanged || uncertainDamage(damage.Damage, p.screen.Frame.Width, p.screen.Frame.Height) {
218+ if frameChanged || uncertainDamage(damage.Damage, p.screen.Columns(), p.screen.Rows()) {
217219 // A cache entry with a new frame has no terminal-shadow equivalent.
218220 // Redraw it even when the pane has no VT damage, such as an untouched
219221 // pane first made visible after a tab or session switch.
@@ -197,8 +197,8 @@ func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, out captured
197 out.damageGeneration = damage.Generation197 out.damageGeneration = damage.Generation
198 out.rawDamage = append(out.rawDamage[:0], damage.Damage...)198 out.rawDamage = append(out.rawDamage[:0], damage.Damage...)
199 199
200- width := min(max(visible.Width, 0), p.screen.Frame.Width)200+ width := min(max(visible.Width, 0), p.screen.Columns())
201- height := min(max(visible.Height, 0), p.screen.Frame.Height)201+ height := min(max(visible.Height, 0), p.screen.Rows())
202 needsFrame := len(damage.Damage) > 0202 needsFrame := len(damage.Damage) > 0
203 frameChanged := out.frame.Width != width || out.frame.Height != height203 frameChanged := out.frame.Width != width || out.frame.Height != height
204 if frameChanged {204 if frameChanged {
@@ -209,11 +209,13 @@ func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, out captured
209 // pane. With no VT damage, retain it rather than copying inactive panes.209 // pane. With no VT damage, retain it rather than copying inactive panes.
210 if needsFrame {210 if needsFrame {
211 for y := range height {211 for y := range height {
212- copy(out.frame.Row(y), p.screen.Frame.Row(y)[:width])212+ for x := range width {
213+ out.frame.Set(x, y, p.screen.Cell(x, y))
214+ }
213 }215 }
214 }216 }
215 217
216- if frameChanged || uncertainDamage(damage.Damage, p.screen.Frame.Width, p.screen.Frame.Height) {218+ if frameChanged || uncertainDamage(damage.Damage, p.screen.Columns(), p.screen.Rows()) {
217 // A cache entry with a new frame has no terminal-shadow equivalent.219 // A cache entry with a new frame has no terminal-shadow equivalent.
218 // Redraw it even when the pane has no VT damage, such as an untouched220 // Redraw it even when the pane has no VT damage, such as an untouched
219 // pane first made visible after a tab or session switch.221 // pane first made visible after a tab or session switch.
M internal/usecase/daemon/render_damage_receipt_test.go +1 -1
@@ -162,7 +162,7 @@ func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T
162162 require.Contains(t, string(out.Data), "changed", "retry must emit the retained terminal bytes")
163163 p.mu.Lock()
164164 require.Empty(t, p.screen.Damage(), "only the successful retry commits its receipt")
165- require.Equal(t, 'c', p.screen.Frame.At(0, 0).Rune, "retry preserves the changed VT shadow")
165+ require.Equal(t, 'c', p.screen.Cell(0, 0).Rune, "retry preserves the changed VT shadow")
166166 p.mu.Unlock()
167167 })
168168 }
@@ -162,7 +162,7 @@ func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T
162 require.Contains(t, string(out.Data), "changed", "retry must emit the retained terminal bytes")162 require.Contains(t, string(out.Data), "changed", "retry must emit the retained terminal bytes")
163 p.mu.Lock()163 p.mu.Lock()
164 require.Empty(t, p.screen.Damage(), "only the successful retry commits its receipt")164 require.Empty(t, p.screen.Damage(), "only the successful retry commits its receipt")
165- require.Equal(t, 'c', p.screen.Frame.At(0, 0).Rune, "retry preserves the changed VT shadow")165+ require.Equal(t, 'c', p.screen.Cell(0, 0).Rune, "retry preserves the changed VT shadow")
166 p.mu.Unlock()166 p.mu.Unlock()
167 })167 })
168 }168 }
M internal/usecase/daemon/render_pipeline.go +38 -9
@@ -2,6 +2,7 @@ package daemon
22
33 import (
44 "errors"
5+ "slices"
56
67 renderer "github.com/bnema/vev-vt"
78 "github.com/bnema/vev/internal/domain"
@@ -85,8 +86,17 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
8586 frame = renderer.NewFrame(width, frameHeight)
8687 }
8788 if in.frame.Width == width && in.frame.Height == frameHeight {
88- for y := 0; y < frame.Height; y++ {
89- copy(frame.Row(y), in.frame.Row(y))
89+ if state.overlays.copyMode != nil {
90+ // Compact Clone preserves page-local style IDs. Re-interning every
91+ // cell into scratch costs more than a structural copy while scrolling.
92+ // The clone still isolates committed state from failed publications.
93+ frame = in.frame.Clone()
94+ } else {
95+ for y := 0; y < frame.Height; y++ {
96+ for x := range frame.Width {
97+ frame.Set(x, y, in.frame.Cell(x, y))
98+ }
99+ }
90100 }
91101 }
92102 styles := state.styles
@@ -99,8 +109,14 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
99109 defaultDimmer := themeui.NewDimmer(state.theme)
100110 neutralBorder := styles.NeutralBorder
101111 inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming))
102- drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)
103- drawStatusBarState(frame.Row(rows+1), state.bars, styles)
112+ // The alternate cache owns these rows; the committed cache remains intact
113+ // until prepare/send succeeds. Row extraction would allocate on each paint.
114+ topBar := slices.Grow(scratch.bars.top[:0], width)[:width]
115+ drawTopBarSnapshot(topBar, state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)
116+ frame.WriteRow(0, 0, topBar)
117+ bottomBar := slices.Grow(scratch.bars.bottom[:0], width)[:width]
118+ drawStatusBarState(bottomBar, state.bars, styles)
119+ frame.WriteRow(rows+1, 0, bottomBar)
104120 content := domain.Rect{Y: contentY, Width: width, Height: rows}
105121 if state.layout.valid {
106122 drawDividers(frame, state.layout.dividers, content.Y, defaultDimmer.Dim(neutralBorder))
@@ -164,11 +180,15 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
164180 frame, damage = composeCapturedCopyMode(state, frame, damage, content)
165181 frame, damage = composeCapturedOverlays(state, frame, damage)
166182 }
183+ for x := range width {
184+ topBar[x] = baseFrame.Cell(x, 0)
185+ bottomBar[x] = baseFrame.Cell(x, rows+1)
186+ }
167187 if !full {
168- if !sameCells(in.bars.top, baseFrame.Row(0)) {
188+ if !sameCells(in.bars.top, topBar) {
169189 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})
170190 }
171- if !sameCells(in.bars.bottom, baseFrame.Row(rows+1)) {
191+ if !sameCells(in.bars.bottom, bottomBar) {
172192 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1})
173193 }
174194 }
@@ -185,8 +205,15 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
185205 cursorInputs := state.cursor
186206 cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive
187207 cursor := desiredCapturedCursor(cursorInputs, contentY)
188- outCache := composeCacheInput{valid: !overlaysActive, frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, toastFootprints: append(scratch.toastFootprints[:0], toastFootprints...), floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars}
189- outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1))
208+ // A plain copy viewport is painted on a clone, never into baseFrame. Keep
209+ // that unadorned base reusable between wheel events. Modal/floating paths
210+ // retain their conservative invalidation, and copy exit requests a reset.
211+ copyOnly := state.overlays.copyMode != nil && state.overlays.copyActive &&
212+ !state.overlays.copySearchActive && !state.overlays.pickerActive &&
213+ !state.overlays.paletteActive && !state.overlays.promptActive &&
214+ !state.overlays.noticesOverlayActive && !state.overlays.resizeActive && !state.floating.visible
215+ outCache := composeCacheInput{valid: !overlaysActive || copyOnly, frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, toastFootprints: append(scratch.toastFootprints[:0], toastFootprints...), floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars}
216+ outCache.bars = barCache{top: topBar, bottom: bottomBar}
190217 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}
191218 }
192219
@@ -345,7 +372,9 @@ func copyModalInner(dst renderer.Frame, target domain.Rect, src renderer.Frame)
345372 return
346373 }
347374 for y := range height {
348- copy(dst.Row(top + y)[left:left+width], src.Row(sourceY + y)[sourceX:sourceX+width])
375+ for x := range width {
376+ dst.Set(left+x, top+y, src.Cell(sourceX+x, sourceY+y))
377+ }
349378 }
350379 }
351380
@@ -2,6 +2,7 @@ package daemon
2 2
3 import (3 import (
4 "errors"4 "errors"
5+ "slices"
5 6
6 renderer "github.com/bnema/vev-vt"7 renderer "github.com/bnema/vev-vt"
7 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
@@ -85,8 +86,17 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
85 frame = renderer.NewFrame(width, frameHeight)86 frame = renderer.NewFrame(width, frameHeight)
86 }87 }
87 if in.frame.Width == width && in.frame.Height == frameHeight {88 if in.frame.Width == width && in.frame.Height == frameHeight {
88- for y := 0; y < frame.Height; y++ {89+ if state.overlays.copyMode != nil {
89- copy(frame.Row(y), in.frame.Row(y))90+ // Compact Clone preserves page-local style IDs. Re-interning every
91+ // cell into scratch costs more than a structural copy while scrolling.
92+ // The clone still isolates committed state from failed publications.
93+ frame = in.frame.Clone()
94+ } else {
95+ for y := 0; y < frame.Height; y++ {
96+ for x := range frame.Width {
97+ frame.Set(x, y, in.frame.Cell(x, y))
98+ }
99+ }
90 }100 }
91 }101 }
92 styles := state.styles102 styles := state.styles
@@ -99,8 +109,14 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
99 defaultDimmer := themeui.NewDimmer(state.theme)109 defaultDimmer := themeui.NewDimmer(state.theme)
100 neutralBorder := styles.NeutralBorder110 neutralBorder := styles.NeutralBorder
101 inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming))111 inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming))
102- drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)112+ // The alternate cache owns these rows; the committed cache remains intact
103- drawStatusBarState(frame.Row(rows+1), state.bars, styles)113+ // until prepare/send succeeds. Row extraction would allocate on each paint.
114+ topBar := slices.Grow(scratch.bars.top[:0], width)[:width]
115+ drawTopBarSnapshot(topBar, state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)
116+ frame.WriteRow(0, 0, topBar)
117+ bottomBar := slices.Grow(scratch.bars.bottom[:0], width)[:width]
118+ drawStatusBarState(bottomBar, state.bars, styles)
119+ frame.WriteRow(rows+1, 0, bottomBar)
104 content := domain.Rect{Y: contentY, Width: width, Height: rows}120 content := domain.Rect{Y: contentY, Width: width, Height: rows}
105 if state.layout.valid {121 if state.layout.valid {
106 drawDividers(frame, state.layout.dividers, content.Y, defaultDimmer.Dim(neutralBorder))122 drawDividers(frame, state.layout.dividers, content.Y, defaultDimmer.Dim(neutralBorder))
@@ -164,11 +180,15 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
164 frame, damage = composeCapturedCopyMode(state, frame, damage, content)180 frame, damage = composeCapturedCopyMode(state, frame, damage, content)
165 frame, damage = composeCapturedOverlays(state, frame, damage)181 frame, damage = composeCapturedOverlays(state, frame, damage)
166 }182 }
183+ for x := range width {
184+ topBar[x] = baseFrame.Cell(x, 0)
185+ bottomBar[x] = baseFrame.Cell(x, rows+1)
186+ }
167 if !full {187 if !full {
168- if !sameCells(in.bars.top, baseFrame.Row(0)) {188+ if !sameCells(in.bars.top, topBar) {
169 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})189 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})
170 }190 }
171- if !sameCells(in.bars.bottom, baseFrame.Row(rows+1)) {191+ if !sameCells(in.bars.bottom, bottomBar) {
172 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1})192 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1})
173 }193 }
174 }194 }
@@ -185,8 +205,15 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
185 cursorInputs := state.cursor205 cursorInputs := state.cursor
186 cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive206 cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive
187 cursor := desiredCapturedCursor(cursorInputs, contentY)207 cursor := desiredCapturedCursor(cursorInputs, contentY)
188- outCache := composeCacheInput{valid: !overlaysActive, frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, toastFootprints: append(scratch.toastFootprints[:0], toastFootprints...), floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars}208+ // A plain copy viewport is painted on a clone, never into baseFrame. Keep
189- outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1))209+ // that unadorned base reusable between wheel events. Modal/floating paths
210+ // retain their conservative invalidation, and copy exit requests a reset.
211+ copyOnly := state.overlays.copyMode != nil && state.overlays.copyActive &&
212+ !state.overlays.copySearchActive && !state.overlays.pickerActive &&
213+ !state.overlays.paletteActive && !state.overlays.promptActive &&
214+ !state.overlays.noticesOverlayActive && !state.overlays.resizeActive && !state.floating.visible
215+ outCache := composeCacheInput{valid: !overlaysActive || copyOnly, frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, toastFootprints: append(scratch.toastFootprints[:0], toastFootprints...), floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars}
216+ outCache.bars = barCache{top: topBar, bottom: bottomBar}
190 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}217 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}
191 }218 }
192 219
@@ -345,7 +372,9 @@ func copyModalInner(dst renderer.Frame, target domain.Rect, src renderer.Frame)
345 return372 return
346 }373 }
347 for y := range height {374 for y := range height {
348- copy(dst.Row(top + y)[left:left+width], src.Row(sourceY + y)[sourceX:sourceX+width])375+ for x := range width {
376+ dst.Set(left+x, top+y, src.Cell(sourceX+x, sourceY+y))
377+ }
349 }378 }
350 }379 }
351 380
M internal/usecase/daemon/render_pipeline_cache_test.go +1 -3
@@ -295,9 +295,7 @@ func cloneCapturedStackState(in capturedRenderState) capturedRenderState {
295295 out.layout.placements = append([]layout.Placement(nil), in.layout.placements...)
296296 out.panes = append([]capturedPaneRenderState(nil), in.panes...)
297297 for i := range out.panes {
298- if in.panes[i].frame.Cells != nil {
299- out.panes[i].frame = in.panes[i].frame.Clone()
300- }
298+ out.panes[i].frame = in.panes[i].frame.Clone()
301299 out.panes[i].rawDamage = append([]renderer.Damage(nil), in.panes[i].rawDamage...)
302300 out.panes[i].damage = append([]renderer.Damage(nil), in.panes[i].damage...)
303301 }
@@ -295,9 +295,7 @@ func cloneCapturedStackState(in capturedRenderState) capturedRenderState {
295 out.layout.placements = append([]layout.Placement(nil), in.layout.placements...)295 out.layout.placements = append([]layout.Placement(nil), in.layout.placements...)
296 out.panes = append([]capturedPaneRenderState(nil), in.panes...)296 out.panes = append([]capturedPaneRenderState(nil), in.panes...)
297 for i := range out.panes {297 for i := range out.panes {
298- if in.panes[i].frame.Cells != nil {298+ out.panes[i].frame = in.panes[i].frame.Clone()
299- out.panes[i].frame = in.panes[i].frame.Clone()
300- }
301 out.panes[i].rawDamage = append([]renderer.Damage(nil), in.panes[i].rawDamage...)299 out.panes[i].rawDamage = append([]renderer.Damage(nil), in.panes[i].rawDamage...)
302 out.panes[i].damage = append([]renderer.Damage(nil), in.panes[i].damage...)300 out.panes[i].damage = append([]renderer.Damage(nil), in.panes[i].damage...)
303 }301 }
M internal/usecase/daemon/render_pipeline_test.go +15 -11
@@ -63,7 +63,7 @@ func TestTransportReplayFinalShadowAndTerminalBytes(t *testing.T) {
6363 terminal.Write(output.Data)
6464 }
6565 require.Equal(t, len(frames), transport.next)
66- require.Equal(t, []string{"one ", "TWO ", " "}, frameRows(terminal.Frame), "terminal replay is the final renderer shadow")
66+ require.Equal(t, []string{"one ", "TWO ", " "}, frameRows(terminal), "terminal replay is the final renderer shadow")
6767 }
6868
6969 func TestCapturePaneRenderStateOwnsVisibleFrameWithoutConsumingDamage(t *testing.T) {
@@ -343,16 +343,20 @@ func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) {
343343 require.Equal(t, "\x1b[1;1H\x1b[0;7m tab \x1b[0m R\x1b[2;1HAAAAAAAAAAAA\x1b[3;1HBBB┌─fl─┐BBB\x1b[4;1H───Prompt───\x1b[5;1HPROMPT\x1b[K\x1b[B\x1b[2K\x1b[7;1H\x1b[0;7m sess \x1b[0m B\x1b[0m\x1b[?25l", string(terminalBytes))
344344 client := vt.NewScreen(composed.frame.Width, composed.frame.Height)
345345 client.Write(terminalBytes)
346- require.Equal(t, frameRows(composed.frame), frameRows(client.Frame))
346+ require.Equal(t, frameRows(composed.frame), frameRows(client))
347347 again, err := stream.renderer.Draw(composed.frame, nil)
348348 require.NoError(t, err)
349349 require.Empty(t, again, "renderer shadow must exactly equal the composed frame")
350350 }
351351
352-func frameRows(frame renderer.Frame) []string {
353- rows := make([]string, frame.Height)
352+func frameRows(frame renderer.CellSource) []string {
353+ rows := make([]string, frame.Rows())
354+ cells := make([]renderer.Cell, frame.Columns())
354355 for y := range rows {
355- rows[y] = rowText(frame.Row(y))
356+ for x := range cells {
357+ cells[x] = frame.Cell(x, y)
358+ }
359+ rows[y] = rowText(cells)
356360 }
357361 return rows
358362 }
@@ -391,7 +395,7 @@ func TestComposeEmitExactReplaySafeAndUnsafeScroll(t *testing.T) {
391395 require.NoError(t, err)
392396 require.Equal(t, tt.wantBytes, string(secondBytes))
393397 client.Write(secondBytes)
394- require.Equal(t, frameRows(second.frame), frameRows(client.Frame))
398+ require.Equal(t, frameRows(second.frame), frameRows(client))
395399 again, err := stream.renderer.Draw(second.frame, nil)
396400 require.NoError(t, err)
397401 require.Empty(t, again, "renderer shadow must equal replayed terminal state")
@@ -494,12 +498,12 @@ func TestComposeFrameModalBackdropDimsCompleteFrameIncludingToasts(t *testing.T)
494498 } {
495499 t.Run(name, func(t *testing.T) {
496500 x, y := point[0], point[1]
497- require.Equal(t, dimmer.Dim(base.frame.At(x, y).Style), composed.frame.At(x, y).Style)
501+ require.Equal(t, dimmer.Dim(base.frame.At(x, y).Style).Canonical(), composed.frame.At(x, y).Style.Canonical())
498502 })
499503 }
500504 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, composed.damage)
501505 require.False(t, composed.cache.valid)
502- require.Equal(t, base.cache.frame.Cells, composed.cache.frame.Cells, "the reusable cache remains toast-free and unadorned")
506+ require.Equal(t, captureTestFrame(base.cache.frame), captureTestFrame(composed.cache.frame), "the reusable cache remains toast-free and unadorned")
503507 }
504508
505509 func TestComposeFrameFloatingBackdropDimsCompleteFrameIncludingToasts(t *testing.T) {
@@ -544,10 +548,10 @@ func TestComposeFrameFloatingBackdropDimsCompleteFrameIncludingToasts(t *testing
544548 } {
545549 t.Run(name, func(t *testing.T) {
546550 x, y := point[0], point[1]
547- require.Equal(t, dimmer.Dim(toastOnly.frame.At(x, y).Style), composed.frame.At(x, y).Style)
551+ require.Equal(t, dimmer.Dim(toastOnly.frame.At(x, y).Style).Canonical(), composed.frame.At(x, y).Style.Canonical())
548552 })
549553 }
550- require.Equal(t, toastOnly.cache.frame.Cells, composed.cache.frame.Cells, "floating decoration must not enter the reusable base cache")
554+ require.Equal(t, captureTestFrame(toastOnly.cache.frame), captureTestFrame(composed.cache.frame), "floating decoration must not enter the reusable base cache")
551555 }
552556
553557 func TestComposeCapturedOverlaysMatchesKeyboardPriority(t *testing.T) {
@@ -593,7 +597,7 @@ func TestComposeCapturedOverlaysMatchesKeyboardPriority(t *testing.T) {
593597 frame, damage := composeCapturedOverlays(state, renderer.NewFrame(5, 3), nil)
594598
595599 require.Equal(t, 'L', frame.At(1, 1).Rune)
596- require.Equal(t, themeui.NewDimmer(theme).Dim(lowerStyle), frame.At(1, 1).Style, "the lower-priority modal is part of the higher modal backdrop")
600+ require.Equal(t, themeui.NewDimmer(theme).Dim(lowerStyle).Canonical(), frame.At(1, 1).Style.Canonical(), "the lower-priority modal is part of the higher modal backdrop")
597601 require.Equal(t, 'H', frame.At(3, 1).Rune)
598602 require.Equal(t, higherStyle, frame.At(3, 1).Style, "the keyboard owner remains visually topmost")
599603 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
@@ -63,7 +63,7 @@ func TestTransportReplayFinalShadowAndTerminalBytes(t *testing.T) {
63 terminal.Write(output.Data)63 terminal.Write(output.Data)
64 }64 }
65 require.Equal(t, len(frames), transport.next)65 require.Equal(t, len(frames), transport.next)
66- require.Equal(t, []string{"one ", "TWO ", " "}, frameRows(terminal.Frame), "terminal replay is the final renderer shadow")66+ require.Equal(t, []string{"one ", "TWO ", " "}, frameRows(terminal), "terminal replay is the final renderer shadow")
67 }67 }
68 68
69 func TestCapturePaneRenderStateOwnsVisibleFrameWithoutConsumingDamage(t *testing.T) {69 func TestCapturePaneRenderStateOwnsVisibleFrameWithoutConsumingDamage(t *testing.T) {
@@ -343,16 +343,20 @@ func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) {
343 require.Equal(t, "\x1b[1;1H\x1b[0;7m tab \x1b[0m R\x1b[2;1HAAAAAAAAAAAA\x1b[3;1HBBB┌─fl─┐BBB\x1b[4;1H───Prompt───\x1b[5;1HPROMPT\x1b[K\x1b[B\x1b[2K\x1b[7;1H\x1b[0;7m sess \x1b[0m B\x1b[0m\x1b[?25l", string(terminalBytes))343 require.Equal(t, "\x1b[1;1H\x1b[0;7m tab \x1b[0m R\x1b[2;1HAAAAAAAAAAAA\x1b[3;1HBBB┌─fl─┐BBB\x1b[4;1H───Prompt───\x1b[5;1HPROMPT\x1b[K\x1b[B\x1b[2K\x1b[7;1H\x1b[0;7m sess \x1b[0m B\x1b[0m\x1b[?25l", string(terminalBytes))
344 client := vt.NewScreen(composed.frame.Width, composed.frame.Height)344 client := vt.NewScreen(composed.frame.Width, composed.frame.Height)
345 client.Write(terminalBytes)345 client.Write(terminalBytes)
346- require.Equal(t, frameRows(composed.frame), frameRows(client.Frame))346+ require.Equal(t, frameRows(composed.frame), frameRows(client))
347 again, err := stream.renderer.Draw(composed.frame, nil)347 again, err := stream.renderer.Draw(composed.frame, nil)
348 require.NoError(t, err)348 require.NoError(t, err)
349 require.Empty(t, again, "renderer shadow must exactly equal the composed frame")349 require.Empty(t, again, "renderer shadow must exactly equal the composed frame")
350 }350 }
351 351
352-func frameRows(frame renderer.Frame) []string {352+func frameRows(frame renderer.CellSource) []string {
353- rows := make([]string, frame.Height)353+ rows := make([]string, frame.Rows())
354+ cells := make([]renderer.Cell, frame.Columns())
354 for y := range rows {355 for y := range rows {
355- rows[y] = rowText(frame.Row(y))356+ for x := range cells {
357+ cells[x] = frame.Cell(x, y)
358+ }
359+ rows[y] = rowText(cells)
356 }360 }
357 return rows361 return rows
358 }362 }
@@ -391,7 +395,7 @@ func TestComposeEmitExactReplaySafeAndUnsafeScroll(t *testing.T) {
391 require.NoError(t, err)395 require.NoError(t, err)
392 require.Equal(t, tt.wantBytes, string(secondBytes))396 require.Equal(t, tt.wantBytes, string(secondBytes))
393 client.Write(secondBytes)397 client.Write(secondBytes)
394- require.Equal(t, frameRows(second.frame), frameRows(client.Frame))398+ require.Equal(t, frameRows(second.frame), frameRows(client))
395 again, err := stream.renderer.Draw(second.frame, nil)399 again, err := stream.renderer.Draw(second.frame, nil)
396 require.NoError(t, err)400 require.NoError(t, err)
397 require.Empty(t, again, "renderer shadow must equal replayed terminal state")401 require.Empty(t, again, "renderer shadow must equal replayed terminal state")
@@ -494,12 +498,12 @@ func TestComposeFrameModalBackdropDimsCompleteFrameIncludingToasts(t *testing.T)
494 } {498 } {
495 t.Run(name, func(t *testing.T) {499 t.Run(name, func(t *testing.T) {
496 x, y := point[0], point[1]500 x, y := point[0], point[1]
497- require.Equal(t, dimmer.Dim(base.frame.At(x, y).Style), composed.frame.At(x, y).Style)501+ require.Equal(t, dimmer.Dim(base.frame.At(x, y).Style).Canonical(), composed.frame.At(x, y).Style.Canonical())
498 })502 })
499 }503 }
500 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, composed.damage)504 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, composed.damage)
501 require.False(t, composed.cache.valid)505 require.False(t, composed.cache.valid)
502- require.Equal(t, base.cache.frame.Cells, composed.cache.frame.Cells, "the reusable cache remains toast-free and unadorned")506+ require.Equal(t, captureTestFrame(base.cache.frame), captureTestFrame(composed.cache.frame), "the reusable cache remains toast-free and unadorned")
503 }507 }
504 508
505 func TestComposeFrameFloatingBackdropDimsCompleteFrameIncludingToasts(t *testing.T) {509 func TestComposeFrameFloatingBackdropDimsCompleteFrameIncludingToasts(t *testing.T) {
@@ -544,10 +548,10 @@ func TestComposeFrameFloatingBackdropDimsCompleteFrameIncludingToasts(t *testing
544 } {548 } {
545 t.Run(name, func(t *testing.T) {549 t.Run(name, func(t *testing.T) {
546 x, y := point[0], point[1]550 x, y := point[0], point[1]
547- require.Equal(t, dimmer.Dim(toastOnly.frame.At(x, y).Style), composed.frame.At(x, y).Style)551+ require.Equal(t, dimmer.Dim(toastOnly.frame.At(x, y).Style).Canonical(), composed.frame.At(x, y).Style.Canonical())
548 })552 })
549 }553 }
550- require.Equal(t, toastOnly.cache.frame.Cells, composed.cache.frame.Cells, "floating decoration must not enter the reusable base cache")554+ require.Equal(t, captureTestFrame(toastOnly.cache.frame), captureTestFrame(composed.cache.frame), "floating decoration must not enter the reusable base cache")
551 }555 }
552 556
553 func TestComposeCapturedOverlaysMatchesKeyboardPriority(t *testing.T) {557 func TestComposeCapturedOverlaysMatchesKeyboardPriority(t *testing.T) {
@@ -593,7 +597,7 @@ func TestComposeCapturedOverlaysMatchesKeyboardPriority(t *testing.T) {
593 frame, damage := composeCapturedOverlays(state, renderer.NewFrame(5, 3), nil)597 frame, damage := composeCapturedOverlays(state, renderer.NewFrame(5, 3), nil)
594 598
595 require.Equal(t, 'L', frame.At(1, 1).Rune)599 require.Equal(t, 'L', frame.At(1, 1).Rune)
596- require.Equal(t, themeui.NewDimmer(theme).Dim(lowerStyle), frame.At(1, 1).Style, "the lower-priority modal is part of the higher modal backdrop")600+ require.Equal(t, themeui.NewDimmer(theme).Dim(lowerStyle).Canonical(), frame.At(1, 1).Style.Canonical(), "the lower-priority modal is part of the higher modal backdrop")
597 require.Equal(t, 'H', frame.At(3, 1).Rune)601 require.Equal(t, 'H', frame.At(3, 1).Rune)
598 require.Equal(t, higherStyle, frame.At(3, 1).Style, "the keyboard owner remains visually topmost")602 require.Equal(t, higherStyle, frame.At(3, 1).Style, "the keyboard owner remains visually topmost")
599 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)603 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
M internal/usecase/daemon/render_test.go +35 -35
@@ -39,23 +39,23 @@ func TestPaletteBackdropDimsSimultaneousCopyMode(t *testing.T) {
3939
4040 d.enterCopyMode(sess, ac)
4141 mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput))
42- undimmed := client.Frame.At(0, 1)
42+ undimmed := client.Cell(0, 1)
4343 require.Equal(t, 'X', undimmed.Rune, "fixture must address copy-mode pane content")
44- copyBar := append([]renderer.Cell(nil), client.Frame.Row(client.Frame.Height-1)...)
44+ copyBar := client.RowCells(client.Rows() - 1)
4545 require.Contains(t, rowText(copyBar), "[SCROLL]", "fixture must capture the copy status bar")
4646
4747 d.enterPalette(sess, ac)
4848 mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput))
49- dimmed := client.Frame.At(0, 1)
49+ dimmed := client.Cell(0, 1)
5050 require.Equal(t, undimmed.Rune, dimmed.Rune, "palette backdrop must preserve copy content")
51- require.Equal(t, themeui.NewDimmer(theme).Dim(undimmed.Style), dimmed.Style, "palette backdrop must dim the composed copy frame")
51+ require.Equal(t, themeui.NewDimmer(theme).Dim(undimmed.Style).Canonical(), dimmed.Style.Canonical(), "palette backdrop must dim the composed copy frame")
5252 for x, cell := range copyBar {
53- cell.Style = themeui.NewDimmer(theme).Dim(cell.Style)
54- require.Equal(t, cell, client.Frame.At(x, client.Frame.Height-1), "copy status bar must be part of the complete backdrop")
53+ cell.Style = themeui.NewDimmer(theme).Dim(cell.Style).Canonical()
54+ require.Equal(t, cell, client.Cell(x, client.Rows()-1), "copy status bar must be part of the complete backdrop")
5555 }
5656 paletteVisible := false
57- for y := range client.Frame.Height {
58- paletteVisible = paletteVisible || strings.Contains(rowText(client.Frame.Row(y)), "Commands")
57+ for y := range client.Rows() {
58+ paletteVisible = paletteVisible || strings.Contains(rowText(client.RowCells(y)), "Commands")
5959 }
6060 require.True(t, paletteVisible, "palette must remain composed above copy mode")
6161 }
@@ -108,16 +108,16 @@ func TestPaletteBackdropDimsSimultaneousPicker(t *testing.T) {
108108
109109 d.enterPicker(sess, ac)
110110 mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput))
111- pickerTitle := client.Frame.At(31, 2)
111+ pickerTitle := client.Cell(31, 2)
112112 require.Equal(t, 'S', pickerTitle.Rune, "fixture must address the picker title")
113- undimmedPane := client.Frame.At(0, 1)
113+ undimmedPane := client.Cell(0, 1)
114114
115115 d.enterPalette(sess, ac)
116116 mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput))
117117 dimmedPickerTitle := pickerTitle
118- dimmedPickerTitle.Style = themeui.NewDimmer(backdropTheme()).Dim(pickerTitle.Style)
119- require.Equal(t, dimmedPickerTitle, client.Frame.At(31, 2), "the lower-priority picker must be part of the palette backdrop")
120- require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmedPane.Style), client.Frame.At(0, 1).Style, "pane content outside overlays must use the theme dim style")
118+ dimmedPickerTitle.Style = themeui.NewDimmer(backdropTheme()).Dim(pickerTitle.Style).Canonical()
119+ require.Equal(t, dimmedPickerTitle, client.Cell(31, 2), "the lower-priority picker must be part of the palette backdrop")
120+ require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmedPane.Style).Canonical(), client.Cell(0, 1).Style.Canonical(), "pane content outside overlays must use the theme dim style")
121121 }
122122
123123 func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) {
@@ -130,27 +130,27 @@ func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) {
130130 pane.screen.Write([]byte("X"))
131131 d.paint(sess, ac, true, nil)
132132 mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput))
133- undimmed := client.Frame.At(0, 1)
134- topBar := client.Frame.At(0, 0)
135- bottomBar := client.Frame.At(0, 24)
133+ undimmed := client.Cell(0, 1)
134+ topBar := client.Cell(0, 0)
135+ bottomBar := client.Cell(0, 24)
136136
137137 d.handleInput(sess, ac, []byte("\x1b "))
138138 mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput))
139- dimmed := client.Frame.At(0, 1)
139+ dimmed := client.Cell(0, 1)
140140 require.Equal(t, 'X', dimmed.Rune)
141- require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmed.Style), dimmed.Style, "open palette must use the theme dim style")
141+ require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmed.Style).Canonical(), dimmed.Style.Canonical(), "open palette must use the theme dim style")
142142 dimmedTopBar := topBar
143- dimmedTopBar.Style = themeui.NewDimmer(backdropTheme()).Dim(topBar.Style)
143+ dimmedTopBar.Style = themeui.NewDimmer(backdropTheme()).Dim(topBar.Style).Canonical()
144144 dimmedBottomBar := bottomBar
145- dimmedBottomBar.Style = themeui.NewDimmer(backdropTheme()).Dim(bottomBar.Style)
146- require.Equal(t, dimmedTopBar, client.Frame.At(0, 0), "top chrome is part of the complete backdrop")
147- require.Equal(t, dimmedBottomBar, client.Frame.At(0, 24), "bottom chrome is part of the complete backdrop")
145+ dimmedBottomBar.Style = themeui.NewDimmer(backdropTheme()).Dim(bottomBar.Style).Canonical()
146+ require.Equal(t, dimmedTopBar, client.Cell(0, 0), "top chrome is part of the complete backdrop")
147+ require.Equal(t, dimmedBottomBar, client.Cell(0, 24), "bottom chrome is part of the complete backdrop")
148148
149149 d.handleInput(sess, ac, []byte("\x1b"))
150150 mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput))
151- require.Equal(t, undimmed, client.Frame.At(0, 1), "full redraw restores pane rune and style")
152- require.Equal(t, topBar, client.Frame.At(0, 0))
153- require.Equal(t, bottomBar, client.Frame.At(0, 24))
151+ require.Equal(t, undimmed, client.Cell(0, 1), "full redraw restores pane rune and style")
152+ require.Equal(t, topBar, client.Cell(0, 0))
153+ require.Equal(t, bottomBar, client.Cell(0, 24))
154154 }
155155
156156 // --- test doubles -----------------------------------------------------------
@@ -672,7 +672,7 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
672672
673673 win := newTab(p, domain.Size{Cols: 4, Rows: 4})
674674 for y, text := range []string{"0000", "1111", "2222", "3333"} {
675- copy(win.focusedPane().screen.Frame.Row(y), testRow(text))
675+ writeTestRow(win.focusedPane().screen, y, text)
676676 }
677677 win.focusedPane().screen.Row = 3
678678
@@ -690,15 +690,15 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
690690 // layout: tabSize reserves 2 chrome rows (top + bottom bar) here, not 1,
691691 // so a client height of 4 (not 3) is what yields the same 2-row tab.
692692 d.resize(sess, ac, domain.Size{Cols: 4, Rows: 4})
693- require.Equal(t, "2222", frameRowString(win.focusedPane().screen.Frame, 0))
694- require.Equal(t, "3333", frameRowString(win.focusedPane().screen.Frame, 1))
693+ require.Equal(t, "2222", frameRowString(captureTestFrame(win.focusedPane().screen), 0))
694+ require.Equal(t, "3333", frameRowString(captureTestFrame(win.focusedPane().screen), 1))
695695 require.Equal(t, 2, win.focusedPane().history.Len())
696696 require.Equal(t, "0000", cellsString(win.focusedPane().history.View().Row(0)))
697697 require.Equal(t, "1111", cellsString(win.focusedPane().history.View().Row(1)))
698698
699699 d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6})
700- require.Equal(t, "2222 ", frameRowString(win.focusedPane().screen.Frame, 0))
701- require.Equal(t, "3333 ", frameRowString(win.focusedPane().screen.Frame, 1))
700+ require.Equal(t, "2222 ", frameRowString(captureTestFrame(win.focusedPane().screen), 0))
701+ require.Equal(t, "3333 ", frameRowString(captureTestFrame(win.focusedPane().screen), 1))
702702 require.Equal(t, 2, win.focusedPane().history.Len())
703703 }
704704
@@ -925,7 +925,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
925925 win := newTab(newScriptPTY(nil), domain.Size{Cols: 80, Rows: 24})
926926 p.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 100, Rows: 28}}).RunAndReturn(func(domain.Geometry) error {
927927 // The screen must not yet be resized when the PTY is: proves order.
928- screenWidthAtResize = win.focusedPane().screen.Frame.Width
928+ screenWidthAtResize = win.focusedPane().screen.Columns()
929929 return nil
930930 }).Once()
931931 win.focusedPane().pty = p
@@ -952,8 +952,8 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
952952 d.paint(sess, ac, false, nil)
953953
954954 require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize")
955- require.Equal(t, 100, win.focusedPane().screen.Frame.Width, "screen resized after pty")
956- require.Equal(t, 28, win.focusedPane().screen.Frame.Height, "screen reserves top and bottom chrome rows")
955+ require.Equal(t, 100, win.focusedPane().screen.Columns(), "screen resized after pty")
956+ require.Equal(t, 28, win.focusedPane().screen.Rows(), "screen reserves top and bottom chrome rows")
957957 require.True(t, gotOutput.Load(), "resize forces a full redraw output")
958958 }
959959
@@ -1141,7 +1141,7 @@ func TestPaintAlignsFloatingCursorWithCommittedGeometry(t *testing.T) {
11411141 cfg := d.currentFloatingConfig()
11421142 committed := calculateContentFloatingGeometry(domain.Size{Cols: contentArea.Width, Rows: contentArea.Height}, cfg)
11431143 floating := newPane("floating", nil, rectSize(committed.Inner))
1144- floating.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'F'})
1144+ writeTestRow(floating.screen, 0, "F")
11451145 floating.popupGeometry = committed
11461146 installTestFloating(testAttachmentTab(sess), floating, true)
11471147
@@ -1218,7 +1218,7 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
12181218 }
12191219 p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2})
12201220 p.screen.Write([]byte("ab\r\ncd"))
1221- document := scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil)
1221+ document := scopy.NewSnapshot(p.history, p.screen, p.screen.LineBounds(), nil)
12221222 mode := scopy.NewMode(scopy.NewDocument(document, domain.DefaultWordSeparators))
12231223 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}
12241224
@@ -39,23 +39,23 @@ func TestPaletteBackdropDimsSimultaneousCopyMode(t *testing.T) {
39 39
40 d.enterCopyMode(sess, ac)40 d.enterCopyMode(sess, ac)
41 mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput))41 mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput))
42- undimmed := client.Frame.At(0, 1)42+ undimmed := client.Cell(0, 1)
43 require.Equal(t, 'X', undimmed.Rune, "fixture must address copy-mode pane content")43 require.Equal(t, 'X', undimmed.Rune, "fixture must address copy-mode pane content")
44- copyBar := append([]renderer.Cell(nil), client.Frame.Row(client.Frame.Height-1)...)44+ copyBar := client.RowCells(client.Rows() - 1)
45 require.Contains(t, rowText(copyBar), "[SCROLL]", "fixture must capture the copy status bar")45 require.Contains(t, rowText(copyBar), "[SCROLL]", "fixture must capture the copy status bar")
46 46
47 d.enterPalette(sess, ac)47 d.enterPalette(sess, ac)
48 mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput))48 mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput))
49- dimmed := client.Frame.At(0, 1)49+ dimmed := client.Cell(0, 1)
50 require.Equal(t, undimmed.Rune, dimmed.Rune, "palette backdrop must preserve copy content")50 require.Equal(t, undimmed.Rune, dimmed.Rune, "palette backdrop must preserve copy content")
51- require.Equal(t, themeui.NewDimmer(theme).Dim(undimmed.Style), dimmed.Style, "palette backdrop must dim the composed copy frame")51+ require.Equal(t, themeui.NewDimmer(theme).Dim(undimmed.Style).Canonical(), dimmed.Style.Canonical(), "palette backdrop must dim the composed copy frame")
52 for x, cell := range copyBar {52 for x, cell := range copyBar {
53- cell.Style = themeui.NewDimmer(theme).Dim(cell.Style)53+ cell.Style = themeui.NewDimmer(theme).Dim(cell.Style).Canonical()
54- require.Equal(t, cell, client.Frame.At(x, client.Frame.Height-1), "copy status bar must be part of the complete backdrop")54+ require.Equal(t, cell, client.Cell(x, client.Rows()-1), "copy status bar must be part of the complete backdrop")
55 }55 }
56 paletteVisible := false56 paletteVisible := false
57- for y := range client.Frame.Height {57+ for y := range client.Rows() {
58- paletteVisible = paletteVisible || strings.Contains(rowText(client.Frame.Row(y)), "Commands")58+ paletteVisible = paletteVisible || strings.Contains(rowText(client.RowCells(y)), "Commands")
59 }59 }
60 require.True(t, paletteVisible, "palette must remain composed above copy mode")60 require.True(t, paletteVisible, "palette must remain composed above copy mode")
61 }61 }
@@ -108,16 +108,16 @@ func TestPaletteBackdropDimsSimultaneousPicker(t *testing.T) {
108 108
109 d.enterPicker(sess, ac)109 d.enterPicker(sess, ac)
110 mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput))110 mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput))
111- pickerTitle := client.Frame.At(31, 2)111+ pickerTitle := client.Cell(31, 2)
112 require.Equal(t, 'S', pickerTitle.Rune, "fixture must address the picker title")112 require.Equal(t, 'S', pickerTitle.Rune, "fixture must address the picker title")
113- undimmedPane := client.Frame.At(0, 1)113+ undimmedPane := client.Cell(0, 1)
114 114
115 d.enterPalette(sess, ac)115 d.enterPalette(sess, ac)
116 mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput))116 mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput))
117 dimmedPickerTitle := pickerTitle117 dimmedPickerTitle := pickerTitle
118- dimmedPickerTitle.Style = themeui.NewDimmer(backdropTheme()).Dim(pickerTitle.Style)118+ dimmedPickerTitle.Style = themeui.NewDimmer(backdropTheme()).Dim(pickerTitle.Style).Canonical()
119- require.Equal(t, dimmedPickerTitle, client.Frame.At(31, 2), "the lower-priority picker must be part of the palette backdrop")119+ require.Equal(t, dimmedPickerTitle, client.Cell(31, 2), "the lower-priority picker must be part of the palette backdrop")
120- require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmedPane.Style), client.Frame.At(0, 1).Style, "pane content outside overlays must use the theme dim style")120+ require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmedPane.Style).Canonical(), client.Cell(0, 1).Style.Canonical(), "pane content outside overlays must use the theme dim style")
121 }121 }
122 122
123 func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) {123 func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) {
@@ -130,27 +130,27 @@ func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) {
130 pane.screen.Write([]byte("X"))130 pane.screen.Write([]byte("X"))
131 d.paint(sess, ac, true, nil)131 d.paint(sess, ac, true, nil)
132 mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput))132 mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput))
133- undimmed := client.Frame.At(0, 1)133+ undimmed := client.Cell(0, 1)
134- topBar := client.Frame.At(0, 0)134+ topBar := client.Cell(0, 0)
135- bottomBar := client.Frame.At(0, 24)135+ bottomBar := client.Cell(0, 24)
136 136
137 d.handleInput(sess, ac, []byte("\x1b "))137 d.handleInput(sess, ac, []byte("\x1b "))
138 mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput))138 mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput))
139- dimmed := client.Frame.At(0, 1)139+ dimmed := client.Cell(0, 1)
140 require.Equal(t, 'X', dimmed.Rune)140 require.Equal(t, 'X', dimmed.Rune)
141- require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmed.Style), dimmed.Style, "open palette must use the theme dim style")141+ require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmed.Style).Canonical(), dimmed.Style.Canonical(), "open palette must use the theme dim style")
142 dimmedTopBar := topBar142 dimmedTopBar := topBar
143- dimmedTopBar.Style = themeui.NewDimmer(backdropTheme()).Dim(topBar.Style)143+ dimmedTopBar.Style = themeui.NewDimmer(backdropTheme()).Dim(topBar.Style).Canonical()
144 dimmedBottomBar := bottomBar144 dimmedBottomBar := bottomBar
145- dimmedBottomBar.Style = themeui.NewDimmer(backdropTheme()).Dim(bottomBar.Style)145+ dimmedBottomBar.Style = themeui.NewDimmer(backdropTheme()).Dim(bottomBar.Style).Canonical()
146- require.Equal(t, dimmedTopBar, client.Frame.At(0, 0), "top chrome is part of the complete backdrop")146+ require.Equal(t, dimmedTopBar, client.Cell(0, 0), "top chrome is part of the complete backdrop")
147- require.Equal(t, dimmedBottomBar, client.Frame.At(0, 24), "bottom chrome is part of the complete backdrop")147+ require.Equal(t, dimmedBottomBar, client.Cell(0, 24), "bottom chrome is part of the complete backdrop")
148 148
149 d.handleInput(sess, ac, []byte("\x1b"))149 d.handleInput(sess, ac, []byte("\x1b"))
150 mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput))150 mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput))
151- require.Equal(t, undimmed, client.Frame.At(0, 1), "full redraw restores pane rune and style")151+ require.Equal(t, undimmed, client.Cell(0, 1), "full redraw restores pane rune and style")
152- require.Equal(t, topBar, client.Frame.At(0, 0))152+ require.Equal(t, topBar, client.Cell(0, 0))
153- require.Equal(t, bottomBar, client.Frame.At(0, 24))153+ require.Equal(t, bottomBar, client.Cell(0, 24))
154 }154 }
155 155
156 // --- test doubles -----------------------------------------------------------156 // --- test doubles -----------------------------------------------------------
@@ -672,7 +672,7 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
672 672
673 win := newTab(p, domain.Size{Cols: 4, Rows: 4})673 win := newTab(p, domain.Size{Cols: 4, Rows: 4})
674 for y, text := range []string{"0000", "1111", "2222", "3333"} {674 for y, text := range []string{"0000", "1111", "2222", "3333"} {
675- copy(win.focusedPane().screen.Frame.Row(y), testRow(text))675+ writeTestRow(win.focusedPane().screen, y, text)
676 }676 }
677 win.focusedPane().screen.Row = 3677 win.focusedPane().screen.Row = 3
678 678
@@ -690,15 +690,15 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
690 // layout: tabSize reserves 2 chrome rows (top + bottom bar) here, not 1,690 // layout: tabSize reserves 2 chrome rows (top + bottom bar) here, not 1,
691 // so a client height of 4 (not 3) is what yields the same 2-row tab.691 // so a client height of 4 (not 3) is what yields the same 2-row tab.
692 d.resize(sess, ac, domain.Size{Cols: 4, Rows: 4})692 d.resize(sess, ac, domain.Size{Cols: 4, Rows: 4})
693- require.Equal(t, "2222", frameRowString(win.focusedPane().screen.Frame, 0))693+ require.Equal(t, "2222", frameRowString(captureTestFrame(win.focusedPane().screen), 0))
694- require.Equal(t, "3333", frameRowString(win.focusedPane().screen.Frame, 1))694+ require.Equal(t, "3333", frameRowString(captureTestFrame(win.focusedPane().screen), 1))
695 require.Equal(t, 2, win.focusedPane().history.Len())695 require.Equal(t, 2, win.focusedPane().history.Len())
696 require.Equal(t, "0000", cellsString(win.focusedPane().history.View().Row(0)))696 require.Equal(t, "0000", cellsString(win.focusedPane().history.View().Row(0)))
697 require.Equal(t, "1111", cellsString(win.focusedPane().history.View().Row(1)))697 require.Equal(t, "1111", cellsString(win.focusedPane().history.View().Row(1)))
698 698
699 d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6})699 d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6})
700- require.Equal(t, "2222 ", frameRowString(win.focusedPane().screen.Frame, 0))700+ require.Equal(t, "2222 ", frameRowString(captureTestFrame(win.focusedPane().screen), 0))
701- require.Equal(t, "3333 ", frameRowString(win.focusedPane().screen.Frame, 1))701+ require.Equal(t, "3333 ", frameRowString(captureTestFrame(win.focusedPane().screen), 1))
702 require.Equal(t, 2, win.focusedPane().history.Len())702 require.Equal(t, 2, win.focusedPane().history.Len())
703 }703 }
704 704
@@ -925,7 +925,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
925 win := newTab(newScriptPTY(nil), domain.Size{Cols: 80, Rows: 24})925 win := newTab(newScriptPTY(nil), domain.Size{Cols: 80, Rows: 24})
926 p.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 100, Rows: 28}}).RunAndReturn(func(domain.Geometry) error {926 p.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 100, Rows: 28}}).RunAndReturn(func(domain.Geometry) error {
927 // The screen must not yet be resized when the PTY is: proves order.927 // The screen must not yet be resized when the PTY is: proves order.
928- screenWidthAtResize = win.focusedPane().screen.Frame.Width928+ screenWidthAtResize = win.focusedPane().screen.Columns()
929 return nil929 return nil
930 }).Once()930 }).Once()
931 win.focusedPane().pty = p931 win.focusedPane().pty = p
@@ -952,8 +952,8 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
952 d.paint(sess, ac, false, nil)952 d.paint(sess, ac, false, nil)
953 953
954 require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize")954 require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize")
955- require.Equal(t, 100, win.focusedPane().screen.Frame.Width, "screen resized after pty")955+ require.Equal(t, 100, win.focusedPane().screen.Columns(), "screen resized after pty")
956- require.Equal(t, 28, win.focusedPane().screen.Frame.Height, "screen reserves top and bottom chrome rows")956+ require.Equal(t, 28, win.focusedPane().screen.Rows(), "screen reserves top and bottom chrome rows")
957 require.True(t, gotOutput.Load(), "resize forces a full redraw output")957 require.True(t, gotOutput.Load(), "resize forces a full redraw output")
958 }958 }
959 959
@@ -1141,7 +1141,7 @@ func TestPaintAlignsFloatingCursorWithCommittedGeometry(t *testing.T) {
1141 cfg := d.currentFloatingConfig()1141 cfg := d.currentFloatingConfig()
1142 committed := calculateContentFloatingGeometry(domain.Size{Cols: contentArea.Width, Rows: contentArea.Height}, cfg)1142 committed := calculateContentFloatingGeometry(domain.Size{Cols: contentArea.Width, Rows: contentArea.Height}, cfg)
1143 floating := newPane("floating", nil, rectSize(committed.Inner))1143 floating := newPane("floating", nil, rectSize(committed.Inner))
1144- floating.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'F'})1144+ writeTestRow(floating.screen, 0, "F")
1145 floating.popupGeometry = committed1145 floating.popupGeometry = committed
1146 installTestFloating(testAttachmentTab(sess), floating, true)1146 installTestFloating(testAttachmentTab(sess), floating, true)
1147 1147
@@ -1218,7 +1218,7 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
1218 }1218 }
1219 p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2})1219 p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2})
1220 p.screen.Write([]byte("ab\r\ncd"))1220 p.screen.Write([]byte("ab\r\ncd"))
1221- document := scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil)1221+ document := scopy.NewSnapshot(p.history, p.screen, p.screen.LineBounds(), nil)
1222 mode := scopy.NewMode(scopy.NewDocument(document, domain.DefaultWordSeparators))1222 mode := scopy.NewMode(scopy.NewDocument(document, domain.DefaultWordSeparators))
1223 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}1223 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}
1224 1224
M internal/usecase/daemon/resume_test.go +1 -1
@@ -1253,7 +1253,7 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) {
12531253 require.Zero(t, output.Base, "resume must start with a complete frame")
12541254 terminal := vt.NewScreen(resumedAC.size.Cols, resumedAC.size.Rows)
12551255 terminal.Write(output.Data)
1256- contents := strings.Join(frameRows(terminal.Frame), "\n")
1256+ contents := strings.Join(frameRows(terminal), "\n")
12571257 require.Contains(t, contents, "survivor", "resume first paint must contain current headless content")
12581258 require.NotContains(t, contents, "closed", "resume first paint must not contain closed pane content")
12591259 }
@@ -1253,7 +1253,7 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) {
1253 require.Zero(t, output.Base, "resume must start with a complete frame")1253 require.Zero(t, output.Base, "resume must start with a complete frame")
1254 terminal := vt.NewScreen(resumedAC.size.Cols, resumedAC.size.Rows)1254 terminal := vt.NewScreen(resumedAC.size.Cols, resumedAC.size.Rows)
1255 terminal.Write(output.Data)1255 terminal.Write(output.Data)
1256- contents := strings.Join(frameRows(terminal.Frame), "\n")1256+ contents := strings.Join(frameRows(terminal), "\n")
1257 require.Contains(t, contents, "survivor", "resume first paint must contain current headless content")1257 require.Contains(t, contents, "survivor", "resume first paint must contain current headless content")
1258 require.NotContains(t, contents, "closed", "resume first paint must not contain closed pane content")1258 require.NotContains(t, contents, "closed", "resume first paint must not contain closed pane content")
1259 }1259 }
M internal/usecase/daemon/session.go +7 -5
@@ -15,6 +15,7 @@ import (
1515 "sync/atomic"
1616 "time"
1717
18+ vt "github.com/bnema/vev-vt"
1819 "github.com/bnema/vev/internal/domain"
1920 "github.com/bnema/vev/internal/ports"
2021 "github.com/bnema/vev/internal/protocol"
@@ -324,7 +325,7 @@ func (d *Daemon) createSessionLockedWithModeAndInactiveFence(name string, epheme
324325 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session: shell failed to start", err)
325326 }
326327 appliedPaneGeometry := initialPaneGeometry
327- tb := newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, tbSize, launch.title)
328+ tb := newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, tbSize, launch.title, d.currentHistoryConfig())
328329 if pane := tb.focusedPane(); pane != nil {
329330 pane.geometry = appliedPaneGeometry
330331 setScreenGeometry(pane.screen, appliedPaneGeometry)
@@ -675,7 +676,7 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, _ dom
675676 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")
676677 return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err)
677678 }
678- tb := newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, tbSize, launch.title)
679+ tb := newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, tbSize, launch.title, d.currentHistoryConfig())
679680 if pane := tb.focusedPane(); pane != nil {
680681 pane.geometry = claimGeometry
681682 setScreenGeometry(pane.screen, claimGeometry)
@@ -736,12 +737,12 @@ func newTab(pty ports.PTY, sz domain.Size) *tab {
736737 }
737738
738739 func newTabWithStableID(tabStableID, paneStableID string, pty ports.PTY, sz domain.Size) *tab {
739- return newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, sz, defaultShellTitle)
740+ return newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, sz, defaultShellTitle, historyConfigFor(domain.DefaultScrollbackConfig()))
740741 }
741742
742-func newTabWithStableIDAndTitle(tabStableID, paneStableID string, pty ports.PTY, sz domain.Size, title string) *tab {
743+func newTabWithStableIDAndTitle(tabStableID, paneStableID string, pty ports.PTY, sz domain.Size, title string, historyConfig vt.HistoryConfig) *tab {
743744 id := layout.PaneID("pane-1")
744- p := newPaneWithStableIDAndTitle(id, paneStableID, pty, sz, title)
745+ p := newPaneWithStableIDAndTitle(id, paneStableID, pty, sz, title, historyConfig)
745746 return &tab{
746747 stableID: tabStableID,
747748 tree: layout.NewTree(id),
@@ -850,6 +851,7 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {
850851 if owner.session != sess || owner.tab != tb {
851852 return
852853 }
854+ d.applyHistoryLimitsToPane(p)
853855 sess.mu.Lock()
854856 name := sess.name
855857 sess.mu.Unlock()
@@ -15,6 +15,7 @@ import (
15 "sync/atomic"15 "sync/atomic"
16 "time"16 "time"
17 17
18+ vt "github.com/bnema/vev-vt"
18 "github.com/bnema/vev/internal/domain"19 "github.com/bnema/vev/internal/domain"
19 "github.com/bnema/vev/internal/ports"20 "github.com/bnema/vev/internal/ports"
20 "github.com/bnema/vev/internal/protocol"21 "github.com/bnema/vev/internal/protocol"
@@ -324,7 +325,7 @@ func (d *Daemon) createSessionLockedWithModeAndInactiveFence(name string, epheme
324 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session: shell failed to start", err)325 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session: shell failed to start", err)
325 }326 }
326 appliedPaneGeometry := initialPaneGeometry327 appliedPaneGeometry := initialPaneGeometry
327- tb := newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, tbSize, launch.title)328+ tb := newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, tbSize, launch.title, d.currentHistoryConfig())
328 if pane := tb.focusedPane(); pane != nil {329 if pane := tb.focusedPane(); pane != nil {
329 pane.geometry = appliedPaneGeometry330 pane.geometry = appliedPaneGeometry
330 setScreenGeometry(pane.screen, appliedPaneGeometry)331 setScreenGeometry(pane.screen, appliedPaneGeometry)
@@ -675,7 +676,7 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, _ dom
675 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")676 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")
676 return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err)677 return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err)
677 }678 }
678- tb := newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, tbSize, launch.title)679+ tb := newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, tbSize, launch.title, d.currentHistoryConfig())
679 if pane := tb.focusedPane(); pane != nil {680 if pane := tb.focusedPane(); pane != nil {
680 pane.geometry = claimGeometry681 pane.geometry = claimGeometry
681 setScreenGeometry(pane.screen, claimGeometry)682 setScreenGeometry(pane.screen, claimGeometry)
@@ -736,12 +737,12 @@ func newTab(pty ports.PTY, sz domain.Size) *tab {
736 }737 }
737 738
738 func newTabWithStableID(tabStableID, paneStableID string, pty ports.PTY, sz domain.Size) *tab {739 func newTabWithStableID(tabStableID, paneStableID string, pty ports.PTY, sz domain.Size) *tab {
739- return newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, sz, defaultShellTitle)740+ return newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, sz, defaultShellTitle, historyConfigFor(domain.DefaultScrollbackConfig()))
740 }741 }
741 742
742-func newTabWithStableIDAndTitle(tabStableID, paneStableID string, pty ports.PTY, sz domain.Size, title string) *tab {743+func newTabWithStableIDAndTitle(tabStableID, paneStableID string, pty ports.PTY, sz domain.Size, title string, historyConfig vt.HistoryConfig) *tab {
743 id := layout.PaneID("pane-1")744 id := layout.PaneID("pane-1")
744- p := newPaneWithStableIDAndTitle(id, paneStableID, pty, sz, title)745+ p := newPaneWithStableIDAndTitle(id, paneStableID, pty, sz, title, historyConfig)
745 return &tab{746 return &tab{
746 stableID: tabStableID,747 stableID: tabStableID,
747 tree: layout.NewTree(id),748 tree: layout.NewTree(id),
@@ -850,6 +851,7 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {
850 if owner.session != sess || owner.tab != tb {851 if owner.session != sess || owner.tab != tb {
851 return852 return
852 }853 }
854+ d.applyHistoryLimitsToPane(p)
853 sess.mu.Lock()855 sess.mu.Lock()
854 name := sess.name856 name := sess.name
855 sess.mu.Unlock()857 sess.mu.Unlock()
M internal/usecase/daemon/snapshot_restore.go +2 -2
@@ -270,7 +270,7 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable
270270 }
271271 return nil, err
272272 }
273- p := newPaneWithStableIDAndTitle(paneSnap.ID, paneStableID, pty, ptyGeometry.Size, launch.title)
273+ p := newPaneWithStableIDAndTitle(paneSnap.ID, paneStableID, pty, ptyGeometry.Size, launch.title, d.currentHistoryConfig())
274274 if !lifetime.publish(p) {
275275 _ = pty.Close()
276276 return nil, lifetime.ctx.Err()
@@ -372,7 +372,7 @@ func restorePaneTerminal(p *pane, snap snapcodec.Pane) error {
372372 }
373373 p.mu.Lock()
374374 defer p.mu.Unlock()
375- screen, err := vt.NewScreenWithRecoveryTranscript(p.screen.Frame.Width, p.screen.Frame.Height, vt.HistoryConfig{MaxRows: defaultScrollbackRows, MaxCells: defaultScrollbackCells}, snap.SealedChunks, snap.Tail, snap.Transcript)
375+ screen, err := vt.NewScreenWithRecoveryTranscript(p.screen.Columns(), p.screen.Rows(), p.history.Limits(), snap.SealedChunks, snap.Tail, snap.Transcript)
376376 if err != nil {
377377 return fmt.Errorf("snapshot recovery: %w", err)
378378 }
@@ -270,7 +270,7 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable
270 }270 }
271 return nil, err271 return nil, err
272 }272 }
273- p := newPaneWithStableIDAndTitle(paneSnap.ID, paneStableID, pty, ptyGeometry.Size, launch.title)273+ p := newPaneWithStableIDAndTitle(paneSnap.ID, paneStableID, pty, ptyGeometry.Size, launch.title, d.currentHistoryConfig())
274 if !lifetime.publish(p) {274 if !lifetime.publish(p) {
275 _ = pty.Close()275 _ = pty.Close()
276 return nil, lifetime.ctx.Err()276 return nil, lifetime.ctx.Err()
@@ -372,7 +372,7 @@ func restorePaneTerminal(p *pane, snap snapcodec.Pane) error {
372 }372 }
373 p.mu.Lock()373 p.mu.Lock()
374 defer p.mu.Unlock()374 defer p.mu.Unlock()
375- screen, err := vt.NewScreenWithRecoveryTranscript(p.screen.Frame.Width, p.screen.Frame.Height, vt.HistoryConfig{MaxRows: defaultScrollbackRows, MaxCells: defaultScrollbackCells}, snap.SealedChunks, snap.Tail, snap.Transcript)375+ screen, err := vt.NewScreenWithRecoveryTranscript(p.screen.Columns(), p.screen.Rows(), p.history.Limits(), snap.SealedChunks, snap.Tail, snap.Transcript)
376 if err != nil {376 if err != nil {
377 return fmt.Errorf("snapshot recovery: %w", err)377 return fmt.Errorf("snapshot recovery: %w", err)
378 }378 }
M internal/usecase/daemon/snapshot_restore_test.go +13 -7
@@ -133,7 +133,7 @@ func cloneAcceptanceGeneration(g ports.SnapshotGeneration) ports.SnapshotGenerat
133133
134134 func restoreAcceptanceSession(t *testing.T, name string) snapcodec.Session {
135135 t.Helper()
136- history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 8 * 293, ChunkRows: 2})
136+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxBytes: 8 * 293 * 32, ChunkRows: 2})
137137 for _, ch := range []rune{'a', 'b', 'c', 'd', 'e'} {
138138 row := make([]renderer.Cell, 293)
139139 for i := range row {
@@ -242,15 +242,21 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) {
242242 pane.mu.Lock()
243243 wantHistory := []string{strings.Repeat("a", 293), strings.Repeat("b", 293), strings.Repeat("c", 293), strings.Repeat("d", 293), strings.Repeat("e", 293), "primary-visible", "alternate-active"}
244244 require.Equal(t, wantHistory, snapshotHistoryTexts(pane.history.View()), "bounded history must precede primary and active alternate transcript rows")
245- for y := range pane.screen.Frame.Height {
246- for x := range pane.screen.Frame.Width {
247- require.Truef(t, pane.screen.Frame.At(x, y).Equal(renderer.BlankCell()), "restored frame cell (%d,%d) must be blank", x, y)
245+ for y := range pane.screen.Rows() {
246+ for x := range pane.screen.Columns() {
247+ require.Truef(t, pane.screen.Cell(x, y).Equal(renderer.BlankCell()), "restored frame cell (%d,%d) must be blank", x, y)
248248 }
249249 }
250250 require.Equal(t, 7, pane.history.Len())
251- require.Equal(t, 7*293, pane.history.Cells())
251+ cells := 0
252+ view := pane.history.View()
253+ for y := range view.Len() {
254+ cells += view.RowWidth(y)
255+ }
256+ require.Equal(t, 7*293, cells)
252257 require.Equal(t, defaultScrollbackRows, pane.history.Cap())
253- require.Equal(t, defaultScrollbackCells, pane.history.CellCap())
258+ require.Equal(t, defaultScrollbackBytes, pane.history.ByteCap())
259+ require.LessOrEqual(t, pane.history.LogicalBytes(), pane.history.ByteCap())
254260 pane.screen.Write([]byte("new-output"))
255261 pane.screen.Write([]byte("\x1b[?1049h\x1b[?1049l"))
256262 require.Equal(t, wantHistory, snapshotHistoryTexts(pane.history.View()), "new output and redraw must not remove recovered history")
@@ -558,7 +564,7 @@ func TestRestorePaneTerminalRequiresCompleteRecoveryAndInstallsAtomically(t *tes
558564 require.Error(t, err)
559565 require.Same(t, oldScreen, p.screen)
560566 require.Same(t, oldHistory, p.history)
561- require.Equal(t, 'o', p.screen.Frame.At(0, 0).Rune, "a failed restore must never install a partial or old persisted frame")
567+ require.Equal(t, 'o', p.screen.Cell(0, 0).Rune, "a failed restore must never install a partial or old persisted frame")
562568 require.Equal(t, "old-history", cellsString(p.history.View().Row(0)))
563569 })
564570 }
@@ -133,7 +133,7 @@ func cloneAcceptanceGeneration(g ports.SnapshotGeneration) ports.SnapshotGenerat
133 133
134 func restoreAcceptanceSession(t *testing.T, name string) snapcodec.Session {134 func restoreAcceptanceSession(t *testing.T, name string) snapcodec.Session {
135 t.Helper()135 t.Helper()
136- history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 8 * 293, ChunkRows: 2})136+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxBytes: 8 * 293 * 32, ChunkRows: 2})
137 for _, ch := range []rune{'a', 'b', 'c', 'd', 'e'} {137 for _, ch := range []rune{'a', 'b', 'c', 'd', 'e'} {
138 row := make([]renderer.Cell, 293)138 row := make([]renderer.Cell, 293)
139 for i := range row {139 for i := range row {
@@ -242,15 +242,21 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) {
242 pane.mu.Lock()242 pane.mu.Lock()
243 wantHistory := []string{strings.Repeat("a", 293), strings.Repeat("b", 293), strings.Repeat("c", 293), strings.Repeat("d", 293), strings.Repeat("e", 293), "primary-visible", "alternate-active"}243 wantHistory := []string{strings.Repeat("a", 293), strings.Repeat("b", 293), strings.Repeat("c", 293), strings.Repeat("d", 293), strings.Repeat("e", 293), "primary-visible", "alternate-active"}
244 require.Equal(t, wantHistory, snapshotHistoryTexts(pane.history.View()), "bounded history must precede primary and active alternate transcript rows")244 require.Equal(t, wantHistory, snapshotHistoryTexts(pane.history.View()), "bounded history must precede primary and active alternate transcript rows")
245- for y := range pane.screen.Frame.Height {245+ for y := range pane.screen.Rows() {
246- for x := range pane.screen.Frame.Width {246+ for x := range pane.screen.Columns() {
247- require.Truef(t, pane.screen.Frame.At(x, y).Equal(renderer.BlankCell()), "restored frame cell (%d,%d) must be blank", x, y)247+ require.Truef(t, pane.screen.Cell(x, y).Equal(renderer.BlankCell()), "restored frame cell (%d,%d) must be blank", x, y)
248 }248 }
249 }249 }
250 require.Equal(t, 7, pane.history.Len())250 require.Equal(t, 7, pane.history.Len())
251- require.Equal(t, 7*293, pane.history.Cells())251+ cells := 0
252+ view := pane.history.View()
253+ for y := range view.Len() {
254+ cells += view.RowWidth(y)
255+ }
256+ require.Equal(t, 7*293, cells)
252 require.Equal(t, defaultScrollbackRows, pane.history.Cap())257 require.Equal(t, defaultScrollbackRows, pane.history.Cap())
253- require.Equal(t, defaultScrollbackCells, pane.history.CellCap())258+ require.Equal(t, defaultScrollbackBytes, pane.history.ByteCap())
259+ require.LessOrEqual(t, pane.history.LogicalBytes(), pane.history.ByteCap())
254 pane.screen.Write([]byte("new-output"))260 pane.screen.Write([]byte("new-output"))
255 pane.screen.Write([]byte("\x1b[?1049h\x1b[?1049l"))261 pane.screen.Write([]byte("\x1b[?1049h\x1b[?1049l"))
256 require.Equal(t, wantHistory, snapshotHistoryTexts(pane.history.View()), "new output and redraw must not remove recovered history")262 require.Equal(t, wantHistory, snapshotHistoryTexts(pane.history.View()), "new output and redraw must not remove recovered history")
@@ -558,7 +564,7 @@ func TestRestorePaneTerminalRequiresCompleteRecoveryAndInstallsAtomically(t *tes
558 require.Error(t, err)564 require.Error(t, err)
559 require.Same(t, oldScreen, p.screen)565 require.Same(t, oldScreen, p.screen)
560 require.Same(t, oldHistory, p.history)566 require.Same(t, oldHistory, p.history)
561- require.Equal(t, 'o', p.screen.Frame.At(0, 0).Rune, "a failed restore must never install a partial or old persisted frame")567+ require.Equal(t, 'o', p.screen.Cell(0, 0).Rune, "a failed restore must never install a partial or old persisted frame")
562 require.Equal(t, "old-history", cellsString(p.history.View().Row(0)))568 require.Equal(t, "old-history", cellsString(p.history.View().Row(0)))
563 })569 })
564 }570 }
M internal/usecase/daemon/status_test.go +1 -1
@@ -90,7 +90,7 @@ func TestStatusCompositionGolden(t *testing.T) {
9090 reset: true,
9191 layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 2}, focus: win.focusedPane().id, valid: true},
9292 panes: []capturedPaneRenderState{{
93- id: win.focusedPane().id, frame: win.focusedPane().screen.Frame.Clone(), placement: layout.Placement{ID: win.focusedPane().id, Content: domain.Rect{Width: 12, Height: 2}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()},
93+ id: win.focusedPane().id, frame: captureTestFrame(win.focusedPane().screen), placement: layout.Placement{ID: win.focusedPane().id, Content: domain.Rect{Width: 12, Height: 2}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()},
9494 }},
9595 bars: barState{status: sess.statusSegmentsFor(ac, true)},
9696 }
@@ -90,7 +90,7 @@ func TestStatusCompositionGolden(t *testing.T) {
90 reset: true,90 reset: true,
91 layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 2}, focus: win.focusedPane().id, valid: true},91 layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 2}, focus: win.focusedPane().id, valid: true},
92 panes: []capturedPaneRenderState{{92 panes: []capturedPaneRenderState{{
93- id: win.focusedPane().id, frame: win.focusedPane().screen.Frame.Clone(), placement: layout.Placement{ID: win.focusedPane().id, Content: domain.Rect{Width: 12, Height: 2}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()},93+ id: win.focusedPane().id, frame: captureTestFrame(win.focusedPane().screen), placement: layout.Placement{ID: win.focusedPane().id, Content: domain.Rect{Width: 12, Height: 2}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()},
94 }},94 }},
95 bars: barState{status: sess.statusSegmentsFor(ac, true)},95 bars: barState{status: sess.statusSegmentsFor(ac, true)},
96 }96 }
M internal/usecase/daemon/theme_cache_test.go +3 -3
@@ -74,7 +74,7 @@ func TestAppliedThemeKeepsDimmerOutsideResolvedChrome(t *testing.T) {
7474 }
7575 out := composeFrame(state, composeCacheInput{})
7676 require.Equal(t, cell, out.frame.At(0, 1))
77- require.Equal(t, themeui.NewDimmer(raw, themeui.WithForegroundDimming(inactivePaneForegroundDimming)).Dim(cell.Style), out.frame.At(2, 1).Style)
77+ require.Equal(t, themeui.NewDimmer(raw, themeui.WithForegroundDimming(inactivePaneForegroundDimming)).Dim(cell.Style).Canonical(), out.frame.At(2, 1).Style.Canonical())
7878 }
7979
8080 func TestChromeDimmersUseNeutralInputsOutsideAccentRamp(t *testing.T) {
@@ -83,8 +83,8 @@ func TestChromeDimmersUseNeutralInputsOutsideAccentRamp(t *testing.T) {
8383 neutral := renderer.DefaultStyle()
8484 neutral.HasForegroundRGB = true
8585 neutral.ForegroundRGB = themeui.Blend(raw.Foreground, raw.Background, 0.40)
86- want := themeui.NewDimmer(raw).Dim(neutral)
87- accentDerived := themeui.NewDimmer(raw).Dim(resolved.Styles.BorderMuted)
86+ want := themeui.NewDimmer(raw).Dim(neutral).Canonical()
87+ accentDerived := themeui.NewDimmer(raw).Dim(resolved.Styles.BorderMuted).Canonical()
8888
8989 split := cachedSplitState("neutral-divider", "left", layout.Horizontal, raw)
9090 split.styles = resolved.Styles
@@ -74,7 +74,7 @@ func TestAppliedThemeKeepsDimmerOutsideResolvedChrome(t *testing.T) {
74 }74 }
75 out := composeFrame(state, composeCacheInput{})75 out := composeFrame(state, composeCacheInput{})
76 require.Equal(t, cell, out.frame.At(0, 1))76 require.Equal(t, cell, out.frame.At(0, 1))
77- require.Equal(t, themeui.NewDimmer(raw, themeui.WithForegroundDimming(inactivePaneForegroundDimming)).Dim(cell.Style), out.frame.At(2, 1).Style)77+ require.Equal(t, themeui.NewDimmer(raw, themeui.WithForegroundDimming(inactivePaneForegroundDimming)).Dim(cell.Style).Canonical(), out.frame.At(2, 1).Style.Canonical())
78 }78 }
79 79
80 func TestChromeDimmersUseNeutralInputsOutsideAccentRamp(t *testing.T) {80 func TestChromeDimmersUseNeutralInputsOutsideAccentRamp(t *testing.T) {
@@ -83,8 +83,8 @@ func TestChromeDimmersUseNeutralInputsOutsideAccentRamp(t *testing.T) {
83 neutral := renderer.DefaultStyle()83 neutral := renderer.DefaultStyle()
84 neutral.HasForegroundRGB = true84 neutral.HasForegroundRGB = true
85 neutral.ForegroundRGB = themeui.Blend(raw.Foreground, raw.Background, 0.40)85 neutral.ForegroundRGB = themeui.Blend(raw.Foreground, raw.Background, 0.40)
86- want := themeui.NewDimmer(raw).Dim(neutral)86+ want := themeui.NewDimmer(raw).Dim(neutral).Canonical()
87- accentDerived := themeui.NewDimmer(raw).Dim(resolved.Styles.BorderMuted)87+ accentDerived := themeui.NewDimmer(raw).Dim(resolved.Styles.BorderMuted).Canonical()
88 88
89 split := cachedSplitState("neutral-divider", "left", layout.Horizontal, raw)89 split := cachedSplitState("neutral-divider", "left", layout.Horizontal, raw)
90 split.styles = resolved.Styles90 split.styles = resolved.Styles
M internal/usecase/daemon/toast_damage_test.go +2 -2
@@ -40,7 +40,7 @@ func TestComposeFrameToastDamageIsIncrementalAndRestoresOldFootprints(t *testing
4040 require.NoError(t, err)
4141 require.Less(t, len(incremental)*2, len(full), "one-cell update with a stable toast must stay bounded below full-screen output")
4242 terminal.Write(incremental)
43- require.Equal(t, frameRows(updated.frame), frameRows(terminal.Frame))
43+ require.Equal(t, frameRows(updated.frame), frameRows(terminal))
4444
4545 // Stacking and overflow stay visually identical while their complete old and
4646 // new coverage remains incremental damage.
@@ -129,7 +129,7 @@ func replayToastFrame(t *testing.T, stream *attachmentOutput, terminal *vt.Scree
129129 data, err := stream.render(composed.frame, composed.damage, composed.reset)
130130 require.NoError(t, err)
131131 terminal.Write(data)
132- require.Equal(t, frameRows(composed.frame), frameRows(terminal.Frame))
132+ require.Equal(t, frameRows(composed.frame), frameRows(terminal))
133133 }
134134
135135 func requireNoFullRedraw(t *testing.T, damage []renderer.Damage) {
@@ -40,7 +40,7 @@ func TestComposeFrameToastDamageIsIncrementalAndRestoresOldFootprints(t *testing
40 require.NoError(t, err)40 require.NoError(t, err)
41 require.Less(t, len(incremental)*2, len(full), "one-cell update with a stable toast must stay bounded below full-screen output")41 require.Less(t, len(incremental)*2, len(full), "one-cell update with a stable toast must stay bounded below full-screen output")
42 terminal.Write(incremental)42 terminal.Write(incremental)
43- require.Equal(t, frameRows(updated.frame), frameRows(terminal.Frame))43+ require.Equal(t, frameRows(updated.frame), frameRows(terminal))
44 44
45 // Stacking and overflow stay visually identical while their complete old and45 // Stacking and overflow stay visually identical while their complete old and
46 // new coverage remains incremental damage.46 // new coverage remains incremental damage.
@@ -129,7 +129,7 @@ func replayToastFrame(t *testing.T, stream *attachmentOutput, terminal *vt.Scree
129 data, err := stream.render(composed.frame, composed.damage, composed.reset)129 data, err := stream.render(composed.frame, composed.damage, composed.reset)
130 require.NoError(t, err)130 require.NoError(t, err)
131 terminal.Write(data)131 terminal.Write(data)
132- require.Equal(t, frameRows(composed.frame), frameRows(terminal.Frame))132+ require.Equal(t, frameRows(composed.frame), frameRows(terminal))
133 }133 }
134 134
135 func requireNoFullRedraw(t *testing.T, damage []renderer.Damage) {135 func requireNoFullRedraw(t *testing.T, damage []renderer.Damage) {
M internal/usecase/daemon/transactional_resize_test.go +18 -18
@@ -136,8 +136,8 @@ func TestReplayResizePendingBuffersSuccessFailureAndBatchOrder(t *testing.T) {
136136 p.resizeApplying = true
137137 p.resizePending = []byte("A\x1b[1;81HB")
138138 d.replayResizePending(sess, tb, p, true, domain.Rect{Width: 120, Height: 23})
139- require.Equal(t, 120, p.screen.Frame.Width)
140- require.Equal(t, 'B', p.screen.Frame.At(80, 0).Rune)
139+ require.Equal(t, 120, p.screen.Columns())
140+ require.Equal(t, 'B', p.screen.Cell(80, 0).Rune)
141141 require.False(t, p.resizeApplying)
142142 })
143143 t.Run("failure retains old parser width", func(t *testing.T) {
@@ -147,8 +147,8 @@ func TestReplayResizePendingBuffersSuccessFailureAndBatchOrder(t *testing.T) {
147147 p.resizeApplying = true
148148 p.resizePending = []byte("\x1b[1;81HB")
149149 d.replayResizePending(sess, tb, p, false, domain.Rect{Width: 120, Height: 23})
150- require.Equal(t, 80, p.screen.Frame.Width)
151- require.Equal(t, 'B', p.screen.Frame.At(79, 0).Rune)
150+ require.Equal(t, 80, p.screen.Columns())
151+ require.Equal(t, 'B', p.screen.Cell(79, 0).Rune)
152152 })
153153 t.Run("batches retain read order", func(t *testing.T) {
154154 pty := &transactionalResizePTY{}
@@ -220,7 +220,7 @@ func TestApplySessionLayoutStopsRetryWhenSessionCanceled(t *testing.T) {
220220 tb.mu.Unlock()
221221 p.mu.Lock()
222222 require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect, "canceled session published a pane rectangle")
223- require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}, "canceled session published a VT size")
223+ require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()}, "canceled session published a VT size")
224224 p.mu.Unlock()
225225 }
226226
@@ -299,7 +299,7 @@ func TestTransactionalResizePartialFailureCommitsOnlySuccessfulPTYState(t *testi
299299 tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"}
300300 first := tb.panes["pane-1"]
301301 oldFailedRect := second.rect
302- oldFailedScreen := domain.Size{Cols: second.screen.Frame.Width, Rows: second.screen.Frame.Height}
302+ oldFailedScreen := domain.Size{Cols: second.screen.Columns(), Rows: second.screen.Rows()}
303303 tb.mu.Unlock()
304304
305305 d.resize(sess, ac, domain.Size{Cols: 60, Rows: 20})
@@ -310,7 +310,7 @@ func TestTransactionalResizePartialFailureCommitsOnlySuccessfulPTYState(t *testi
310310 "all pane rectangles publish with the committed layout")
311311 require.Equal(t, []domain.Size{{Cols: 30, Rows: 18}}, ok.requested())
312312 require.Equal(t, []domain.Size{{Cols: 29, Rows: 18}}, failed.requested())
313- require.Equal(t, oldFailedScreen, domain.Size{Cols: second.screen.Frame.Width, Rows: second.screen.Frame.Height},
313+ require.Equal(t, oldFailedScreen, domain.Size{Cols: second.screen.Columns(), Rows: second.screen.Rows()},
314314 "failed PTY retains its parser/screen size for clipped padded composition")
315315 require.NotEqual(t, oldFailedRect, second.rect, "failed PTY rectangle still follows the committed layout")
316316 }
@@ -345,7 +345,7 @@ func TestTransactionalResizeRetryMarksNamedSnapshotDirtyOnlyOnSuccess(t *testing
345345 sess.snapshotMu.Unlock()
346346 require.Equal(t, tc.wantGeneration, generation)
347347 require.True(t, sess.snapDirty.Load())
348- require.Equal(t, tc.wantScreen, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height})
348+ require.Equal(t, tc.wantScreen, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()})
349349 })
350350 }
351351 }
@@ -371,7 +371,7 @@ func TestTransactionalResizeRetryTargetsNewestCommittedEpoch(t *testing.T) {
371371 // S3 retries 59x32, then commits the parser/screen and requests a reset.
372372 require.Equal(t, []domain.Size{{Cols: 49, Rows: 28}, {Cols: 59, Rows: 32}}, failed.requested(),
373373 "the failed old apply is historical; only the authoritative newest epoch retries it")
374- require.Equal(t, domain.Size{Cols: 59, Rows: 32}, domain.Size{Cols: retry.screen.Frame.Width, Rows: retry.screen.Frame.Height})
374+ require.Equal(t, domain.Size{Cols: 59, Rows: 32}, domain.Size{Cols: retry.screen.Columns(), Rows: retry.screen.Rows()})
375375 require.Equal(t, uint64(2), sess.renderCoordinator().resizeSnapshot().committed,
376376 "the retry belongs to the newest committed epoch")
377377 }
@@ -422,7 +422,7 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing
422422 p := tb.focusedPane()
423423 p.mu.Lock()
424424 require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect)
425- require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height})
425+ require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()})
426426 p.mu.Unlock()
427427 }
428428 require.False(t, sess.snapDirty.Load())
@@ -440,7 +440,7 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing
440440 require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size, "rejected epoch published a tab size")
441441 p := tb.focusedPane()
442442 require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect, "rejected epoch published a pane rectangle")
443- require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}, "rejected epoch published a VT size")
443+ require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()}, "rejected epoch published a VT size")
444444 }
445445
446446 require.True(t, sess.geometry.runResizeTransaction(d, sess, ac, lease, newer))
@@ -448,7 +448,7 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing
448448 require.Equal(t, domain.Size{Cols: 120, Rows: 32}, tb.size)
449449 p := tb.focusedPane()
450450 require.Equal(t, domain.Rect{Width: 120, Height: 32}, p.rect)
451- require.Equal(t, domain.Size{Cols: 120, Rows: 32}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height})
451+ require.Equal(t, domain.Size{Cols: 120, Rows: 32}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()})
452452 }
453453 committed := 0
454454 for _, mark := range observer.marks {
@@ -621,7 +621,7 @@ func TestTransactionalResizeFloatingBreakpointRace(t *testing.T) {
621621 popup.mu.Lock()
622622 require.Equal(t, fromGeometry, popup.popupGeometry, "in-flight resize exposed an uncommitted mode")
623623 require.Equal(t, fromGeometry.Inner, popup.rect)
624- require.Equal(t, rectSize(fromGeometry.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
624+ require.Equal(t, rectSize(fromGeometry.Inner), domain.Size{Cols: popup.screen.Columns(), Rows: popup.screen.Rows()})
625625 popup.mu.Unlock()
626626 close(release)
627627 require.True(t, <-done)
@@ -631,7 +631,7 @@ func TestTransactionalResizeFloatingBreakpointRace(t *testing.T) {
631631 require.Equal(t, tc.wantMode, popup.popupGeometry.Mode)
632632 require.Equal(t, toGeometry, popup.popupGeometry)
633633 require.Equal(t, toGeometry.Inner, popup.rect)
634- require.Equal(t, rectSize(toGeometry.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
634+ require.Equal(t, rectSize(toGeometry.Inner), domain.Size{Cols: popup.screen.Columns(), Rows: popup.screen.Rows()})
635635 popup.mu.Unlock()
636636 })
637637 }
@@ -677,7 +677,7 @@ func TestApplyVisibleFloatingLayoutPreservesCommittedGeometryOnPTYResizeFailure(
677677 popup.mu.Lock()
678678 require.Equal(t, committed.Inner, popup.rect, "failed PTY resize published a speculative rectangle")
679679 require.Equal(t, committed, popup.popupGeometry, "failed PTY resize published speculative popup geometry")
680- require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
680+ require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Columns(), Rows: popup.screen.Rows()})
681681 require.True(t, popup.resizeRetry, "failed PTY resize must remain retryable")
682682 require.False(t, popup.resizeApplying, "failed PTY resize must reopen the parser gate")
683683 popup.mu.Unlock()
@@ -715,7 +715,7 @@ func TestApplyVisibleFloatingLayoutPublishesSameSizeGeometryWithoutPTYResize(t *
715715 popup.mu.Lock()
716716 require.Equal(t, requested.Inner, popup.rect)
717717 require.Equal(t, requested, popup.popupGeometry)
718- require.Equal(t, rectSize(requested.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
718+ require.Equal(t, rectSize(requested.Inner), domain.Size{Cols: popup.screen.Columns(), Rows: popup.screen.Rows()})
719719 require.False(t, popup.resizeRetry)
720720 popup.mu.Unlock()
721721 }
@@ -747,7 +747,7 @@ func TestZeroInnerFloatingDrawerPublicationDoesNotRecheckCurrent(t *testing.T) {
747747 popup.mu.Lock()
748748 require.Equal(t, requested, popup.popupGeometry)
749749 require.Equal(t, committed.Inner, popup.rect)
750- require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
750+ require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Columns(), Rows: popup.screen.Rows()})
751751 popup.mu.Unlock()
752752 }
753753
@@ -772,7 +772,7 @@ func TestZeroInnerFloatingDrawerRejectsStaleRequestWithoutPhysicalPublication(t
772772 popup.mu.Lock()
773773 require.Equal(t, committed, popup.popupGeometry)
774774 require.Equal(t, committed.Inner, popup.rect)
775- require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
775+ require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Columns(), Rows: popup.screen.Rows()})
776776 popup.mu.Unlock()
777777 }
778778
@@ -136,8 +136,8 @@ func TestReplayResizePendingBuffersSuccessFailureAndBatchOrder(t *testing.T) {
136 p.resizeApplying = true136 p.resizeApplying = true
137 p.resizePending = []byte("A\x1b[1;81HB")137 p.resizePending = []byte("A\x1b[1;81HB")
138 d.replayResizePending(sess, tb, p, true, domain.Rect{Width: 120, Height: 23})138 d.replayResizePending(sess, tb, p, true, domain.Rect{Width: 120, Height: 23})
139- require.Equal(t, 120, p.screen.Frame.Width)139+ require.Equal(t, 120, p.screen.Columns())
140- require.Equal(t, 'B', p.screen.Frame.At(80, 0).Rune)140+ require.Equal(t, 'B', p.screen.Cell(80, 0).Rune)
141 require.False(t, p.resizeApplying)141 require.False(t, p.resizeApplying)
142 })142 })
143 t.Run("failure retains old parser width", func(t *testing.T) {143 t.Run("failure retains old parser width", func(t *testing.T) {
@@ -147,8 +147,8 @@ func TestReplayResizePendingBuffersSuccessFailureAndBatchOrder(t *testing.T) {
147 p.resizeApplying = true147 p.resizeApplying = true
148 p.resizePending = []byte("\x1b[1;81HB")148 p.resizePending = []byte("\x1b[1;81HB")
149 d.replayResizePending(sess, tb, p, false, domain.Rect{Width: 120, Height: 23})149 d.replayResizePending(sess, tb, p, false, domain.Rect{Width: 120, Height: 23})
150- require.Equal(t, 80, p.screen.Frame.Width)150+ require.Equal(t, 80, p.screen.Columns())
151- require.Equal(t, 'B', p.screen.Frame.At(79, 0).Rune)151+ require.Equal(t, 'B', p.screen.Cell(79, 0).Rune)
152 })152 })
153 t.Run("batches retain read order", func(t *testing.T) {153 t.Run("batches retain read order", func(t *testing.T) {
154 pty := &transactionalResizePTY{}154 pty := &transactionalResizePTY{}
@@ -220,7 +220,7 @@ func TestApplySessionLayoutStopsRetryWhenSessionCanceled(t *testing.T) {
220 tb.mu.Unlock()220 tb.mu.Unlock()
221 p.mu.Lock()221 p.mu.Lock()
222 require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect, "canceled session published a pane rectangle")222 require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect, "canceled session published a pane rectangle")
223- require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}, "canceled session published a VT size")223+ require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()}, "canceled session published a VT size")
224 p.mu.Unlock()224 p.mu.Unlock()
225 }225 }
226 226
@@ -299,7 +299,7 @@ func TestTransactionalResizePartialFailureCommitsOnlySuccessfulPTYState(t *testi
299 tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"}299 tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"}
300 first := tb.panes["pane-1"]300 first := tb.panes["pane-1"]
301 oldFailedRect := second.rect301 oldFailedRect := second.rect
302- oldFailedScreen := domain.Size{Cols: second.screen.Frame.Width, Rows: second.screen.Frame.Height}302+ oldFailedScreen := domain.Size{Cols: second.screen.Columns(), Rows: second.screen.Rows()}
303 tb.mu.Unlock()303 tb.mu.Unlock()
304 304
305 d.resize(sess, ac, domain.Size{Cols: 60, Rows: 20})305 d.resize(sess, ac, domain.Size{Cols: 60, Rows: 20})
@@ -310,7 +310,7 @@ func TestTransactionalResizePartialFailureCommitsOnlySuccessfulPTYState(t *testi
310 "all pane rectangles publish with the committed layout")310 "all pane rectangles publish with the committed layout")
311 require.Equal(t, []domain.Size{{Cols: 30, Rows: 18}}, ok.requested())311 require.Equal(t, []domain.Size{{Cols: 30, Rows: 18}}, ok.requested())
312 require.Equal(t, []domain.Size{{Cols: 29, Rows: 18}}, failed.requested())312 require.Equal(t, []domain.Size{{Cols: 29, Rows: 18}}, failed.requested())
313- require.Equal(t, oldFailedScreen, domain.Size{Cols: second.screen.Frame.Width, Rows: second.screen.Frame.Height},313+ require.Equal(t, oldFailedScreen, domain.Size{Cols: second.screen.Columns(), Rows: second.screen.Rows()},
314 "failed PTY retains its parser/screen size for clipped padded composition")314 "failed PTY retains its parser/screen size for clipped padded composition")
315 require.NotEqual(t, oldFailedRect, second.rect, "failed PTY rectangle still follows the committed layout")315 require.NotEqual(t, oldFailedRect, second.rect, "failed PTY rectangle still follows the committed layout")
316 }316 }
@@ -345,7 +345,7 @@ func TestTransactionalResizeRetryMarksNamedSnapshotDirtyOnlyOnSuccess(t *testing
345 sess.snapshotMu.Unlock()345 sess.snapshotMu.Unlock()
346 require.Equal(t, tc.wantGeneration, generation)346 require.Equal(t, tc.wantGeneration, generation)
347 require.True(t, sess.snapDirty.Load())347 require.True(t, sess.snapDirty.Load())
348- require.Equal(t, tc.wantScreen, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height})348+ require.Equal(t, tc.wantScreen, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()})
349 })349 })
350 }350 }
351 }351 }
@@ -371,7 +371,7 @@ func TestTransactionalResizeRetryTargetsNewestCommittedEpoch(t *testing.T) {
371 // S3 retries 59x32, then commits the parser/screen and requests a reset.371 // S3 retries 59x32, then commits the parser/screen and requests a reset.
372 require.Equal(t, []domain.Size{{Cols: 49, Rows: 28}, {Cols: 59, Rows: 32}}, failed.requested(),372 require.Equal(t, []domain.Size{{Cols: 49, Rows: 28}, {Cols: 59, Rows: 32}}, failed.requested(),
373 "the failed old apply is historical; only the authoritative newest epoch retries it")373 "the failed old apply is historical; only the authoritative newest epoch retries it")
374- require.Equal(t, domain.Size{Cols: 59, Rows: 32}, domain.Size{Cols: retry.screen.Frame.Width, Rows: retry.screen.Frame.Height})374+ require.Equal(t, domain.Size{Cols: 59, Rows: 32}, domain.Size{Cols: retry.screen.Columns(), Rows: retry.screen.Rows()})
375 require.Equal(t, uint64(2), sess.renderCoordinator().resizeSnapshot().committed,375 require.Equal(t, uint64(2), sess.renderCoordinator().resizeSnapshot().committed,
376 "the retry belongs to the newest committed epoch")376 "the retry belongs to the newest committed epoch")
377 }377 }
@@ -422,7 +422,7 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing
422 p := tb.focusedPane()422 p := tb.focusedPane()
423 p.mu.Lock()423 p.mu.Lock()
424 require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect)424 require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect)
425- require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height})425+ require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()})
426 p.mu.Unlock()426 p.mu.Unlock()
427 }427 }
428 require.False(t, sess.snapDirty.Load())428 require.False(t, sess.snapDirty.Load())
@@ -440,7 +440,7 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing
440 require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size, "rejected epoch published a tab size")440 require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size, "rejected epoch published a tab size")
441 p := tb.focusedPane()441 p := tb.focusedPane()
442 require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect, "rejected epoch published a pane rectangle")442 require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect, "rejected epoch published a pane rectangle")
443- require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}, "rejected epoch published a VT size")443+ require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()}, "rejected epoch published a VT size")
444 }444 }
445 445
446 require.True(t, sess.geometry.runResizeTransaction(d, sess, ac, lease, newer))446 require.True(t, sess.geometry.runResizeTransaction(d, sess, ac, lease, newer))
@@ -448,7 +448,7 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing
448 require.Equal(t, domain.Size{Cols: 120, Rows: 32}, tb.size)448 require.Equal(t, domain.Size{Cols: 120, Rows: 32}, tb.size)
449 p := tb.focusedPane()449 p := tb.focusedPane()
450 require.Equal(t, domain.Rect{Width: 120, Height: 32}, p.rect)450 require.Equal(t, domain.Rect{Width: 120, Height: 32}, p.rect)
451- require.Equal(t, domain.Size{Cols: 120, Rows: 32}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height})451+ require.Equal(t, domain.Size{Cols: 120, Rows: 32}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()})
452 }452 }
453 committed := 0453 committed := 0
454 for _, mark := range observer.marks {454 for _, mark := range observer.marks {
@@ -621,7 +621,7 @@ func TestTransactionalResizeFloatingBreakpointRace(t *testing.T) {
621 popup.mu.Lock()621 popup.mu.Lock()
622 require.Equal(t, fromGeometry, popup.popupGeometry, "in-flight resize exposed an uncommitted mode")622 require.Equal(t, fromGeometry, popup.popupGeometry, "in-flight resize exposed an uncommitted mode")
623 require.Equal(t, fromGeometry.Inner, popup.rect)623 require.Equal(t, fromGeometry.Inner, popup.rect)
624- require.Equal(t, rectSize(fromGeometry.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})624+ require.Equal(t, rectSize(fromGeometry.Inner), domain.Size{Cols: popup.screen.Columns(), Rows: popup.screen.Rows()})
625 popup.mu.Unlock()625 popup.mu.Unlock()
626 close(release)626 close(release)
627 require.True(t, <-done)627 require.True(t, <-done)
@@ -631,7 +631,7 @@ func TestTransactionalResizeFloatingBreakpointRace(t *testing.T) {
631 require.Equal(t, tc.wantMode, popup.popupGeometry.Mode)631 require.Equal(t, tc.wantMode, popup.popupGeometry.Mode)
632 require.Equal(t, toGeometry, popup.popupGeometry)632 require.Equal(t, toGeometry, popup.popupGeometry)
633 require.Equal(t, toGeometry.Inner, popup.rect)633 require.Equal(t, toGeometry.Inner, popup.rect)
634- require.Equal(t, rectSize(toGeometry.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})634+ require.Equal(t, rectSize(toGeometry.Inner), domain.Size{Cols: popup.screen.Columns(), Rows: popup.screen.Rows()})
635 popup.mu.Unlock()635 popup.mu.Unlock()
636 })636 })
637 }637 }
@@ -677,7 +677,7 @@ func TestApplyVisibleFloatingLayoutPreservesCommittedGeometryOnPTYResizeFailure(
677 popup.mu.Lock()677 popup.mu.Lock()
678 require.Equal(t, committed.Inner, popup.rect, "failed PTY resize published a speculative rectangle")678 require.Equal(t, committed.Inner, popup.rect, "failed PTY resize published a speculative rectangle")
679 require.Equal(t, committed, popup.popupGeometry, "failed PTY resize published speculative popup geometry")679 require.Equal(t, committed, popup.popupGeometry, "failed PTY resize published speculative popup geometry")
680- require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})680+ require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Columns(), Rows: popup.screen.Rows()})
681 require.True(t, popup.resizeRetry, "failed PTY resize must remain retryable")681 require.True(t, popup.resizeRetry, "failed PTY resize must remain retryable")
682 require.False(t, popup.resizeApplying, "failed PTY resize must reopen the parser gate")682 require.False(t, popup.resizeApplying, "failed PTY resize must reopen the parser gate")
683 popup.mu.Unlock()683 popup.mu.Unlock()
@@ -715,7 +715,7 @@ func TestApplyVisibleFloatingLayoutPublishesSameSizeGeometryWithoutPTYResize(t *
715 popup.mu.Lock()715 popup.mu.Lock()
716 require.Equal(t, requested.Inner, popup.rect)716 require.Equal(t, requested.Inner, popup.rect)
717 require.Equal(t, requested, popup.popupGeometry)717 require.Equal(t, requested, popup.popupGeometry)
718- require.Equal(t, rectSize(requested.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})718+ require.Equal(t, rectSize(requested.Inner), domain.Size{Cols: popup.screen.Columns(), Rows: popup.screen.Rows()})
719 require.False(t, popup.resizeRetry)719 require.False(t, popup.resizeRetry)
720 popup.mu.Unlock()720 popup.mu.Unlock()
721 }721 }
@@ -747,7 +747,7 @@ func TestZeroInnerFloatingDrawerPublicationDoesNotRecheckCurrent(t *testing.T) {
747 popup.mu.Lock()747 popup.mu.Lock()
748 require.Equal(t, requested, popup.popupGeometry)748 require.Equal(t, requested, popup.popupGeometry)
749 require.Equal(t, committed.Inner, popup.rect)749 require.Equal(t, committed.Inner, popup.rect)
750- require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})750+ require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Columns(), Rows: popup.screen.Rows()})
751 popup.mu.Unlock()751 popup.mu.Unlock()
752 }752 }
753 753
@@ -772,7 +772,7 @@ func TestZeroInnerFloatingDrawerRejectsStaleRequestWithoutPhysicalPublication(t
772 popup.mu.Lock()772 popup.mu.Lock()
773 require.Equal(t, committed, popup.popupGeometry)773 require.Equal(t, committed, popup.popupGeometry)
774 require.Equal(t, committed.Inner, popup.rect)774 require.Equal(t, committed.Inner, popup.rect)
775- require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})775+ require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Columns(), Rows: popup.screen.Rows()})
776 popup.mu.Unlock()776 popup.mu.Unlock()
777 }777 }
778 778
M internal/usecase/palette/model_test.go +2 -2
@@ -414,7 +414,7 @@ func TestRenderUsesConfiguredStyles(t *testing.T) {
414414 assert: func(t *testing.T, style renderer.Style) {
415415 require.True(t, style.Italic)
416416 require.True(t, style.HasForegroundRGB)
417- require.Equal(t, -1, style.Foreground)
417+ require.Equal(t, 0, style.Foreground, "inactive indexed color is canonicalized")
418418 require.Equal(t, renderer.RGB{R: 10, G: 20, B: 30}, style.ForegroundRGB)
419419 },
420420 },
@@ -548,7 +548,7 @@ func TestPaletteDescriptionKeepsInactiveRowSurfaceAcrossFallbacks(t *testing.T)
548548 SelectionDescription: styles.PickerSelectionMuted,
549549 }})
550550 inactive := frame.At(4, 1).Style
551- require.Equal(t, styles.SurfaceInactive.Background, inactive.Background)
551+ require.Equal(t, styles.SurfaceInactive.Canonical().Background, inactive.Background)
552552 require.Equal(t, styles.SurfaceInactive.HasBackgroundRGB, inactive.HasBackgroundRGB)
553553 require.Equal(t, styles.SurfaceInactive.BackgroundRGB, inactive.BackgroundRGB)
554554 if tt.name == "indexed only" {
@@ -414,7 +414,7 @@ func TestRenderUsesConfiguredStyles(t *testing.T) {
414 assert: func(t *testing.T, style renderer.Style) {414 assert: func(t *testing.T, style renderer.Style) {
415 require.True(t, style.Italic)415 require.True(t, style.Italic)
416 require.True(t, style.HasForegroundRGB)416 require.True(t, style.HasForegroundRGB)
417- require.Equal(t, -1, style.Foreground)417+ require.Equal(t, 0, style.Foreground, "inactive indexed color is canonicalized")
418 require.Equal(t, renderer.RGB{R: 10, G: 20, B: 30}, style.ForegroundRGB)418 require.Equal(t, renderer.RGB{R: 10, G: 20, B: 30}, style.ForegroundRGB)
419 },419 },
420 },420 },
@@ -548,7 +548,7 @@ func TestPaletteDescriptionKeepsInactiveRowSurfaceAcrossFallbacks(t *testing.T)
548 SelectionDescription: styles.PickerSelectionMuted,548 SelectionDescription: styles.PickerSelectionMuted,
549 }})549 }})
550 inactive := frame.At(4, 1).Style550 inactive := frame.At(4, 1).Style
551- require.Equal(t, styles.SurfaceInactive.Background, inactive.Background)551+ require.Equal(t, styles.SurfaceInactive.Canonical().Background, inactive.Background)
552 require.Equal(t, styles.SurfaceInactive.HasBackgroundRGB, inactive.HasBackgroundRGB)552 require.Equal(t, styles.SurfaceInactive.HasBackgroundRGB, inactive.HasBackgroundRGB)
553 require.Equal(t, styles.SurfaceInactive.BackgroundRGB, inactive.BackgroundRGB)553 require.Equal(t, styles.SurfaceInactive.BackgroundRGB, inactive.BackgroundRGB)
554 if tt.name == "indexed only" {554 if tt.name == "indexed only" {
M internal/usecase/snapshot/codec.go +4 -3
@@ -22,7 +22,7 @@ var (
2222
2323 const (
2424 magic = "VEVS"
25- version = uint16(4)
25+ version = uint16(5) // Compact VTC1 payloads; dense checkpoints are incompatible.
2626 maxDecodedBodySize = 256 << 20
2727 maxSnapshotObjects = 1 << 16
2828 maxSnapshotBytes = 256 << 20
@@ -34,8 +34,9 @@ const (
3434 maxSnapshotDecodedAllocation = 256 << 20
3535 )
3636
37-// Unmarshal validates the complete manifest and all VT blobs without
38-// allocations first, then decodes the already preflighted data.
37+// Unmarshal first measures the complete manifest and its VT blobs without
38+// allocating. After accepting aggregate budgets, it validates references and
39+// blob semantics before decoding the session.
3940 func Unmarshal(b []byte) (Session, error) {
4041 if len(b) < 16 {
4142 return Session{}, ErrShortPayload
@@ -22,7 +22,7 @@ var (
22 22
23 const (23 const (
24 magic = "VEVS"24 magic = "VEVS"
25- version = uint16(4)25+ version = uint16(5) // Compact VTC1 payloads; dense checkpoints are incompatible.
26 maxDecodedBodySize = 256 << 2026 maxDecodedBodySize = 256 << 20
27 maxSnapshotObjects = 1 << 1627 maxSnapshotObjects = 1 << 16
28 maxSnapshotBytes = 256 << 2028 maxSnapshotBytes = 256 << 20
@@ -34,8 +34,9 @@ const (
34 maxSnapshotDecodedAllocation = 256 << 2034 maxSnapshotDecodedAllocation = 256 << 20
35 )35 )
36 36
37-// Unmarshal validates the complete manifest and all VT blobs without37+// Unmarshal first measures the complete manifest and its VT blobs without
38-// allocations first, then decodes the already preflighted data.38+// allocating. After accepting aggregate budgets, it validates references and
39+// blob semantics before decoding the session.
39 func Unmarshal(b []byte) (Session, error) {40 func Unmarshal(b []byte) (Session, error) {
40 if len(b) < 16 {41 if len(b) < 16 {
41 return Session{}, ErrShortPayload42 return Session{}, ErrShortPayload
M internal/usecase/snapshot/codec_test.go +7 -7
@@ -140,7 +140,7 @@ func TestActiveTabReferenceMustBeExact(t *testing.T) {
140140 }
141141
142142 func TestUnmarshalRejectsV4LegacyVersions(t *testing.T) {
143- for _, version := range []uint16{1, 2, 3} {
143+ for _, version := range []uint16{1, 2, 3, 4} {
144144 if _, err := Unmarshal(v4Envelope(version, 0, nil)); !errors.Is(err, ErrBadVersion) {
145145 t.Fatalf("version %d: %v", version, err)
146146 }
@@ -227,11 +227,11 @@ func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) {
227227 if len(sealed) < 2 {
228228 t.Fatalf("expected at least two sealed chunks, got %d", len(sealed))
229229 }
230- multiChunk := append([]byte(nil), sealed[0][:17]...)
231- binary.BigEndian.PutUint32(multiChunk[5:9], 2)
232- binary.BigEndian.PutUint64(multiChunk[9:17], 4)
233- multiChunk = append(multiChunk, sealed[0][17:]...)
234- multiChunk = append(multiChunk, sealed[1][17:]...)
230+ multiChunk := append([]byte(nil), sealed[0][:16]...)
231+ binary.BigEndian.PutUint32(multiChunk[4:8], 2)
232+ binary.BigEndian.PutUint64(multiChunk[8:16], 4)
233+ multiChunk = append(multiChunk, sealed[0][16:]...)
234+ multiChunk = append(multiChunk, sealed[1][16:]...)
235235 transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
236236 emptyTranscript := transcriptBlob(t, nil)
237237
@@ -477,7 +477,7 @@ func transcriptBlob(t *testing.T, rows [][]renderer.Cell) []byte {
477477 }
478478 func requireV4Envelope(t *testing.T, data []byte) {
479479 t.Helper()
480- if version != 4 || len(data) < 16 || binary.BigEndian.Uint16(data[4:6]) != 4 || binary.BigEndian.Uint16(data[6:8]) != 0 {
480+ if version != 5 || len(data) < 16 || binary.BigEndian.Uint16(data[4:6]) != 5 || binary.BigEndian.Uint16(data[6:8]) != 0 {
481481 t.Fatalf("not v4 envelope: % x", data)
482482 }
483483 }
@@ -140,7 +140,7 @@ func TestActiveTabReferenceMustBeExact(t *testing.T) {
140 }140 }
141 141
142 func TestUnmarshalRejectsV4LegacyVersions(t *testing.T) {142 func TestUnmarshalRejectsV4LegacyVersions(t *testing.T) {
143- for _, version := range []uint16{1, 2, 3} {143+ for _, version := range []uint16{1, 2, 3, 4} {
144 if _, err := Unmarshal(v4Envelope(version, 0, nil)); !errors.Is(err, ErrBadVersion) {144 if _, err := Unmarshal(v4Envelope(version, 0, nil)); !errors.Is(err, ErrBadVersion) {
145 t.Fatalf("version %d: %v", version, err)145 t.Fatalf("version %d: %v", version, err)
146 }146 }
@@ -227,11 +227,11 @@ func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) {
227 if len(sealed) < 2 {227 if len(sealed) < 2 {
228 t.Fatalf("expected at least two sealed chunks, got %d", len(sealed))228 t.Fatalf("expected at least two sealed chunks, got %d", len(sealed))
229 }229 }
230- multiChunk := append([]byte(nil), sealed[0][:17]...)230+ multiChunk := append([]byte(nil), sealed[0][:16]...)
231- binary.BigEndian.PutUint32(multiChunk[5:9], 2)231+ binary.BigEndian.PutUint32(multiChunk[4:8], 2)
232- binary.BigEndian.PutUint64(multiChunk[9:17], 4)232+ binary.BigEndian.PutUint64(multiChunk[8:16], 4)
233- multiChunk = append(multiChunk, sealed[0][17:]...)233+ multiChunk = append(multiChunk, sealed[0][16:]...)
234- multiChunk = append(multiChunk, sealed[1][17:]...)234+ multiChunk = append(multiChunk, sealed[1][16:]...)
235 transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})235 transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
236 emptyTranscript := transcriptBlob(t, nil)236 emptyTranscript := transcriptBlob(t, nil)
237 237
@@ -477,7 +477,7 @@ func transcriptBlob(t *testing.T, rows [][]renderer.Cell) []byte {
477 }477 }
478 func requireV4Envelope(t *testing.T, data []byte) {478 func requireV4Envelope(t *testing.T, data []byte) {
479 t.Helper()479 t.Helper()
480- if version != 4 || len(data) < 16 || binary.BigEndian.Uint16(data[4:6]) != 4 || binary.BigEndian.Uint16(data[6:8]) != 0 {480+ if version != 5 || len(data) < 16 || binary.BigEndian.Uint16(data[4:6]) != 5 || binary.BigEndian.Uint16(data[6:8]) != 0 {
481 t.Fatalf("not v4 envelope: % x", data)481 t.Fatalf("not v4 envelope: % x", data)
482 }482 }
483 }483 }
M internal/usecase/snapshot/manifest.go +1 -1
@@ -16,7 +16,7 @@ const (
1616 manifestMagic = "VEVM"
1717 headMagic = "VEVH"
1818 objectMagic = "VEVO"
19- ManifestVersion = uint16(3)
19+ ManifestVersion = uint16(4) // Reject dense VT checkpoints before loading their objects.
2020 manifestHeaderSize = 16
2121 objectEnvelopeBodyPrefixSize = 1 + 4 // object kind and payload length
2222 minObjectEnvelopeSize = manifestHeaderSize + objectEnvelopeBodyPrefixSize + 1
@@ -16,7 +16,7 @@ const (
16 manifestMagic = "VEVM"16 manifestMagic = "VEVM"
17 headMagic = "VEVH"17 headMagic = "VEVH"
18 objectMagic = "VEVO"18 objectMagic = "VEVO"
19- ManifestVersion = uint16(3)19+ ManifestVersion = uint16(4) // Reject dense VT checkpoints before loading their objects.
20 manifestHeaderSize = 1620 manifestHeaderSize = 16
21 objectEnvelopeBodyPrefixSize = 1 + 4 // object kind and payload length21 objectEnvelopeBodyPrefixSize = 1 + 4 // object kind and payload length
22 minObjectEnvelopeSize = manifestHeaderSize + objectEnvelopeBodyPrefixSize + 122 minObjectEnvelopeSize = manifestHeaderSize + objectEnvelopeBodyPrefixSize + 1
M internal/usecase/snapshot/preflight.go +11 -5
@@ -243,7 +243,7 @@ func preflightBlob(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, budg
243243 }
244244 blob := r.b[:n]
245245 r.b = r.b[n:]
246- stats, err := vt.PreflightHistoryBlob(blob)
246+ stats, err := vt.MeasureHistoryBlob(blob)
247247 if err != nil {
248248 return vt.DecodeStats{}, fmt.Errorf("%w: VT blob", ErrInvalidData)
249249 }
@@ -361,14 +361,14 @@ func preflightTabReferences(r *payloadReader) error {
361361 return err
362362 }
363363 for range n {
364- if err := skipBlob(r); err != nil {
364+ if err := validateBlob(r); err != nil {
365365 return err
366366 }
367367 }
368- if err := skipBlob(r); err != nil {
368+ if err := validateBlob(r); err != nil {
369369 return err
370370 }
371- if err := skipBlob(r); err != nil {
371+ if err := validateBlob(r); err != nil {
372372 return err
373373 }
374374 if err := preflightProcess(r); err != nil {
@@ -386,7 +386,10 @@ func preflightTabReferences(r *payloadReader) error {
386386 return nil
387387 }
388388
389-func skipBlob(r *payloadReader) error {
389+// validateBlob runs only after the complete collection passes resource sizing.
390+// Dictionary/identity validation may allocate, but cannot run once per pane in
391+// an over-budget collection. Sizing alone never authorizes session construction.
392+func validateBlob(r *payloadReader) error {
390393 n, err := r.getUint32()
391394 if err != nil {
392395 return err
@@ -394,6 +397,9 @@ func skipBlob(r *payloadReader) error {
394397 if uint64(n) > uint64(len(r.b)) {
395398 return ErrShortPayload
396399 }
400+ if _, err := vt.PreflightHistoryBlob(r.b[:n]); err != nil {
401+ return fmt.Errorf("%w: VT blob", ErrInvalidData)
402+ }
397403 r.b = r.b[n:]
398404 return nil
399405 }
@@ -243,7 +243,7 @@ func preflightBlob(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, budg
243 }243 }
244 blob := r.b[:n]244 blob := r.b[:n]
245 r.b = r.b[n:]245 r.b = r.b[n:]
246- stats, err := vt.PreflightHistoryBlob(blob)246+ stats, err := vt.MeasureHistoryBlob(blob)
247 if err != nil {247 if err != nil {
248 return vt.DecodeStats{}, fmt.Errorf("%w: VT blob", ErrInvalidData)248 return vt.DecodeStats{}, fmt.Errorf("%w: VT blob", ErrInvalidData)
249 }249 }
@@ -361,14 +361,14 @@ func preflightTabReferences(r *payloadReader) error {
361 return err361 return err
362 }362 }
363 for range n {363 for range n {
364- if err := skipBlob(r); err != nil {364+ if err := validateBlob(r); err != nil {
365 return err365 return err
366 }366 }
367 }367 }
368- if err := skipBlob(r); err != nil {368+ if err := validateBlob(r); err != nil {
369 return err369 return err
370 }370 }
371- if err := skipBlob(r); err != nil {371+ if err := validateBlob(r); err != nil {
372 return err372 return err
373 }373 }
374 if err := preflightProcess(r); err != nil {374 if err := preflightProcess(r); err != nil {
@@ -386,7 +386,10 @@ func preflightTabReferences(r *payloadReader) error {
386 return nil386 return nil
387 }387 }
388 388
389-func skipBlob(r *payloadReader) error {389+// validateBlob runs only after the complete collection passes resource sizing.
390+// Dictionary/identity validation may allocate, but cannot run once per pane in
391+// an over-budget collection. Sizing alone never authorizes session construction.
392+func validateBlob(r *payloadReader) error {
390 n, err := r.getUint32()393 n, err := r.getUint32()
391 if err != nil {394 if err != nil {
392 return err395 return err
@@ -394,6 +397,9 @@ func skipBlob(r *payloadReader) error {
394 if uint64(n) > uint64(len(r.b)) {397 if uint64(n) > uint64(len(r.b)) {
395 return ErrShortPayload398 return ErrShortPayload
396 }399 }
400+ if _, err := vt.PreflightHistoryBlob(r.b[:n]); err != nil {
401+ return fmt.Errorf("%w: VT blob", ErrInvalidData)
402+ }
397 r.b = r.b[n:]403 r.b = r.b[n:]
398 return nil404 return nil
399 }405 }
M internal/usecase/snapshot/vevs_v4_golden_test.go +8 -6
@@ -9,8 +9,10 @@ import (
99 "github.com/bnema/vev/internal/usecase/layout"
1010 )
1111
12-func TestVEVSV4GoldenRestoreCompatibility(t *testing.T) {
13- const golden = "5645565300040000000001291707ae820007726573746f7265000000000000007b0000000100037461620050001800000000000000020001700000017000010001700006737461626c6500042f746d700000000100000078565448310300000001000000000000002a0000000100000000000000290000000200000041000000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff00000000000000010000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff000000000000020000000011565448310300000000000000000000002a0000004f565448310300000001000000000000002b00000001000000000000002a000000010000754cff000ffffffffffffffff9000000000000010101020304050605fffffffffffffff7070809000000010100"
12+const compactVEVSGolden = "564556530005000000000104dbb0765f0007726573746f7265000000000000007b0000000100037461620050001800000000000000020001700000017000010001700006737461626c6500042f746d70000000010000003f5654433100000001000000000000002a0000000200000001000000010000000000000000000000290000000100000000410000000000000000200000000000000000105654433100000000000000000000002a000000645654433100000001000000000000002b000000020000000100000002000000000900000000000000000000ffffffffffffffff010203000000000000000000000000000000000000000000002a00000002000000754c000000010000000000000000010100"
13+
14+func TestVEVSV4CompactGoldenRestore(t *testing.T) {
15+ const golden = compactVEVSGolden
1416 data, err := hex.DecodeString(golden)
1517 if err != nil {
1618 t.Fatal(err)
@@ -65,7 +67,7 @@ func TestVEVSV4GoldenRestoreCompatibility(t *testing.T) {
6567 }
6668
6769 func TestVEVSV4GoldenRejectsMalformedInput(t *testing.T) {
68- const golden = "5645565300040000000001291707ae820007726573746f7265000000000000007b0000000100037461620050001800000000000000020001700000017000010001700006737461626c6500042f746d700000000100000078565448310300000001000000000000002a0000000100000000000000290000000200000041000000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff00000000000000010000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff000000000000020000000011565448310300000000000000000000002a0000004f565448310300000001000000000000002b00000001000000000000002a000000010000754cff000ffffffffffffffff9000000000000010101020304050605fffffffffffffff7070809000000010100"
70+ const golden = compactVEVSGolden
6971 valid, err := hex.DecodeString(golden)
7072 if err != nil {
7173 t.Fatal(err)
@@ -88,14 +90,14 @@ func TestVEVSV4GoldenRejectsMalformedInput(t *testing.T) {
8890 }
8991
9092 body := append([]byte(nil), valid[16:]...)
91- if offset := bytes.Index(body, []byte("VTH1")); offset < 0 {
93+ if offset := bytes.Index(body, []byte("VTC1")); offset < 0 {
9294 t.Fatal("golden has no VT blob")
9395 } else {
94- body[offset+4] = 2
96+ body[offset+3] = '2'
9597 cases = append(cases, struct {
9698 name string
9799 data []byte
98- }{name: "bad nested VT version", data: v4Envelope(4, 0, body)})
100+ }{name: "bad nested VT version", data: v4Envelope(version, 0, body)})
99101 }
100102
101103 for _, test := range cases {
@@ -9,8 +9,10 @@ import (
9 "github.com/bnema/vev/internal/usecase/layout"9 "github.com/bnema/vev/internal/usecase/layout"
10 )10 )
11 11
12-func TestVEVSV4GoldenRestoreCompatibility(t *testing.T) {12+const compactVEVSGolden = "564556530005000000000104dbb0765f0007726573746f7265000000000000007b0000000100037461620050001800000000000000020001700000017000010001700006737461626c6500042f746d70000000010000003f5654433100000001000000000000002a0000000200000001000000010000000000000000000000290000000100000000410000000000000000200000000000000000105654433100000000000000000000002a000000645654433100000001000000000000002b000000020000000100000002000000000900000000000000000000ffffffffffffffff010203000000000000000000000000000000000000000000002a00000002000000754c000000010000000000000000010100"
13- const golden = "5645565300040000000001291707ae820007726573746f7265000000000000007b0000000100037461620050001800000000000000020001700000017000010001700006737461626c6500042f746d700000000100000078565448310300000001000000000000002a0000000100000000000000290000000200000041000000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff00000000000000010000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff000000000000020000000011565448310300000000000000000000002a0000004f565448310300000001000000000000002b00000001000000000000002a000000010000754cff000ffffffffffffffff9000000000000010101020304050605fffffffffffffff7070809000000010100"13+
14+func TestVEVSV4CompactGoldenRestore(t *testing.T) {
15+ const golden = compactVEVSGolden
14 data, err := hex.DecodeString(golden)16 data, err := hex.DecodeString(golden)
15 if err != nil {17 if err != nil {
16 t.Fatal(err)18 t.Fatal(err)
@@ -65,7 +67,7 @@ func TestVEVSV4GoldenRestoreCompatibility(t *testing.T) {
65 }67 }
66 68
67 func TestVEVSV4GoldenRejectsMalformedInput(t *testing.T) {69 func TestVEVSV4GoldenRejectsMalformedInput(t *testing.T) {
68- const golden = "5645565300040000000001291707ae820007726573746f7265000000000000007b0000000100037461620050001800000000000000020001700000017000010001700006737461626c6500042f746d700000000100000078565448310300000001000000000000002a0000000100000000000000290000000200000041000000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff00000000000000010000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff000000000000020000000011565448310300000000000000000000002a0000004f565448310300000001000000000000002b00000001000000000000002a000000010000754cff000ffffffffffffffff9000000000000010101020304050605fffffffffffffff7070809000000010100"70+ const golden = compactVEVSGolden
69 valid, err := hex.DecodeString(golden)71 valid, err := hex.DecodeString(golden)
70 if err != nil {72 if err != nil {
71 t.Fatal(err)73 t.Fatal(err)
@@ -88,14 +90,14 @@ func TestVEVSV4GoldenRejectsMalformedInput(t *testing.T) {
88 }90 }
89 91
90 body := append([]byte(nil), valid[16:]...)92 body := append([]byte(nil), valid[16:]...)
91- if offset := bytes.Index(body, []byte("VTH1")); offset < 0 {93+ if offset := bytes.Index(body, []byte("VTC1")); offset < 0 {
92 t.Fatal("golden has no VT blob")94 t.Fatal("golden has no VT blob")
93 } else {95 } else {
94- body[offset+4] = 296+ body[offset+3] = '2'
95 cases = append(cases, struct {97 cases = append(cases, struct {
96 name string98 name string
97 data []byte99 data []byte
98- }{name: "bad nested VT version", data: v4Envelope(4, 0, body)})100+ }{name: "bad nested VT version", data: v4Envelope(version, 0, body)})
99 }101 }
100 102
101 for _, test := range cases {103 for _, test := range cases {
M internal/usecase/snapshot/weights_test.go +3 -3
@@ -86,10 +86,10 @@ func TestSnapshotEnvelopeCompatibility(t *testing.T) {
8686 const (
8787 headGolden = "56455648000100000000002898ebcac200000000000000070100000000000000000000000000000000000000000000000000000000000000"
8888 objectGolden = "5645564f00010000000000111cb8f09f030000000c63616e6f6e6963616c2d7674"
89- manifestGolden = "5645564d0003000000000133d118cc030000000000000001010000000000000000000000000000000000056e616d6564000000000000000000000001000374616200500018000000000000000000036f6e65010000020000036f6e6500000374776f000200036f6e6500036f6e6500042f6f6e65000101010000000000000000000000000000000000000000000000000000000000000000000016020200000000000000000000000000000000000000000000000000000000000000000000160303000000000000000000000000000000000000000000000000000000000000000000001600000374776f000374776f00042f74776f0000020400000000000000000000000000000000000000000000000000000000000000000000160305000000000000000000000000000000000000000000000000000000000000000000001600"
89+ manifestGolden = "5645564d0004000000000133d118cc030000000000000001010000000000000000000000000000000000056e616d6564000000000000000000000001000374616200500018000000000000000000036f6e65010000020000036f6e6500000374776f000200036f6e6500036f6e6500042f6f6e65000101010000000000000000000000000000000000000000000000000000000000000000000016020200000000000000000000000000000000000000000000000000000000000000000000160303000000000000000000000000000000000000000000000000000000000000000000001600000374776f000374776f00042f74776f0000020400000000000000000000000000000000000000000000000000000000000000000000160305000000000000000000000000000000000000000000000000000000000000000000001600"
9090 )
91- if ManifestVersion != 3 {
92- t.Fatalf("ManifestVersion = %d, want 3", ManifestVersion)
91+ if ManifestVersion != 4 {
92+ t.Fatalf("ManifestVersion = %d, want 4", ManifestVersion)
9393 }
9494
9595 head, err := MarshalHead(Head{Generation: 7, ManifestDigest: SnapshotDigest{1}})
@@ -86,10 +86,10 @@ func TestSnapshotEnvelopeCompatibility(t *testing.T) {
86 const (86 const (
87 headGolden = "56455648000100000000002898ebcac200000000000000070100000000000000000000000000000000000000000000000000000000000000"87 headGolden = "56455648000100000000002898ebcac200000000000000070100000000000000000000000000000000000000000000000000000000000000"
88 objectGolden = "5645564f00010000000000111cb8f09f030000000c63616e6f6e6963616c2d7674"88 objectGolden = "5645564f00010000000000111cb8f09f030000000c63616e6f6e6963616c2d7674"
89- manifestGolden = "5645564d0003000000000133d118cc030000000000000001010000000000000000000000000000000000056e616d6564000000000000000000000001000374616200500018000000000000000000036f6e65010000020000036f6e6500000374776f000200036f6e6500036f6e6500042f6f6e65000101010000000000000000000000000000000000000000000000000000000000000000000016020200000000000000000000000000000000000000000000000000000000000000000000160303000000000000000000000000000000000000000000000000000000000000000000001600000374776f000374776f00042f74776f0000020400000000000000000000000000000000000000000000000000000000000000000000160305000000000000000000000000000000000000000000000000000000000000000000001600"89+ manifestGolden = "5645564d0004000000000133d118cc030000000000000001010000000000000000000000000000000000056e616d6564000000000000000000000001000374616200500018000000000000000000036f6e65010000020000036f6e6500000374776f000200036f6e6500036f6e6500042f6f6e65000101010000000000000000000000000000000000000000000000000000000000000000000016020200000000000000000000000000000000000000000000000000000000000000000000160303000000000000000000000000000000000000000000000000000000000000000000001600000374776f000374776f00042f74776f0000020400000000000000000000000000000000000000000000000000000000000000000000160305000000000000000000000000000000000000000000000000000000000000000000001600"
90 )90 )
91- if ManifestVersion != 3 {91+ if ManifestVersion != 4 {
92- t.Fatalf("ManifestVersion = %d, want 3", ManifestVersion)92+ t.Fatalf("ManifestVersion = %d, want 4", ManifestVersion)
93 }93 }
94 94
95 head, err := MarshalHead(Head{Generation: 7, ManifestDigest: SnapshotDigest{1}})95 head, err := MarshalHead(Head{Generation: 7, ManifestDigest: SnapshotDigest{1}})
M internal/usecase/ui/separator_test.go +1 -1
@@ -64,7 +64,7 @@ func TestDrawSeparatorClipsAndRejectsInvalid(t *testing.T) {
6464 }
6565
6666 func TestDrawSeparatorInvalidFrame(t *testing.T) {
67- f := renderer.Frame{Width: 2, Height: 2, Cells: make([]renderer.Cell, 3)}
67+ f := renderer.Frame{Width: 2, Height: 2}
6868 DrawSeparator(f, domain.Rect{Width: 2, Height: 1}, SeparatorHorizontal, renderer.DefaultStyle())
6969 }
7070
@@ -64,7 +64,7 @@ func TestDrawSeparatorClipsAndRejectsInvalid(t *testing.T) {
64 }64 }
65 65
66 func TestDrawSeparatorInvalidFrame(t *testing.T) {66 func TestDrawSeparatorInvalidFrame(t *testing.T) {
67- f := renderer.Frame{Width: 2, Height: 2, Cells: make([]renderer.Cell, 3)}67+ f := renderer.Frame{Width: 2, Height: 2}
68 DrawSeparator(f, domain.Rect{Width: 2, Height: 1}, SeparatorHorizontal, renderer.DefaultStyle())68 DrawSeparator(f, domain.Rect{Width: 2, Height: 1}, SeparatorHorizontal, renderer.DefaultStyle())
69 }69 }
70 70
M scripts/remote-picker-harness/visual_probe.go +5 -5
@@ -209,7 +209,7 @@ func (p *visualProbe) apply(output protocol.Output) visualOutputResult {
209209
210210 // The client writes side effects as well as state-bearing frames, but only
211211 // state-bearing frames advance the dependency chain and receive an ACK.
212- if p.screen.Frame.Width != output.Size.Cols || p.screen.Frame.Height != output.Size.Rows {
212+ if p.screen.Columns() != output.Size.Cols || p.screen.Rows() != output.Size.Rows {
213213 p.screen.Resize(output.Size.Cols, output.Size.Rows)
214214 }
215215 p.screen.Write(output.Data)
@@ -255,11 +255,11 @@ func visualScreenLines(screen *vt.Screen) []string {
255255 if screen == nil {
256256 return nil
257257 }
258- lines := make([]string, screen.Frame.Height)
259- for y := range screen.Frame.Height {
258+ lines := make([]string, screen.Rows())
259+ for y := range screen.Rows() {
260260 var line strings.Builder
261- for x := range screen.Frame.Width {
262- r := screen.Frame.At(x, y).Rune
261+ for x := range screen.Columns() {
262+ r := screen.Cell(x, y).Rune
263263 if r == 0 {
264264 r = ' '
265265 }
@@ -209,7 +209,7 @@ func (p *visualProbe) apply(output protocol.Output) visualOutputResult {
209 209
210 // The client writes side effects as well as state-bearing frames, but only210 // The client writes side effects as well as state-bearing frames, but only
211 // state-bearing frames advance the dependency chain and receive an ACK.211 // state-bearing frames advance the dependency chain and receive an ACK.
212- if p.screen.Frame.Width != output.Size.Cols || p.screen.Frame.Height != output.Size.Rows {212+ if p.screen.Columns() != output.Size.Cols || p.screen.Rows() != output.Size.Rows {
213 p.screen.Resize(output.Size.Cols, output.Size.Rows)213 p.screen.Resize(output.Size.Cols, output.Size.Rows)
214 }214 }
215 p.screen.Write(output.Data)215 p.screen.Write(output.Data)
@@ -255,11 +255,11 @@ func visualScreenLines(screen *vt.Screen) []string {
255 if screen == nil {255 if screen == nil {
256 return nil256 return nil
257 }257 }
258- lines := make([]string, screen.Frame.Height)258+ lines := make([]string, screen.Rows())
259- for y := range screen.Frame.Height {259+ for y := range screen.Rows() {
260 var line strings.Builder260 var line strings.Builder
261- for x := range screen.Frame.Width {261+ for x := range screen.Columns() {
262- r := screen.Frame.At(x, y).Rune262+ r := screen.Cell(x, y).Rune
263 if r == 0 {263 if r == 0 {
264 r = ' '264 r = ' '
265 }265 }
M scripts/remote-picker-harness/visual_probe_test.go +2 -2
@@ -93,8 +93,8 @@ func TestVisualProbeResizesToAcceptedOutput(t *testing.T) {
9393 })
9494
9595 require.True(t, result.Accepted)
96- require.Equal(t, outputSize.Cols, probe.screen.Frame.Width)
97- require.Equal(t, outputSize.Rows, probe.screen.Frame.Height)
96+ require.Equal(t, outputSize.Cols, probe.screen.Columns())
97+ require.Equal(t, outputSize.Rows, probe.screen.Rows())
9898 require.Len(t, probe.checkpoints, 1)
9999 require.Equal(t, outputSize.Cols, probe.checkpoints[0].snapshot.Columns())
100100 require.Equal(t, outputSize.Rows, probe.checkpoints[0].snapshot.Rows())
@@ -93,8 +93,8 @@ func TestVisualProbeResizesToAcceptedOutput(t *testing.T) {
93 })93 })
94 94
95 require.True(t, result.Accepted)95 require.True(t, result.Accepted)
96- require.Equal(t, outputSize.Cols, probe.screen.Frame.Width)96+ require.Equal(t, outputSize.Cols, probe.screen.Columns())
97- require.Equal(t, outputSize.Rows, probe.screen.Frame.Height)97+ require.Equal(t, outputSize.Rows, probe.screen.Rows())
98 require.Len(t, probe.checkpoints, 1)98 require.Len(t, probe.checkpoints, 1)
99 require.Equal(t, outputSize.Cols, probe.checkpoints[0].snapshot.Columns())99 require.Equal(t, outputSize.Cols, probe.checkpoints[0].snapshot.Columns())
100 require.Equal(t, outputSize.Rows, probe.checkpoints[0].snapshot.Rows())100 require.Equal(t, outputSize.Rows, probe.checkpoints[0].snapshot.Rows())
M transport_replay_integration_test.go +5 -6
@@ -46,7 +46,7 @@ func TestTransportReplayIntegration(t *testing.T) {
4646 }
4747 wg.Wait()
4848 require.Equal(t, "\x1b[2J\x1b[Hone\r\ntwo\x1b[2;1HTWO", string(bytes))
49- require.Equal(t, []string{"one ", "TWO ", " "}, integrationFrameRows(terminal.Frame))
49+ require.Equal(t, []string{"one ", "TWO ", " "}, integrationFrameRows(terminal))
5050 }
5151
5252 // TestThemeGenerationTransportSequences proves the generation's cleared then
@@ -124,12 +124,11 @@ func TestThemeGenerationTransportSequences(t *testing.T) {
124124 }
125125 }
126126
127-func integrationFrameRows(frame renderer.Frame) []string {
128- rows := make([]string, frame.Height)
127+func integrationFrameRows(frame renderer.CellSource) []string {
128+ rows := make([]string, frame.Rows())
129129 for y := range rows {
130- cells := frame.Row(y)
131- for _, cell := range cells {
132- rows[y] += string(cell.Rune)
130+ for x := range frame.Columns() {
131+ rows[y] += string(frame.Cell(x, y).Rune)
133132 }
134133 }
135134 return rows
@@ -46,7 +46,7 @@ func TestTransportReplayIntegration(t *testing.T) {
46 }46 }
47 wg.Wait()47 wg.Wait()
48 require.Equal(t, "\x1b[2J\x1b[Hone\r\ntwo\x1b[2;1HTWO", string(bytes))48 require.Equal(t, "\x1b[2J\x1b[Hone\r\ntwo\x1b[2;1HTWO", string(bytes))
49- require.Equal(t, []string{"one ", "TWO ", " "}, integrationFrameRows(terminal.Frame))49+ require.Equal(t, []string{"one ", "TWO ", " "}, integrationFrameRows(terminal))
50 }50 }
51 51
52 // TestThemeGenerationTransportSequences proves the generation's cleared then52 // TestThemeGenerationTransportSequences proves the generation's cleared then
@@ -124,12 +124,11 @@ func TestThemeGenerationTransportSequences(t *testing.T) {
124 }124 }
125 }125 }
126 126
127-func integrationFrameRows(frame renderer.Frame) []string {127+func integrationFrameRows(frame renderer.CellSource) []string {
128- rows := make([]string, frame.Height)128+ rows := make([]string, frame.Rows())
129 for y := range rows {129 for y := range rows {
130- cells := frame.Row(y)130+ for x := range frame.Columns() {
131- for _, cell := range cells {131+ rows[y] += string(frame.Cell(x, y).Rune)
132- rows[y] += string(cell.Rune)
133 }132 }
134 }133 }
135 return rows134 return rows

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