a73x

0716f07b

feat: keep native selections anchored as output scrolls

a73x   2026-09-06 13:25

Commit message
feat: keep native selections anchored as output scrolls

RETRO.md
Old New
@@ -1516,3 +1516,60 @@ preserved. Recording, page and test evidence remain; cleanup is recorded in
1516 `dist/application-mouse/accepted-demo-teardown.json`. The existing rendering 1516 `dist/application-mouse/accepted-demo-teardown.json`. The existing rendering
1517 budget issue and selection-identity/paste follow-ups remain open. No next sprint 1517 budget issue and selection-identity/paste follow-ups remain open. No next sprint
1518 was started by this sign-off. 1518 was started by this sign-off.
1519
1520
1521 ### Selection follows terminal output — 2026-09-06
1522
1523 Completed native selections now follow their original terminal occurrence
1524 through primary history and vertical scroll regions. Ctrl+Shift+C resolves the
1525 tracked endpoints; a held copy uses its current range and release registers the
1526 final range. One selection request/reply now covers extract/start/copy/clear for
1527 native, TUI and browser callers. Daemon and clients must be rebuilt together;
1528 compatibility was explicitly excluded.
1529
1530 The daemon owns Ghostty pins and text extraction, the pump owns correlation and
1531 attachment lifetime, and the GUI receives position and grid together. Opening
1532 inspection needed no separate cleanup. Closing cleanup removed the parallel
1533 protocol and unused state. Independent Terra review approved these boundaries;
1534 Luna handled bounded implementation/fixtures, and root integrated, reviewed and
1535 tested actual behavior. Ghostty owns ordinary page movement and eviction. Its
1536 partial-region row-copy/rotation paths need explicit external-pin movement;
1537 REP now reuses the normal print hook rather than cancelling ordinary redraws.
1538
1539 Review fixed an extraction lifetime bug, pin-allocation cleanup, alternate-screen
1540 allocator reuse, and partial-region endpoint movement. Assertions that a pin
1541 still existed hid wrong coordinates: retained selections now assert both moved
1542 coordinates and extracted distinct text. Validate library behavior against the
1543 pinned implementation and compiler, not assumptions about APIs or privacy.
1544 Re-copy acceptance first replaces the clipboard with a sentinel; unchanged
1545 clipboard text alone proves nothing. Fixture header clicks clear selection, so
1546 neighbour output uses the owned PTY directly. Wait for visible Wayland motion
1547 before a separate FIFO key injection; neither channel orders the other.
1548
1549 Final CI passed all 117 e2e scenarios, 10 agent checks and throughput. Native
1550 units/integration and actual NVIDIA Wayland selection, wheel, scale, foot/tmux
1551 and live tmux counter checks passed. A prior CI run compiled while source was
1552 changing; coordinator action: hold the freeze through the entire CI command,
1553 including later agent/throughput builds, not only its check stage. Early failure
1554 logs are retained alongside the successful final runs.
1555
1556 The raw-output frame gate still misses its existing 20 ms budget: 20.716 ms p99.
1557 Output and reopen progressed; sampled input-to-painted upper bounds were at most
1558 60.4 ms including polling. Do not claim a performance pass or rerun until green.
1559 The 25-second actual GUI demo and review page remain on localhost, pending user
1560 product acceptance. The owned compositor and fixture apps are stopped; stop only
1561 server PID 257482 after verifying ownership on acceptance. Preserve unrelated
1562 demos. Full evidence and teardown are in the selection-follow sprint plan.
1563
1564 Next owners/triggers:
1565
1566 - Renderer owner: resolve the retained NVIDIA presentation budget variability
1567 using the same isolated raw-output fixture; retain the budget and baseline.
1568 - Selection owner, when continuing this feature: held-drag movement, reflow,
1569 character edits, rectangular margins and ranges crossing scroll-region
1570 boundaries need explicit policies. They currently cancel conservatively;
1571 preserve existing clipboard contents and add coordinate-plus-text oracles.
1572 - GUI input owner: paste and composition remain separate slices; ligatures are
1573 still deferred. Platform owner: Linux evidence does not establish macOS.
1574 - Coordinator, on acceptance: record product review separately from architecture
1575 and tests, verify/stop this demo server, and only then begin an authorized slice.
build.zig
Old New
@@ -1213,10 +1213,14 @@ pub fn build(b: *std.Build) void {
1213 native_selection.addArtifactArg(mux_exe); 1213 native_selection.addArtifactArg(mux_exe);
1214 native_selection.addArtifactArg(muxg_exe); 1214 native_selection.addArtifactArg(muxg_exe);
1215 native_selection.step.dependOn(&native_theme_config.step); 1215 native_selection.step.dependOn(&native_theme_config.step);
1216 const native_selection_follow = b.addSystemCommand(&.{ "python3", "-B", "test/native_selection_follow.py" });
1217 native_selection_follow.addArtifactArg(mux_exe);
1218 native_selection_follow.addArtifactArg(muxg_exe);
1219 native_selection_follow.step.dependOn(&native_selection.step);
1216 const native_wheel = b.addSystemCommand(&.{ "python3", "-B", "test/native_wheel.py" }); 1220 const native_wheel = b.addSystemCommand(&.{ "python3", "-B", "test/native_wheel.py" });
1217 native_wheel.addArtifactArg(mux_exe); 1221 native_wheel.addArtifactArg(mux_exe);
1218 native_wheel.addArtifactArg(muxg_exe); 1222 native_wheel.addArtifactArg(muxg_exe);
1219 native_wheel.step.dependOn(&native_selection.step); 1223 native_wheel.step.dependOn(&native_selection_follow.step);
1220 const native_e2e_step = b.step("native-e2e", "Run the native client's end-to-end leg (opt-in)"); 1224 const native_e2e_step = b.step("native-e2e", "Run the native client's end-to-end leg (opt-in)");
1221 const native_mouse = b.addSystemCommand(&.{ "python3", "-B", "test/native_mouse.py" }); 1225 const native_mouse = b.addSystemCommand(&.{ "python3", "-B", "test/native_mouse.py" });
1222 native_mouse.addArtifactArg(mux_exe); 1226 native_mouse.addArtifactArg(mux_exe);
docs/demos/native-selection-follow.html
Old New
@@ -0,0 +1,46 @@
1 <!doctype html>
2 <html lang="en">
3 <head>
4 <meta charset="utf-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1">
6 <title>Selection follow through terminal output</title>
7 <style>
8 :root { color-scheme:dark; --bg:#101318; --panel:#181d25; --ink:#edf2f7; --muted:#aab5c2; --accent:#7dd3fc; --line:#303947; }
9 * { box-sizing:border-box; } body { margin:0; background:var(--bg); color:var(--ink); font:16px/1.55 system-ui,sans-serif; }
10 main { max-width:1000px; margin:auto; padding:3rem 1.25rem 5rem; } h1 { margin:0 0 .5rem; font-size:clamp(2rem,5vw,3.5rem); line-height:1.05; } h2 { margin-top:2rem; font-size:1.25rem; color:var(--accent); } p { color:var(--muted); } .lede { font-size:1.15rem; max-width:70ch; }
11 .card { margin-top:2rem; padding:1rem; background:var(--panel); border:1px solid var(--line); border-radius:12px; } video { display:block; width:100%; max-height:70vh; background:#080a0d; border-radius:8px; } a { color:var(--accent); } ul { color:var(--muted); padding-left:1.25rem; } li+li { margin-top:.4rem; } code { color:var(--ink); }
12 .status { display:inline-block; padding:.2rem .55rem; border:1px solid #a87927; border-radius:999px; color:#ffd580; font-size:.85rem; }
13 </style>
14 </head>
15 <body>
16 <main>
17 <span class="status">Functional checks passed · product review pending</span>
18 <h1>Selection follows terminal output</h1>
19 <p class="lede">A completed selection keeps naming the same terminal occurrence when new output scrolls it upward. The recording uses a duplicate marker and its distinct successor so a stale coordinate or the other duplicate cannot pass the check.</p>
20 <section class="card">
21 <video src="demo.mp4" poster="preview.png" controls playsinline preload="metadata"></video>
22 <p><a href="demo.mp4" download>Download the recording</a> · 25 seconds · 960 × 600 · Linux NVIDIA Wayland · no audio</p>
23 </section>
24 <h2>What the recording shows</h2>
25 <ul>
26 <li>A notes pane sits on the left, the source pane has a nonzero origin on the right, and a third live pane remains active.</li>
27 <li>An ordinary Wayland pointer drag selects <code>SOURCE-DUPLICATE</code> and its <code>SOURCE-AFTER-006</code> successor while another duplicate appears elsewhere.</li>
28 <li>Three source rows arrive at one-second intervals. The selected occurrence moves with the terminal output, and its original framebuffer position clears.</li>
29 <li>An idle pane writes a clipboard sentinel through OSC 52. Notes reads that sentinel with independent <code>wl-paste</code>; Ctrl+Shift+C in the source pane then restores the followed selection, read back visibly in notes.</li>
30 </ul>
31 <h2>Controls and input</h2>
32 <p>Ordinary drag selects locally when the application has not requested mouse reporting. Hold Shift at press to select locally inside a mouse-reporting application. Ctrl+Shift+C asks the daemon to copy the completed tracked selection. The video uses genuine Wayland pointer input with a visible cursor; fixture output is emitted through fixture-owned PTYs.</p>
33 <h2>Validation</h2>
34 <p>Native unit and integration gates passed. Real NVIDIA Wayland checks passed for selection, scrolling, scale transitions, shared foot/tmux mouse behavior, and live tmux counters in windowed and fullscreen modes. Web ABI: 798 passed, 0 failed. Full CI passed: 117 end-to-end scenarios, 10 agent checks and throughput. Architectural review has no open blocker; product acceptance is pending.</p>
35 <p>Rebuild both the daemon and client: they share the new selection protocol, with one request/reply path and no compatibility fallback.</p>
36 <h2>Limits and known debt</h2>
37 <ul>
38 <li>Completed selections follow output. A held drag still cancels if its coordinate source moves; copying during a held drag remains supported. Resize/reflow, screen/mode changes and reset clear selection. Character insertion/deletion, rectangular margins and a range crossing a partially scrolled region also clear it. Paste, IME and ligatures are deferred.</li>
39 <li>The alternate screen and mouse-reporting scenario belongs to separate automation and is not claimed by this video.</li>
40 <li>Evidence is Linux-only. macOS behavior remains unverified.</li>
41 <li>The NVIDIA raw-output frame gate remains red: p99 20.7 ms against 20 ms. Output continued and reopen succeeded; sampled input-to-painted time stayed at or below 60.4 ms (including 5 ms polling). Prior-sprint measurements also missed this budget; no performance improvement is claimed.</li>
42 <li>Recording metadata: continuous capture, no cuts, no audio, 5 fps at 200% scale. The recording is a continuous 25-second capture.</li>
43 </ul>
44 </main>
45 </body>
46 </html>
docs/superpowers/plans/2026-09-06-native-selection-follow.md
Old New
@@ -0,0 +1,128 @@
1 # Native selection follows output
2
3 Status: implemented, functionally validated and architecturally reviewed.
4 Product acceptance pending; existing NVIDIA frame-time debt remains open.
5
6 Goal: a completed local selection follows the same terminal text as output
7 scrolls. This includes Shift+drag in an application using mouse reporting.
8 The existing desktop clipboard stays unchanged until another copy; copying
9 again resolves the tracked text, rather than its former screen position.
10
11 ## Scope and ownership
12
13 | Behavior | Reuse | Owner |
14 | --- | --- | --- |
15 | Terminal identity | Pinned Ghostty Selection.track and PageList pins | Engine wrapper, one owned selection per attached server client |
16 | Text extraction | Engine.extractSelection and existing formatter | Engine |
17 | Correlation and compatibility | Framed protocol, server client slots | Shared protocol and server |
18 | Position and clipboard delivery | Session pump mutex, request tickets and attachment lifetime | Shared client |
19 | Drag and highlight | Existing selection.Drag and pane-relative hit testing | Native interaction |
20
21 No second terminal parser, text matching, or global Ghostty Screen.selection.
22 Opening inspection found no independent cleanup needed; the engine adapter
23 belongs to the feature. Source stays frozen throughout validation before commit.
24
25 The existing selection request/reply now carries a gesture ID, copy request ID,
26 terminal sequence and authoritative source token. Registering against a stale
27 source is refused. Source tokens must advance even when identical rows produce
28 no visual delta. Positions are delivered after the matching terminal frame;
29 the renderer hides an unmatched position until its corresponding state arrives.
30 Copy uses the daemon's tracked endpoints. The user explicitly excluded backward
31 compatibility: clients require the updated daemon. One action field selects
32 extract/start/copy/clear; TUI and browser extraction use the same exchange.
33 There is no parallel legacy request or capability fallback.
34
35 The released range becomes the durable selection. Ctrl+Shift+C during a held
36 drag still copies the current range; later copies and release register their
37 current endpoints. Output moving during a held drag
38 retains conservative cancellation; tracking an unfinished gesture is a later
39 slice. Resize/reflow, screen replacement, disconnection and discarded endpoints
40 invalidate selection and preserve the clipboard. GUI paste and ligatures remain
41 outside this slice. A remote daemon needs rebuilding alongside the client.
42 Character insertion/deletion and rectangular margins retire local selection;
43 a linear range crossing a partially scrolled region also retires rather than
44 claiming to represent unchanged text.
45
46 ## Acceptance
47
48 - Select a line in a pane with nonzero origin; append output until it moves up.
49 Its highlight follows it, and copying again returns the intended text.
50 - Exercise primary history and alternate-screen vertical scrolling, including
51 repeated identical rows and a scroll region. Position follows engine identity.
52 - Shift-local selection sends no application mouse gesture and follows output.
53 - Two client selections remain independent; another pane's output has no effect.
54 - Discarded endpoints, resize, screen switch and reconnect clear the highlight
55 without replacing the clipboard. Late requests cannot select unrelated text.
56 - Retain existing viewport-wheel selection, soft-wrap extraction and app mouse
57 behavior. Verify real GUI pixels and clipboard, plus engine/wire boundaries.
58
59 ## Delivery
60
61 Use the repository sprint gates and a localhost review page with an actual GUI
62 recording. Publishing is optional when requested. Product acceptance remains
63 separate from tests and architectural review. Carry forward the existing NVIDIA
64 frame-time budget miss; retain evidence rather than rerunning until green.
65
66 ## Integrated architecture and review
67
68 The engine adapter owns one pair of Ghostty tracked pins per attached client.
69 Ghostty owns page growth, history eviction, Unicode width and wrapping. Partial
70 vertical operations that copy/rotate row contents need explicit endpoint moves;
71 the adapter uses parsed actions and rebinds the existing pins after those moves.
72 REP delegates each repeated character to the existing print hook using Ghostty's
73 previous character. No client parses VT and no duplicate terminal state exists.
74
75 The server owns tracker teardown, source validation and copy extraction. The
76 pump owns queued/sent correlation, attachment cancellation and matching position
77 metadata to the displayed snapshot or history chunk. The renderer receives grid,
78 origin and position together. During a sequence gap it hides unmatched geometry
79 without destroying the gesture. Clipboard delivery remains separate from
80 unsolicited position updates.
81
82 Closing cleanup consolidated selection into one action-based wire exchange and
83 removed unused follow-state bookkeeping. Direct TUI/browser extraction reuses
84 the same codecs and formatter. Review resolved extraction lifetime, tracked-pin
85 allocation cleanup, alternate-screen allocator reuse, discarded-row tracking,
86 partial-region movement and held-copy regressions. The independent Terra review
87 has no remaining architectural blocker; final gate results remain separate.
88
89 ## Final evidence and handoff
90
91 - `dist/selection-follow/ci-final.log`: full CI passed, including check,
92 117 e2e scenarios / 38 convergence points, 10 agent checks and throughput.
93 - `native-final-rerun.log`: ReleaseSafe native-core/native tests and the full
94 native integration chain passed. Its offscreen tmux skip is supplemented by
95 the separate actual Wayland fixture below.
96 - `wayland-final.log`: selection, follow-selection, wheel and real
97 200%/100%/150%/200% scale checks passed with the matched release binaries.
98 - `wayland-tmux-final.log`: three shared foot/tmux mouse checkpoints passed.
99 A later counter assertion exposed ordering between real Wayland motion and
100 the FIFO key hook; the fixture now observes the extended highlight first.
101 `wayland-tmux-counter-final.log` passes held/released copying through tmux in
102 both windowed and fullscreen modes with that independent pixel observation.
103 - `stress-final.log` and retained `stress-result.json`: raw output continued
104 for 30.1 seconds (120.5 MB read), reopen succeeded, and 186 sampled input-to-
105 painted observations stayed at or below 60.4 ms including 5 ms polling.
106 The frame gate remains **red**: p99 20.576/20.716 ms against 20 ms. This is the
107 previously recorded NVIDIA presentation debt, not a performance pass.
108 - Earlier failing logs remain. They include an existing PTY exit timing flake,
109 actual implementation failures fixed before delivery, fixture mistakes, and
110 a CI agent compile caught during a source change. Final CI is a separate,
111 successful frozen-source run. No red result was overwritten as a pass.
112
113 Matched demo binaries were built with `make install
114 INSTDIR=dist/native-follow-release BINDIR=dist/native-follow-bin`; daemon and
115 GUI both use ReleaseSafe. The maintained page is
116 [the selection-follow review page](../../demos/native-selection-follow.html).
117 Its 25-second, 960×600 H.264 recording is continuous at 5 fps, no audio, on
118 NVIDIA Wayland at 200% scale. It shows a real pointer drag, three appended rows,
119 neighbour output and independently read clipboard replacement/recopy.
120 Video decoding, page loading and HTTP byte ranges were verified.
121
122 Local review URL: <http://localhost:18778/selection-follow/>. No publication was
123 requested or created. Server PID 257482 is recorded in
124 `dist/selection-follow/server.json`; after product acceptance verify its command
125 still names this worktree's `dist/selection-follow/serve-page.py`, then run
126 `kill 257482`. Recording and test rigs closed; owned compositor PID 141229 was
127 verified and stopped (`fixture-teardown.json`). Preserve unrelated services and
128 retain page/video/check evidence. Product sign-off has not yet been given.
src/client/client_core.zig
Old New
@@ -312,12 +312,13 @@ test "client core begins selection with exact request bytes" {
312 }; 312 };
313 313
314 const encoded = core.beginSelection(req); 314 const encoded = core.beginSelection(req);
315 try std.testing.expectEqualSlices(u8, &.{ 315 const expected = [_]u8{
316 0x12, 0x34, 0x56, 0x78, 316 0x12, 0x34, 0x56, 0x78,
317 0x11, 0x22, 0x33, 0x44, 317 0x11, 0x22, 0x33, 0x44,
318 0x55, 0x66, 0x77, 0x88, 318 0x55, 0x66, 0x77, 0x88,
319 0x99, 0xaa, 0xbb, 0xcc, 319 0x99, 0xaa, 0xbb, 0xcc,
320 }, &encoded); 320 } ++ ([_]u8{0} ** 21);
321 try std.testing.expectEqualSlices(u8, &expected, &encoded);
321 try std.testing.expectEqualDeep(req, try proto.decodeSelectionReq(&encoded)); 322 try std.testing.expectEqualDeep(req, try proto.decodeSelectionReq(&encoded));
322 try std.testing.expectEqual(@as(?u32, req.id), core.pending_selection_id); 323 try std.testing.expectEqual(@as(?u32, req.id), core.pending_selection_id);
323 } 324 }
@@ -329,8 +330,15 @@ test "client core ignores stale selection reply then accepts matching reply once
329 .anchor = .{ .row = 1, .col = 2 }, 330 .anchor = .{ .row = 1, .col = 2 },
330 .active = .{ .row = 3, .col = 4 }, 331 .active = .{ .row = 3, .col = 4 },
331 }); 332 });
332 const stale = [_]u8{ 21, 0, 0, 0, 0, 0, 0, 0, 0, 'n', 'o' }; 333 var stale = [_]u8{0} ** (proto.selection_reply_prefix_len + 2);
333 const matching = [_]u8{ 22, 0, 0, 0, 0, 8, 0, 0, 0, 'o', 'k' }; 334 std.mem.writeInt(u32, stale[0..4], 21, .little);
335 stale[proto.selection_reply_prefix_len..][0] = 'n';
336 stale[proto.selection_reply_prefix_len..][1] = 'o';
337 var matching = [_]u8{0} ** (proto.selection_reply_prefix_len + 2);
338 std.mem.writeInt(u32, matching[0..4], 22, .little);
339 std.mem.writeInt(u32, matching[5..9], 8, .little);
340 matching[proto.selection_reply_prefix_len..][0] = 'o';
341 matching[proto.selection_reply_prefix_len..][1] = 'k';
334 342
335 try expectIgnored(core.receive(.selection_reply, &stale)); 343 try expectIgnored(core.receive(.selection_reply, &stale));
336 try std.testing.expectEqual(@as(?u32, 22), core.pending_selection_id); 344 try std.testing.expectEqual(@as(?u32, 22), core.pending_selection_id);
@@ -352,9 +360,15 @@ test "client core latest selection begin replaces the older pending id" {
352 .active = .{ .row = 4, .col = 5 }, 360 .active = .{ .row = 4, .col = 5 },
353 }); 361 });
354 362
355 try expectIgnored(core.receive(.selection_reply, &.{ 7, 0, 0, 0, 0, 0, 0, 0, 0, 'x' })); 363 var stale = [_]u8{0} ** (proto.selection_reply_prefix_len + 1);
364 std.mem.writeInt(u32, stale[0..4], 7, .little);
365 stale[proto.selection_reply_prefix_len] = 'x';
366 try expectIgnored(core.receive(.selection_reply, &stale));
356 try std.testing.expectEqual(@as(?u32, 8), core.pending_selection_id); 367 try std.testing.expectEqual(@as(?u32, 8), core.pending_selection_id);
357 try expectSelection(core.receive(.selection_reply, &.{ 8, 0, 0, 0, 0, 0, 0, 0, 0, 'y' }), 8, .ok, 0, "y"); 368 var matching = [_]u8{0} ** (proto.selection_reply_prefix_len + 1);
369 std.mem.writeInt(u32, matching[0..4], 8, .little);
370 matching[proto.selection_reply_prefix_len] = 'y';
371 try expectSelection(core.receive(.selection_reply, &matching), 8, .ok, 0, "y");
358 } 372 }
359 373
360 test "client core malformed matching selection reply preserves pending request" { 374 test "client core malformed matching selection reply preserves pending request" {
@@ -365,9 +379,15 @@ test "client core malformed matching selection reply preserves pending request"
365 .active = .{ .row = 0, .col = 0 }, 379 .active = .{ .row = 0, .col = 0 },
366 }); 380 });
367 381
368 try expectIgnored(core.receive(.selection_reply, &.{ 9, 0, 0, 0, 1, 0, 0, 0, 0, 'x' })); 382 var malformed = [_]u8{0} ** (proto.selection_reply_prefix_len + 1);
383 std.mem.writeInt(u32, malformed[0..4], 9, .little);
384 malformed[4] = @intFromEnum(proto.SelectionStatus.invalid);
385 malformed[proto.selection_reply_prefix_len] = 'x';
386 try expectIgnored(core.receive(.selection_reply, &malformed));
369 try std.testing.expectEqual(@as(?u32, 9), core.pending_selection_id); 387 try std.testing.expectEqual(@as(?u32, 9), core.pending_selection_id);
370 try expectSelection(core.receive(.selection_reply, &.{ 9, 0, 0, 0, 0, 0, 0, 0, 0 }), 9, .ok, 0, ""); 388 var matching = [_]u8{0} ** proto.selection_reply_prefix_len;
389 std.mem.writeInt(u32, matching[0..4], 9, .little);
390 try expectSelection(core.receive(.selection_reply, &matching), 9, .ok, 0, "");
371 } 391 }
372 392
373 test "client core delivers every matching non-ok selection status with empty text" { 393 test "client core delivers every matching non-ok selection status with empty text" {
@@ -380,8 +400,9 @@ test "client core delivers every matching non-ok selection status with empty tex
380 .anchor = .{ .row = 0, .col = 0 }, 400 .anchor = .{ .row = 0, .col = 0 },
381 .active = .{ .row = 0, .col = 0 }, 401 .active = .{ .row = 0, .col = 0 },
382 }); 402 });
383 var payload = [_]u8{ 0, 0, 0, 0, @intFromEnum(status), 0, 0, 0, 0 }; 403 var payload = [_]u8{0} ** proto.selection_reply_prefix_len;
384 std.mem.writeInt(u32, payload[0..4], id, .little); 404 std.mem.writeInt(u32, payload[0..4], id, .little);
405 payload[4] = @intFromEnum(status);
385 406
386 try expectSelection(core.receive(.selection_reply, &payload), id, status, 0, ""); 407 try expectSelection(core.receive(.selection_reply, &payload), id, status, 0, "");
387 try std.testing.expectEqual(@as(?u32, null), core.pending_selection_id); 408 try std.testing.expectEqual(@as(?u32, null), core.pending_selection_id);
@@ -395,7 +416,11 @@ test "client core selection reply text borrows the frame payload" {
395 .anchor = .{ .row = 0, .col = 0 }, 416 .anchor = .{ .row = 0, .col = 0 },
396 .active = .{ .row = 0, .col = 0 }, 417 .active = .{ .row = 0, .col = 0 },
397 }); 418 });
398 var payload = [_]u8{ 1, 0, 0, 0, 0, 4, 0, 0, 0, 'h', 'i' }; 419 var payload = [_]u8{0} ** (proto.selection_reply_prefix_len + 2);
420 std.mem.writeInt(u32, payload[0..4], 1, .little);
421 std.mem.writeInt(u32, payload[5..9], 4, .little);
422 payload[proto.selection_reply_prefix_len..][0] = 'h';
423 payload[proto.selection_reply_prefix_len..][1] = 'i';
399 424
400 const result = core.receive(.selection_reply, &payload); 425 const result = core.receive(.selection_reply, &payload);
401 payload[proto.selection_reply_prefix_len] = 'H'; 426 payload[proto.selection_reply_prefix_len] = 'H';
src/client/session_pump.zig
Old New
@@ -12,9 +12,10 @@ const Wire = @import("buffered_wire.zig").Wire;
12 /// Live selection keeps its range through redraws and copies current text. 12 /// Live selection keeps its range through redraws and copies current text.
13 /// History views retain exact source freshness because their cells are cached. 13 /// History views retain exact source freshness because their cells are cached.
14 /// Revision invalidates coordinates across reconnects, resizes and mode frames. 14 /// Revision invalidates coordinates across reconnects, resizes and mode frames.
15 pub const SelectionVersion = struct { seq: u64, history_rows: u32, epoch: u64, revision: u64, history: bool = false }; 15 pub const SelectionVersion = struct { seq: u64, source: u64 = 0, history_rows: u32, epoch: u64, revision: u64, history: bool = false };
16 pub const SelectionRequest = struct { 16 pub const SelectionRequest = struct {
17 id: u32, 17 id: u32,
18 gesture: u32 = 0,
18 anchor: proto.SelectionPoint, 19 anchor: proto.SelectionPoint,
19 active: proto.SelectionPoint, 20 active: proto.SelectionPoint,
20 version: SelectionVersion, 21 version: SelectionVersion,
@@ -59,7 +60,16 @@ const HistoryRequest = struct {
59 revision: u64, 60 revision: u64,
60 until: i64, 61 until: i64,
61 }; 62 };
62 pub const SelectionResult = struct { id: u32, status: proto.SelectionStatus, text: []u8, version: SelectionVersion }; 63 pub const SelectionResult = struct { id: u32, gesture: u32 = 0, status: proto.SelectionStatus, text: []u8, version: SelectionVersion };
64 pub const FollowPosition = struct {
65 id: u32,
66 seq: u64,
67 source: u64,
68 history_rows: u32,
69 status: proto.SelectionStatus,
70 anchor: proto.SelectionPoint,
71 active: proto.SelectionPoint,
72 };
63 pub const Phase = enum { dialing, attached, reconnecting, exited, refused, taken, failed, dial_failed }; 73 pub const Phase = enum { dialing, attached, reconnecting, exited, refused, taken, failed, dial_failed };
64 pub const EndPhase = enum { idle, pending, accepted, refused, unknown }; 74 pub const EndPhase = enum { idle, pending, accepted, refused, unknown };
65 pub const EndState = struct { 75 pub const EndState = struct {
@@ -119,10 +129,17 @@ pub const Pump = struct {
119 admitted: bool = false, 129 admitted: bool = false,
120 end_until: i64 = 0, // guarded by mu with status.ending 130 end_until: i64 = 0, // guarded by mu with status.ending
121 selection_pending: ?SelectionRequest = null, 131 selection_pending: ?SelectionRequest = null,
132 selection_pending_copy: bool = false,
122 selection_revision: u64 = 0, // mu: connection, geometry and terminal modes 133 selection_revision: u64 = 0, // mu: connection, geometry and terminal modes
123 selection_ticket: u64 = 0, // mu: queued and sent request cancellation 134 selection_ticket: u64 = 0, // mu: queued and sent request cancellation
124 selection_until: i64 = 0, 135 selection_until: i64 = 0,
125 selection_result: ?SelectionResult = null, 136 selection_result: ?SelectionResult = null,
137 selection_gesture: u32 = 0,
138 selection_clear: ?u32 = null,
139 follow_position: ?FollowPosition = null,
140 follow_source: u64 = 0,
141 follow_seq: u64 = 0,
142 history_waiting_metadata: bool = false,
126 scroll_rows: u32 = 0, // mu: requested distance from live output 143 scroll_rows: u32 = 0, // mu: requested distance from live output
127 history: ?*term.grid.Grid = null, 144 history: ?*term.grid.Grid = null,
128 history_start: u32 = 0, 145 history_start: u32 = 0,
@@ -161,10 +178,13 @@ pub const Pump = struct {
161 .selection => |req| { 178 .selection => |req| {
162 self.mu.lock(); 179 self.mu.lock();
163 defer self.mu.unlock(); 180 defer self.mu.unlock();
164 self.invalidateSelectionLocked(); 181 self.cancelPendingSelectionLocked();
165 var owned = req; 182 var owned = req;
183 if (owned.gesture == 0) owned.gesture = owned.id;
166 owned.ticket = self.selection_ticket; 184 owned.ticket = self.selection_ticket;
167 try self.mailbox.append(self.alloc, .{ .selection = owned }); 185 try self.mailbox.append(self.alloc, .{ .selection = owned });
186 self.selection_pending = owned;
187 self.selection_until = std.time.milliTimestamp() + self.opts.selection_timeout_ms;
168 }, 188 },
169 .end => |req| { 189 .end => |req| {
170 self.mu.lock(); 190 self.mu.lock();
@@ -247,7 +267,7 @@ pub const Pump = struct {
247 /// Caller holds mu while copying both this version and the displayed grid. 267 /// Caller holds mu while copying both this version and the displayed grid.
248 pub fn selectionVersionLocked(self: *const Pump) SelectionVersion { 268 pub fn selectionVersionLocked(self: *const Pump) SelectionVersion {
249 if (self.history != null) return self.history_version; 269 if (self.history != null) return self.history_version;
250 return .{ .seq = self.replica.last_seq, .history_rows = self.replica.history_rows, .epoch = self.replica.session_epoch, .revision = self.selection_revision }; 270 return .{ .seq = self.replica.last_seq, .source = if (self.follow_seq == self.replica.last_seq) self.follow_source else 0, .history_rows = self.replica.history_rows, .epoch = self.replica.session_epoch, .revision = self.selection_revision };
251 } 271 }
252 pub fn viewGridLocked(self: *const Pump) *const term.grid.Grid { 272 pub fn viewGridLocked(self: *const Pump) *const term.grid.Grid {
253 return self.history orelse self.grid; 273 return self.history orelse self.grid;
@@ -255,13 +275,23 @@ pub const Pump = struct {
255 pub fn viewOriginLocked(self: *const Pump) u32 { 275 pub fn viewOriginLocked(self: *const Pump) u32 {
256 return if (self.history != null) self.history_start else self.replica.history_rows; 276 return if (self.history != null) self.history_start else self.replica.history_rows;
257 } 277 }
278 pub fn followPositionLocked(self: *const Pump) ?FollowPosition {
279 const position = self.follow_position orelse return null;
280 if (position.id != self.selection_gesture or position.status != .ok or position.seq != self.replica.last_seq) return null;
281 if (self.history != null and (self.history_version.seq != position.seq or
282 self.history_version.source != position.source)) return null;
283 return position;
284 }
258 fn selectionFreshLocked(self: *const Pump, version: SelectionVersion) bool { 285 fn selectionFreshLocked(self: *const Pump, version: SelectionVersion) bool {
286 return self.selectionAliveLocked(version) and
287 version.history_rows == self.replica.history_rows and
288 version.source != 0 and version.source == self.follow_source and
289 (!version.history or version.seq == self.replica.last_seq);
290 }
291 fn selectionAliveLocked(self: *const Pump, version: SelectionVersion) bool {
259 return !self.closing.load(.acquire) and self.status.phase == .attached and 292 return !self.closing.load(.acquire) and self.status.phase == .attached and
260 self.replica.state_since_attach and version.epoch == self.replica.session_epoch and 293 self.replica.state_since_attach and version.epoch == self.replica.session_epoch and
261 version.history_rows == self.replica.history_rows and version.revision == self.selection_revision and 294 version.revision == self.selection_revision;
262 (!version.history or version.seq == self.replica.last_seq) and
263 (self.history == null or (self.history_version.seq == self.replica.last_seq and
264 self.history_version.revision == self.selection_revision));
265 } 295 }
266 fn returnLiveLocked(self: *Pump) void { 296 fn returnLiveLocked(self: *Pump) void {
267 if (self.scroll_rows == 0 and self.history == null and !self.history_dirty) return; 297 if (self.scroll_rows == 0 and self.history == null and !self.history_dirty) return;
@@ -270,6 +300,7 @@ pub const Pump = struct {
270 self.history_revision +%= 1; 300 self.history_revision +%= 1;
271 if (self.history) |g| g.deinit(); 301 if (self.history) |g| g.deinit();
272 self.history = null; 302 self.history = null;
303 self.history_waiting_metadata = false;
273 } 304 }
274 fn requestHistory(self: *Pump, wire: *Wire) !void { 305 fn requestHistory(self: *Pump, wire: *Wire) !void {
275 self.mu.lock(); 306 self.mu.lock();
@@ -309,6 +340,7 @@ pub const Pump = struct {
309 self.history = view; 340 self.history = view;
310 self.history_start = origin; 341 self.history_start = origin;
311 self.history_version = .{ .seq = self.replica.last_seq, .history_rows = self.replica.history_rows, .epoch = self.replica.session_epoch, .revision = self.selection_revision, .history = true }; 342 self.history_version = .{ .seq = self.replica.last_seq, .history_rows = self.replica.history_rows, .epoch = self.replica.session_epoch, .revision = self.selection_revision, .history = true };
343 self.history_waiting_metadata = true;
312 return .changed; 344 return .changed;
313 } 345 }
314 pub fn selectionFresh(self: *Pump, version: SelectionVersion) bool { 346 pub fn selectionFresh(self: *Pump, version: SelectionVersion) bool {
@@ -316,6 +348,19 @@ pub const Pump = struct {
316 defer self.mu.unlock(); 348 defer self.mu.unlock();
317 return self.selectionFreshLocked(version); 349 return self.selectionFreshLocked(version);
318 } 350 }
351 pub fn selectionAlive(self: *Pump, gesture: u32, version: SelectionVersion) bool {
352 self.mu.lock();
353 defer self.mu.unlock();
354 if (!self.selectionAliveLocked(version)) return false;
355 if (self.selection_pending) |pending| {
356 if ((if (pending.gesture == 0) pending.id else pending.gesture) == gesture) return true;
357 }
358 if (self.selection_result) |result| if (result.gesture == gesture) return true;
359 if (self.follow_position) |position| {
360 if (self.selection_gesture == gesture and position.id == gesture and position.status == .ok) return true;
361 }
362 return false;
363 }
319 /// Validate again under the same lock that transfers ownership. A newer 364 /// Validate again under the same lock that transfers ownership. A newer
320 /// frame after reply decoding must not leave an old copy ready for the UI. 365 /// frame after reply decoding must not leave an old copy ready for the UI.
321 pub fn takeSelection(self: *Pump) ?SelectionResult { 366 pub fn takeSelection(self: *Pump) ?SelectionResult {
@@ -323,15 +368,22 @@ pub const Pump = struct {
323 defer self.mu.unlock(); 368 defer self.mu.unlock();
324 const result = self.selection_result orelse return null; 369 const result = self.selection_result orelse return null;
325 self.selection_result = null; 370 self.selection_result = null;
326 if (!self.selectionFreshLocked(result.version)) { 371 if (!self.selectionAliveLocked(result.version)) {
327 self.alloc.free(result.text); 372 self.alloc.free(result.text);
328 return null; 373 return null;
329 } 374 }
330 return result; 375 return result;
331 } 376 }
332 fn invalidateSelectionLocked(self: *Pump) void { 377 fn invalidateSelectionLocked(self: *Pump) void {
378 if (self.selection_gesture != 0) self.selection_clear = self.selection_gesture;
379 self.selection_gesture = 0;
380 self.follow_position = null;
381 self.cancelPendingSelectionLocked();
382 }
383 fn cancelPendingSelectionLocked(self: *Pump) void {
333 self.selection_ticket +%= 1; 384 self.selection_ticket +%= 1;
334 self.selection_pending = null; 385 self.selection_pending = null;
386 self.selection_pending_copy = false;
335 self.selection_until = 0; 387 self.selection_until = 0;
336 self.core.pending_selection_id = null; 388 self.core.pending_selection_id = null;
337 if (self.selection_result) |result| self.alloc.free(result.text); 389 if (self.selection_result) |result| self.alloc.free(result.text);
@@ -339,14 +391,48 @@ pub const Pump = struct {
339 } 391 }
340 pub fn cancelSelection(self: *Pump) void { 392 pub fn cancelSelection(self: *Pump) void {
341 self.mu.lock(); 393 self.mu.lock();
342 defer self.mu.unlock();
343 self.invalidateSelectionLocked(); 394 self.invalidateSelectionLocked();
395 self.mu.unlock();
396 ring(self.wake_pipe[1], 1);
397 }
398 fn flushSelectionClear(self: *Pump, wire: *Wire) !void {
399 self.mu.lock();
400 const gesture = self.selection_clear;
401 self.selection_clear = null;
402 self.mu.unlock();
403 if (gesture) |id| {
404 const clear = proto.encodeSelectionReq(.{
405 .action = .clear,
406 .id = 0,
407 .gesture = id,
408 .epoch = 0,
409 .source = 0,
410 .anchor = .{ .row = 0, .col = 0 },
411 .active = .{ .row = 0, .col = 0 },
412 });
413 try wire.send(.selection_req, &clear);
414 }
344 } 415 }
345 fn beginSelectionLocked(self: *Pump, req: SelectionRequest) ?[proto.selection_req_len]u8 { 416 fn beginSelectionLocked(self: *Pump, req: SelectionRequest) ?[proto.selection_req_len]u8 {
346 if (req.ticket != self.selection_ticket or !self.selectionFreshLocked(req.version)) return null; 417 const gesture = if (req.gesture == 0) req.id else req.gesture;
418 const action: @FieldType(proto.SelectionReq, "action") =
419 if (self.selection_gesture == gesture) .copy else .start;
420 if (req.ticket != self.selection_ticket or
421 (if (action == .copy) !self.selectionAliveLocked(req.version) else !self.selectionFreshLocked(req.version))) return null;
422 self.selection_gesture = gesture;
423 if (action == .start) self.follow_position = null;
347 self.selection_pending = req; 424 self.selection_pending = req;
425 self.selection_pending_copy = action == .copy;
348 self.selection_until = std.time.milliTimestamp() + self.opts.selection_timeout_ms; 426 self.selection_until = std.time.milliTimestamp() + self.opts.selection_timeout_ms;
349 return self.core.beginSelection(.{ .id = req.id, .anchor = req.anchor, .active = req.active }); 427 return proto.encodeSelectionReq(.{
428 .action = action,
429 .id = req.id,
430 .gesture = gesture,
431 .epoch = req.version.epoch,
432 .source = req.version.source,
433 .anchor = req.anchor,
434 .active = req.active,
435 });
350 } 436 }
351 437
352 fn wake(self: *Pump) void { 438 fn wake(self: *Pump) void {
@@ -435,6 +521,12 @@ pub const Pump = struct {
435 self.clearClipboardLocked(); 521 self.clearClipboardLocked();
436 self.invalidateSelectionLocked(); 522 self.invalidateSelectionLocked();
437 self.selection_revision +%= 1; 523 self.selection_revision +%= 1;
524 self.follow_position = null;
525 self.follow_source = 0;
526 self.follow_seq = 0;
527 self.history_waiting_metadata = false;
528 self.selection_gesture = 0;
529 self.selection_clear = null;
438 self.returnLiveLocked(); 530 self.returnLiveLocked();
439 if (!fresh) self.history_pending = null; 531 if (!fresh) self.history_pending = null;
440 const args = self.replica.attachArgs(); 532 const args = self.replica.attachArgs();
@@ -456,6 +548,7 @@ pub const Pump = struct {
456 for (messages.items[0..consumed]) |msg| if (msg == .input) self.alloc.free(msg.input); 548 for (messages.items[0..consumed]) |msg| if (msg == .input) self.alloc.free(msg.input);
457 messages.deinit(self.alloc); 549 messages.deinit(self.alloc);
458 } 550 }
551 try self.flushSelectionClear(wire);
459 for (messages.items, 0..) |msg, i| { 552 for (messages.items, 0..) |msg, i| {
460 if (msg == .wheel and !allow_wheel) { 553 if (msg == .wheel and !allow_wheel) {
461 // Keep the wheel and following input ordered, while allowing 554 // Keep the wheel and following input ordered, while allowing
@@ -512,7 +605,10 @@ pub const Pump = struct {
512 self.mu.unlock(); 605 self.mu.unlock();
513 self.opts.cols = size.cols; 606 self.opts.cols = size.cols;
514 self.opts.rows = size.rows; 607 self.opts.rows = size.rows;
515 if (!self.opts.existing_only or self.admitted) { 608 self.mu.lock();
609 const admitted = self.admitted;
610 self.mu.unlock();
611 if (!self.opts.existing_only or admitted) {
516 const buf = proto.encodeSize(size.cols, size.rows); 612 const buf = proto.encodeSize(size.cols, size.rows);
517 try wire.send(.resize, &buf); 613 try wire.send(.resize, &buf);
518 } 614 }
@@ -520,6 +616,10 @@ pub const Pump = struct {
520 .quit, .detach => unreachable, 616 .quit, .detach => unreachable,
521 } 617 }
522 } 618 }
619 // Input/resize can retire a tracker while this batch is being sent.
620 // Flush after the batch as well so a quiet connection still receives
621 // the bounded clear request.
622 try self.flushSelectionClear(wire);
523 } 623 }
524 624
525 fn writeMouse(wire: *Wire, event: Mouse) !bool { 625 fn writeMouse(wire: *Wire, event: Mouse) !bool {
@@ -648,12 +748,16 @@ pub const Pump = struct {
648 .frame => |frame| { 748 .frame => |frame| {
649 defer frame.deinit(self.alloc); 749 defer frame.deinit(self.alloc);
650 const action = try self.onFrame(frame.type, frame.payload); 750 const action = try self.onFrame(frame.type, frame.payload);
751 var admitted_now = false;
752 self.mu.lock();
651 if (!self.admitted and (frame.type == .snapshot or frame.type == .delta) and action == .changed) { 753 if (!self.admitted and (frame.type == .snapshot or frame.type == .delta) and action == .changed) {
652 self.admitted = true; 754 self.admitted = true;
653 if (self.opts.existing_only) { 755 admitted_now = true;
654 const size = proto.encodeSize(self.opts.cols, self.opts.rows); 756 }
655 try wire.send(.resize, &size); 757 self.mu.unlock();
656 } 758 if (admitted_now and self.opts.existing_only) {
759 const size = proto.encodeSize(self.opts.cols, self.opts.rows);
760 try wire.send(.resize, &size);
657 } 761 }
658 changed = changed or action != .skip; 762 changed = changed or action != .skip;
659 switch (action) { 763 switch (action) {
@@ -715,6 +819,7 @@ pub const Pump = struct {
715 fn onFrame(self: *Pump, kind: proto.MsgType, payload: []const u8) !Action { 819 fn onFrame(self: *Pump, kind: proto.MsgType, payload: []const u8) !Action {
716 self.mu.lock(); 820 self.mu.lock();
717 defer self.mu.unlock(); 821 defer self.mu.unlock();
822 if (kind != .selection_reply) self.history_waiting_metadata = false;
718 switch (kind) { 823 switch (kind) {
719 .scrollback_chunk => return self.historyReplyLocked(payload), 824 .scrollback_chunk => return self.historyReplyLocked(payload),
720 .end_reply => { 825 .end_reply => {
@@ -727,7 +832,6 @@ pub const Pump = struct {
727 .snapshot, .delta => { 832 .snapshot, .delta => {
728 const begin = std.time.nanoTimestamp(); 833 const begin = std.time.nanoTimestamp();
729 const old_epoch = self.replica.session_epoch; 834 const old_epoch = self.replica.session_epoch;
730 const old_history_rows = self.replica.history_rows;
731 const old_cols = self.grid.cols; 835 const old_cols = self.grid.cols;
732 const old_rows = self.grid.rows; 836 const old_rows = self.grid.rows;
733 const applied = self.replica.apply(kind, payload) catch |err| switch (err) { 837 const applied = self.replica.apply(kind, payload) catch |err| switch (err) {
@@ -735,8 +839,10 @@ pub const Pump = struct {
735 else => return err, 839 else => return err,
736 }; 840 };
737 self.last_apply_us = @intCast(@min(std.math.maxInt(u32), @max(0, @divTrunc(std.time.nanoTimestamp() - begin, 1000)))); 841 self.last_apply_us = @intCast(@min(std.math.maxInt(u32), @max(0, @divTrunc(std.time.nanoTimestamp() - begin, 1000))));
738 if (applied == .resync or self.history != null or 842 // Output and scrollback growth preserve a tracked source. Only
739 self.replica.session_epoch != old_epoch or self.replica.history_rows != old_history_rows or 843 // a resync, session epoch, or geometry change invalidates its
844 // coordinates; the follow-state reply remaps ordinary output.
845 if (applied == .resync or self.replica.session_epoch != old_epoch or
740 self.grid.cols != old_cols or self.grid.rows != old_rows) 846 self.grid.cols != old_cols or self.grid.rows != old_rows)
741 { 847 {
742 self.selection_revision +%= 1; 848 self.selection_revision +%= 1;
@@ -765,20 +871,54 @@ pub const Pump = struct {
765 return .end; 871 return .end;
766 }, 872 },
767 .selection_reply => { 873 .selection_reply => {
768 const pending = self.selection_pending orelse return .skip; 874 const reply = proto.decodeSelectionReply(payload) catch return .skip;
769 const result = self.core.receive(kind, payload); 875 if (reply.seq == self.replica.last_seq) {
770 const reply = switch (result) { 876 self.follow_seq = reply.seq;
771 .reply => |r| r.selection, 877 self.follow_source = reply.source;
772 else => return .skip, 878 // The server emits this position metadata immediately after
773 }; 879 // a history chunk. Bind that chunk to the source that was
774 if (!self.selectionFreshLocked(pending.version) or reply.history_rows != pending.version.history_rows) { 880 // actually rendered; never borrow a later live source.
881 if (self.history != null and self.history_waiting_metadata and reply.id == 0) {
882 self.history_version.source = reply.source;
883 self.history_version.seq = reply.seq;
884 self.history_version.history_rows = reply.history_rows;
885 self.history_waiting_metadata = false;
886 }
887 if (reply.gesture != 0) {
888 const matches = self.selection_gesture == reply.gesture;
889 if (matches) self.follow_position = .{
890 .id = reply.gesture,
891 .seq = reply.seq,
892 .source = reply.source,
893 .history_rows = reply.history_rows,
894 .status = if (reply.status == .too_large) .ok else reply.status,
895 .anchor = reply.anchor,
896 .active = reply.active,
897 };
898 } else if (self.selection_gesture != 0) {
899 // A zero-gesture state is the daemon's explicit
900 // tracker retirement (metadata before a gesture has
901 // no tracker to retire).
902 self.follow_position = null;
903 }
904 }
905 const pending = self.selection_pending orelse return .changed;
906 const gesture = if (pending.gesture == 0) pending.id else pending.gesture;
907 if (reply.id != pending.id or reply.gesture != gesture) return .changed;
908 const pending_alive = if (self.selection_pending_copy)
909 self.selectionAliveLocked(pending.version)
910 else
911 self.selectionFreshLocked(pending.version);
912 if (!pending_alive or reply.seq != self.replica.last_seq or
913 (!self.selection_pending_copy and pending.version.source != reply.source))
914 {
775 self.selection_pending = null; 915 self.selection_pending = null;
776 return .skip; 916 return .changed;
777 } 917 }
778 self.selection_pending = null;
779 const text = try self.alloc.dupe(u8, reply.text); 918 const text = try self.alloc.dupe(u8, reply.text);
919 self.selection_pending = null;
780 if (self.selection_result) |old| self.alloc.free(old.text); 920 if (self.selection_result) |old| self.alloc.free(old.text);
781 self.selection_result = .{ .id = reply.id, .status = reply.status, .text = text, .version = pending.version }; 921 self.selection_result = .{ .id = reply.id, .gesture = gesture, .status = reply.status, .text = text, .version = pending.version };
782 return .changed; 922 return .changed;
783 }, 923 },
784 else => switch (self.core.receive(kind, payload)) { 924 else => switch (self.core.receive(kind, payload)) {
@@ -833,7 +973,7 @@ pub const Pump = struct {
833 if (expired) { 973 if (expired) {
834 const pending = self.selection_pending.?; 974 const pending = self.selection_pending.?;
835 self.invalidateSelectionLocked(); 975 self.invalidateSelectionLocked();
836 self.selection_result = .{ .id = pending.id, .status = .unavailable, .text = &.{}, .version = pending.version }; 976 self.selection_result = .{ .id = pending.id, .gesture = if (pending.gesture == 0) pending.id else pending.gesture, .status = .unavailable, .text = &.{}, .version = pending.version };
837 } 977 }
838 self.mu.unlock(); 978 self.mu.unlock();
839 if (expired) self.wake(); 979 if (expired) self.wake();
@@ -1473,6 +1613,10 @@ fn selectionTestPump() !*Pump {
1473 const p = try a.create(Pump); 1613 const p = try a.create(Pump);
1474 p.* = .{ .alloc = a, .opts = .{ .target = .{ .sock = "unused" }, .cols = 11, .rows = 3 }, .grid = g, .replica = .init(a, g), .wake_pipe = wp, .cancel_pipe = cp }; 1614 p.* = .{ .alloc = a, .opts = .{ .target = .{ .sock = "unused" }, .cols = 11, .rows = 3 }, .grid = g, .replica = .init(a, g), .wake_pipe = wp, .cancel_pipe = cp };
1475 _ = try p.onFrame(.snapshot, &testSnapshot()); 1615 _ = try p.onFrame(.snapshot, &testSnapshot());
1616 // The unified native path requires a nonzero follow source. Tests model
1617 // the metadata frame that the daemon sends after the initial snapshot.
1618 p.follow_seq = p.replica.last_seq;
1619 p.follow_source = 1;
1476 return p; 1620 return p;
1477 } 1621 }
1478 fn selectionTestQueue(p: *Pump, id: u32) !SelectionRequest { 1622 fn selectionTestQueue(p: *Pump, id: u32) !SelectionRequest {
@@ -1492,11 +1636,57 @@ fn selectionTestReply(p: *Pump, id: u32, text: []const u8) !void {
1492 defer payload.deinit(std.testing.allocator); 1636 defer payload.deinit(std.testing.allocator);
1493 p.mu.lock(); 1637 p.mu.lock();
1494 const history_rows = p.replica.history_rows; 1638 const history_rows = p.replica.history_rows;
1639 const gesture = if (p.selection_pending) |pending| if (pending.gesture == 0) pending.id else pending.gesture else id;
1495 p.mu.unlock(); 1640 p.mu.unlock();
1496 try proto.encodeSelectionReply(&payload, std.testing.allocator, id, .ok, history_rows, text); 1641 try proto.encodeSelectionReply(&payload, std.testing.allocator, .{ .id = id, .gesture = gesture, .seq = p.replica.last_seq, .source = p.follow_source, .history_rows = history_rows, .status = .ok, .text = text });
1497 _ = try p.onFrame(.selection_reply, payload.items); 1642 _ = try p.onFrame(.selection_reply, payload.items);
1498 @memset(payload.items, 0xaa); // The published result must own its text. 1643 @memset(payload.items, 0xaa); // The published result must own its text.
1499 } 1644 }
1645 fn selectionTestFollowMetadata(p: *Pump, source: u64) !void {
1646 var payload: std.ArrayList(u8) = .empty;
1647 defer payload.deinit(std.testing.allocator);
1648 try proto.encodeSelectionReply(&payload, std.testing.allocator, .{
1649 .id = 0,
1650 .gesture = 0,
1651 .seq = p.replica.last_seq,
1652 .source = source,
1653 .history_rows = p.replica.history_rows,
1654 .status = .ok,
1655 .text = "",
1656 });
1657 _ = try p.onFrame(.selection_reply, payload.items);
1658 }
1659
1660 test "follow selection sends a source-bound start then a gesture-bound copy" {
1661 const p = try selectionTestPump();
1662 defer p.stop();
1663 p.admitted = true;
1664 p.follow_seq = p.replica.last_seq;
1665 p.follow_source = 91;
1666
1667 const version = p.selectionVersionLocked();
1668 const first: SelectionRequest = .{
1669 .id = 7,
1670 .gesture = 42,
1671 .anchor = .{ .row = 0, .col = 0 },
1672 .active = .{ .row = 1, .col = 3 },
1673 .version = version,
1674 .ticket = p.selection_ticket,
1675 };
1676 const start = p.beginSelectionLocked(first).?;
1677 const start_req = try proto.decodeSelectionReq(&start);
1678 try std.testing.expectEqual(.start, start_req.action);
1679 try std.testing.expectEqual(@as(u32, 7), start_req.id);
1680 try std.testing.expectEqual(@as(u32, 42), start_req.gesture);
1681 try std.testing.expectEqual(@as(u64, 91), start_req.source);
1682
1683 const second = SelectionRequest{ .id = 8, .gesture = 42, .anchor = first.anchor, .active = first.active, .version = version, .ticket = p.selection_ticket };
1684 const copy = p.beginSelectionLocked(second).?;
1685 const copy_req = try proto.decodeSelectionReq(&copy);
1686 try std.testing.expectEqual(.copy, copy_req.action);
1687 try std.testing.expectEqual(@as(u32, 8), copy_req.id);
1688 try std.testing.expectEqual(@as(u32, 42), copy_req.gesture);
1689 }
1500 1690
1501 test "selection cancels queued work but retains live requests through redraw" { 1691 test "selection cancels queued work but retains live requests through redraw" {
1502 const p = try selectionTestPump(); 1692 const p = try selectionTestPump();
@@ -1508,6 +1698,8 @@ test "selection cancels queued work but retains live requests through redraw" {
1508 var newer = testSnapshot(); 1698 var newer = testSnapshot();
1509 std.mem.writeInt(u64, newer[0..8], 38, .little); 1699 std.mem.writeInt(u64, newer[0..8], 38, .little);
1510 _ = try p.onFrame(.snapshot, &newer); 1700 _ = try p.onFrame(.snapshot, &newer);
1701 p.follow_seq = p.replica.last_seq;
1702 p.follow_source = 1;
1511 try std.testing.expect(selectionTestBegin(p, before_redraw) != null); 1703 try std.testing.expect(selectionTestBegin(p, before_redraw) != null);
1512 const current = try selectionTestQueue(p, 3); 1704 const current = try selectionTestQueue(p, 3);
1513 const bytes = selectionTestBegin(p, current).?; 1705 const bytes = selectionTestBegin(p, current).?;
@@ -1548,6 +1740,8 @@ test "live selection survives pending redraw; history and geometry remain guarde
1548 var newer = testSnapshot(); 1740 var newer = testSnapshot();
1549 std.mem.writeInt(u64, newer[0..8], 38, .little); 1741 std.mem.writeInt(u64, newer[0..8], 38, .little);
1550 _ = try p.onFrame(.snapshot, &newer); 1742 _ = try p.onFrame(.snapshot, &newer);
1743 p.follow_seq = p.replica.last_seq;
1744 p.follow_source = 1;
1551 try std.testing.expect(p.selectionFresh(req.version)); 1745 try std.testing.expect(p.selectionFresh(req.version));
1552 try selectionTestReply(p, 1, "current text"); 1746 try selectionTestReply(p, 1, "current text");
1553 const result = p.takeSelection().?; 1747 const result = p.takeSelection().?;
@@ -1635,6 +1829,7 @@ test "wheel history tombstones, refresh, resize and timeout preserve the live re
1635 defer fresh.deinit(std.testing.allocator); 1829 defer fresh.deinit(std.testing.allocator);
1636 try std.testing.expect(p.history_pending.?.revision != revision); 1830 try std.testing.expect(p.history_pending.?.revision != revision);
1637 try std.testing.expectEqual(Pump.Action.changed, try p.onFrame(.scrollback_chunk, &chunk)); 1831 try std.testing.expectEqual(Pump.Action.changed, try p.onFrame(.scrollback_chunk, &chunk));
1832 try selectionTestFollowMetadata(p, 1);
1638 try std.testing.expectEqual(@as(u32, 17), p.viewOriginLocked()); 1833 try std.testing.expectEqual(@as(u32, 17), p.viewOriginLocked());
1639 try std.testing.expect(p.viewGridLocked() != p.grid); 1834 try std.testing.expect(p.viewGridLocked() != p.grid);
1640 try std.testing.expectEqual(@as(u16, 3), p.viewGridLocked().cursor.y); 1835 try std.testing.expectEqual(@as(u16, 3), p.viewGridLocked().cursor.y);
@@ -1654,6 +1849,7 @@ test "wheel history tombstones, refresh, resize and timeout preserve the live re
1654 const restored = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; 1849 const restored = (try proto.readFrame(std.testing.allocator, outgoing[0])).?;
1655 defer restored.deinit(std.testing.allocator); 1850 defer restored.deinit(std.testing.allocator);
1656 _ = try p.onFrame(.scrollback_chunk, &chunk); 1851 _ = try p.onFrame(.scrollback_chunk, &chunk);
1852 try selectionTestFollowMetadata(p, 1);
1657 try std.testing.expect(p.selectionFresh(history_version)); 1853 try std.testing.expect(p.selectionFresh(history_version));
1658 const history_request = try selectionTestQueue(p, 42); 1854 const history_request = try selectionTestQueue(p, 42);
1659 try std.testing.expect(history_request.version.history); 1855 try std.testing.expect(history_request.version.history);
@@ -1672,8 +1868,9 @@ test "wheel history tombstones, refresh, resize and timeout preserve the live re
1672 const after_live = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; 1868 const after_live = (try proto.readFrame(std.testing.allocator, outgoing[0])).?;
1673 defer after_live.deinit(std.testing.allocator); 1869 defer after_live.deinit(std.testing.allocator);
1674 _ = try p.onFrame(.scrollback_chunk, &chunk); 1870 _ = try p.onFrame(.scrollback_chunk, &chunk);
1871 try selectionTestFollowMetadata(p, 1);
1675 var newer = testSnapshot(); 1872 var newer = testSnapshot();
1676 proto.writeSnapshotPrefix(newer[0..proto.snapshot_prefix_len], .{ .seq = 38, .history_rows = 20, .cols = 11, .rows = 3, .epoch = 93 }); 1873 proto.writeSnapshotPrefix(newer[0..proto.snapshot_prefix_len], .{ .seq = 39, .history_rows = 20, .cols = 11, .rows = 3, .epoch = 93 });
1677 _ = try p.onFrame(.snapshot, &newer); 1874 _ = try p.onFrame(.snapshot, &newer);
1678 try std.testing.expect(p.history_dirty); 1875 try std.testing.expect(p.history_dirty);
1679 try std.testing.expect(!p.selectionFresh(p.selectionVersionLocked())); 1876 try std.testing.expect(!p.selectionFresh(p.selectionVersionLocked()));
@@ -1681,9 +1878,14 @@ test "wheel history tombstones, refresh, resize and timeout preserve the live re
1681 const refresh = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; 1878 const refresh = (try proto.readFrame(std.testing.allocator, outgoing[0])).?;
1682 defer refresh.deinit(std.testing.allocator); 1879 defer refresh.deinit(std.testing.allocator);
1683 _ = try p.onFrame(.scrollback_chunk, &chunk); 1880 _ = try p.onFrame(.scrollback_chunk, &chunk);
1881 try selectionTestFollowMetadata(p, 1);
1684 try std.testing.expect(p.selectionFresh(p.selectionVersionLocked())); 1882 try std.testing.expect(p.selectionFresh(p.selectionVersionLocked()));
1685 _ = try p.onFrame(.term_modes, &proto.encodeTermModes(.{ .bracketed_paste = false, .cursor_keys = true })); 1883 _ = try p.onFrame(.term_modes, &proto.encodeTermModes(.{ .bracketed_paste = false, .cursor_keys = true }));
1686 try std.testing.expect(p.history != null and p.history_dirty); 1884 try std.testing.expect(p.history != null and p.history_dirty);
1885 try p.mail(&wire, true);
1886 const cleared = (try proto.readFrame(std.testing.allocator, outgoing[0])).?;
1887 defer cleared.deinit(std.testing.allocator);
1888 try std.testing.expectEqual(proto.MsgType.selection_req, cleared.type);
1687 try p.requestHistory(&wire); 1889 try p.requestHistory(&wire);
1688 const before_resize = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; 1890 const before_resize = (try proto.readFrame(std.testing.allocator, outgoing[0])).?;
1689 defer before_resize.deinit(std.testing.allocator); 1891 defer before_resize.deinit(std.testing.allocator);
@@ -1695,12 +1897,12 @@ test "wheel history tombstones, refresh, resize and timeout preserve the live re
1695 try std.testing.expect(p.history_pending != null and p.history == null); 1897 try std.testing.expect(p.history_pending != null and p.history == null);
1696 _ = try p.onFrame(.scrollback_chunk, &chunk); 1898 _ = try p.onFrame(.scrollback_chunk, &chunk);
1697 try std.testing.expect(p.history == null); 1899 try std.testing.expect(p.history == null);
1698 try std.testing.expectEqual(@as(u64, 38), p.replica.last_seq); 1900 try std.testing.expectEqual(@as(u64, 39), p.replica.last_seq);
1699 p.scroll_rows = 3; 1901 p.scroll_rows = 3;
1700 p.history_pending = .{ .start = 17, .size = .{ .cols = 11, .rows = 3 }, .revision = p.history_revision, .until = 0 }; 1902 p.history_pending = .{ .start = 17, .size = .{ .cols = 11, .rows = 3 }, .revision = p.history_revision, .until = 0 };
1701 try std.testing.expectError(error.ConnectionTimedOut, p.requestHistory(&wire)); 1903 try std.testing.expectError(error.ConnectionTimedOut, p.requestHistory(&wire));
1702 try std.testing.expectError(error.BadPayload, p.onFrame(.scrollback_chunk, &.{ 17, 0, 0, 0, 4, 0 })); 1904 try std.testing.expectError(error.BadPayload, p.onFrame(.scrollback_chunk, &.{ 17, 0, 0, 0, 4, 0 }));
1703 try std.testing.expectEqual(@as(u64, 38), p.replica.last_seq); 1905 try std.testing.expectEqual(@as(u64, 39), p.replica.last_seq);
1704 p.history_pending = .{ .start = 17, .size = .{ .cols = 11, .rows = 3 }, .revision = p.history_revision, .until = 0 }; 1906 p.history_pending = .{ .start = 17, .size = .{ .cols = 11, .rows = 3 }, .revision = p.history_revision, .until = 0 };
1705 try p.attach(&wire, true); 1907 try p.attach(&wire, true);
1706 try std.testing.expect(p.history_pending != null); 1908 try std.testing.expect(p.history_pending != null);
@@ -1710,6 +1912,77 @@ test "wheel history tombstones, refresh, resize and timeout preserve the live re
1710 try std.testing.expect(p.history == null and p.history_pending == null and p.scroll_rows == 0); 1912 try std.testing.expect(p.history == null and p.history_pending == null and p.scroll_rows == 0);
1711 } 1913 }
1712 1914
1915 test "selection tracker survives source movement while history source stays paired" {
1916 const p = try selectionTestPump();
1917 defer p.stop();
1918 p.admitted = true;
1919
1920 const start = try selectionTestQueue(p, 7);
1921 try std.testing.expect(p.selectionAlive(7, start.version));
1922 _ = selectionTestBegin(p, start).?;
1923
1924 var position: std.ArrayList(u8) = .empty;
1925 defer position.deinit(std.testing.allocator);
1926 try proto.encodeSelectionReply(&position, std.testing.allocator, .{
1927 .id = 0,
1928 .gesture = 7,
1929 .seq = p.replica.last_seq,
1930 .source = 1,
1931 .history_rows = p.replica.history_rows,
1932 .status = .ok,
1933 .anchor = .{ .row = 0, .col = 0 },
1934 .active = .{ .row = 0, .col = 1 },
1935 .text = "",
1936 });
1937 _ = try p.onFrame(.selection_reply, position.items);
1938 var redraw = testSnapshot();
1939 std.mem.writeInt(u64, redraw[0..8], p.replica.last_seq + 1, .little);
1940 _ = try p.onFrame(.snapshot, &redraw);
1941 try std.testing.expect(p.followPositionLocked() == null);
1942 try std.testing.expect(p.selectionAlive(7, start.version));
1943 position.clearRetainingCapacity();
1944 try proto.encodeSelectionReply(&position, std.testing.allocator, .{
1945 .id = 0,
1946 .gesture = 7,
1947 .seq = p.replica.last_seq,
1948 .source = 2,
1949 .history_rows = p.replica.history_rows,
1950 .status = .ok,
1951 .anchor = .{ .row = 0, .col = 0 },
1952 .active = .{ .row = 0, .col = 1 },
1953 .text = "",
1954 });
1955 _ = try p.onFrame(.selection_reply, position.items);
1956 try std.testing.expect(p.followPositionLocked() != null);
1957
1958 const copy = SelectionRequest{ .id = 8, .gesture = 7, .anchor = start.anchor, .active = start.active, .version = start.version, .ticket = p.selection_ticket };
1959 try std.testing.expect(selectionTestBegin(p, copy) != null);
1960 try selectionTestReply(p, 8, "moved source");
1961 const result = p.takeSelection().?;
1962 defer std.testing.allocator.free(result.text);
1963 try std.testing.expectEqualStrings("moved source", result.text);
1964
1965 const stale_start = SelectionRequest{ .id = 9, .gesture = 9, .anchor = start.anchor, .active = start.active, .version = start.version, .ticket = p.selection_ticket };
1966 try std.testing.expect(selectionTestBegin(p, stale_start) == null);
1967
1968 const h = try selectionTestPump();
1969 defer h.stop();
1970 h.admitted = true;
1971 h.replica.history_rows = 20;
1972 h.scroll_rows = 1;
1973 h.history_pending = .{ .start = 17, .size = .{ .cols = 11, .rows = 3 }, .revision = h.history_revision, .until = std.math.maxInt(i64) };
1974 const chunk = [_]u8{ 17, 0, 0, 0, 1, 0, 0, 0 };
1975 try std.testing.expectEqual(Pump.Action.changed, try h.onFrame(.scrollback_chunk, &chunk));
1976 try std.testing.expectEqual(@as(u64, 0), h.history_version.source);
1977 try selectionTestFollowMetadata(h, 3);
1978 try std.testing.expectEqual(@as(u64, 3), h.history_version.source);
1979 var history_redraw = testSnapshot();
1980 std.mem.writeInt(u64, history_redraw[0..8], h.replica.last_seq + 1, .little);
1981 _ = try h.onFrame(.snapshot, &history_redraw);
1982 try selectionTestFollowMetadata(h, 4);
1983 try std.testing.expectEqual(@as(u64, 3), h.history_version.source);
1984 }
1985
1713 test "ready terminal mode frames precede queued wheel input through the actual wire" { 1986 test "ready terminal mode frames precede queued wheel input through the actual wire" {
1714 const p = try selectionTestPump(); 1987 const p = try selectionTestPump();
1715 defer p.stop(); 1988 defer p.stop();
src/engine/engine.zig
Old New
@@ -38,10 +38,59 @@ pub const MuxHandler = struct {
38 comptime action: StreamAction.Tag, 38 comptime action: StreamAction.Tag,
39 value: StreamAction.Value(action), 39 value: StreamAction.Value(action),
40 ) void { 40 ) void {
41 const eng = self.engineOf();
42 if (comptime action == .print_repeat) {
43 // Ghostty implements REP as repeated print calls. Route each
44 // through the same tracking hook, retaining its character state
45 // and width/wrap implementation instead of predicting the moves.
46 if (eng.term.previous_char) |c| for (0..@max(value, 1)) |_| self.vt(.print, .{ .cp = c });
47 return;
48 }
49 const active_before = eng.term.screens.active_key;
50 const print_x = if (comptime action == .print) eng.term.screens.active.cursor.x else 0;
51 const print_y = if (comptime action == .print) eng.term.screens.active.cursor.y else 0;
52 const print_at_scroll_bottom = if (comptime action == .print) print_y == eng.term.scrolling_region.bottom and
53 print_x >= eng.term.scrolling_region.left and print_x <= eng.term.scrolling_region.right else false;
54 // The source token guards a client coordinate pair while it crosses
55 // the wire to become tracked pins. It is deliberately narrower than
56 // "received bytes": a counter repaint elsewhere must not make a
57 // completed drag refuse to register. These are the stream actions
58 // which can remap a coordinate to another cell identity. Test the
59 // terminal state before forwarding because index/reverse-index and a
60 // pending wrap decide whether this invocation actually scrolls.
61 const source_before = self.movesCoordinateIdentity(action);
62 if (source_before) eng.selection_source +%= 1;
63 // A narrow character at the right margin only arms pending wrap, but
64 // a wide character can wrap and discard the top row immediately. Mark
65 // the possible victims before Ghostty remaps their pins, then retire
66 // them only when the cursor proves that this print really wrapped.
67 if (comptime action == .print) eng.markPossiblePrintDiscard();
68 eng.prepareTrackedMutation(action, value);
69 // Ghostty's full reset frees the alternate Screen. Its allocator may
70 // reuse the exact address on the next alternate entry, so pointer
71 // equality alone cannot make a stale tracked pin safe to deinit.
72 if (comptime action == .full_reset)
73 eng.alternate_generation +%= 1;
41 if (comptime action == .semantic_prompt) self.onSemanticPrompt(value); 74 if (comptime action == .semantic_prompt) self.onSemanticPrompt(value);
42 if (comptime action == .clipboard_contents) self.onClipboard(value); 75 if (comptime action == .clipboard_contents) self.onClipboard(value);
43 if (comptime action == .bell) self.onBell(); 76 if (comptime action == .bell) self.onBell();
44 self.inner.vt(action, value); 77 self.inner.vt(action, value);
78 if (comptime action == .print) {
79 const cursor = eng.term.screens.active.cursor;
80 // A narrow glyph at the last column merely arms pending wrap; it
81 // does not yet remap a coordinate. Actual wrapping moves left or
82 // to another row, including a wide glyph that cannot fit.
83 const wrapped = cursor.x < print_x or cursor.y != print_y;
84 if (!source_before and wrapped)
85 eng.selection_source +%= 1;
86 if (wrapped and print_at_scroll_bottom) eng.commitTrackedMutation() else eng.clearTrackedMutation();
87 } else eng.commitTrackedMutation();
88 if (comptime action == .full_reset)
89 eng.screen_epoch +%= 1;
90 if (eng.term.screens.active_key != active_before) {
91 eng.screen_epoch +%= 1;
92 eng.selection_source +%= 1;
93 }
45 // After the stock handler, so the mode is on when the first report goes 94 // After the stock handler, so the mode is on when the first report goes
46 // out. A bare vt answers DECRQM 2048 as recognised but never speaks the 95 // out. A bare vt answers DECRQM 2048 as recognised but never speaks the
47 // report, and an app that turns it on then ignores SIGWINCH and waits. 96 // report, and an app that turns it on then ignores SIGWINCH and waits.
@@ -56,6 +105,36 @@ pub const MuxHandler = struct {
56 return @alignCast(@fieldParentPtr("stream", stream_ptr)); 105 return @alignCast(@fieldParentPtr("stream", stream_ptr));
57 } 106 }
58 107
108 fn movesCoordinateIdentity(self: *MuxHandler, comptime action: StreamAction.Tag) bool {
109 const term = &self.engineOf().term;
110 const cursor = term.screens.active.cursor;
111 const in_horizontal_region = cursor.x >= term.scrolling_region.left and
112 cursor.x <= term.scrolling_region.right;
113 return switch (action) {
114 // These either shift cells/lines directly, or can move retained
115 // rows into or out of scrollback. A no-op is conservatively a
116 // new source, which only rejects a racing registration.
117 .insert_lines,
118 .delete_lines,
119 .insert_blanks,
120 .delete_chars,
121 .scroll_up,
122 .scroll_down,
123 .erase_display_complete,
124 .erase_display_scrollback,
125 .erase_display_scroll_complete,
126 .full_reset,
127 => true,
128
129 // A pending wrap necessarily invokes printWrap. For an otherwise
130 // ordinary print, `vt` compares pre/post cursor coordinates.
131 .print => term.modes.get(.insert) or cursor.pending_wrap,
132 .linefeed, .index, .next_line => in_horizontal_region and cursor.y == term.scrolling_region.bottom,
133 .reverse_index => in_horizontal_region and cursor.y == term.scrolling_region.top,
134 else => false,
135 };
136 }
137
59 fn onSemanticPrompt( 138 fn onSemanticPrompt(
60 self: *MuxHandler, 139 self: *MuxHandler,
61 value: StreamAction.Value(.semantic_prompt), 140 value: StreamAction.Value(.semantic_prompt),
@@ -137,6 +216,19 @@ pub const Engine = struct {
137 side_events: std.ArrayList(SideEvent), 216 side_events: std.ArrayList(SideEvent),
138 /// Copied from Options: the interception path reads it per event. 217 /// Copied from Options: the interception path reads it per event.
139 clipboard_max: usize, 218 clipboard_max: usize,
219 /// Full primary scrollback preserves pins only while the screen retains
220 /// scrolled-off rows. With no scrollback, that same scroll discards them.
221 scrollback_enabled: bool,
222 /// Monotonic coordinate-identity token. Clients use this to identify the
223 /// exact source state at which a tracked selection was installed.
224 selection_source: u64,
225 /// Changes when screen identity or geometry is reset, switched, or resized.
226 /// Ordinary output leaves this stable so tracked pins can follow it.
227 screen_epoch: u64,
228 /// Lifetime of Ghostty's lazily allocated alternate Screen. Full reset
229 /// destroys it; a new alternate may reuse its address.
230 alternate_generation: u64,
231 tracked_head: ?*TrackedSelection = null,
140 232
141 pub const MarkEvent = struct { 233 pub const MarkEvent = struct {
142 pub const Kind = enum(u8) { prompt_start, command_start, command_end }; 234 pub const Kind = enum(u8) { prompt_start, command_start, command_end };
@@ -198,6 +290,96 @@ pub const Engine = struct {
198 } 290 }
199 }; 291 };
200 292
293 /// A selection whose endpoints are pinned in one active Ghostty screen.
294 /// The daemon keeps this per client, rather than using Screen.selection,
295 /// so independent clients cannot overwrite one another's selection.
296 const SelectionPoints = struct { anchor: proto.SelectionPoint, active: proto.SelectionPoint };
297
298 pub const TrackedSelection = struct {
299 engine: *Engine,
300 screen: *vt.Screen,
301 screen_key: vt.ScreenSet.Key,
302 screen_epoch: u64,
303 alternate_generation: u64,
304 cols: u16,
305 rows: u16,
306 selection: vt.Selection,
307 prev: ?*TrackedSelection = null,
308 next: ?*TrackedSelection = null,
309 valid: bool = true,
310 discard_candidate: bool = false,
311 remap: ?SelectionPoints = null,
312 rotate_rows: u16 = 0,
313
314 pub fn deinit(self: *TrackedSelection) void {
315 self.unlink();
316 // A full reset may destroy the alternate Screen and its pins.
317 // Its allocator can then recreate it at this exact address; the
318 // explicit generation prevents an ABA untrack in that new Screen.
319 if (self.screen_key == .alternate and
320 self.engine.alternate_generation != self.alternate_generation)
321 return self.engine.alloc.destroy(self);
322 const current = self.engine.term.screens.all.get(self.screen_key) orelse {
323 self.engine.alloc.destroy(self);
324 return;
325 };
326 if (current == self.screen) self.selection.deinit(self.screen);
327 self.engine.alloc.destroy(self);
328 }
329
330 fn unlink(self: *TrackedSelection) void {
331 if (self.prev) |prev| prev.next = self.next else if (self.engine.tracked_head == self) self.engine.tracked_head = self.next;
332 if (self.next) |next| next.prev = self.prev;
333 self.prev = null;
334 self.next = null;
335 }
336
337 /// Current screen-space endpoints. A garbage pin, screen switch, or
338 /// geometry change retires the durable selection.
339 pub fn points(self: *const TrackedSelection) ?SelectionPoints {
340 if (!self.valid or self.engine.term.screens.active_key != self.screen_key or
341 self.engine.screen_epoch != self.screen_epoch or
342 self.engine.term.cols != self.cols or self.engine.term.rows != self.rows)
343 return null;
344
345 const anchor_pin = self.selection.start();
346 const active_pin = self.selection.end();
347 if (anchor_pin.garbage or active_pin.garbage) return null;
348 const anchor = self.screen.pages.pointFromPin(.screen, anchor_pin) orelse return null;
349 const active = self.screen.pages.pointFromPin(.screen, active_pin) orelse return null;
350 const anchor_coord = switch (anchor) {
351 .screen => |coord| coord,
352 else => return null,
353 };
354 const active_coord = switch (active) {
355 .screen => |coord| coord,
356 else => return null,
357 };
358 return .{
359 .anchor = .{ .row = anchor_coord.y, .col = @intCast(anchor_coord.x) },
360 .active = .{ .row = active_coord.y, .col = @intCast(active_coord.x) },
361 };
362 }
363
364 /// Extract from the endpoints' current positions. Null means the
365 /// tracked pins no longer name the original active screen geometry.
366 pub fn extract(
367 self: *const TrackedSelection,
368 alloc: std.mem.Allocator,
369 max_bytes: usize,
370 ) !?SelectionExtract {
371 const p = self.points() orelse return null;
372 return try self.engine.extractSelection(
373 alloc,
374 p.anchor.row,
375 p.anchor.col,
376 p.active.row,
377 p.active.col,
378 max_bytes,
379 );
380 }
381 };
382
201 /// Heap-allocates: stream.handler holds a pointer to `term`, so an 383 /// Heap-allocates: stream.handler holds a pointer to `term`, so an
202 /// Engine must never move after init. 384 /// Engine must never move after init.
203 pub fn init(alloc: std.mem.Allocator, opts: Options) !*Engine { 385 pub fn init(alloc: std.mem.Allocator, opts: Options) !*Engine {
@@ -216,6 +398,11 @@ pub const Engine = struct {
216 .mark_events = .empty, 398 .mark_events = .empty,
217 .side_events = .empty, 399 .side_events = .empty,
218 .clipboard_max = opts.clipboard_max, 400 .clipboard_max = opts.clipboard_max,
401 .scrollback_enabled = opts.max_scrollback != 0,
402 .selection_source = 1,
403 .screen_epoch = 1,
404 .alternate_generation = 1,
405 .tracked_head = null,
219 }; 406 };
220 errdefer self.term.deinit(alloc); 407 errdefer self.term.deinit(alloc);
221 408
@@ -230,6 +417,7 @@ pub const Engine = struct {
230 } 417 }
231 418
232 pub fn deinit(self: *Engine) void { 419 pub fn deinit(self: *Engine) void {
420 while (self.tracked_head) |tracked| tracked.deinit();
233 self.pty_out.deinit(self.alloc); 421 self.pty_out.deinit(self.alloc);
234 self.clearSideEvents(); 422 self.clearSideEvents();
235 self.side_events.deinit(self.alloc); 423 self.side_events.deinit(self.alloc);
@@ -243,6 +431,141 @@ pub const Engine = struct {
243 self.stream.nextSlice(bytes); 431 self.stream.nextSlice(bytes);
244 } 432 }
245 433
434 fn prepareTrackedMutation(self: *Engine, comptime action: StreamAction.Tag, value: StreamAction.Value(action)) void {
435 if (self.tracked_head == null) return;
436 const t = &self.term;
437 const c = t.screens.active.cursor;
438 const in_region = c.x >= t.scrolling_region.left and c.x <= t.scrolling_region.right;
439 switch (action) {
440 .print => if (t.modes.get(.insert)) self.invalidateActive(),
441 .scroll_up => self.prepareScrollUp(value),
442 .scroll_down => self.shiftSelectionRows(t.scrolling_region.top, t.scrolling_region.bottom, value, .down),
443 .index, .linefeed, .next_line => if (in_region and c.y == t.scrolling_region.bottom) self.prepareScrollUp(1),
444 .reverse_index => if (in_region and c.y == t.scrolling_region.top) self.shiftSelectionRows(t.scrolling_region.top, t.scrolling_region.bottom, 1, .down),
445 .insert_lines, .delete_lines => if (in_region and c.y >= t.scrolling_region.top and c.y <= t.scrolling_region.bottom) {
446 self.shiftSelectionRows(c.y, t.scrolling_region.bottom, value, if (action == .insert_lines) .down else .up);
447 },
448 // Horizontal edits need a different range policy from vertical moves.
449 .insert_blanks, .delete_chars => self.invalidateActive(),
450 .erase_display_complete, .erase_display_scrollback, .erase_display_scroll_complete, .full_reset => self.invalidateActive(),
451 else => {},
452 }
453 }
454 fn prepareScrollUp(self: *Engine, count: usize) void {
455 const t = &self.term;
456 if (count == 0) return;
457 if (t.scrolling_region.top == 0 and t.scrolling_region.left == 0 and t.scrolling_region.right == t.cols - 1) {
458 // Ghostty moves retained history itself. A partial bottom also
459 // rotates the untouched rows below it; those external pins need
460 // the same displacement after Ghostty has grown/pruned pages.
461 const n: u16 = @intCast(@min(count, t.scrolling_region.bottom + 1));
462 const origin = self.historyRows();
463 const bottom = origin + t.scrolling_region.bottom;
464 var it = self.tracked_head;
465 while (it) |tracked| : (it = tracked.next) if (tracked.points()) |points| {
466 const first = @min(points.anchor.row, points.active.row);
467 const last = @max(points.anchor.row, points.active.row);
468 if (first <= bottom and last > bottom) tracked.discard_candidate = true;
469 if (first > bottom) tracked.rotate_rows = n;
470 if (!self.scrollback_enabled or t.screens.active_key == .alternate)
471 tracked.discard_candidate = tracked.discard_candidate or first < n;
472 };
473 return;
474 }
475 self.shiftSelectionRows(t.scrolling_region.top, t.scrolling_region.bottom, count, .up);
476 }
477 const ShiftDirection = enum { up, down };
478 fn shiftSelectionRows(self: *Engine, first: u16, last: u16, count: usize, direction: ShiftDirection) void {
479 if (count == 0 or self.tracked_head == null) return;
480 const t = &self.term;
481 // A rectangular margin can split a linear selection. Retire it until
482 // that separate selection policy is supported.
483 if (t.scrolling_region.left != 0 or t.scrolling_region.right != t.cols - 1) return self.invalidateActive();
484 const origin = self.historyRows();
485 const top = origin + first;
486 const bottom = origin + last;
487 const n: u32 = @intCast(@min(count, last - first + 1));
488 var it = self.tracked_head;
489 while (it) |tracked| : (it = tracked.next) if (tracked.points()) |points| {
490 const start = @min(points.anchor.row, points.active.row);
491 const end = @max(points.anchor.row, points.active.row);
492 if (start <= bottom and end >= top and (start < top or end > bottom)) {
493 tracked.discard_candidate = true;
494 continue;
495 }
496 var moved = points;
497 for ([_]*proto.SelectionPoint{ &moved.anchor, &moved.active }) |point| {
498 if (point.row < top or point.row > bottom) continue;
499 switch (direction) {
500 .up => if (point.row < top + n) {
501 tracked.discard_candidate = true;
502 } else {
503 point.row -= n;
504 },
505 .down => if (point.row + n > bottom) {
506 tracked.discard_candidate = true;
507 } else {
508 point.row += n;
509 },
510 }
511 }
512 tracked.remap = moved;
513 };
514 }
515 fn markPossiblePrintDiscard(self: *Engine) void {
516 if (self.tracked_head == null) return;
517 const t = &self.term;
518 const c = t.screens.active.cursor;
519 if (c.y == t.scrolling_region.bottom and c.x >= t.scrolling_region.left and c.x <= t.scrolling_region.right and
520 (c.x == t.scrolling_region.right or c.pending_wrap)) self.prepareScrollUp(1);
521 }
522 fn commitTrackedMutation(self: *Engine) void {
523 var it = self.tracked_head;
524 while (it) |tracked| : (it = tracked.next) {
525 if (tracked.discard_candidate) tracked.valid = false;
526 if (tracked.rotate_rows != 0) {
527 if (tracked.points()) |points| {
528 var moved = points;
529 moved.anchor.row += tracked.rotate_rows;
530 moved.active.row += tracked.rotate_rows;
531 tracked.remap = moved;
532 } else tracked.valid = false;
533 }
534 if (tracked.valid) if (tracked.remap) |points| {
535 // IL/DL copy row contents without remapping arbitrary pins.
536 // Rebind after the action, also covering index's fast path
537 // without duplicating Ghostty's choice of row-copy algorithm.
538 const a = tracked.screen.pages.pin(.{ .screen = .{ .x = points.anchor.col, .y = points.anchor.row } });
539 const b = tracked.screen.pages.pin(.{ .screen = .{ .x = points.active.col, .y = points.active.row } });
540 if (a != null and b != null) {
541 tracked.selection.startPtr().* = a.?;
542 tracked.selection.endPtr().* = b.?;
543 } else tracked.valid = false;
544 };
545 tracked.discard_candidate = false;
546 tracked.remap = null;
547 tracked.rotate_rows = 0;
548 }
549 }
550 fn clearTrackedMutation(self: *Engine) void {
551 var it = self.tracked_head;
552 while (it) |tracked| : (it = tracked.next) {
553 tracked.discard_candidate = false;
554 tracked.remap = null;
555 tracked.rotate_rows = 0;
556 }
557 }
558 fn invalidateActive(self: *Engine) void {
559 var it = self.tracked_head;
560 while (it) |tracked| : (it = tracked.next) {
561 if (tracked.screen == self.term.screens.active) tracked.valid = false;
562 }
563 }
564
565 pub fn selectionSource(self: *const Engine) u64 {
566 return self.selection_source;
567 }
568
246 pub fn ptyOutput(self: *const Engine) []const u8 { 569 pub fn ptyOutput(self: *const Engine) []const u8 {
247 return self.pty_out.items; 570 return self.pty_out.items;
248 } 571 }
@@ -592,6 +915,46 @@ pub const Engine = struct {
592 g.cursor = .{ .x = cur.x, .y = cur.y }; 915 g.cursor = .{ .x = cur.x, .y = cur.y };
593 } 916 }
594 917
918 /// Pin a selection to the currently active screen. The returned owner
919 /// must be deinitialized by the caller, even when the terminal later
920 /// makes either endpoint unavailable.
921 pub fn trackSelection(
922 self: *Engine,
923 anchor: proto.SelectionPoint,
924 active: proto.SelectionPoint,
925 ) !?*TrackedSelection {
926 if (anchor.col >= self.term.cols or active.col >= self.term.cols)
927 return null;
928
929 const screen = self.term.screens.active;
930 const start = screen.pages.pin(.{ .screen = .{
931 .x = anchor.col,
932 .y = anchor.row,
933 } }) orelse return null;
934 const end = screen.pages.pin(.{ .screen = .{
935 .x = active.col,
936 .y = active.row,
937 } }) orelse return null;
938 const selection = vt.Selection.init(start, end, false);
939 const pinned = try selection.track(screen);
940 errdefer pinned.deinit(screen);
941 const tracked = try self.alloc.create(TrackedSelection);
942 tracked.* = .{
943 .engine = self,
944 .screen = screen,
945 .screen_key = self.term.screens.active_key,
946 .screen_epoch = self.screen_epoch,
947 .alternate_generation = self.alternate_generation,
948 .cols = @intCast(self.term.cols),
949 .rows = @intCast(self.term.rows),
950 .selection = pinned,
951 };
952 tracked.next = self.tracked_head;
953 if (self.tracked_head) |head| head.prev = tracked;
954 self.tracked_head = tracked;
955 return tracked;
956 }
957
595 /// Screen-space rows, zero being the oldest retained. Formatting writes 958 /// Screen-space rows, zero being the oldest retained. Formatting writes
596 /// into a fixed-size allocation: hostile coordinates cannot blow it up. 959 /// into a fixed-size allocation: hostile coordinates cannot blow it up.
597 pub fn extractSelection( 960 pub fn extractSelection(
@@ -674,11 +1037,16 @@ pub const Engine = struct {
674 1037
675 /// Full reset (RIS). Also DISCARDS queued side_events: drain them first. 1038 /// Full reset (RIS). Also DISCARDS queued side_events: drain them first.
676 pub fn reset(self: *Engine) void { 1039 pub fn reset(self: *Engine) void {
1040 self.selection_source +%= 1;
1041 self.screen_epoch +%= 1;
1042 self.alternate_generation +%= 1;
677 self.term.fullReset(); 1043 self.term.fullReset();
678 self.clearSideEvents(); 1044 self.clearSideEvents();
679 } 1045 }
680 1046
681 pub fn resize(self: *Engine, cols: u16, rows: u16) !void { 1047 pub fn resize(self: *Engine, cols: u16, rows: u16) !void {
1048 self.selection_source +%= 1;
1049 self.screen_epoch +%= 1;
682 try self.term.resize(self.alloc, @intCast(cols), @intCast(rows)); 1050 try self.term.resize(self.alloc, @intCast(cols), @intCast(rows));
683 if (self.term.modes.get(.in_band_size_reports)) self.reportSize(); 1051 if (self.term.modes.get(.in_band_size_reports)) self.reportSize();
684 } 1052 }
@@ -948,6 +1316,308 @@ test "Engine: selection extraction uses only the active alternate screen" {
948 try std.testing.expectEqual(@as(?[]u8, null), unavailable.text); 1316 try std.testing.expectEqual(@as(?[]u8, null), unavailable.text);
949 } 1317 }
950 1318
1319 test "Engine: tracked selection follows output and preserves duplicate row identity" {
1320 const alloc = std.testing.allocator;
1321 var e = try Engine.init(alloc, .{ .cols = 16, .rows = 3, .max_scrollback = 16 });
1322 defer e.deinit();
1323
1324 e.feed("DUPLICATE\r\nsame\r\nDUPLICATE\r\nother");
1325 var tracked = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 8 })).?;
1326 defer tracked.deinit();
1327 var other = (try e.trackSelection(.{ .row = 2, .col = 0 }, .{ .row = 2, .col = 8 })).?;
1328 defer other.deinit();
1329 try std.testing.expect(tracked.points().?.anchor.row != other.points().?.anchor.row);
1330 var first = (try tracked.extract(alloc, 64)).?;
1331 defer first.deinit(alloc);
1332 try std.testing.expectEqualStrings("DUPLICATE", first.text.?);
1333
1334 // More output advances the viewport and creates another identical row;
1335 // tracked pins keep naming the first row rather than matching by text.
1336 e.feed("\r\nnew-1\r\nnew-2\r\nnew-3");
1337 var after_output = (try tracked.extract(alloc, 64)).?;
1338 defer after_output.deinit(alloc);
1339 try std.testing.expectEqualStrings("DUPLICATE", after_output.text.?);
1340 try std.testing.expect(after_output.history_rows > first.history_rows);
1341 var other_after = (try other.extract(alloc, 64)).?;
1342 defer other_after.deinit(alloc);
1343 try std.testing.expectEqualStrings("DUPLICATE", other_after.text.?);
1344 }
1345
1346 test "Engine: selection source advances only for coordinate remapping actions" {
1347 const alloc = std.testing.allocator;
1348 var e = try Engine.init(alloc, .{ .cols = 4, .rows = 3 });
1349 defer e.deinit();
1350
1351 const initial = e.selectionSource();
1352 // Side effects and ordinary redraws leave coordinates meaningful. This is
1353 // the live-counter case: a completed drag elsewhere must still register.
1354 e.feed("\x07x\rY\n");
1355 try std.testing.expectEqual(initial, e.selectionSource());
1356
1357 // Insert mode shifts the remainder of the row even away from its edge.
1358 e.feed("\x1b[4hZ\x1b[4l");
1359 const inserted = e.selectionSource();
1360 try std.testing.expect(inserted != initial);
1361
1362 // Full-row repainting reaches the last column but only arms pending-wrap;
1363 // it must not reject a drag elsewhere, such as a live counter redraw.
1364 e.feed("\x1b[1;1H1234");
1365 try std.testing.expectEqual(inserted, e.selectionSource());
1366
1367 // A wide glyph at the right margin cannot fit, so Ghostty immediately
1368 // wraps and remaps coordinates. The post-print cursor proves that path.
1369 e.feed("\x1b[1;4H漢");
1370 const wrapped = e.selectionSource();
1371 try std.testing.expect(wrapped != inserted);
1372
1373 // Index at the bottom scrolls; the same LF away from the bottom above did
1374 // not. ED 3 clears scrollback and changes absolute row identities.
1375 e.feed("\x1b[3;1H\n");
1376 const scrolled = e.selectionSource();
1377 try std.testing.expect(scrolled != wrapped);
1378 e.feed("\x1b[3J");
1379 try std.testing.expect(e.selectionSource() != scrolled);
1380 }
1381
1382 test "Engine: tracked selection rejects active screen and geometry changes" {
1383 const alloc = std.testing.allocator;
1384 var e = try Engine.init(alloc, .{ .cols = 8, .rows = 3 });
1385 defer e.deinit();
1386 e.feed("tracked");
1387
1388 var tracked = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 6 })).?;
1389 defer tracked.deinit();
1390 try std.testing.expect(tracked.points() != null);
1391
1392 // Switching away and back in one feed still retires the source identity;
1393 // comparing only the final active screen key would miss this.
1394 e.feed("\x1b[?1049halt\x1b[?1049l");
1395 try std.testing.expect(tracked.points() == null);
1396
1397 // Ordinary alternate leave retains its Screen and pins, but switching
1398 // away/re-entering still retires this selection's presentation epoch.
1399 e.feed("\x1b[?1049h");
1400 var alt_tracked = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 3 })).?;
1401 defer alt_tracked.deinit();
1402 e.feed("\x1b[?1049l\x1b[?1049h");
1403 try std.testing.expect(alt_tracked.points() == null);
1404 e.feed("\x1b[?1049l");
1405
1406 var resized = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 6 })).?;
1407 defer resized.deinit();
1408 try e.resize(9, 3);
1409 try std.testing.expect(resized.points() == null);
1410 try e.resize(8, 3);
1411 try std.testing.expect(resized.points() == null);
1412
1413 var reset = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 6 })).?;
1414 defer reset.deinit();
1415 e.reset();
1416 try std.testing.expect(reset.points() == null);
1417 }
1418
1419 test "Engine: alternate tracked pin deinit rejects a reset ABA lifetime" {
1420 const alloc = std.testing.allocator;
1421 var e = try Engine.init(alloc, .{ .cols = 8, .rows = 3 });
1422 defer e.deinit();
1423
1424 e.feed("\x1b[?1049halt");
1425 var tracked = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 2 })).?;
1426 const lifetime = tracked.alternate_generation;
1427 e.reset();
1428 try std.testing.expect(e.alternate_generation != lifetime);
1429 // This must be a no-op even if Ghostty's next alternate allocation takes
1430 // the former address. The generation, not allocator behavior, is proof.
1431 tracked.deinit();
1432 e.feed("\x1b[?1049hnew-alt");
1433 }
1434
1435 test "Engine: bounded scroll retires discarded endpoints but keeps shifted rows" {
1436 const alloc = std.testing.allocator;
1437 var e = try Engine.init(alloc, .{ .cols = 8, .rows = 4 });
1438 defer e.deinit();
1439 e.feed("\x1b[?1049hA\r\nB\r\nC\r\nD");
1440 const discarded = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 })).?;
1441 defer discarded.deinit();
1442 const kept = (try e.trackSelection(.{ .row = 2, .col = 0 }, .{ .row = 2, .col = 0 })).?;
1443 defer kept.deinit();
1444 // Alt screen never receives scrollback: CSI S discards its top row.
1445 e.feed("\x1b[1S");
1446 try std.testing.expect(discarded.points() == null);
1447 try std.testing.expectEqual(@as(u32, 1), kept.points().?.anchor.row);
1448 }
1449
1450 test "Engine: print wrapping and REP retire an alternate discarded row" {
1451 const alloc = std.testing.allocator;
1452 var e = try Engine.init(alloc, .{ .cols = 4, .rows = 3 });
1453 defer e.deinit();
1454
1455 e.feed("\x1b[?1049hA\r\nB\r\nC");
1456 const wide_discarded = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 })).?;
1457 defer wide_discarded.deinit();
1458 // A wide glyph at the final cell wraps immediately; it does not first set
1459 // pending_wrap, so the candidate must be remembered before Ghostty scrolls.
1460 e.feed("\x1b[3;4H漢");
1461 try std.testing.expect(wide_discarded.points() == null);
1462
1463 e.reset();
1464 e.feed("\x1b[?1049hA\r\nB\r\nC");
1465 const repeated_discarded = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 })).?;
1466 defer repeated_discarded.deinit();
1467 // REP at the margin prints twice: the second character consumes pending
1468 // wrap and scrolls the alternate screen.
1469 e.feed("\x1b[3;4H\x1b[2b");
1470 try std.testing.expect(repeated_discarded.points() == null);
1471 }
1472
1473 test "Engine: a non-bottom wrap retains primary history selection" {
1474 const alloc = std.testing.allocator;
1475 var e = try Engine.init(alloc, .{ .cols = 4, .rows = 4, .max_scrollback = 16 });
1476 defer e.deinit();
1477 e.feed("zero\r\none\r\ntwo\r\nthree\r\nfour\r\nfive");
1478 const history = e.historyRows();
1479 try std.testing.expect(history > 0);
1480 const tracked = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 })).?;
1481 defer tracked.deinit();
1482 // This is an actual wide-character wrap, but it lands on the next row
1483 // above the bottom of the full scrolling region and cannot discard text.
1484 e.feed("\x1b[2;4H漢");
1485 try std.testing.expect(tracked.points() != null);
1486 }
1487
1488 test "Engine: full primary scroll without scrollback retires its top row" {
1489 const alloc = std.testing.allocator;
1490 var e = try Engine.init(alloc, .{ .cols = 8, .rows = 3, .max_scrollback = 0 });
1491 defer e.deinit();
1492 e.feed("top\r\nmid\r\nbottom");
1493 const tracked = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 2 })).?;
1494 defer tracked.deinit();
1495 e.feed("\x1b[3;1H\n");
1496 try std.testing.expect(tracked.points() == null);
1497 }
1498
1499 test "Engine: partial primary discard compares absolute rows with history" {
1500 const alloc = std.testing.allocator;
1501 var e = try Engine.init(alloc, .{ .cols = 8, .rows = 4, .max_scrollback = 16 });
1502 defer e.deinit();
1503
1504 e.feed("zero\r\none\r\ntwo\r\nthree\r\nfour\r\nfive");
1505 const history = e.historyRows();
1506 try std.testing.expect(history > 0);
1507 const discarded = (try e.trackSelection(.{ .row = history + 1, .col = 0 }, .{ .row = history + 1, .col = 0 })).?;
1508 defer discarded.deinit();
1509 const kept = (try e.trackSelection(.{ .row = history + 2, .col = 0 }, .{ .row = history + 2, .col = 0 })).?;
1510 defer kept.deinit();
1511 e.feed("\x1b[2;4r\x1b[1S");
1512 try std.testing.expect(discarded.points() == null);
1513 try std.testing.expectEqual(history + 1, kept.points().?.anchor.row);
1514 const text = (try kept.extract(alloc, 100)).?;
1515 defer text.deinit(alloc);
1516 try std.testing.expectEqualStrings("f", text.text.?);
1517 }
1518
1519 test "Engine: retained region selection follows IL DL index and reverse scrolling" {
1520 const alloc = std.testing.allocator;
1521 const cases = .{
1522 .{ "\x1b[2S", @as(u32, 1) },
1523 .{ "\x1b[1T", @as(u32, 4) },
1524 .{ "\x1b[3;1H\x1b[1L", @as(u32, 4) },
1525 .{ "\x1b[2;1H\x1b[1M", @as(u32, 2) },
1526 .{ "\x1b[6;1H\n", @as(u32, 2) },
1527 .{ "\x1b[44m\x1b[6;1H\n", @as(u32, 2) },
1528 .{ "\x1b[2;1H\x1bM", @as(u32, 4) },
1529 .{ "\x1b[6;8H漢", @as(u32, 2) },
1530 };
1531 inline for (cases) |case| {
1532 var e = try Engine.init(alloc, .{ .cols = 8, .rows = 6 });
1533 defer e.deinit();
1534 e.feed("\x1b[?1049hA\r\nB\r\nC\r\nD\r\nE\r\nF\x1b[2;6r");
1535 const kept = (try e.trackSelection(.{ .row = 3, .col = 0 }, .{ .row = 3, .col = 0 })).?;
1536 defer kept.deinit();
1537 e.feed(case[0]);
1538 try std.testing.expectEqual(case[1], kept.points().?.anchor.row);
1539 const text = (try kept.extract(alloc, 100)).?;
1540 defer text.deinit(alloc);
1541 try std.testing.expectEqualStrings("D", text.text.?);
1542 }
1543 }
1544
1545 test "Engine: REP uses the normal print tracking path" {
1546 const alloc = std.testing.allocator;
1547 var e = try Engine.init(alloc, .{ .cols = 8, .rows = 4 });
1548 defer e.deinit();
1549 e.feed("\x1b[?1049hA\r\nB\r\nC\r\nD");
1550 const kept = (try e.trackSelection(.{ .row = 2, .col = 0 }, .{ .row = 2, .col = 0 })).?;
1551 defer kept.deinit();
1552 const source = e.selectionSource();
1553 e.feed("\x1b[1;2Hx\x1b[3b");
1554 try std.testing.expectEqual(source, e.selectionSource());
1555 try std.testing.expectEqual(@as(u32, 2), kept.points().?.anchor.row);
1556 e.feed("\x1b[4;8H\x1b[2b");
1557 try std.testing.expectEqual(@as(u32, 1), kept.points().?.anchor.row);
1558 const text = (try kept.extract(alloc, 100)).?;
1559 defer text.deinit(alloc);
1560 try std.testing.expectEqualStrings("C", text.text.?);
1561 }
1562
1563 test "Engine: split region ranges and insert-mode prints retire selection" {
1564 const alloc = std.testing.allocator;
1565 var e = try Engine.init(alloc, .{ .cols = 8, .rows = 6 });
1566 defer e.deinit();
1567 e.feed("\x1b[?1049hA\r\nB\r\nC\r\nD\r\nE\r\nF");
1568 const crossing = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 4, .col = 0 })).?;
1569 defer crossing.deinit();
1570 e.feed("\x1b[2;4r\x1b[1S");
1571 try std.testing.expect(crossing.points() == null);
1572 e.feed("\x1b[r\x1b[1;1Hhello");
1573 const inserted = (try e.trackSelection(.{ .row = 0, .col = 1 }, .{ .row = 0, .col = 3 })).?;
1574 defer inserted.deinit();
1575 e.feed("\x1b[1;1H\x1b[4hZ");
1576 try std.testing.expect(inserted.points() == null);
1577 }
1578
1579 test "Engine: top-zero partial-bottom scrolling preserves selected occurrence" {
1580 const alloc = std.testing.allocator;
1581 inline for (.{ "\x1b[1S", "\x1b[4;1H\n" }) |action| {
1582 inline for (.{ true, false }) |alternate| {
1583 var e = try Engine.init(alloc, .{ .cols = 8, .rows = 6 });
1584 defer e.deinit();
1585 if (alternate) e.feed("\x1b[?1049h");
1586 e.feed("A\r\nB\r\nC\r\nD\r\nE\r\nF\x1b[1;4r");
1587 const kept = (try e.trackSelection(.{ .row = 2, .col = 0 }, .{ .row = 2, .col = 0 })).?;
1588 defer kept.deinit();
1589 const below = (try e.trackSelection(.{ .row = 4, .col = 0 }, .{ .row = 5, .col = 0 })).?;
1590 defer below.deinit();
1591 const crossing = (try e.trackSelection(.{ .row = 2, .col = 0 }, .{ .row = 4, .col = 0 })).?;
1592 defer crossing.deinit();
1593 e.feed(action);
1594 try std.testing.expect(crossing.points() == null);
1595 try std.testing.expectEqual(e.historyRows() + 4, below.points().?.anchor.row);
1596 const outside_text = (try below.extract(alloc, 100)).?;
1597 defer outside_text.deinit(alloc);
1598 try std.testing.expectEqualStrings("E\nF", outside_text.text.?);
1599 try std.testing.expectEqual(e.historyRows() + 1, kept.points().?.anchor.row);
1600 const text = (try kept.extract(alloc, 100)).?;
1601 defer text.deinit(alloc);
1602 try std.testing.expectEqualStrings("C", text.text.?);
1603 }
1604 }
1605 }
1606
1607 test "Engine: history eviction makes tracked pins unavailable" {
1608 const alloc = std.testing.allocator;
1609 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 3, .max_scrollback = 3 });
1610 defer e.deinit();
1611
1612 e.feed("old\r\none\r\ntwo\r\nthree\r\nfour");
1613 const oldest = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 2 })).?;
1614 defer oldest.deinit();
1615 // Scrollback is page-backed. Drive past several pages rather than
1616 // assuming max_scrollback is a line count.
1617 for (0..1600) |_| e.feed("discard\r\n");
1618 try std.testing.expect(oldest.points() == null);
1619 }
1620
951 test "ghostty-vt boots headless and text lands in the grid" { 1621 test "ghostty-vt boots headless and text lands in the grid" {
952 const alloc = std.testing.allocator; 1622 const alloc = std.testing.allocator;
953 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); 1623 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
src/engine/protocol.zig
Old New
@@ -377,111 +377,106 @@ pub const SelectionPoint = struct {
377 col: u16, 377 col: u16,
378 }; 378 };
379 379
380 /// One selection contract for direct extraction and per-client tracking.
381 /// Start guards the coordinate source; copy and clear name an owned gesture.
380 pub const SelectionReq = struct { 382 pub const SelectionReq = struct {
383 action: enum(u8) { extract, start, copy, clear } = .extract,
381 id: u32, 384 id: u32,
385 gesture: u32 = 0,
386 epoch: u64 = 0,
387 source: u64 = 0,
382 anchor: SelectionPoint, 388 anchor: SelectionPoint,
383 active: SelectionPoint, 389 active: SelectionPoint,
384 }; 390 };
385
386 pub const selection_text_max: usize = 1024 * 1024; 391 pub const selection_text_max: usize = 1024 * 1024;
387 pub const selection_req_len: usize = 16; 392 pub const selection_req_len: usize = 37;
388 pub const selection_reply_prefix_len: usize = 9; 393 pub const selection_reply_prefix_len: usize = 41;
389 394
390 pub fn encodeSelectionReq(req: SelectionReq) [selection_req_len]u8 { 395 fn writeSelectionPoint(out: *[6]u8, point: SelectionPoint) void {
391 var buf: [selection_req_len]u8 = undefined; 396 std.mem.writeInt(u32, out[0..4], point.row, .little);
392 std.mem.writeInt(u32, buf[0..4], req.id, .little); 397 std.mem.writeInt(u16, out[4..6], point.col, .little);
393 std.mem.writeInt(u32, buf[4..8], req.anchor.row, .little);
394 std.mem.writeInt(u16, buf[8..10], req.anchor.col, .little);
395 std.mem.writeInt(u32, buf[10..14], req.active.row, .little);
396 std.mem.writeInt(u16, buf[14..16], req.active.col, .little);
397 return buf;
398 } 398 }
399 399 fn readSelectionPoint(bytes: *const [6]u8) SelectionPoint {
400 pub fn decodeSelectionReq(payload: []const u8) !SelectionReq { 400 return .{ .row = std.mem.readInt(u32, bytes[0..4], .little), .col = std.mem.readInt(u16, bytes[4..6], .little) };
401 if (payload.len != selection_req_len) return error.BadPayload; 401 }
402 return .{ 402 pub fn encodeSelectionReq(req: SelectionReq) [selection_req_len]u8 {
403 .id = std.mem.readInt(u32, payload[0..4], .little), 403 var out: [selection_req_len]u8 = undefined;
404 .anchor = .{ 404 std.mem.writeInt(u32, out[0..4], req.id, .little);
405 .row = std.mem.readInt(u32, payload[4..8], .little), 405 writeSelectionPoint(out[4..10], req.anchor);
406 .col = std.mem.readInt(u16, payload[8..10], .little), 406 writeSelectionPoint(out[10..16], req.active);
407 }, 407 out[16] = @intFromEnum(req.action);
408 .active = .{ 408 std.mem.writeInt(u32, out[17..21], req.gesture, .little);
409 .row = std.mem.readInt(u32, payload[10..14], .little), 409 std.mem.writeInt(u64, out[21..29], req.epoch, .little);
410 .col = std.mem.readInt(u16, payload[14..16], .little), 410 std.mem.writeInt(u64, out[29..37], req.source, .little);
411 }, 411 return out;
412 }
413 pub fn decodeSelectionReq(bytes: []const u8) !SelectionReq {
414 if (bytes.len != selection_req_len) return error.BadPayload;
415 const req: SelectionReq = .{
416 .id = std.mem.readInt(u32, bytes[0..4], .little),
417 .anchor = readSelectionPoint(bytes[4..10]),
418 .active = readSelectionPoint(bytes[10..16]),
419 .action = try enumFromByte(@FieldType(SelectionReq, "action"), bytes[16]),
420 .gesture = std.mem.readInt(u32, bytes[17..21], .little),
421 .epoch = std.mem.readInt(u64, bytes[21..29], .little),
422 .source = std.mem.readInt(u64, bytes[29..37], .little),
412 }; 423 };
424 const valid = switch (req.action) {
425 .extract => req.id != 0 and req.gesture == 0,
426 .start, .copy => req.id != 0 and req.gesture != 0,
427 .clear => req.id == 0 and req.gesture != 0,
428 };
429 if (!valid) return error.BadPayload;
430 return req;
413 } 431 }
414 432 pub const SelectionStatus = enum(u8) { ok = 0, invalid = 1, too_large = 2, unavailable = 3 };
415 pub const SelectionStatus = enum(u8) {
416 ok = 0,
417 invalid = 1,
418 too_large = 2,
419 unavailable = 3,
420 };
421
422 pub const SelectionReply = struct { 433 pub const SelectionReply = struct {
423 id: u32, 434 id: u32 = 0, // zero is a position update, never a clipboard write
424 status: SelectionStatus, 435 gesture: u32 = 0,
425 /// Retained history rows on the screen the text was extracted from. Absolute 436 seq: u64 = 0,
426 /// rows count from the OLDEST RETAINED row, so an eviction shifts every one 437 source: u64 = 0,
427 /// of them under a request in flight and the reply is `.ok`, valid UTF-8,
428 /// and the wrong text.
429 ///
430 /// A watermark rather than a lease on purpose: the daemon holds no
431 /// per-client selection state, so the requester compares and decides.
432 /// Ordinary output RAISES it without moving row zero; only eviction lowers it.
433 history_rows: u32, 438 history_rows: u32,
434 /// Borrowed from the frame payload and valid only as long as that 439 status: SelectionStatus = .unavailable,
435 /// payload remains alive and unchanged. 440 anchor: SelectionPoint = .{ .row = 0, .col = 0 },
436 text: []const u8, 441 active: SelectionPoint = .{ .row = 0, .col = 0 },
442 /// Borrows the frame payload. Only successful copy/extract replies carry text.
443 text: []const u8 = &.{},
437 }; 444 };
438 445 fn checkSelectionText(reply: SelectionReply) !void {
439 /// One rule for both ends of a selection_reply: `.ok` carries valid UTF-8 446 if (reply.status != .ok or reply.id == 0) {
440 /// within the cap, every refusal carries nothing at all. A writer that 447 if (reply.text.len != 0) return error.BadPayload;
441 /// checked less than the reader would mint a frame it could not read back. 448 } else if (reply.text.len > selection_text_max or !std.unicode.utf8ValidateSlice(reply.text)) return error.BadPayload;
442 fn checkSelectionText(status: SelectionStatus, text_value: []const u8) !void { 449 }
443 switch (status) { 450 /// Validate before appending so malformed replies leave the caller's buffer intact.
444 .ok => { 451 pub fn encodeSelectionReply(out: *std.ArrayList(u8), alloc: std.mem.Allocator, reply: SelectionReply) !void {
445 if (text_value.len > selection_text_max or !std.unicode.utf8ValidateSlice(text_value)) 452 try checkSelectionText(reply);
446 return error.BadPayload;
447 },
448 .invalid, .too_large, .unavailable => {
449 if (text_value.len != 0) return error.BadPayload;
450 },
451 }
452 }
453
454 /// Validation completes before the first append, so `error.BadPayload`
455 /// leaves a reused `out` unchanged.
456 pub fn encodeSelectionReply(
457 out: *std.ArrayList(u8),
458 alloc: std.mem.Allocator,
459 id: u32,
460 status: SelectionStatus,
461 history_rows: u32,
462 text_value: []const u8,
463 ) !void {
464 try checkSelectionText(status, text_value);
465
466 var prefix: [selection_reply_prefix_len]u8 = undefined; 453 var prefix: [selection_reply_prefix_len]u8 = undefined;
467 std.mem.writeInt(u32, prefix[0..4], id, .little); 454 std.mem.writeInt(u32, prefix[0..4], reply.id, .little);
468 prefix[4] = @intFromEnum(status); 455 prefix[4] = @intFromEnum(reply.status);
469 std.mem.writeInt(u32, prefix[5..9], history_rows, .little); 456 std.mem.writeInt(u32, prefix[5..9], reply.history_rows, .little);
457 std.mem.writeInt(u32, prefix[9..13], reply.gesture, .little);
458 std.mem.writeInt(u64, prefix[13..21], reply.seq, .little);
459 std.mem.writeInt(u64, prefix[21..29], reply.source, .little);
460 writeSelectionPoint(prefix[29..35], reply.anchor);
461 writeSelectionPoint(prefix[35..41], reply.active);
470 try out.appendSlice(alloc, &prefix); 462 try out.appendSlice(alloc, &prefix);
471 try out.appendSlice(alloc, text_value); 463 try out.appendSlice(alloc, reply.text);
472 } 464 }
473 465 pub fn decodeSelectionReply(bytes: []const u8) !SelectionReply {
474 pub fn decodeSelectionReply(payload: []const u8) !SelectionReply { 466 if (bytes.len < selection_reply_prefix_len) return error.BadPayload;
475 if (payload.len < selection_reply_prefix_len) return error.BadPayload; 467 const reply: SelectionReply = .{
476 const status = try enumFromByte(SelectionStatus, payload[4]); 468 .id = std.mem.readInt(u32, bytes[0..4], .little),
477 const text_value = payload[selection_reply_prefix_len..]; 469 .status = try enumFromByte(SelectionStatus, bytes[4]),
478 try checkSelectionText(status, text_value); 470 .history_rows = std.mem.readInt(u32, bytes[5..9], .little),
479 return .{ 471 .gesture = std.mem.readInt(u32, bytes[9..13], .little),
480 .id = std.mem.readInt(u32, payload[0..4], .little), 472 .seq = std.mem.readInt(u64, bytes[13..21], .little),
481 .status = status, 473 .source = std.mem.readInt(u64, bytes[21..29], .little),
482 .history_rows = std.mem.readInt(u32, payload[5..9], .little), 474 .anchor = readSelectionPoint(bytes[29..35]),
483 .text = text_value, 475 .active = readSelectionPoint(bytes[35..41]),
476 .text = bytes[selection_reply_prefix_len..],
484 }; 477 };
478 try checkSelectionText(reply);
479 return reply;
485 } 480 }
486 481
487 /// Where the command-boundary signal came from, weakest-last. `marks` is the 482 /// Where the command-boundary signal came from, weakest-last. `marks` is the
@@ -2672,7 +2667,8 @@ test "selection request has a fixed little-endian wire layout" {
2672 0x14, 0x13, 0x12, 0x11, 2667 0x14, 0x13, 0x12, 0x11,
2673 0x22, 0x21, 0x34, 0x33, 2668 0x22, 0x21, 0x34, 0x33,
2674 0x32, 0x31, 0x42, 0x41, 2669 0x32, 0x31, 0x42, 0x41,
2675 }, &bytes); 2670 }, bytes[0..16]);
2671 try std.testing.expectEqualSlices(u8, &(@as([21]u8, @splat(0))), bytes[16..]);
2676 try std.testing.expectEqualDeep(req, try decodeSelectionReq(&bytes)); 2672 try std.testing.expectEqualDeep(req, try decodeSelectionReq(&bytes));
2677 try std.testing.expectError(error.BadPayload, decodeSelectionReq(bytes[0..15])); 2673 try std.testing.expectError(error.BadPayload, decodeSelectionReq(bytes[0..15]));
2678 var long: [selection_req_len + 1]u8 = .{0} ** (selection_req_len + 1); 2674 var long: [selection_req_len + 1]u8 = .{0} ** (selection_req_len + 1);
@@ -2685,11 +2681,11 @@ test "selection reply validates status and text shape" {
2685 var payload: std.ArrayList(u8) = .empty; 2681 var payload: std.ArrayList(u8) = .empty;
2686 defer payload.deinit(alloc); 2682 defer payload.deinit(alloc);
2687 2683
2688 try encodeSelectionReply(&payload, alloc, 7, .ok, 0x11223344, "hello"); 2684 try encodeSelectionReply(&payload, alloc, .{ .id = 7, .status = .ok, .history_rows = 0x11223344, .text = "hello" });
2689 try std.testing.expectEqualSlices( 2685 try std.testing.expectEqualSlices(
2690 u8, 2686 u8,
2691 &.{ 7, 0, 0, 0, 0, 0x44, 0x33, 0x22, 0x11, 'h', 'e', 'l', 'l', 'o' }, 2687 &.{ 7, 0, 0, 0, 0, 0x44, 0x33, 0x22, 0x11 },
2692 payload.items, 2688 payload.items[0..9],
2693 ); 2689 );
2694 const ok = try decodeSelectionReply(payload.items); 2690 const ok = try decodeSelectionReply(payload.items);
2695 try std.testing.expectEqual(@as(u32, 7), ok.id); 2691 try std.testing.expectEqual(@as(u32, 7), ok.id);
@@ -2698,27 +2694,60 @@ test "selection reply validates status and text shape" {
2698 try std.testing.expectEqualStrings("hello", ok.text); 2694 try std.testing.expectEqualStrings("hello", ok.text);
2699 2695
2700 payload.clearRetainingCapacity(); 2696 payload.clearRetainingCapacity();
2701 try encodeSelectionReply(&payload, alloc, 9, .invalid, 5, ""); 2697 try encodeSelectionReply(&payload, alloc, .{ .id = 9, .status = .invalid, .history_rows = 5, .text = "" });
2702 try std.testing.expectEqualSlices(u8, &.{ 9, 0, 0, 0, 1, 5, 0, 0, 0 }, payload.items); 2698 try std.testing.expectEqualSlices(u8, &.{ 9, 0, 0, 0, 1, 5, 0, 0, 0 }, payload.items[0..9]);
2703 const invalid = try decodeSelectionReply(payload.items); 2699 const invalid = try decodeSelectionReply(payload.items);
2704 try std.testing.expectEqual(@as(u32, 9), invalid.id); 2700 try std.testing.expectEqual(@as(u32, 9), invalid.id);
2705 try std.testing.expectEqual(SelectionStatus.invalid, invalid.status); 2701 try std.testing.expectEqual(SelectionStatus.invalid, invalid.status);
2706 try std.testing.expectEqual(@as(u32, 5), invalid.history_rows); 2702 try std.testing.expectEqual(@as(u32, 5), invalid.history_rows);
2707 try std.testing.expectEqual(@as(usize, 0), invalid.text.len); 2703 try std.testing.expectEqual(@as(usize, 0), invalid.text.len);
2708 2704
2705 payload.items[4] = 0xff;
2706 try std.testing.expectError(error.BadPayload, decodeSelectionReply(payload.items));
2707 payload.items[4] = @intFromEnum(SelectionStatus.invalid);
2708 try payload.append(alloc, 'x');
2709 try std.testing.expectError(error.BadPayload, decodeSelectionReply(payload.items));
2710 payload.items[4] = @intFromEnum(SelectionStatus.ok);
2711 payload.items[selection_reply_prefix_len] = 0xff;
2712 try std.testing.expectError(error.BadPayload, decodeSelectionReply(payload.items));
2713
2709 try std.testing.expectError(error.BadPayload, decodeSelectionReply(&.{ 1, 0, 0, 0, 0xff, 0, 0, 0, 0 })); 2714 try std.testing.expectError(error.BadPayload, decodeSelectionReply(&.{ 1, 0, 0, 0, 0xff, 0, 0, 0, 0 }));
2710 try std.testing.expectError(error.BadPayload, decodeSelectionReply(&.{ 1, 0, 0, 0, 1, 0, 0, 0, 0, 'x' })); 2715 try std.testing.expectError(error.BadPayload, decodeSelectionReply(&.{ 1, 0, 0, 0, 1, 0, 0, 0, 0, 'x' }));
2711 try std.testing.expectError(error.BadPayload, decodeSelectionReply(&.{ 1, 0, 0, 0, 0, 0, 0, 0, 0, 0xff })); 2716 try std.testing.expectError(error.BadPayload, decodeSelectionReply(&.{ 1, 0, 0, 0, 0, 0, 0, 0, 0, 0xff }));
2712 2717
2713 // The watermark is part of the prefix, so a reply carrying only the 2718 // The watermark is part of the prefix, so a reply carrying only the
2714 // pre-watermark five bytes is short, not a legacy reply to interpret. 2719 // pre-watermark five bytes is short, not a legacy reply to interpret.
2715 const prefix = [_]u8{ 1, 0, 0, 0, 0, 0, 0, 0, 0 }; 2720 const prefix: [selection_reply_prefix_len]u8 = @splat(0);
2716 for (0..selection_reply_prefix_len) |len| { 2721 for (0..selection_reply_prefix_len) |len| {
2717 try std.testing.expectError(error.BadPayload, decodeSelectionReply(prefix[0..len])); 2722 try std.testing.expectError(error.BadPayload, decodeSelectionReply(prefix[0..len]));
2718 } 2723 }
2719 } 2724 }
2720 2725
2721 test "selection status discriminants have exact nine-byte golden replies" { 2726 test "selection tracking has one request and reply codec with guarded action shapes" {
2727 var req: SelectionReq = .{ .action = .start, .id = 7, .gesture = 9, .epoch = 11, .source = 13, .anchor = .{ .row = 70000, .col = 3 }, .active = .{ .row = 70001, .col = 4 } };
2728 const bytes = encodeSelectionReq(req);
2729 try std.testing.expectEqualDeep(req, try decodeSelectionReq(&bytes));
2730 try std.testing.expectEqualSlices(u8, &.{ 1, 9, 0, 0, 0, 11, 0, 0, 0, 0, 0, 0, 0, 13, 0, 0, 0, 0, 0, 0, 0 }, bytes[16..]);
2731 req.id = 0;
2732 try std.testing.expectError(error.BadPayload, decodeSelectionReq(&encodeSelectionReq(req)));
2733 req.action = .clear;
2734 try std.testing.expectEqualDeep(req, try decodeSelectionReq(&encodeSelectionReq(req)));
2735 req.gesture = 0;
2736 try std.testing.expectError(error.BadPayload, decodeSelectionReq(&encodeSelectionReq(req)));
2737 var bad = bytes;
2738 bad[16] = 0xff;
2739 try std.testing.expectError(error.BadPayload, decodeSelectionReq(&bad));
2740
2741 const reply: SelectionReply = .{ .gesture = 9, .seq = 15, .source = 13, .history_rows = 69999, .status = .ok, .anchor = req.anchor, .active = req.active };
2742 var out: std.ArrayList(u8) = .empty;
2743 defer out.deinit(std.testing.allocator);
2744 try encodeSelectionReply(&out, std.testing.allocator, reply);
2745 try std.testing.expectEqualDeep(reply, try decodeSelectionReply(out.items));
2746 try out.append(std.testing.allocator, 'x');
2747 try std.testing.expectError(error.BadPayload, decodeSelectionReply(out.items));
2748 }
2749
2750 test "selection status discriminants retain exact scalar prefixes" {
2722 const alloc = std.testing.allocator; 2751 const alloc = std.testing.allocator;
2723 const cases = [_]struct { status: SelectionStatus, wire: u8 }{ 2752 const cases = [_]struct { status: SelectionStatus, wire: u8 }{
2724 .{ .status = .ok, .wire = 0 }, 2753 .{ .status = .ok, .wire = 0 },
@@ -2729,11 +2758,11 @@ test "selection status discriminants have exact nine-byte golden replies" {
2729 for (cases) |case| { 2758 for (cases) |case| {
2730 var payload: std.ArrayList(u8) = .empty; 2759 var payload: std.ArrayList(u8) = .empty;
2731 defer payload.deinit(alloc); 2760 defer payload.deinit(alloc);
2732 try encodeSelectionReply(&payload, alloc, 0x01020304, case.status, 0x0a0b0c0d, ""); 2761 try encodeSelectionReply(&payload, alloc, .{ .id = 0x01020304, .status = case.status, .history_rows = 0x0a0b0c0d, .text = "" });
2733 try std.testing.expectEqualSlices( 2762 try std.testing.expectEqualSlices(
2734 u8, 2763 u8,
2735 &.{ 0x04, 0x03, 0x02, 0x01, case.wire, 0x0d, 0x0c, 0x0b, 0x0a }, 2764 &.{ 0x04, 0x03, 0x02, 0x01, case.wire, 0x0d, 0x0c, 0x0b, 0x0a },
2736 payload.items, 2765 payload.items[0..9],
2737 ); 2766 );
2738 const reply = try decodeSelectionReply(payload.items); 2767 const reply = try decodeSelectionReply(payload.items);
2739 try std.testing.expectEqual(@as(u32, 0x01020304), reply.id); 2768 try std.testing.expectEqual(@as(u32, 0x01020304), reply.id);
@@ -2751,7 +2780,7 @@ test "selection text accepts the exact cap and rejects one byte more" {
2751 2780
2752 var payload: std.ArrayList(u8) = .empty; 2781 var payload: std.ArrayList(u8) = .empty;
2753 defer payload.deinit(alloc); 2782 defer payload.deinit(alloc);
2754 try encodeSelectionReply(&payload, alloc, 1, .ok, 0, at_cap); 2783 try encodeSelectionReply(&payload, alloc, .{ .id = 1, .status = .ok, .history_rows = 0, .text = at_cap });
2755 try std.testing.expectEqual(selection_reply_prefix_len + selection_text_max, payload.items.len); 2784 try std.testing.expectEqual(selection_reply_prefix_len + selection_text_max, payload.items.len);
2756 const decoded = try decodeSelectionReply(payload.items); 2785 const decoded = try decodeSelectionReply(payload.items);
2757 try std.testing.expectEqual(selection_text_max, decoded.text.len); 2786 try std.testing.expectEqual(selection_text_max, decoded.text.len);
@@ -2759,7 +2788,7 @@ test "selection text accepts the exact cap and rejects one byte more" {
2759 const over_cap = try alloc.alloc(u8, selection_text_max + 1); 2788 const over_cap = try alloc.alloc(u8, selection_text_max + 1);
2760 defer alloc.free(over_cap); 2789 defer alloc.free(over_cap);
2761 @memset(over_cap, 'x'); 2790 @memset(over_cap, 'x');
2762 try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, 1, .ok, 0, over_cap)); 2791 try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, .{ .id = 1, .status = .ok, .history_rows = 0, .text = over_cap }));
2763 2792
2764 var oversized_payload = try alloc.alloc(u8, selection_reply_prefix_len + selection_text_max + 1); 2793 var oversized_payload = try alloc.alloc(u8, selection_reply_prefix_len + selection_text_max + 1);
2765 defer alloc.free(oversized_payload); 2794 defer alloc.free(oversized_payload);
@@ -2776,20 +2805,20 @@ test "selection reply validation errors do not modify a reused output buffer" {
2776 defer payload.deinit(alloc); 2805 defer payload.deinit(alloc);
2777 try payload.appendSlice(alloc, "sentinel"); 2806 try payload.appendSlice(alloc, "sentinel");
2778 2807
2779 try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, 1, .invalid, 0, "x")); 2808 try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, .{ .id = 1, .status = .invalid, .history_rows = 0, .text = "x" }));
2780 try std.testing.expectEqualStrings("sentinel", payload.items); 2809 try std.testing.expectEqualStrings("sentinel", payload.items);
2781 try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, 1, .ok, 0, &.{0xff})); 2810 try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, .{ .id = 1, .status = .ok, .history_rows = 0, .text = &.{0xff} }));
2782 try std.testing.expectEqualStrings("sentinel", payload.items); 2811 try std.testing.expectEqualStrings("sentinel", payload.items);
2783 2812
2784 const over_cap = try alloc.alloc(u8, selection_text_max + 1); 2813 const over_cap = try alloc.alloc(u8, selection_text_max + 1);
2785 defer alloc.free(over_cap); 2814 defer alloc.free(over_cap);
2786 @memset(over_cap, 'x'); 2815 @memset(over_cap, 'x');
2787 try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, 1, .ok, 0, over_cap)); 2816 try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, .{ .id = 1, .status = .ok, .history_rows = 0, .text = over_cap }));
2788 try std.testing.expectEqualStrings("sentinel", payload.items); 2817 try std.testing.expectEqualStrings("sentinel", payload.items);
2789 } 2818 }
2790 2819
2791 test "decoded selection text borrows the payload" { 2820 test "decoded selection text borrows the payload" {
2792 var payload = [_]u8{ 7, 0, 0, 0, 0, 3, 0, 0, 0, 'o', 'n', 'e' }; 2821 var payload = [_]u8{ 7, 0, 0, 0, 0, 3, 0, 0, 0 } ++ [_]u8{0} ** 32 ++ [_]u8{ 'o', 'n', 'e' };
2793 const reply = try decodeSelectionReply(&payload); 2822 const reply = try decodeSelectionReply(&payload);
2794 try std.testing.expectEqualStrings("one", reply.text); 2823 try std.testing.expectEqualStrings("one", reply.text);
2795 payload[selection_reply_prefix_len] = 'O'; 2824 payload[selection_reply_prefix_len] = 'O';
@@ -2799,8 +2828,8 @@ test "decoded selection text borrows the payload" {
2799 test "selection message values and fixed lengths are pinned" { 2828 test "selection message values and fixed lengths are pinned" {
2800 try std.testing.expectEqual(@as(u8, 0x0b), @intFromEnum(MsgType.selection_req)); 2829 try std.testing.expectEqual(@as(u8, 0x0b), @intFromEnum(MsgType.selection_req));
2801 try std.testing.expectEqual(@as(u8, 0x90), @intFromEnum(MsgType.selection_reply)); 2830 try std.testing.expectEqual(@as(u8, 0x90), @intFromEnum(MsgType.selection_reply));
2802 try std.testing.expectEqual(@as(usize, 16), selection_req_len); 2831 try std.testing.expectEqual(@as(usize, 37), selection_req_len);
2803 try std.testing.expectEqual(@as(usize, 9), selection_reply_prefix_len); 2832 try std.testing.expectEqual(@as(usize, 41), selection_reply_prefix_len);
2804 try std.testing.expectEqual(@as(usize, 1024 * 1024), selection_text_max); 2833 try std.testing.expectEqual(@as(usize, 1024 * 1024), selection_text_max);
2805 } 2834 }
2806 2835
src/gui/interaction.zig
Old New
@@ -98,6 +98,7 @@ pub const Controller = struct {
98 wheel_remainder: [model.max_panes]struct { key: ?model.Attachment = null, wheel: Wheel = .{} } = @splat(.{}), 98 wheel_remainder: [model.max_panes]struct { key: ?model.Attachment = null, wheel: Wheel = .{} } = @splat(.{}),
99 selection_key: ?model.Attachment = null, 99 selection_key: ?model.Attachment = null,
100 selection_request: u32 = 0, 100 selection_request: u32 = 0,
101 selection_gesture: u32 = 0,
101 selection_version: client.session_pump.SelectionVersion = .{ .seq = 0, .history_rows = 0, .epoch = 0, .revision = 0 }, 102 selection_version: client.session_pump.SelectionVersion = .{ .seq = 0, .history_rows = 0, .epoch = 0, .revision = 0 },
102 consumed_key: ?u32 = null, 103 consumed_key: ?u32 = null,
103 notice: []const u8 = "", 104 notice: []const u8 = "",
@@ -125,6 +126,7 @@ pub const Controller = struct {
125 if (self.selection_key) |key| if (self.rt.get(key.pane)) |live| live.pump.cancelSelection(); 126 if (self.selection_key) |key| if (self.rt.get(key.pane)) |live| live.pump.cancelSelection();
126 self.selection_drag.clear(); 127 self.selection_drag.clear();
127 self.selection_key = null; 128 self.selection_key = null;
129 self.selection_gesture = 0;
128 self.dirty = true; 130 self.dirty = true;
129 } 131 }
130 fn hit(self: *Controller, x: u32, y: u32) ?client.selection.Hit { 132 fn hit(self: *Controller, x: u32, y: u32) ?client.selection.Hit {
@@ -146,6 +148,15 @@ pub const Controller = struct {
146 } 148 }
147 pub fn selectedSpan(self: *const Controller, tile: usize, row: u32, cols: u16) ?client.selection.Span { 149 pub fn selectedSpan(self: *const Controller, tile: usize, row: u32, cols: u16) ?client.selection.Span {
148 if (self.selection_key) |key| if (@as(usize, @intCast(key.pane)) == tile and self.rt.accepts(key)) { 150 if (self.selection_key) |key| if (@as(usize, @intCast(key.pane)) == tile and self.rt.accepts(key)) {
151 if (self.selection_gesture != 0 and !self.selection_drag.buttonHeld()) {
152 const position = self.rt.get(key.pane).?.snapshot_follow orelse return null;
153 if (position.id != self.selection_gesture or position.status != .ok) return null;
154 const range: client.selection.Range = .{
155 .from = .{ .tile = tile, .row = position.anchor.row, .col = position.anchor.col },
156 .to = .{ .tile = tile, .row = position.active.row, .col = position.active.col },
157 };
158 return range.span(tile, row, cols);
159 }
149 return (self.selection_drag.range() orelse return null).span(tile, row, cols); 160 return (self.selection_drag.range() orelse return null).span(tile, row, cols);
150 }; 161 };
151 return null; 162 return null;
@@ -160,7 +171,13 @@ pub const Controller = struct {
160 self.clearSelection(); 171 self.clearSelection();
161 return true; 172 return true;
162 }; 173 };
163 if (!self.rt.accepts(key) or !live.pump.selectionFresh(self.selection_version)) { 174 const fresh = if (self.selection_drag.buttonHeld())
175 live.pump.selectionFresh(self.selection_version)
176 else if (self.selection_gesture != 0)
177 live.pump.selectionAlive(self.selection_gesture, self.selection_version)
178 else
179 live.pump.selectionFresh(self.selection_version);
180 if (!self.rt.accepts(key) or !fresh) {
164 self.clearSelection(); 181 self.clearSelection();
165 return true; 182 return true;
166 } 183 }
@@ -172,7 +189,7 @@ pub const Controller = struct {
172 const live = self.rt.get(key.pane) orelse return null; 189 const live = self.rt.get(key.pane) orelse return null;
173 if (!self.rt.accepts(key)) return null; 190 if (!self.rt.accepts(key)) return null;
174 const result = live.pump.takeSelection() orelse return null; 191 const result = live.pump.takeSelection() orelse return null;
175 if (result.id != self.selection_request or !std.meta.eql(result.version, self.selection_version) or result.status != .ok) { 192 if (result.id != self.selection_request or result.gesture != self.selection_gesture or result.status != .ok) {
176 self.rt.alloc.free(result.text); 193 self.rt.alloc.free(result.text);
177 if (result.status != .ok) self.setNotice(if (result.status == .too_large) "Selection too large" else "Selection unavailable"); 194 if (result.status != .ok) self.setNotice(if (result.status == .too_large) "Selection too large" else "Selection unavailable");
178 return null; 195 return null;
@@ -455,6 +472,9 @@ pub const Controller = struct {
455 switch (self.selection_drag.release()) { 472 switch (self.selection_drag.release()) {
456 .click => {}, 473 .click => {},
457 .selection => |range| { 474 .selection => |range| {
475 // A release always starts a new daemon tracker, even when a
476 // copy was requested while the button was held.
477 self.selection_gesture = 0;
458 try self.queueSelection(live, range); 478 try self.queueSelection(live, range);
459 }, 479 },
460 .nothing => self.clearSelection(), 480 .nothing => self.clearSelection(),
@@ -463,14 +483,22 @@ pub const Controller = struct {
463 } 483 }
464 fn queueSelection(self: *Controller, live: *runtime.Live, range: client.selection.Range) !void { 484 fn queueSelection(self: *Controller, live: *runtime.Live, range: client.selection.Range) !void {
465 self.selection_request +%= 1; 485 self.selection_request +%= 1;
466 try self.rt.requestSelection(live.key, self.selection_request, range, self.selection_version); 486 if (self.selection_request == 0) self.selection_request = 1;
487 if (self.selection_gesture == 0) self.selection_gesture = self.selection_request;
488 try self.rt.requestSelection(live.key, self.selection_request, self.selection_gesture, range, self.selection_version);
467 } 489 }
468 pub fn copySelection(self: *Controller) !void { 490 pub fn copySelection(self: *Controller) !void {
469 const key = self.selection_key orelse return; 491 const key = self.selection_key orelse return;
470 if (!self.rt.accepts(key)) return self.clearSelection(); 492 if (!self.rt.accepts(key)) return self.clearSelection();
471 const live = self.rt.get(key.pane) orelse return; 493 const live = self.rt.get(key.pane) orelse return;
472 const range = self.selection_drag.range() orelse return; 494 const range = self.selection_drag.range() orelse return;
473 if (!live.pump.selectionFresh(self.selection_version)) return self.clearSelection(); 495 const held = self.selection_drag.buttonHeld();
496 const valid = if (held)
497 live.pump.selectionFresh(self.selection_version)
498 else
499 live.pump.selectionAlive(self.selection_gesture, self.selection_version);
500 if (!valid) return self.clearSelection();
501 if (held) self.selection_gesture = 0;
474 try self.queueSelection(live, range); 502 try self.queueSelection(live, range);
475 } 503 }
476 pub fn pointerMove(self: *Controller, x: i64, y: i64) !void { 504 pub fn pointerMove(self: *Controller, x: i64, y: i64) !void {
@@ -834,6 +862,45 @@ test "selection hit keeps absolute history and pane-local pointer cells" {
834 try std.testing.expect(ui.selection_drag.range() == null); 862 try std.testing.expect(ui.selection_drag.range() == null);
835 } 863 }
836 864
865 test "held selection copies restart the gesture and release starts another" {
866 var rt = runtime.Runtime.init(std.testing.allocator, .{});
867 defer rt.deinit();
868 const metrics: model.Metrics = .{ .cell_w = 8, .cell_h = 16 };
869 const id = try rt.add(.{ .via = "cat" }, "copy-gesture", 800, 600, metrics);
870 var ui: Controller = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600 };
871 defer ui.deinit();
872 ui.layout = rt.workspace.layout(800, 600, metrics);
873 const live = rt.get(id).?;
874 live.pump.mu.lock();
875 try live.snapshot.resize(22, 9);
876 live.pump.status.phase = .attached;
877 live.pump.admitted = true;
878 live.pump.replica.state_since_attach = true;
879 live.pump.follow_seq = live.pump.replica.last_seq;
880 live.pump.follow_source = 1;
881 const source_version = live.pump.selectionVersionLocked();
882 live.pump.mu.unlock();
883 const p = ui.layout.get(id).?.content;
884 try ui.pointerDown(p.x + 4, p.y + 8, 0, 0);
885 ui.selection_version = source_version;
886 try ui.pointerMove(p.x + 20, p.y + 8);
887 const first_range = ui.selection_drag.range().?;
888 try std.testing.expectEqual(@as(u16, 2), first_range.to.col);
889 try ui.copySelection();
890 const first = ui.selection_gesture;
891 try std.testing.expect(first != 0);
892 try ui.pointerMove(p.x + 28, p.y + 8);
893 const extended_range = ui.selection_drag.range().?;
894 try std.testing.expectEqual(@as(u16, 3), extended_range.to.col);
895 try std.testing.expect(extended_range.to.col != first_range.to.col);
896 try ui.copySelection();
897 try std.testing.expect(ui.selection_gesture != first);
898 const second = ui.selection_gesture;
899 try ui.pointerUp(p.x + 36, p.y + 8);
900 try std.testing.expect(ui.selection_gesture != second);
901 try std.testing.expectEqual(@as(u16, 4), ui.selection_drag.range().?.to.col);
902 }
903
837 test "wheel reuses an existing pane remainder before a vacant earlier slot" { 904 test "wheel reuses an existing pane remainder before a vacant earlier slot" {
838 var rt = runtime.Runtime.init(std.testing.allocator, .{}); 905 var rt = runtime.Runtime.init(std.testing.allocator, .{});
839 defer rt.deinit(); 906 defer rt.deinit();
src/gui/runtime.zig
Old New
@@ -12,6 +12,7 @@ pub const Live = struct {
12 snapshot: *term.grid.Grid, 12 snapshot: *term.grid.Grid,
13 snapshot_seq: u64 = 0, 13 snapshot_seq: u64 = 0,
14 snapshot_version: client.session_pump.SelectionVersion = .{ .seq = 0, .history_rows = 0, .epoch = 0, .revision = 0 }, 14 snapshot_version: client.session_pump.SelectionVersion = .{ .seq = 0, .history_rows = 0, .epoch = 0, .revision = 0 },
15 snapshot_follow: ?client.session_pump.FollowPosition = null,
15 painted_seq: u64 = 0, 16 painted_seq: u64 = 0,
16 view_origin: u32 = 0, 17 view_origin: u32 = 0,
17 status: client.session_pump.State = .{}, 18 status: client.session_pump.State = .{},
@@ -51,6 +52,7 @@ pub const Live = struct {
51 try copyGrid(self.snapshot, src, @min(cols, src.cols), @min(rows, src.rows)); 52 try copyGrid(self.snapshot, src, @min(cols, src.cols), @min(rows, src.rows));
52 self.snapshot_seq = self.pump.replica.last_seq; 53 self.snapshot_seq = self.pump.replica.last_seq;
53 self.snapshot_version = self.pump.selectionVersionLocked(); 54 self.snapshot_version = self.pump.selectionVersionLocked();
55 self.snapshot_follow = self.pump.followPositionLocked();
54 self.view_origin = self.pump.viewOriginLocked(); 56 self.view_origin = self.pump.viewOriginLocked();
55 return self.pump.last_apply_us; 57 return self.pump.last_apply_us;
56 } 58 }
@@ -183,10 +185,10 @@ pub const Runtime = struct {
183 try live.pump.say(.{ .wheel = event }); 185 try live.pump.say(.{ .wheel = event });
184 } 186 }
185 187
186 pub fn requestSelection(self: *Runtime, key: model.Attachment, id: u32, range: client.selection.Range, version: client.session_pump.SelectionVersion) !void { 188 pub fn requestSelection(self: *Runtime, key: model.Attachment, id: u32, gesture: u32, range: client.selection.Range, version: client.session_pump.SelectionVersion) !void {
187 const live = self.get(key.pane) orelse return error.MissingPane; 189 const live = self.get(key.pane) orelse return error.MissingPane;
188 if (!self.accepts(key)) return error.StaleAttachment; 190 if (!self.accepts(key)) return error.StaleAttachment;
189 try live.pump.say(.{ .selection = .{ .id = id, .anchor = .{ .row = range.from.row, .col = range.from.col }, .active = .{ .row = range.to.row, .col = range.to.col }, .version = version } }); 191 try live.pump.say(.{ .selection = .{ .id = id, .gesture = gesture, .anchor = .{ .row = range.from.row, .col = range.from.col }, .active = .{ .row = range.to.row, .col = range.to.col }, .version = version } });
190 } 192 }
191 pub fn poll(self: *Runtime, now: i64) bool { 193 pub fn poll(self: *Runtime, now: i64) bool {
192 var changed = false; 194 var changed = false;
src/server/server.zig
Old New
@@ -268,6 +268,12 @@ const ClientSlot = struct {
268 /// This client volunteered an SSH agent (`.agent_offer`). Opt-in and 268 /// This client volunteered an SSH agent (`.agent_offer`). Opt-in and
269 /// per-connection: a redial is a new slot and must offer again. 269 /// per-connection: a redial is a new slot and must offer again.
270 agent_offer: bool = false, 270 agent_offer: bool = false,
271 selection: ?struct { id: u32, tracked: *Engine.TrackedSelection } = null,
272
273 fn clearSelection(self: *ClientSlot) void {
274 if (self.selection) |*selection| selection.tracked.deinit();
275 self.selection = null;
276 }
271 }; 277 };
272 278
273 /// One client's outstanding await: the request as asked, plus the two pieces 279 /// One client's outstanding await: the request as asked, plus the two pieces
@@ -1287,6 +1293,7 @@ pub const Server = struct {
1287 fn teardownClient(self: *Server, i: usize, close_sink: bool) void { 1293 fn teardownClient(self: *Server, i: usize, close_sink: bool) void {
1288 self.agents.closeOfClient(self, i); 1294 self.agents.closeOfClient(self, i);
1289 if (self.clients[i]) |*slot| { 1295 if (self.clients[i]) |*slot| {
1296 slot.clearSelection();
1290 slot.pending.deinit(self.alloc); 1297 slot.pending.deinit(self.alloc);
1291 slot.inbound.deinit(self.alloc); 1298 slot.inbound.deinit(self.alloc);
1292 if (close_sink) slot.sink.close(); 1299 if (close_sink) slot.sink.close();
@@ -1322,26 +1329,22 @@ pub const Server = struct {
1322 return self.clients[i] != null; 1329 return self.clients[i] != null;
1323 } 1330 }
1324 1331
1325 /// The lane is TOTAL: a decodable request leaves with exactly one answer, 1332 pub fn queueSelectionReply(self: *Server, i: usize, reply: proto.SelectionReply) void {
1326 /// so the fallback reserves capacity first. 1333 var bytes: std.ArrayList(u8) = .empty;
1327 pub fn queueSelectionReply( 1334 defer bytes.deinit(self.alloc);
1328 self: *Server, 1335 // Reserve the refusal before formatting a potentially large reply.
1329 i: usize, 1336 bytes.ensureTotalCapacity(self.alloc, proto.selection_reply_prefix_len) catch {
1330 id: u32, 1337 self.dropClient(i);
1331 status: proto.SelectionStatus,
1332 history_rows: u32,
1333 value: []const u8,
1334 ) void {
1335 var payload: std.ArrayList(u8) = .empty;
1336 defer payload.deinit(self.alloc);
1337 payload.ensureTotalCapacity(self.alloc, proto.selection_reply_prefix_len) catch return;
1338 proto.encodeSelectionReply(&payload, self.alloc, id, status, history_rows, value) catch {
1339 payload.clearRetainingCapacity();
1340 proto.encodeSelectionReply(&payload, self.alloc, id, .unavailable, 0, "") catch return;
1341 _ = self.queueFrame(i, .selection_reply, payload.items);
1342 return; 1338 return;
1343 }; 1339 };
1344 _ = self.queueFrame(i, .selection_reply, payload.items); 1340 proto.encodeSelectionReply(&bytes, self.alloc, reply) catch {
1341 bytes.clearRetainingCapacity();
1342 var refusal = reply;
1343 refusal.status = .unavailable;
1344 refusal.text = &.{};
1345 proto.encodeSelectionReply(&bytes, self.alloc, refusal) catch unreachable;
1346 };
1347 _ = self.queueFrame(i, .selection_reply, bytes.items);
1345 } 1348 }
1346 1349
1347 /// Wait up to `budget_ms` for clients to accept what they are owed. Only 1350 /// Wait up to `budget_ms` for clients to accept what they are owed. Only
@@ -1855,7 +1858,7 @@ pub const Server = struct {
1855 .resize => self.onResize(i, frame), 1858 .resize => self.onResize(i, frame),
1856 .input => self.onInput(i, frame), 1859 .input => self.onInput(i, frame),
1857 .fetch_scrollback => self.onFetchScrollback(i, frame), 1860 .fetch_scrollback => self.onFetchScrollback(i, frame),
1858 .selection_req => self.onSelectionReq(i, frame), 1861 .selection_req => self.onSelectionReq(i, frame.payload),
1859 .detach => self.dropClient(i), 1862 .detach => self.dropClient(i),
1860 .status_req => self.onStatusReq(i, frame), 1863 .status_req => self.onStatusReq(i, frame),
1861 .await_req => self.onAwaitReq(i, frame), 1864 .await_req => self.onAwaitReq(i, frame),
@@ -2018,6 +2021,7 @@ pub const Server = struct {
2018 return; 2021 return;
2019 }; 2022 };
2020 if (self.clients[i]) |*c| { 2023 if (self.clients[i]) |*c| {
2024 c.clearSelection();
2021 // Seq series are per-session, so an await's `since_seq` from the 2025 // Seq series are per-session, so an await's `since_seq` from the
2022 // old one would answer instantly or never, arbitrarily. Only on a 2026 // old one would answer instantly or never, arbitrarily. Only on a
2023 // real change: a reconnect re-resolving the same name keeps its await. 2027 // real change: a reconnect re-resolving the same name keeps its await.
@@ -2080,47 +2084,79 @@ pub const Server = struct {
2080 @memcpy(payload[0..6], &proto.encodeScrollbackReq(got.first, got.count)); 2084 @memcpy(payload[0..6], &proto.encodeScrollbackReq(got.first, got.count));
2081 @memcpy(payload[6..], got.bytes); 2085 @memcpy(payload[6..], got.bytes);
2082 _ = self.queueFrame(i, .scrollback_chunk, payload); 2086 _ = self.queueFrame(i, .scrollback_chunk, payload);
2087 self.sendSelectionState(si, i);
2083 } 2088 }
2084 2089
2085 fn onSelectionReq(self: *Server, i: usize, frame: proto.Frame) void { 2090 fn onSelectionReq(self: *Server, i: usize, payload: []const u8) void {
2086 // Selection is a client-local observation, not input: it claims no 2091 const req = proto.decodeSelectionReq(payload) catch return;
2087 // size and repaints nobody. Decoded BEFORE the session lookup, because
2088 // a malformed payload carries no id to correlate and only silence is
2089 // honest — while a well-formed one from a session-less slot deserves
2090 // its answer rather than the client's whole timeout.
2091 const req = proto.decodeSelectionReq(frame.payload) catch return;
2092 const si = self.clients[i].?.session orelse { 2092 const si = self.clients[i].?.session orelse {
2093 self.queueSelectionReply(i, req.id, selectionReplyStatus(null), 0, ""); 2093 self.queueSelectionReply(i, .{ .id = req.id, .gesture = req.gesture, .history_rows = 0 });
2094 return; 2094 return;
2095 }; 2095 };
2096 var result = self.ses(si).eng.extractSelection( 2096 const s = self.ses(si);
2097 self.alloc, 2097 if (req.action == .clear) {
2098 req.anchor.row, 2098 if (self.clients[i].?.selection) |selection| {
2099 req.anchor.col, 2099 if (selection.id == req.gesture) self.clients[i].?.clearSelection();
2100 req.active.row, 2100 }
2101 req.active.col,
2102 proto.selection_text_max,
2103 ) catch {
2104 // No extraction happened, so there is no screen whose
2105 // history this could honestly quote. Zero is the
2106 // watermark that claims nothing, and a non-ok status is
2107 // never compared against one anyway.
2108 self.queueSelectionReply(i, req.id, selectionReplyStatus(null), 0, "");
2109 return; 2101 return;
2110 }; 2102 }
2111 defer result.deinit(self.alloc); 2103 var state = self.selectionState(si, i);
2112 // Status and text are one invariant, not two independent 2104 state.id = req.id;
2113 // fields. In particular, an impossible `.ok` without the 2105 state.gesture = req.gesture;
2114 // owned text must not become a successful empty selection. 2106 if (req.action == .start) {
2115 const base = result.history_rows; 2107 const tracked = if (req.gesture != 0 and req.epoch == s.epoch and req.source == s.eng.selectionSource())
2116 switch (result.status) { 2108 s.eng.trackSelection(req.anchor, req.active) catch null
2117 .ok => if (result.text) |value|
2118 self.queueSelectionReply(i, req.id, selectionReplyStatus(.ok), base, value)
2119 else 2109 else
2120 self.queueSelectionReply(i, req.id, selectionReplyStatus(null), base, ""), 2110 null;
2121 .invalid => self.queueSelectionReply(i, req.id, selectionReplyStatus(.invalid), base, ""), 2111 if (tracked) |selection| {
2122 .too_large => self.queueSelectionReply(i, req.id, selectionReplyStatus(.too_large), base, ""), 2112 self.clients[i].?.clearSelection();
2113 self.clients[i].?.selection = .{ .id = req.gesture, .tracked = selection };
2114 state = self.selectionState(si, i);
2115 state.id = req.id;
2116 } else {
2117 state.status = .unavailable;
2118 self.queueSelectionReply(i, state);
2119 return;
2120 }
2121 }
2122 const result: ?Engine.SelectionExtract = if (req.action == .extract)
2123 s.eng.extractSelection(self.alloc, req.anchor.row, req.anchor.col, req.active.row, req.active.col, proto.selection_text_max) catch null
2124 else if (self.clients[i].?.selection) |*selection|
2125 if (selection.id == req.gesture and req.epoch == s.epoch)
2126 selection.tracked.extract(self.alloc, proto.selection_text_max) catch null
2127 else
2128 null
2129 else
2130 null;
2131 defer if (result) |value| value.deinit(self.alloc);
2132 if (result) |value| {
2133 state.status = selectionReplyStatus(value.status);
2134 state.text = value.text orelse &.{};
2135 } else state.status = .unavailable;
2136 self.queueSelectionReply(i, state);
2137 }
2138
2139 fn selectionState(self: *Server, si: usize, i: usize) proto.SelectionReply {
2140 const s = self.ses(si);
2141 var state: proto.SelectionReply = .{
2142 .seq = s.tracker.seq,
2143 .source = s.eng.selectionSource(),
2144 .history_rows = s.eng.historyRows(),
2145 };
2146 if (self.clients[i].?.selection) |*selection| {
2147 state.gesture = selection.id;
2148 if (selection.tracked.points()) |points| {
2149 state.status = .ok;
2150 state.anchor = points.anchor;
2151 state.active = points.active;
2152 } else self.clients[i].?.clearSelection();
2123 } 2153 }
2154 return state;
2155 }
2156
2157 fn sendSelectionState(self: *Server, si: usize, i: usize) void {
2158 if (!self.inSession(i, si)) return;
2159 self.queueSelectionReply(i, self.selectionState(si, i));
2124 } 2160 }
2125 2161
2126 fn onStatusReq(self: *Server, i: usize, frame: proto.Frame) void { 2162 fn onStatusReq(self: *Server, i: usize, frame: proto.Frame) void {
@@ -2573,6 +2609,7 @@ pub const Server = struct {
2573 for (0..max_clients) |i| { 2609 for (0..max_clients) |i| {
2574 if (if (to) |only| i != only else !self.inSession(i, si)) continue; 2610 if (if (to) |only| i != only else !self.inSession(i, si)) continue;
2575 if (!self.queueFrame(i, t, payload)) continue; 2611 if (!self.queueFrame(i, t, payload)) continue;
2612 self.sendSelectionState(si, i);
2576 sent = true; 2613 sent = true;
2577 if (t == .delta) { 2614 if (t == .delta) {
2578 self.stats.deltas += 1; 2615 self.stats.deltas += 1;
@@ -2612,7 +2649,7 @@ pub const Server = struct {
2612 } 2649 }
2613 const upd = s.tracker.update(self.alloc, s.eng) catch return; 2650 const upd = s.tracker.update(self.alloc, s.eng) catch return;
2614 switch (upd) { 2651 switch (upd) {
2615 .none => {}, 2652 .none => for (0..max_clients) |i| self.sendSelectionState(si, i),
2616 .discontinuity => self.resyncSnapshot(si), 2653 .discontinuity => self.resyncSnapshot(si),
2617 .advanced => { 2654 .advanced => {
2618 // Losing the payload would strand the clients a seq behind 2655 // Losing the payload would strand the clients a seq behind
@@ -2866,6 +2903,9 @@ pub const Server = struct {
2866 /// unlike sendResync: every attached client was told the mode on attach 2903 /// unlike sendResync: every attached client was told the mode on attach
2867 /// and at each change; a resync that skips it breaks that, silently. 2904 /// and at each change; a resync that skips it breaks that, silently.
2868 pub fn resyncSnapshot(self: *Server, si: usize) void { 2905 pub fn resyncSnapshot(self: *Server, si: usize) void {
2906 for (0..max_clients) |i| {
2907 if (self.inSession(i, si)) self.clients[i].?.clearSelection();
2908 }
2869 if (!self.rebuildTracker(si)) return; 2909 if (!self.rebuildTracker(si)) return;
2870 if (!self.hasClientsIn(si)) return; 2910 if (!self.hasClientsIn(si)) return;
2871 const payload = self.buildSnapshotPayload(si) catch return; 2911 const payload = self.buildSnapshotPayload(si) catch return;
src/server/server_test_attach.zig
Old New
@@ -610,6 +610,7 @@ test "Server: scrollback fetch is per-client and independent" {
610 if (frame.type != .selection_reply) return; 610 if (frame.type != .selection_reply) return;
611 const self: *@This() = @ptrCast(@alignCast(ctx.?)); 611 const self: *@This() = @ptrCast(@alignCast(ctx.?));
612 const reply = try proto.decodeSelectionReply(frame.payload); 612 const reply = try proto.decodeSelectionReply(frame.payload);
613 if (reply.id == 0) return;
613 switch (self.n) { 614 switch (self.n) {
614 0 => { 615 0 => {
615 try std.testing.expectEqual(@as(u32, 77), reply.id); 616 try std.testing.expectEqual(@as(u32, 77), reply.id);
@@ -656,7 +657,7 @@ test "Server: scrollback fetch is per-client and independent" {
656 fn on(_: ?*anyopaque, frame: proto.Frame) anyerror!void { 657 fn on(_: ?*anyopaque, frame: proto.Frame) anyerror!void {
657 // A fifth reply to four requests would mean the malformed frame 658 // A fifth reply to four requests would mean the malformed frame
658 // generated one of its own. 659 // generated one of its own.
659 if (frame.type == .selection_reply) return error.LateSelectionReply; 660 if (frame.type == .selection_reply and (try proto.decodeSelectionReply(frame.payload)).id != 0) return error.LateSelectionReply;
660 } 661 }
661 }; 662 };
662 var status_reply: ?proto.StatusReply = null; 663 var status_reply: ?proto.StatusReply = null;
src/server/server_test_clipboard.zig
Old New
@@ -12,6 +12,7 @@ const awaitFrame = h.awaitFrame;
12 const awaitGridText = h.awaitGridText; 12 const awaitGridText = h.awaitGridText;
13 const connectedPair = h.connectedPair; 13 const connectedPair = h.connectedPair;
14 const writeDyingGapShell = h.writeDyingGapShell; 14 const writeDyingGapShell = h.writeDyingGapShell;
15 const SelectionAction = @FieldType(proto.SelectionReq, "action");
15 16
16 /// Returns the parts of the reply that outlive the frame's payload — which 17 /// Returns the parts of the reply that outlive the frame's payload — which
17 /// is the whole reason this is not `awaitFrame` spelled at each call site: 18 /// is the whole reason this is not `awaitFrame` spelled at each call site:
@@ -21,10 +22,223 @@ fn awaitSelectionReply(
21 srv: *Server, 22 srv: *Server,
22 peer: std.posix.fd_t, 23 peer: std.posix.fd_t,
23 ) !?struct { id: u32, status: proto.SelectionStatus, text_len: usize } { 24 ) !?struct { id: u32, status: proto.SelectionStatus, text_len: usize } {
24 const frame = (try awaitFrame(alloc, srv, peer, .selection_reply, 400)) orelse return null; 25 // Every grid update also carries an id-zero source/position state. A
25 defer frame.deinit(alloc); 26 // direct extraction is correlated by its nonzero id, as ClientCore is.
26 const reply = try proto.decodeSelectionReply(frame.payload); 27 for (0..16) |_| {
27 return .{ .id = reply.id, .status = reply.status, .text_len = reply.text.len }; 28 const frame = (try awaitFrame(alloc, srv, peer, .selection_reply, 400)) orelse return null;
29 defer frame.deinit(alloc);
30 const reply = try proto.decodeSelectionReply(frame.payload);
31 if (reply.id == 0) continue;
32 return .{ .id = reply.id, .status = reply.status, .text_len = reply.text.len };
33 }
34 return null;
35 }
36
37 const TrackedReply = struct {
38 id: u32,
39 gesture: u32,
40 seq: u64,
41 source: u64,
42 status: proto.SelectionStatus,
43 anchor: proto.SelectionPoint,
44 active: proto.SelectionPoint,
45 text: [128]u8 = undefined,
46 text_len: usize = 0,
47
48 fn textValue(self: *const TrackedReply) []const u8 {
49 return self.text[0..self.text_len];
50 }
51 };
52
53 /// Position updates have id zero; a copy/start reply carries its request id.
54 /// Skip the other client-local updates so tests never depend on send order.
55 fn awaitTracked(
56 alloc: std.mem.Allocator,
57 srv: *Server,
58 peer: std.posix.fd_t,
59 gesture: u32,
60 id: u32,
61 ) !?TrackedReply {
62 for (0..16) |_| {
63 const frame = (try awaitFrame(alloc, srv, peer, .selection_reply, 400)) orelse return null;
64 defer frame.deinit(alloc);
65 const reply = try proto.decodeSelectionReply(frame.payload);
66 if (reply.gesture != gesture or reply.id != id) continue;
67 if (reply.text.len > 128) return error.TrackedTextTooLong;
68 var result: TrackedReply = .{
69 .id = reply.id,
70 .gesture = reply.gesture,
71 .seq = reply.seq,
72 .source = reply.source,
73 .status = reply.status,
74 .anchor = reply.anchor,
75 .active = reply.active,
76 };
77 @memcpy(result.text[0..reply.text.len], reply.text);
78 result.text_len = reply.text.len;
79 return result;
80 }
81 return null;
82 }
83
84 fn writeTracked(
85 peer: std.posix.fd_t,
86 action: SelectionAction,
87 id: u32,
88 gesture: u32,
89 epoch: u64,
90 source: u64,
91 anchor: proto.SelectionPoint,
92 active: proto.SelectionPoint,
93 ) !void {
94 const bytes = proto.encodeSelectionReq(.{
95 .action = action,
96 .id = id,
97 .gesture = gesture,
98 .epoch = epoch,
99 .source = source,
100 .anchor = anchor,
101 .active = active,
102 });
103 try proto.writeFrame(peer, .selection_req, &bytes);
104 }
105
106 fn attachTracked(alloc: std.mem.Allocator, td: *h.TestDaemon) !std.net.Stream {
107 const c = try dial.dialAttach(td.sock_path, 16, 9);
108 errdefer c.close();
109 (try awaitFrame(alloc, &td.srv, c.handle, .snapshot, 400) orelse return error.NoTrackedSnapshot).deinit(alloc);
110 const state_frame = (try awaitFrame(alloc, &td.srv, c.handle, .selection_reply, 400)) orelse return error.NoTrackedSource;
111 defer state_frame.deinit(alloc);
112 const state = try proto.decodeSelectionReply(state_frame.payload);
113 if (state.id != 0 or state.gesture != 0) return error.BadTrackedSource;
114 return c;
115 }
116
117 test "Server: tracked selection rejects a stale exact source even when the grid did not change" {
118 const alloc = std.testing.allocator;
119 var td = try h.TestDaemon.init(alloc, "follow-stale", .{ .shell = "/bin/cat" });
120 defer td.deinit();
121 const c = try attachTracked(alloc, &td);
122 defer c.close();
123
124 const session = td.srv.sessions.table[0].?;
125 const source = session.eng.selectionSource();
126 // Scroll a blank screen: row identities move but the rendered grid is
127 // unchanged. The source guard must still reject a delayed coordinate.
128 session.eng.feed("\x1b[1S");
129 td.srv.sendUpdate(0);
130 try writeTracked(c.handle, .start, 1, 41, session.epoch, source, .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 });
131 const reply = (try awaitTracked(alloc, &td.srv, c.handle, 41, 1)) orelse return error.NoStaleTrackedReply;
132 try std.testing.expectEqual(proto.SelectionStatus.unavailable, reply.status);
133 try std.testing.expectEqual(@as(usize, 0), reply.text_len);
134 }
135
136 test "Server: a stale start does not retire the client's existing tracked gesture" {
137 const alloc = std.testing.allocator;
138 var td = try h.TestDaemon.init(alloc, "tracked-stale-replace", .{ .shell = "/bin/cat", .cols = 16, .rows = 9 });
139 defer td.deinit();
140 const c = try attachTracked(alloc, &td);
141 defer c.close();
142 const session = td.srv.sessions.table[0].?;
143
144 session.eng.feed("KEEP");
145 td.srv.sendUpdate(0);
146 const source = session.eng.selectionSource();
147 try writeTracked(c.handle, .start, 1, 71, session.epoch, source, .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 3 });
148 _ = (try awaitTracked(alloc, &td.srv, c.handle, 71, 1)) orelse return error.NoInitialTrackedStart;
149
150 // This changes the coordinate source after gesture 71 is owned. A late
151 // gesture 72 must be refused without clearing the still-live pins.
152 session.eng.feed("\x1b[1S");
153 td.srv.sendUpdate(0);
154 try writeTracked(c.handle, .start, 2, 72, session.epoch, source, .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 });
155 const stale = (try awaitTracked(alloc, &td.srv, c.handle, 72, 2)) orelse return error.NoStaleReplacementReply;
156 try std.testing.expectEqual(proto.SelectionStatus.unavailable, stale.status);
157
158 try writeTracked(c.handle, .copy, 3, 71, session.epoch, session.eng.selectionSource(), .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 });
159 const kept = (try awaitTracked(alloc, &td.srv, c.handle, 71, 3)) orelse return error.NoKeptTrackedCopy;
160 try std.testing.expectEqual(proto.SelectionStatus.ok, kept.status);
161 try std.testing.expectEqualStrings("KEEP", kept.textValue());
162 }
163
164 test "Server: tracked selection tracks duplicate occurrences per client and clear ids cannot cross" {
165 const alloc = std.testing.allocator;
166 var td = try h.TestDaemon.init(alloc, "follow-clients", .{ .shell = "/bin/cat", .cols = 16, .rows = 9 });
167 defer td.deinit();
168 const a = try attachTracked(alloc, &td);
169 defer a.close();
170 const b = try attachTracked(alloc, &td);
171 defer b.close();
172 const session = td.srv.sessions.table[0].?;
173
174 session.eng.feed("DUPLICATE\r\nBEFORE\r\nDUPLICATE\r\nAFTER");
175 td.srv.sendUpdate(0);
176 const epoch = session.epoch;
177 const source = session.eng.selectionSource();
178 try writeTracked(a.handle, .start, 1, 101, epoch, source, .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 8 });
179 try writeTracked(b.handle, .start, 1, 202, epoch, source, .{ .row = 2, .col = 0 }, .{ .row = 2, .col = 8 });
180 const started_a = (try awaitTracked(alloc, &td.srv, a.handle, 101, 1)) orelse return error.NoTrackedA;
181 const started_b = (try awaitTracked(alloc, &td.srv, b.handle, 202, 1)) orelse return error.NoTrackedB;
182 try std.testing.expectEqualStrings("DUPLICATE", started_a.textValue());
183 try std.testing.expectEqualStrings("DUPLICATE", started_b.textValue());
184 try std.testing.expect(started_a.anchor.row != started_b.anchor.row);
185
186 // Start at the bottom and append enough to shift both matching strings;
187 // no text comparison can identify which one each client intended.
188 session.eng.feed("\r\nTAIL-1\r\nTAIL-2\r\nTAIL-3\r\nTAIL-4\r\nTAIL-5\r\nTAIL-6");
189 td.srv.sendUpdate(0);
190 const moved_a = (try awaitTracked(alloc, &td.srv, a.handle, 101, 0)) orelse return error.NoMovedTrackedA;
191 const moved_b = (try awaitTracked(alloc, &td.srv, b.handle, 202, 0)) orelse return error.NoMovedTrackedB;
192 try std.testing.expect(moved_a.anchor.row != moved_b.anchor.row);
193
194 // A foreign clear cannot destroy A's pins. Its following copy must still
195 // name A's original duplicate, not B's or a current coordinate.
196 try writeTracked(a.handle, .clear, 0, 999, epoch, source, .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 });
197 try writeTracked(a.handle, .copy, 2, 101, epoch, source, .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 });
198 const copied_a = (try awaitTracked(alloc, &td.srv, a.handle, 101, 2)) orelse return error.NoTrackedCopy;
199 try std.testing.expectEqual(proto.SelectionStatus.ok, copied_a.status);
200 try std.testing.expectEqualStrings("DUPLICATE", copied_a.textValue());
201 }
202
203 test "Server: tracked selection is retired by resync, resize and detach" {
204 const alloc = std.testing.allocator;
205 var td = try h.TestDaemon.init(alloc, "follow-retire", .{ .shell = "/bin/cat", .cols = 16, .rows = 9 });
206 defer td.deinit();
207 var c = try attachTracked(alloc, &td);
208 var c_open = true;
209 defer if (c_open) c.close();
210 const session = td.srv.sessions.table[0].?;
211 session.eng.feed("tracked");
212 td.srv.sendUpdate(0);
213 try writeTracked(c.handle, .start, 1, 77, session.epoch, session.eng.selectionSource(), .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 6 });
214 _ = (try awaitTracked(alloc, &td.srv, c.handle, 77, 1)) orelse return error.NoTrackedFollow;
215
216 td.srv.resyncSnapshot(0);
217 const resynced = (try awaitTracked(alloc, &td.srv, c.handle, 0, 0)) orelse return error.NoResyncFollow;
218 try std.testing.expectEqual(proto.SelectionStatus.unavailable, resynced.status);
219
220 // Re-arm before a real client resize; its snapshot and following state
221 // must likewise retire the pins rather than projecting them through reflow.
222 try writeTracked(c.handle, .start, 2, 78, session.epoch, session.eng.selectionSource(), .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 6 });
223 _ = (try awaitTracked(alloc, &td.srv, c.handle, 78, 2)) orelse return error.NoResizeFollowStart;
224 const size = proto.encodeSize(17, 9);
225 try proto.writeFrame(c.handle, .resize, &size);
226 const resized = (try awaitTracked(alloc, &td.srv, c.handle, 0, 0)) orelse return error.NoResizeFollow;
227 try std.testing.expectEqual(proto.SelectionStatus.unavailable, resized.status);
228
229 // Socket closure takes the sole teardown path. The testing allocator at
230 // TestDaemon.deinit then proves its tracked pins were unregistered.
231 c.close();
232 c_open = false;
233 var detached = false;
234 for (0..80) |_| {
235 try td.srv.pumpOnce(5);
236 if (!td.srv.hasClientsIn(0)) {
237 detached = true;
238 break;
239 }
240 }
241 try std.testing.expect(detached);
28 } 242 }
29 243
30 test "Server: a session-less slot still answers a well-formed selection request" { 244 test "Server: a session-less slot still answers a well-formed selection request" {
@@ -79,7 +293,7 @@ test "Server: an unencodable selection result still answers with unavailable" {
79 }); 293 });
80 const real = td.srv.alloc; 294 const real = td.srv.alloc;
81 td.srv.alloc = failing.allocator(); 295 td.srv.alloc = failing.allocator();
82 td.srv.queueSelectionReply(0, 99, .ok, 7, text); 296 td.srv.queueSelectionReply(0, .{ .id = 99, .status = .ok, .history_rows = 7, .text = text });
83 td.srv.alloc = real; 297 td.srv.alloc = real;
84 try std.testing.expect(failing.has_induced_failure); 298 try std.testing.expect(failing.has_induced_failure);
85 299
src/server/server_test_deliver.zig
Old New
@@ -61,8 +61,8 @@ test "Server: a stalled client does not block delivery to others" {
61 try std.testing.expect(td.srv.clients[1] != null); 61 try std.testing.expect(td.srv.clients[1] != null);
62 try std.testing.expectEqual(@as(usize, 0), td.srv.clients[1].?.pending.items.len); 62 try std.testing.expectEqual(@as(usize, 0), td.srv.clients[1].?.pending.items.len);
63 63
64 // ...and what it received is whole parseable frames, one snapshot per 64 // ...and what it received is whole parseable snapshot/state pairs, one
65 // round, not a stream truncated or interleaved by A's backpressure. 65 // per round, not a stream truncated or interleaved by A's backpressure.
66 var frames: usize = 0; 66 var frames: usize = 0;
67 while (frames < rounds) : (frames += 1) { 67 while (frames < rounds) : (frames += 1) {
68 const f = (try proto.readFrame(alloc, b.peer)) orelse break; 68 const f = (try proto.readFrame(alloc, b.peer)) orelse break;
@@ -70,6 +70,12 @@ test "Server: a stalled client does not block delivery to others" {
70 try std.testing.expectEqual(proto.MsgType.snapshot, f.type); 70 try std.testing.expectEqual(proto.MsgType.snapshot, f.type);
71 const p = try proto.readSnapshotPrefix(f.payload); 71 const p = try proto.readSnapshotPrefix(f.payload);
72 try std.testing.expectEqual(@as(u16, 80), p.cols); 72 try std.testing.expectEqual(@as(u16, 80), p.cols);
73 const state = (try proto.readFrame(alloc, b.peer)) orelse return error.NoSnapshotSource;
74 defer state.deinit(alloc);
75 try std.testing.expectEqual(proto.MsgType.selection_reply, state.type);
76 const reply = try proto.decodeSelectionReply(state.payload);
77 try std.testing.expectEqual(@as(u32, 0), reply.id);
78 try std.testing.expectEqual(@as(u32, 0), reply.gesture);
73 } 79 }
74 try std.testing.expectEqual(rounds, frames); 80 try std.testing.expectEqual(rounds, frames);
75 } 81 }
src/server/server_test_harness.zig
Old New
@@ -526,7 +526,12 @@ pub const ReplicaFeed = struct {
526 pub fn on(ctx: ?*anyopaque, frame: proto.Frame) anyerror!void { 526 pub fn on(ctx: ?*anyopaque, frame: proto.Frame) anyerror!void {
527 const self: *@This() = @ptrCast(@alignCast(ctx.?)); 527 const self: *@This() = @ptrCast(@alignCast(ctx.?));
528 for (self.w.reject) |bad| { 528 for (self.w.reject) |bad| {
529 if (frame.type == bad) return error.RejectedFrameArrived; 529 if (frame.type == bad) {
530 // Unsolicited position metadata accompanies every grid. Only
531 // a correlated copy reply would leak another client's read.
532 if (bad == .selection_reply and (try proto.decodeSelectionReply(frame.payload)).id == 0) continue;
533 return error.RejectedFrameArrived;
534 }
530 } 535 }
531 // applyFrame ignores everything that is not state, so the stream's 536 // applyFrame ignores everything that is not state, so the stream's
532 // replies and marks pass through untouched. 537 // replies and marks pass through untouched.
src/tui/interact.zig
Old New
@@ -4577,6 +4577,7 @@ fn replyBytes(
4577 std.mem.writeInt(u32, buf[0..4], id, .little); 4577 std.mem.writeInt(u32, buf[0..4], id, .little);
4578 buf[4] = @intFromEnum(status); 4578 buf[4] = @intFromEnum(status);
4579 std.mem.writeInt(u32, buf[5..9], history_rows, .little); 4579 std.mem.writeInt(u32, buf[5..9], history_rows, .little);
4580 @memset(buf[9..proto.selection_reply_prefix_len], 0);
4580 @memcpy(buf[proto.selection_reply_prefix_len..][0..text.len], text); 4581 @memcpy(buf[proto.selection_reply_prefix_len..][0..text.len], text);
4581 return buf[0 .. proto.selection_reply_prefix_len + text.len]; 4582 return buf[0 .. proto.selection_reply_prefix_len + text.len];
4582 } 4583 }
src/tui/wall_test_harness.zig
Old New
@@ -281,6 +281,7 @@ pub fn replyBytes(
281 std.mem.writeInt(u32, buf[0..4], id, .little); 281 std.mem.writeInt(u32, buf[0..4], id, .little);
282 buf[4] = @intFromEnum(status); 282 buf[4] = @intFromEnum(status);
283 std.mem.writeInt(u32, buf[5..9], history_rows, .little); 283 std.mem.writeInt(u32, buf[5..9], history_rows, .little);
284 @memset(buf[9..proto.selection_reply_prefix_len], 0);
284 @memcpy(buf[proto.selection_reply_prefix_len..][0..text.len], text); 285 @memcpy(buf[proto.selection_reply_prefix_len..][0..text.len], text);
285 return buf[0 .. proto.selection_reply_prefix_len + text.len]; 286 return buf[0 .. proto.selection_reply_prefix_len + text.len];
286 } 287 }
test/native_selection.py
Old New
@@ -245,6 +245,15 @@ def active_output(rig, pane):
245 'active counter cleared the held selection') 245 'active counter cleared the held selection')
246 rig.key('copy') 246 rig.key('copy')
247 rig.copied('alpha') 247 rig.copied('alpha')
248 # A second copy during the same held gesture must use the newly
249 # extended range. Release then registers its final, shorter range.
250 rig.send('mousemove:' + rig.cell_point(state, pane, 6, 1))
251 # Wayland motion and the FIFO keyboard hook arrive independently.
252 # Observe the extended highlight before asking the other channel to copy.
253 eventually(lambda: cell_background(rig, rig.state(), pane, 6, 1) != background,
254 'held drag did not visibly extend before copying')
255 rig.key('copy')
256 rig.copied('alpha c')
248 rig.send('mouseup:' + rig.cell_point(state, pane, 4, 1)) 257 rig.send('mouseup:' + rig.cell_point(state, pane, 4, 1))
249 rig.copied('alpha') 258 rig.copied('alpha')
250 initial = count(rig.state()) 259 initial = count(rig.state())
test/native_selection_follow.py
Old New
@@ -0,0 +1,239 @@
1 #!/usr/bin/env python3
2 """Real-PTY acceptance for completed local selections that follow terminal edits.
3
4 The source panes deliberately contain duplicate text. The surrounding numbered
5 rows and framebuffer location make a copied duplicate insufficient evidence:
6 the selected *occurrence* must move with the terminal's own tracked pins.
7 """
8 import shlex
9 import os
10 import sys
11 import time
12
13 sys.dont_write_bytecode = True
14 from native_lifecycle import start_persistent
15 from native_resize import by_id
16 from native_selection import SelectionRig, cell_background
17 from native_tiling import eventually, require
18
19
20 SHIFT = 1
21
22
23 def pty_output(rig, pane, data):
24 """Write fixture-owned terminal output without changing GUI focus."""
25 fd = os.open(rig.tty_paths[pane], os.O_WRONLY | os.O_NOCTTY)
26 try:
27 os.write(fd, data)
28 finally:
29 os.close(fd)
30
31
32 def row_of(state, pane, prefix):
33 for row, line in enumerate(by_id(state)[pane]['painted_text'].splitlines()):
34 if line.startswith(prefix):
35 return row
36 return None
37
38
39 def mouse_cell(rig, state, pane, col, row, kind, mods=0):
40 point = rig.cell_point(state, pane, col, row)
41 x, y = point.split(',')
42 rig.send(f'mouse:{kind},{x},{y},0,{mods}')
43
44
45 def local_shift_select(rig, pane, start, finish):
46 """Use the ordinary SDL mouse hook: an app may be reporting mouse input."""
47 state = rig.state()
48 mouse_cell(rig, state, pane, *start, 'down', SHIFT)
49 mouse_cell(rig, state, pane, *finish, 'move', SHIFT)
50 mouse_cell(rig, state, pane, *finish, 'up', SHIFT)
51 return state
52
53
54 def shell_follow_program(rig, pane):
55 trigger = rig.root / 'shell-follow-go'
56 source = rig.root / 'shell-follow.py'
57 rows = by_id(rig.state())[pane]['rows']
58 require(rows >= 9, 'shell follow fixture needs nine terminal rows')
59 source.write_text(
60 'import os, time\nfrom pathlib import Path\n'
61 f'trigger=Path({str(trigger)!r})\n'
62 f'rows={rows}\n'
63 'body=["SHELL-%03d" % n for n in range(rows)]\n'
64 'body[rows-8]="SHELL-DUPLICATE"; body[rows-7]="SHELL-BEFORE-003"\n'
65 'body[rows-5]="SHELL-DUPLICATE"; body[rows-4]="SHELL-AFTER-006"; body[-1]="SHELL-READY"\n'
66 'os.write(1, ("\\033[0m\\033[2J\\033[H"+"\\r\\n".join(body)).encode())\n'
67 'while not trigger.exists(): time.sleep(.01)\n'
68 'for n in range(3):\n'
69 ' os.write(1, ("\\r\\nSHELL-TAIL-%03d" % n).encode()); time.sleep(.03)\n'
70 'while True: time.sleep(60)\n')
71 rig.focus(pane)
72 rig.shell('python3 ' + shlex.quote(str(source)))
73 state = rig.wait_state(lambda s: row_of(s, pane, 'SHELL-READY') is not None)
74 return trigger, state
75
76
77 def shell_primary_scroll(rig, target, neighbour):
78 trigger, state = shell_follow_program(rig, target)
79 after = row_of(state, target, 'SHELL-AFTER-006')
80 start = None if after is None else after - 1
81 require(start is not None and start + 1 < by_id(state)[target]['rows'],
82 'shell fixture did not leave a two-row selection in the viewport')
83 background = cell_background(rig, state, target, 2, start)
84 # Select a duplicate plus its distinct successor. Matching the word alone
85 # would let a stale range select a different identical line.
86 local_shift_select(rig, target, (0, start), (16, start + 1))
87 rig.copied('SHELL-DUPLICATE\nSHELL-AFTER-006')
88 eventually(lambda: cell_background(rig, rig.state(), target, 2, start) != background,
89 'completed shell selection was not visibly painted')
90
91 # Unselected output must not move or invalidate this pane's occurrence.
92 pty_output(rig, neighbour, b'\033[0mNEIGHBOUR-OUTPUT\r\n')
93 rig.wait_state(lambda s: 'NEIGHBOUR-OUTPUT' in by_id(s)[neighbour]['painted_text'])
94 rig.key('copy')
95 rig.copied('SHELL-DUPLICATE\nSHELL-AFTER-006')
96
97 trigger.touch()
98 moved = rig.wait_state(lambda s: row_of(s, target, 'SHELL-TAIL-002') is not None)
99 after = row_of(moved, target, 'SHELL-AFTER-006')
100 new_row = None if after is None else after - 1
101 require(new_row == start - 3,
102 f'shell occurrence moved to row {new_row}, expected {start - 3}')
103 eventually(lambda: cell_background(rig, rig.state(), target, 2, new_row) != background,
104 'shell highlight did not follow its selected occurrence upward')
105 require(cell_background(rig, rig.state(), target, 2, start) == background,
106 'shell highlight remained at the old viewport row')
107 pty_output(rig, neighbour, b'\033]52;c;Q09QWS1TRU5USU5FTA==\a')
108 rig.copied('COPY-SENTINEL')
109 rig.key('copy')
110 rig.copied('SHELL-DUPLICATE\nSHELL-AFTER-006')
111 rig.ok('completed local selection follows primary-screen append through duplicate rows; neighbouring output is independent')
112 # Return the target pane to its shell before the alternate-screen fixture
113 # starts; otherwise its launch command would be consumed by the old app.
114 rig.key('interrupt')
115 time.sleep(.12)
116
117
118 def alt_reporter(rig, pane):
119 control = rig.root / 'alt-follow-control'
120 received = rig.root / 'alt-follow-received.bin'
121 source = rig.root / 'alt-follow.py'
122 rows = by_id(rig.state())[pane]['rows']
123 require(rows >= 9, 'alt follow fixture needs nine terminal rows')
124 source.write_text(
125 'import os, select, sys, termios, tty, time\nfrom pathlib import Path\n'
126 f'control=Path({str(control)!r}); received=Path({str(received)!r})\n'
127 'fd=0; old=termios.tcgetattr(fd); tty.setraw(fd); seen=0\n'
128 f'rows={rows}\n'
129 'body=["ALT-%03d" % n for n in range(rows)]\n'
130 'body[rows-8]="ALT-DUPLICATE"; body[rows-7]="ALT-BEFORE-003"\n'
131 'body[rows-5]="ALT-DUPLICATE"; body[rows-4]="ALT-AFTER-006"; body[-1]="ALT-READY"\n'
132 'try:\n'
133 ' os.write(1, ("\\033[?1049h\\033[?25l\\033[2J\\033[H"+"\\r\\n".join(body)+"\\033[?1002h\\033[?1006h").encode())\n'
134 ' while True:\n'
135 ' if control.exists():\n'
136 ' data=control.read_text()[seen:]; seen += len(data)\n'
137 ' for command in data.splitlines():\n'
138 ' if command == "scroll": os.write(1, ("\\033[2;%dr\\033[2S\\033[r" % rows).encode())\n'
139 ' if command == "reset": os.write(1, b"\\033c")\n'
140 ' if select.select([fd], [], [], .02)[0]:\n'
141 ' with received.open("ab") as out: out.write(os.read(fd, 4096))\n'
142 'finally: termios.tcsetattr(fd, termios.TCSADRAIN, old)\n')
143 rig.focus(pane)
144 rig.shell('python3 ' + shlex.quote(str(source)))
145 state = rig.wait_state(lambda s: row_of(s, pane, 'ALT-READY') is not None)
146 return control, received, state
147
148
149 def alt_region_scroll(rig, target, neighbour):
150 control, received, state = alt_reporter(rig, target)
151 after = row_of(state, target, 'ALT-AFTER-006')
152 start = None if after is None else after - 1
153 require(start is not None and start + 1 < by_id(state)[target]['rows'], 'alt fixture rows missing')
154 background = cell_background(rig, state, target, 2, start)
155 before_bytes = received.stat().st_size if received.exists() else 0
156 local_shift_select(rig, target, (0, start), (14, start + 1))
157 rig.copied('ALT-DUPLICATE\nALT-AFTER-006')
158 time.sleep(.12)
159 require((received.stat().st_size if received.exists() else 0) == before_bytes,
160 'Shift local selection leaked mouse reports into the reporting app')
161 with control.open('a') as out:
162 out.write('scroll\n')
163 moved = rig.wait_state(lambda s: row_of(s, target, 'ALT-AFTER-006') == start - 1)
164 after = row_of(moved, target, 'ALT-AFTER-006')
165 new_row = None if after is None else after - 1
166 eventually(lambda: cell_background(rig, rig.state(), target, 2, new_row) != background,
167 'alt-screen region scroll did not move the highlight')
168 require(cell_background(rig, rig.state(), target, 2, start) == background,
169 'alt-screen region scroll left highlight at its old row')
170 # Ctrl+Shift+C must ask the daemon for the tracked occurrence after the
171 # scroll, rather than reusing the old client coordinates.
172 pty_output(rig, neighbour, b'\033]52;c;Q09QWS1TRU5USU5FTA==\a')
173 rig.copied('COPY-SENTINEL')
174 rig.key('copy')
175 rig.copied('ALT-DUPLICATE\nALT-AFTER-006')
176 rig.ok('Shift selection in a mouse-reporting alt screen follows vertical-region scrolling and Ctrl+Shift+C copies it')
177 return control
178
179
180 def invalidation(rig, target, control, neighbour):
181 stable = rig.clipboard()
182 require(stable == 'ALT-DUPLICATE\nALT-AFTER-006', 'missing selection-copy baseline')
183 # A real pane geometry change makes terminal screen coordinates ambiguous.
184 state = rig.state()
185 rig.drag('beside', dx=-2 * state['cell_w'])
186 state = rig.wait_state(lambda s: row_of(s, target, 'ALT-AFTER-006') is not None)
187 rig.unchanged(stable)
188 pty_output(rig, neighbour, b'\033]52;c;Q09QWS1TRU5USU5FTA==\a')
189 rig.copied('COPY-SENTINEL')
190 rig.key('copy')
191 rig.unchanged('COPY-SENTINEL')
192 after = row_of(state, target, 'ALT-AFTER-006')
193 start = None if after is None else after - 1
194 require(start is not None and start + 1 < by_id(state)[target]['rows'],
195 'resized alt fixture lost its selection markers')
196 background = cell_background(rig, state, target, 2, start)
197 local_shift_select(rig, target, (0, start), (14, start + 1))
198 rig.copied(stable)
199 eventually(lambda: cell_background(rig, rig.state(), target, 2, start) != background,
200 'selection could not be recreated after resize')
201 # RIS resets the terminal's pages and makes Ghostty's tracked pins garbage.
202 with control.open('a') as out:
203 out.write('reset\n')
204 rig.wait_state(lambda s: row_of(s, target, 'ALT-DUPLICATE') is None)
205 rig.unchanged(stable)
206 pty_output(rig, neighbour, b'\033]52;c;Q09QWS1TRU5USU5FTA==\a')
207 rig.copied('COPY-SENTINEL')
208 rig.key('copy')
209 rig.unchanged('COPY-SENTINEL')
210 rig.ok('resize and terminal reset invalidate a followed selection without changing the desktop clipboard')
211
212
213 def exercise(rig):
214 refs = start_persistent(rig)
215 rig.drag('stacked', dy=rig.state()['cell_h'] * 3)
216 panes = list(refs)
217 target, neighbour = panes[1], panes[2]
218 shell_primary_scroll(rig, target, neighbour)
219 control = alt_region_scroll(rig, target, neighbour)
220 invalidation(rig, target, control, neighbour)
221 rig.assert_cli_untouched()
222
223
224 def main():
225 require(len(sys.argv) == 3, 'usage: native_selection_follow.py MUX MUXG')
226 rig = SelectionRig(*sys.argv[1:])
227 try:
228 exercise(rig)
229 rig.quit()
230 print('Native selection-follow acceptance passed; artifacts:', rig.root, flush=True)
231 except BaseException:
232 rig.failure_artifacts()
233 raise
234 finally:
235 rig.close()
236
237
238 if __name__ == '__main__':
239 main()
web/mux.js
Old New
@@ -1158,7 +1158,7 @@ class Tile {
1158 const n = this.core.mux_selection_request( 1158 const n = this.core.mux_selection_request(
1159 this.nextSelectionId, a.row, a.col, b.row, b.col, 1159 this.nextSelectionId, a.row, a.col, b.row, b.col,
1160 ); 1160 );
1161 if (n === 16) { 1161 if (n > 0) {
1162 const selection = this.selection; 1162 const selection = this.selection;
1163 // Establish reply authority before send: a host is allowed to deliver 1163 // Establish reply authority before send: a host is allowed to deliver
1164 // a matching semantic reply synchronously from its send hook. 1164 // a matching semantic reply synchronously from its send hook.
web/verify.js
Old New
@@ -894,7 +894,7 @@ async function verifySelectionShell(shell, html) {
894 const makeTile = () => { 894 const makeTile = () => {
895 const selected = new h.Tile(4, 'selection fixture', h.document.createElement('div'), ''); 895 const selected = new h.Tile(4, 'selection fixture', h.document.createElement('div'), '');
896 const memory = { buffer: new ArrayBuffer(1024) }; 896 const memory = { buffer: new ArrayBuffer(1024) };
897 let requestResult = 16; 897 let requestResult = 37;
898 let scrollFeedResult = 0; 898 let scrollFeedResult = 0;
899 let result = { id: 0, status: 3, historyRows: 30, ptr: 96, len: 0 }; 899 let result = { id: 0, status: 3, historyRows: 30, ptr: 96, len: 0 };
900 const requestCalls = []; 900 const requestCalls = [];
@@ -913,7 +913,7 @@ async function verifySelectionShell(shell, html) {
913 mux_input_cap: () => 1024, 913 mux_input_cap: () => 1024,
914 mux_input_ptr: () => 0, 914 mux_input_ptr: () => 0,
915 mux_output_ptr: () => 256, 915 mux_output_ptr: () => 256,
916 mux_output_len: () => requestResult === 16 ? 16 : 0, 916 mux_output_len: () => requestResult === 37 ? 37 : 0,
917 mux_scroll_start: (pages, rows) => 30 - pages * rows, 917 mux_scroll_start: (pages, rows) => 30 - pages * rows,
918 mux_scroll_feed: () => scrollFeedResult, 918 mux_scroll_feed: () => scrollFeedResult,
919 mux_init: () => 0, 919 mux_init: () => 0,
@@ -927,14 +927,14 @@ async function verifySelectionShell(shell, html) {
927 mux_selection_len: () => result.len, 927 mux_selection_len: () => result.len,
928 mux_selection_request: (id, ar, ac, br, bc) => { 928 mux_selection_request: (id, ar, ac, br, bc) => {
929 requestCalls.push([id >>> 0, ar, ac, br, bc]); 929 requestCalls.push([id >>> 0, ar, ac, br, bc]);
930 if (requestResult !== 16) return requestResult; 930 if (requestResult !== 37) return requestResult;
931 const view = new DataView(memory.buffer, 256, 16); 931 const view = new DataView(memory.buffer, 256, 37);
932 view.setUint32(0, id, true); 932 view.setUint32(0, id, true);
933 view.setUint32(4, ar, true); 933 view.setUint32(4, ar, true);
934 view.setUint16(8, ac, true); 934 view.setUint16(8, ac, true);
935 view.setUint32(10, br, true); 935 view.setUint32(10, br, true);
936 view.setUint16(14, bc, true); 936 view.setUint16(14, bc, true);
937 return 16; 937 return 37;
938 }, 938 },
939 }; 939 };
940 selected.zoomed = true; 940 selected.zoomed = true;
@@ -1258,9 +1258,9 @@ async function verifySelectionShell(shell, html) {
1258 JSON.stringify([[1, 31, 2, 33, 5]]), 1258 JSON.stringify([[1, 31, 2, 33, 5]]),
1259 ); 1259 );
1260 check( 1260 check(
1261 'drag release sends the exact 16-byte request', 1261 'drag release sends the exact 37-byte request',
1262 Buffer.from(forward.sent[0]?.payload ?? []).toString('hex'), 1262 Buffer.from(forward.sent[0]?.payload ?? []).toString('hex'),
1263 '010000001f0000000200210000000500', 1263 '010000001f0000000200210000000500000000000000000000000000000000000000000000',
1264 ); 1264 );
1265 1265
1266 const reverse = makeTile(); 1266 const reverse = makeTile();
@@ -3139,11 +3139,11 @@ async function main() {
3139 3139
3140 const selectionReply = (id, status, text = '', historyRows = 0) => { 3140 const selectionReply = (id, status, text = '', historyRows = 0) => {
3141 const body = Buffer.from(text, 'utf8'); 3141 const body = Buffer.from(text, 'utf8');
3142 const reply = Buffer.alloc(9 + body.length); 3142 const reply = Buffer.alloc(41 + body.length);
3143 reply.writeUInt32LE(id, 0); 3143 reply.writeUInt32LE(id, 0);
3144 reply[4] = status; 3144 reply[4] = status;
3145 reply.writeUInt32LE(historyRows, 5); 3145 reply.writeUInt32LE(historyRows, 5);
3146 body.copy(reply, 9); 3146 body.copy(reply, 41);
3147 return reply; 3147 return reply;
3148 }; 3148 };
3149 const selectionText = () => Buffer.from(mem().subarray( 3149 const selectionText = () => Buffer.from(mem().subarray(
@@ -3151,12 +3151,12 @@ async function main() {
3151 e.mux_selection_ptr() + e.mux_selection_len(), 3151 e.mux_selection_ptr() + e.mux_selection_len(),
3152 )); 3152 ));
3153 const firstSelectionId = 0x78563412; 3153 const firstSelectionId = 0x78563412;
3154 check('selection request len', e.mux_selection_request(firstSelectionId, 0x44332211, 0x6655, 0xaa998877, 0xccbb), 16); 3154 check('selection request len', e.mux_selection_request(firstSelectionId, 0x44332211, 0x6655, 0xaa998877, 0xccbb), 37);
3155 check('selection request output len', e.mux_output_len(), 16); 3155 check('selection request output len', e.mux_output_len(), 37);
3156 check( 3156 check(
3157 'selection request golden bytes', 3157 'selection request golden bytes',
3158 outBytes().toString('hex'), 3158 outBytes().toString('hex'),
3159 '12345678112233445566778899aabbcc', 3159 '12345678112233445566778899aabbcc000000000000000000000000000000000000000000',
3160 ); 3160 );
3161 check('selection invalid anchor col', e.mux_selection_request(30, 1, 65536, 2, 3), -3); 3161 check('selection invalid anchor col', e.mux_selection_request(30, 1, 65536, 2, 3), -3);
3162 check('selection invalid anchor col clears output', e.mux_output_len(), 0); 3162 check('selection invalid anchor col clears output', e.mux_output_len(), 0);
@@ -3169,13 +3169,13 @@ async function main() {
3169 ); 3169 );
3170 3170
3171 const supersededSelectionId = 0x01020304; 3171 const supersededSelectionId = 0x01020304;
3172 check('selection superseded request', e.mux_selection_request(supersededSelectionId, 5, 6, 7, 8), 16); 3172 check('selection superseded request', e.mux_selection_request(supersededSelectionId, 5, 6, 7, 8), 37);
3173 const latestSelectionId = 0x10203040; 3173 const latestSelectionId = 0x10203040;
3174 check('selection latest request replaces pending', e.mux_selection_request(latestSelectionId, 9, 10, 11, 12), 16); 3174 check('selection latest request replaces pending', e.mux_selection_request(latestSelectionId, 9, 10, 11, 12), 37);
3175 check( 3175 check(
3176 'selection request survives enlarged input staging capacity', 3176 'selection request survives enlarged input staging capacity',
3177 outBytes().toString('hex'), 3177 outBytes().toString('hex'),
3178 '40302010090000000a000b0000000c00', 3178 '40302010090000000a000b0000000c00000000000000000000000000000000000000000000',
3179 ); 3179 );
3180 check( 3180 check(
3181 'selection stale reply ignored', 3181 'selection stale reply ignored',
@@ -3206,7 +3206,7 @@ async function main() {
3206 check('selection repeated reply clears getter', e.mux_selection_len(), 0); 3206 check('selection repeated reply clears getter', e.mux_selection_len(), 0);
3207 3207
3208 const highSelectionId = 0xfedcba98; 3208 const highSelectionId = 0xfedcba98;
3209 check('high-bit selection request', e.mux_selection_request(highSelectionId, 0, 0, 0, 0), 16); 3209 check('high-bit selection request', e.mux_selection_request(highSelectionId, 0, 0, 0, 0), 37);
3210 check( 3210 check(
3211 'high-bit selection reply action', 3211 'high-bit selection reply action',
3212 e.mux_client_frame(0x90, stage(selectionReply(highSelectionId, 0, 'high'))), 3212 e.mux_client_frame(0x90, stage(selectionReply(highSelectionId, 0, 'high'))),
@@ -3218,7 +3218,7 @@ async function main() {
3218 3218
3219 for (const [name, status] of [['invalid', 1], ['too large', 2], ['unavailable', 3]]) { 3219 for (const [name, status] of [['invalid', 1], ['too large', 2], ['unavailable', 3]]) {
3220 const id = 100 + status; 3220 const id = 100 + status;
3221 check(`selection ${name} request`, e.mux_selection_request(id, 0, 0, 0, 0), 16); 3221 check(`selection ${name} request`, e.mux_selection_request(id, 0, 0, 0, 0), 37);
3222 check( 3222 check(
3223 `selection ${name} action`, 3223 `selection ${name} action`,
3224 e.mux_client_frame(0x90, stage(selectionReply(id, status))), 3224 e.mux_client_frame(0x90, stage(selectionReply(id, status))),
@@ -3230,7 +3230,7 @@ async function main() {
3230 } 3230 }
3231 3231
3232 const populateSelection = (id) => { 3232 const populateSelection = (id) => {
3233 check(`selection ${id} request`, e.mux_selection_request(id, 0, 0, 0, 0), 16); 3233 check(`selection ${id} request`, e.mux_selection_request(id, 0, 0, 0, 0), 37);
3234 check( 3234 check(
3235 `selection ${id} reply`, 3235 `selection ${id} reply`,
3236 e.mux_client_frame(0x90, stage(selectionReply(id, 0, 'x'))), 3236 e.mux_client_frame(0x90, stage(selectionReply(id, 0, 'x'))),
@@ -3251,7 +3251,7 @@ async function main() {
3251 check('wide type after selection', e.mux_client_frame(0x100, 0), clientAction.ignored); 3251 check('wide type after selection', e.mux_client_frame(0x100, 0), clientAction.ignored);
3252 check('wide type clears selection getter', e.mux_selection_len(), 0); 3252 check('wide type clears selection getter', e.mux_selection_len(), 0);
3253 populateSelection(205); 3253 populateSelection(205);
3254 check('valid new request after selection', e.mux_selection_request(206, 1, 2, 3, 4), 16); 3254 check('valid new request after selection', e.mux_selection_request(206, 1, 2, 3, 4), 37);
3255 check('valid new request clears selection getter', e.mux_selection_len(), 0); 3255 check('valid new request clears selection getter', e.mux_selection_len(), 0);
3256 3256
3257 populateSelection(207); 3257 populateSelection(207);
@@ -3281,7 +3281,7 @@ async function main() {
3281 check('invalid selection request clears exposed id', e.mux_selection_id(), 0); 3281 check('invalid selection request clears exposed id', e.mux_selection_id(), 0);
3282 check('invalid selection request clears exposed status', e.mux_selection_status(), 3); 3282 check('invalid selection request clears exposed status', e.mux_selection_status(), 3);
3283 check('invalid selection request clears exposed len', e.mux_selection_len(), 0); 3283 check('invalid selection request clears exposed len', e.mux_selection_len(), 0);
3284 check('pending selection request before invalid request', e.mux_selection_request(213, 1, 2, 3, 4), 16); 3284 check('pending selection request before invalid request', e.mux_selection_request(213, 1, 2, 3, 4), 37);
3285 check('invalid selection request preserves pending correlation', e.mux_selection_request(214, 1, 2, 3, 65536), -3); 3285 check('invalid selection request preserves pending correlation', e.mux_selection_request(214, 1, 2, 3, 65536), -3);
3286 check( 3286 check(
3287 'matching reply after invalid request is accepted', 3287 'matching reply after invalid request is accepted',
@@ -3456,7 +3456,7 @@ async function main() {
3456 // The staging cap is pinned to the largest selection reply: its nine-byte 3456 // The staging cap is pinned to the largest selection reply: its nine-byte
3457 // correlation/status/history prefix plus the protocol's one-MiB text 3457 // correlation/status/history prefix plus the protocol's one-MiB text
3458 // maximum. 3458 // maximum.
3459 check('input cap', e.mux_input_cap(), 1024 * 1024 + 9); 3459 check('input cap', e.mux_input_cap(), 1024 * 1024 + 41);
3460 3460
3461 // --- the shell's ACTUAL call list, read out of mux.js --- 3461 // --- the shell's ACTUAL call list, read out of mux.js ---
3462 // Everything above pins exports this file happens to name. This pins 3462 // Everything above pins exports this file happens to name. This pins
@@ -3711,7 +3711,7 @@ async function main() {
3711 check('clipboard before lifecycle reset', e.mux_client_frame(0x8f, stage(clipboard)), clientAction.clipboard); 3711 check('clipboard before lifecycle reset', e.mux_client_frame(0x8f, stage(clipboard)), clientAction.clipboard);
3712 check('clipboard populated before lifecycle reset', e.mux_clipboard_len(), 4); 3712 check('clipboard populated before lifecycle reset', e.mux_clipboard_len(), 4);
3713 check('modes populated before lifecycle reset', e.mux_bracketed_paste(), 1); 3713 check('modes populated before lifecycle reset', e.mux_bracketed_paste(), 1);
3714 check('selection before lifecycle reset request', e.mux_selection_request(301, 0, 0, 0, 0), 16); 3714 check('selection before lifecycle reset request', e.mux_selection_request(301, 0, 0, 0, 0), 37);
3715 check('selection before lifecycle reset reply', e.mux_client_frame(0x90, stage(selectionReply(301, 0, 'reset'))), clientAction.selection); 3715 check('selection before lifecycle reset reply', e.mux_client_frame(0x90, stage(selectionReply(301, 0, 'reset'))), clientAction.selection);
3716 check('selection populated before lifecycle reset', e.mux_selection_len(), 5); 3716 check('selection populated before lifecycle reset', e.mux_selection_len(), 5);
3717 e.mux_deinit(); 3717 e.mux_deinit();