a73x

a1b93332

fix: preserve native selection while scrolling history

a73x   2026-09-06 09:54

Commit message
fix: preserve native selection while scrolling history

RETRO.md
Old New
@@ -1320,3 +1320,46 @@ The localhost page is http://127.0.0.1:18776/wheel-scrolling/ ; publishing is no
1320 longer a pending action under the clarified workflow. Owned recording/browser 1320 longer a pending action under the clarified workflow. Owned recording/browser
1321 and compositor fixtures are stopped after validation; the local review server 1321 and compositor fixtures are stopped after validation; the local review server
1322 remains pending acceptance. 1322 remains pending acceptance.
1323
1324
1325 ### Wheel selection feedback — 2026-09-06
1326
1327 Hands-on feedback corrected the initial cancellation policy: a completed
1328 highlight must follow selected text through viewport scrolling, including
1329 scrolling away and back. The pump already distinguishes absolute source rows
1330 from the viewport origin. Removed the three viewport-only invalidations and
1331 made input invalidation explicit in the mailbox; no new production state,
1332 modules or dependencies were needed (four fewer production lines).
1333
1334 Luna implemented the pump change; Terra independently reviewed freshness and
1335 input/resize/reconnect guards. Root corrected the regression fixture's history
1336 setup and verified actual pixels and independent Wayland clipboard reads.
1337 Held selection keeps its original anchor, with pointer motion/release resolving
1338 the endpoint in the current viewport. This intentionally permits extending a
1339 selection through scrolled history.
1340
1341 The user requested hands-on review instead of a new demo recording for this
1342 feedback fix. The existing recording is retained and labelled as preceding this
1343 change. Demo acceptance remains pending.
1344
1345 Lesson for the next mouse slice: distinguish source validity from viewport
1346 position; reuse the existing absolute selection coordinates. Keep the required
1347 Shift+drag override in that slice's acceptance criteria.
1348
1349 Validation: client/native unit tests and the full native integration gate pass.
1350 Real NVIDIA Wayland wheel/selection checks pass all ten checkpoints at the
1351 retained scale transitions; the separate ten-checkpoint copy suite also passes,
1352 including stale output, delayed replies, resize and detach. Logs are retained as
1353 `dist/wheel-scrolling/selection-preserve-*.log`. The first unit attempt exposed
1354 an incomplete test setup after returning live; restoring history fixed the
1355 fixture, and the final run passed. The isolated test compositor was stopped.
1356 This bounded feedback fix makes no new performance or macOS claim.
1357
1358 Repository `make check` passed inside the full `make ci` attempt. That attempt
1359 stopped in the existing terminal-client `agent-nested` exit wait (10 seconds),
1360 after the preceding e2e groups passed. The targeted `10_agent` rerun passed all
1361 six scenarios without source changes. Retain the failure rather than claiming
1362 a fully green CI run; the later e2e groups did not run in this attempt. Next
1363 trigger for the agent-test owner: investigate if this exit-wait timeout recurs.
1364
1365 The remaining agent and throughput gates passed in the follow-up run.
docs/demos/native-wheel-scrolling.html
Old New
@@ -18,8 +18,9 @@ video{width:100%;display:block;background:#101114;border-radius:10px;margin:22px
18 <p class="status">Implemented · Final checks passed · Demo acceptance pending</p> 18 <p class="status">Implemented · Final checks passed · Demo acceptance pending</p>
19 <video controls playsinline preload="metadata" poster="preview.png"><source src="demo.mp4" type="video/mp4">Your browser can <a href="demo.mp4">download the recording</a>.</video> 19 <video controls playsinline preload="metadata" poster="preview.png"><source src="demo.mp4" type="video/mp4">Your browser can <a href="demo.mp4">download the recording</a>.</video>
20 <p class="muted">27-second continuous recording · NVIDIA RTX 3080 · Wayland at 200% scale · <a href="demo.mp4">Open video</a></p> 20 <p class="muted">27-second continuous recording · NVIDIA RTX 3080 · Wayland at 200% scale · <a href="demo.mp4">Open video</a></p>
21 <div class="cards"><div class="card"><strong>Shell history</strong>Three rows per notch. Scroll down to live output, or type to return immediately.</div><div class="card"><strong>Application input</strong>Alternate-screen arrows honor cursor-key mode; mouse-aware apps receive the encoding they request.</div><div class="card"><strong>History selection</strong>Drag to copy the displayed history rows. Scrolling that pane cancels an active selection safely.</div></div> 21 <div class="cards"><div class="card"><strong>Shell history</strong>Three rows per notch. Scroll down to live output, or type to return immediately.</div><div class="card"><strong>Application input</strong>Alternate-screen arrows honor cursor-key mode; mouse-aware apps receive the encoding they request.</div><div class="card"><strong>History selection</strong>Drag to copy the displayed history rows. The highlight follows its text when you scroll, including away and back.</div></div>
22 <h2>What the recording shows</h2> 22 <h2>What the recording shows</h2>
23 <p class="muted">The recording below predates the selection-preservation fix. That fix is ready for hands-on review; no replacement recording was requested.</p>
23 <p>Three panes on two daemons. Scroll an unfocused shell pane, select a history row, read its text with a separate desktop clipboard client, return to live output, then scroll a real less process. Pointer motion, selection and wheel events go through the Wayland compositor. Setup commands use the ordinary SDL keyboard event path.</p> 24 <p>Three panes on two daemons. Scroll an unfocused shell pane, select a history row, read its text with a separate desktop clipboard client, return to live output, then scroll a real less process. Pointer motion, selection and wheel events go through the Wayland compositor. Setup commands use the ordinary SDL keyboard event path.</p>
24 <h2>Controls</h2> 25 <h2>Controls</h2>
25 <ul><li>Wheel over terminal content to scroll that pane without moving keyboard focus.</li><li>At a shell prompt, wheel up browses history and wheel down returns toward live output. Typing into that pane returns it to live.</li><li>Drag across displayed text and release to copy; Ctrl+Shift+C also copies the current selection.</li><li>Menus, headers, dividers and command mode consume wheel events. Horizontal wheel behavior remains outside this slice.</li></ul> 26 <ul><li>Wheel over terminal content to scroll that pane without moving keyboard focus.</li><li>At a shell prompt, wheel up browses history and wheel down returns toward live output. Typing into that pane returns it to live.</li><li>Drag across displayed text and release to copy; Ctrl+Shift+C also copies the current selection.</li><li>Menus, headers, dividers and command mode consume wheel events. Horizontal wheel behavior remains outside this slice.</li></ul>
docs/superpowers/plans/2026-09-06-native-wheel-scrolling.md
Old New
@@ -71,9 +71,10 @@ The earlier font-settings cleanup already supplies the useful opening refactor;
71 the current inspection found no additional cleanup needed before this feature. 71 the current inspection found no additional cleanup needed before this feature.
72 Application mouse encodings are checked against the 72 Application mouse encodings are checked against the
73 [xterm mouse protocol](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Mouse-Tracking). 73 [xterm mouse protocol](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Mouse-Tracking).
74 Selection on a pane whose viewport moves must cancel; a new selection in history 74 Selection stays attached to its source text when the viewport moves, including
75 must address the displayed history rows. Scrolling another pane must preserve 75 scrolling offscreen and back or returning through live view. A held drag keeps
76 the selected pane's text. These are part of wheel acceptance, not later polish. 76 its original text anchor and extends using the current pane coordinates. Source
77 changes still invalidate selection. These are part of wheel acceptance, not later polish.
77 78
78 `frame.zig` owns SDL events and logical-to-framebuffer conversion through 79 `frame.zig` owns SDL events and logical-to-framebuffer conversion through
79 `physicalPoint`. `interaction.Controller` owns pane hit-testing and modal policy; 80 `physicalPoint`. `interaction.Controller` owns pane hit-testing and modal policy;
@@ -234,3 +235,25 @@ Local review page: http://127.0.0.1:18776/wheel-scrolling/ . The loopback server
234 is recorded in `dist/wheel-scrolling/server.json`; stop it after acceptance. 235 is recorded in `dist/wheel-scrolling/server.json`; stop it after acceptance.
235 No remote publication is required or pending. Publishing is opt-in at the user's 236 No remote publication is required or pending. Publishing is opt-in at the user's
236 request. Sources, recording and all validation logs remain in the worktree. 237 request. Sources, recording and all validation logs remain in the worktree.
238
239
240 ### Hands-on feedback: keep the highlight while scrolling
241
242 The user expects a completed selection to remain attached to its source text,
243 including scrolling it offscreen and back. `Pump` now preserves selection
244 freshness through local viewport requests, history replies and return-to-live.
245 Input explicitly invalidates selection at the mailbox boundary; terminal output,
246 modes, geometry and attachment changes retain their existing guards. Existing
247 absolute selection rows and `Live.view_origin` already provide correct painting
248 and hit testing, so no new production state or abstraction is needed.
249
250 A held drag retains its original anchor; subsequent motion or release resolves
251 its endpoint against the current viewport. The real Wayland regression covers
252 both a release on the moved source row and extending across newly visible rows.
253 The user requested hands-on review instead of a new demo recording for this fix.
254
255 Client/native units, the full native integration suite and real NVIDIA Wayland
256 wheel/copy regressions passed. The repository check gate passed; full CI stopped
257 on an existing nested-agent exit timeout, which passed on targeted rerun without
258 changes. Full-run CI success is therefore not claimed for this feedback commit.
259 Evidence: `dist/wheel-scrolling/selection-preserve-*.log`; details in `RETRO.md`.
src/client/session_pump.zig
Old New
@@ -212,8 +212,6 @@ pub const Pump = struct {
212 self.scroll_rows = 0; 212 self.scroll_rows = 0;
213 self.history_dirty = false; 213 self.history_dirty = false;
214 self.history_revision +%= 1; 214 self.history_revision +%= 1;
215 self.selection_revision +%= 1;
216 self.invalidateSelectionLocked();
217 if (self.history) |g| g.deinit(); 215 if (self.history) |g| g.deinit();
218 self.history = null; 216 self.history = null;
219 } 217 }
@@ -254,8 +252,6 @@ pub const Pump = struct {
254 if (self.history) |old| old.deinit(); 252 if (self.history) |old| old.deinit();
255 self.history = view; 253 self.history = view;
256 self.history_start = origin; 254 self.history_start = origin;
257 self.selection_revision +%= 1;
258 self.invalidateSelectionLocked();
259 self.history_version = .{ .seq = self.replica.last_seq, .history_rows = self.replica.history_rows, .epoch = self.replica.session_epoch, .revision = self.selection_revision }; 255 self.history_version = .{ .seq = self.replica.last_seq, .history_rows = self.replica.history_rows, .epoch = self.replica.session_epoch, .revision = self.selection_revision };
260 return .changed; 256 return .changed;
261 } 257 }
@@ -430,6 +426,8 @@ pub const Pump = struct {
430 .input => |bytes| { 426 .input => |bytes| {
431 self.mu.lock(); 427 self.mu.lock();
432 const scrolled = self.scroll_rows != 0; 428 const scrolled = self.scroll_rows != 0;
429 self.selection_revision +%= 1;
430 self.invalidateSelectionLocked();
433 self.returnLiveLocked(); 431 self.returnLiveLocked();
434 self.mu.unlock(); 432 self.mu.unlock();
435 if (scrolled) self.wake(); 433 if (scrolled) self.wake();
@@ -476,8 +474,6 @@ pub const Pump = struct {
476 self.scroll_rows = next; 474 self.scroll_rows = next;
477 self.history_revision +%= 1; 475 self.history_revision +%= 1;
478 self.history_dirty = true; 476 self.history_dirty = true;
479 self.selection_revision +%= 1;
480 self.invalidateSelectionLocked();
481 } 477 }
482 } 478 }
483 self.mu.unlock(); 479 self.mu.unlock();
@@ -1349,7 +1345,10 @@ fn selectionTestBegin(p: *Pump, req: SelectionRequest) ?[proto.selection_req_len
1349 fn selectionTestReply(p: *Pump, id: u32, text: []const u8) !void { 1345 fn selectionTestReply(p: *Pump, id: u32, text: []const u8) !void {
1350 var payload: std.ArrayList(u8) = .empty; 1346 var payload: std.ArrayList(u8) = .empty;
1351 defer payload.deinit(std.testing.allocator); 1347 defer payload.deinit(std.testing.allocator);
1352 try proto.encodeSelectionReply(&payload, std.testing.allocator, id, .ok, 0, text); 1348 p.mu.lock();
1349 const history_rows = p.replica.history_rows;
1350 p.mu.unlock();
1351 try proto.encodeSelectionReply(&payload, std.testing.allocator, id, .ok, history_rows, text);
1353 _ = try p.onFrame(.selection_reply, payload.items); 1352 _ = try p.onFrame(.selection_reply, payload.items);
1354 @memset(payload.items, 0xaa); // The published result must own its text. 1353 @memset(payload.items, 0xaa); // The published result must own its text.
1355 } 1354 }
@@ -1431,7 +1430,9 @@ test "wheel history tombstones, refresh, resize and timeout preserve the live re
1431 var wire = try Wire.init(std.testing.allocator, &tr); 1430 var wire = try Wire.init(std.testing.allocator, &tr);
1432 defer wire.deinit(); 1431 defer wire.deinit();
1433 const up: Wheel = .{ .notches = 1, .col = 2, .row = 1, .pixel_x = 22, .pixel_y = 18 }; 1432 const up: Wheel = .{ .notches = 1, .col = 2, .row = 1, .pixel_x = 22, .pixel_y = 18 };
1433 const source_version = p.selectionVersionLocked();
1434 try p.routeWheel(&wire, up); 1434 try p.routeWheel(&wire, up);
1435 try std.testing.expect(p.selectionFresh(source_version));
1435 try p.requestHistory(&wire); 1436 try p.requestHistory(&wire);
1436 const first = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; 1437 const first = (try proto.readFrame(std.testing.allocator, outgoing[0])).?;
1437 defer first.deinit(std.testing.allocator); 1438 defer first.deinit(std.testing.allocator);
@@ -1443,7 +1444,9 @@ test "wheel history tombstones, refresh, resize and timeout preserve the live re
1443 const typed = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; 1444 const typed = (try proto.readFrame(std.testing.allocator, outgoing[0])).?;
1444 defer typed.deinit(std.testing.allocator); 1445 defer typed.deinit(std.testing.allocator);
1445 try std.testing.expectEqualStrings("live", typed.payload); 1446 try std.testing.expectEqualStrings("live", typed.payload);
1447 try std.testing.expect(!p.selectionFresh(source_version));
1446 try std.testing.expect(p.history_pending != null); // Keep the cancelled on-wire request. 1448 try std.testing.expect(p.history_pending != null); // Keep the cancelled on-wire request.
1449 const after_input = p.selectionVersionLocked();
1447 try p.routeWheel(&wire, up); 1450 try p.routeWheel(&wire, up);
1448 try p.requestHistory(&wire); 1451 try p.requestHistory(&wire);
1449 try std.testing.expectEqual(revision, p.history_pending.?.revision); 1452 try std.testing.expectEqual(revision, p.history_pending.?.revision);
@@ -1458,7 +1461,23 @@ test "wheel history tombstones, refresh, resize and timeout preserve the live re
1458 try std.testing.expectEqual(@as(u32, 17), p.viewOriginLocked()); 1461 try std.testing.expectEqual(@as(u32, 17), p.viewOriginLocked());
1459 try std.testing.expect(p.viewGridLocked() != p.grid); 1462 try std.testing.expect(p.viewGridLocked() != p.grid);
1460 try std.testing.expectEqual(@as(u16, 3), p.viewGridLocked().cursor.y); 1463 try std.testing.expectEqual(@as(u16, 3), p.viewGridLocked().cursor.y);
1461 try std.testing.expect(p.selectionFresh(p.selectionVersionLocked())); 1464 try std.testing.expect(p.selectionFresh(after_input));
1465 const history_version = p.selectionVersionLocked();
1466 _ = selectionTestBegin(p, try selectionTestQueue(p, 41)).?;
1467 p.mu.lock();
1468 p.returnLiveLocked();
1469 p.mu.unlock();
1470 try selectionTestReply(p, 41, "kept through viewport");
1471 const kept = p.takeSelection().?;
1472 defer std.testing.allocator.free(kept.text);
1473 try std.testing.expectEqualStrings("kept through viewport", kept.text);
1474 try std.testing.expectEqualDeep(history_version, kept.version);
1475 try p.routeWheel(&wire, up);
1476 try p.requestHistory(&wire);
1477 const restored = (try proto.readFrame(std.testing.allocator, outgoing[0])).?;
1478 defer restored.deinit(std.testing.allocator);
1479 _ = try p.onFrame(.scrollback_chunk, &chunk);
1480 try std.testing.expect(p.selectionFresh(history_version));
1462 var newer = testSnapshot(); 1481 var newer = testSnapshot();
1463 proto.writeSnapshotPrefix(newer[0..proto.snapshot_prefix_len], .{ .seq = 38, .history_rows = 20, .cols = 11, .rows = 3, .epoch = 93 }); 1482 proto.writeSnapshotPrefix(newer[0..proto.snapshot_prefix_len], .{ .seq = 38, .history_rows = 20, .cols = 11, .rows = 3, .epoch = 93 });
1464 _ = try p.onFrame(.snapshot, &newer); 1483 _ = try p.onFrame(.snapshot, &newer);
test/native_wheel.py
Old New
@@ -106,21 +106,68 @@ def shell_history(rig, panes):
106 rig.wheel(target, 4) 106 rig.wheel(target, 4)
107 state = rig.wait_state(lambda s: first_number(by_id(s)[target]['painted_text']) == baseline - 12) 107 state = rig.wait_state(lambda s: first_number(by_id(s)[target]['painted_text']) == baseline - 12)
108 first = by_id(state)[target]['painted_text'].splitlines()[0] 108 first = by_id(state)[target]['painted_text'].splitlines()[0]
109 background = cell_background(rig, state, target, 1, 0)
109 rig.select(target, (0, 0), (11, 0)) 110 rig.select(target, (0, 0), (11, 0))
110 rig.copied(first[:12]) 111 rig.copied(first[:12])
112 eventually(lambda: cell_background(rig, state, target, 1, 0) != background,
113 'history selection did not paint')
111 rig.wheel(target, 1) 114 rig.wheel(target, 1)
112 rig.wait_state(lambda s: first_number(by_id(s)[target]['painted_text']) == baseline - 15) 115 rig.wait_state(lambda s: first_number(by_id(s)[target]['painted_text']) == baseline - 15)
116 eventually(lambda: cell_background(rig, state, target, 1, 3) != background and
117 cell_background(rig, state, target, 1, 0) == background,
118 'highlight did not follow its text three rows down')
119 rig.wheel(target, 1000)
120 rig.wait_state(lambda s: 'HISTORY-0000' in by_id(s)[target]['painted_text'])
121 eventually(lambda: all(cell_background(rig, state, target, 1, row) == background
122 for row in range(by_id(state)[target]['rows'])),
123 'offscreen selection left a highlight in the viewport')
124 rig.wheel(target, -1000)
125 rig.wait_state(lambda s: by_id(s)[target]['painted_text'] == live[target])
126 rig.wheel(target, 4)
127 rig.wait_state(lambda s: first_number(by_id(s)[target]['painted_text']) == baseline - 12)
128 eventually(lambda: cell_background(rig, state, target, 1, 0) != background,
129 'highlight did not return with selected history text')
130 rig.key('copy')
113 rig.unchanged(first[:12]) 131 rig.unchanged(first[:12])
132 rig.ok('released history highlight follows text, survives offscreen/live round trips and remains copyable')
133
134 # Keep the original anchor while a held drag moves through history. Release
135 # at its new screen row to copy the same source row, then repeat with an
136 # explicit motion to extend the selection across the newly visible rows.
137 held = by_id(rig.state())[target]['painted_text'].splitlines()[1]
138 rig.select(target, (0, 1), (11, 1), release=False)
139 rig.wheel(target, 1, col=11, row=1)
140 rig.wait_state(lambda s: first_number(by_id(s)[target]['painted_text']) == baseline - 15)
141 rig.send('mouseup:' + rig.cell_point(state, target, 11, 4))
142 rig.copied(held[:12])
114 rig.select(target, (0, 0), (11, 0), release=False) 143 rig.select(target, (0, 0), (11, 0), release=False)
115 held_state = rig.state() 144 rig.wheel(target, 1, col=11, row=0)
145 moved = rig.wait_state(lambda s: first_number(by_id(s)[target]['painted_text']) == baseline - 18)
146 lines = by_id(moved)[target]['painted_text'].splitlines()
147 rig.send('mousemove:' + rig.cell_point(moved, target, 11, 4))
148 rig.key('copy')
149 rig.copied(lines[3] + '\n' + lines[4][:12])
150 rig.send('mouseup:' + rig.cell_point(moved, target, 11, 4))
151 rig.copied(lines[3] + '\n' + lines[4][:12])
152 rig.ok('held drag retains its text anchor and extends using the scrolled pane coordinates')
153
154 rig.wheel(target, -1000)
155 rig.wait_state(lambda s: by_id(s)[target]['painted_text'] == live[target])
156 rig.select(target, (0, 0), (11, 0))
157 rig.copied(live[target].splitlines()[0][:12])
116 rig.wheel(target, 1) 158 rig.wheel(target, 1)
117 rig.wait_state(lambda s: first_number(by_id(s)[target]['painted_text']) == baseline - 18) 159 rig.wait_state(lambda s: first_number(by_id(s)[target]['painted_text']) == baseline - 3)
118 rig.send('mouseup:' + rig.cell_point(held_state, target, 11, 0)) 160 eventually(lambda: cell_background(rig, state, target, 1, 3) != background,
119 rig.unchanged(first[:12]) 161 'live selection disappeared on entering history')
120 rig.focus(target) 162 rig.wheel(target, -1)
163 rig.wait_state(lambda s: by_id(s)[target]['painted_text'] == live[target])
164 eventually(lambda: cell_background(rig, state, target, 1, 0) != background,
165 'live selection disappeared on returning from history')
121 rig.shell("printf 'RETURN-%s\\n' LIVE") 166 rig.shell("printf 'RETURN-%s\\n' LIVE")
122 rig.wait_state(lambda s: 'RETURN-LIVE' in by_id(s)[target]['painted_text']) 167 rig.wait_state(lambda s: 'RETURN-LIVE' in by_id(s)[target]['painted_text'])
123 rig.ok('history bounds, history selection/copy, held-drag cancellation and typing back to live') 168 eventually(lambda: cell_background(rig, state, target, 1, 0) == background,
169 'input failed to clear the selection')
170 rig.ok('live selection survives history navigation; typing returns live and clears it')
124 171
125 172
126 def raw_reader(rig, pane): 173 def raw_reader(rig, pane):