a73x

f2b711e7

fix: the cursor rests in the focused tile, whoever painted last

a73x   2026-08-24 11:32

Commit message
fix: the cursor rests in the focused tile, whoever painted last

Every painter ends by showing the cursor where its own stripe's
cursor sits, so the visible cursor belonged to whichever pump painted
last: focus on tile 1, a prompt redraw in tile 2, and the eye is told
2 is live while the keys go elsewhere. The zoomed wall never saw it —
one tile painted. The focused tile's paint now records its screen
cursor in Shared under paint_mu, and an unfocused paint's last act is
to put the cursor back there. A one-tile wall pays zero bytes, so the
plain client's stream is unchanged; a predicted overlay may still
park the cursor a cell ahead until the next focused paint, which is
the overlay's whole job.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

src/interact.zig
Old New
@@ -1345,6 +1345,14 @@ pub const Core = struct {
1345 paintOverlay(self.alloc, &self.overlay, self.rep.eng.cursorPos(), self.size, self.row_off, self.out_fd); 1345 paintOverlay(self.alloc, &self.overlay, self.rep.eng.cursorPos(), self.size, self.row_off, self.out_fd);
1346 } 1346 }
1347 1347
1348 /// The terminal cursor the painters end on — the same
1349 /// clampCursor-plus-row_off math. An overlay may park ahead; the
1350 /// replica cursor is the approximation.
1351 pub fn screenCursor(self: *Core) Engine.CursorPos {
1352 const c = paint_mod.clampCursor(self.rep.eng.cursorPos(), self.size);
1353 return .{ .x = c.x, .y = c.y + self.row_off };
1354 }
1355
1348 /// Repaint the rows a drag report changed, and only those. 1356 /// Repaint the rows a drag report changed, and only those.
1349 /// 1357 ///
1350 /// The anchor does not move, so a report that moved the active end 1358 /// The anchor does not move, so a report that moved the active end
src/wallview.zig
Old New
@@ -224,6 +224,12 @@ const Shared = struct {
224 /// drawn: a pump repainting on a frame must draw the focus the keyboard 224 /// drawn: a pump repainting on a frame must draw the focus the keyboard
225 /// has, not the one it had when the frame arrived. 225 /// has, not the one it had when the frame arrived.
226 sel: usize = 0, 226 sel: usize = 0,
227 /// Where the focused tile's last paint left the cursor on the terminal.
228 /// The cursor belongs to the focus, not to whichever pump painted last:
229 /// an unfocused paint's last act is to put the cursor back here. Under
230 /// `paint_mu` because the painters that read and write it already stand
231 /// there. Home until the focused tile has painted once.
232 cursor: Engine.CursorPos = .{ .x = 0, .y = 0 },
227 /// One label bar per tile when the wall holds more than one session; a 233 /// One label bar per tile when the wall holds more than one session; a
228 /// one-tile wall owns every row and draws no bar. Set by `relayout`, so 234 /// one-tile wall owns every row and draws no bar. Set by `relayout`, so
229 /// `viewRows` and `core.row_off` agree with what is on the screen. 235 /// `viewRows` and `core.row_off` agree with what is on the screen.
@@ -249,6 +255,11 @@ const Tile = struct {
249 /// This tile's place in the wall: what the focus and the `1`-`9` 255 /// This tile's place in the wall: what the focus and the `1`-`9`
250 /// jump are indices into. 256 /// jump are indices into.
251 idx: usize, 257 idx: usize,
258 /// This tile's interaction core, set by its OWN pump thread once the
259 /// core exists and dereferenced only inside that pump's paint-end hook.
260 /// No lifetime beyond the pump's scope: the core is freed before the
261 /// pump returns, and no other thread reads this.
262 core: ?*interact.Core = null,
252 /// The state its label last narrated. Owned by the pump but stored 263 /// The state its label last narrated. Owned by the pump but stored
253 /// here (under `paint_mu`) because the KEYBOARD repaints this bar too, 264 /// here (under `paint_mu`) because the KEYBOARD repaints this bar too,
254 /// when the focus moves, and it has no other way to know it. 265 /// when the focus moves, and it has no other way to know it.
@@ -539,7 +550,18 @@ fn tilePaintBegin(ctx: ?*anyopaque) bool {
539 550
540 fn tilePaintEnd(ctx: ?*anyopaque) void { 551 fn tilePaintEnd(ctx: ?*anyopaque) void {
541 const t: *Tile = @ptrCast(@alignCast(ctx.?)); 552 const t: *Tile = @ptrCast(@alignCast(ctx.?));
542 t.shared.paint_mu.unlock(); 553 defer t.shared.paint_mu.unlock();
554 // Before releasing the terminal: the cursor belongs to the FOCUSED tile.
555 // A focused paint records where its cursor landed; an unfocused paint's
556 // last act is to put the cursor back there, so a redraw in tile 2 cannot
557 // steal the eye while the keys go to tile 1.
558 if (t.idx == t.shared.sel) {
559 if (t.core) |core| t.shared.cursor = core.screenCursor();
560 } else {
561 var cbuf: [16]u8 = undefined;
562 const cup = std.fmt.bufPrint(&cbuf, "\x1b[{d};{d}H", .{ t.shared.cursor.y + 1, t.shared.cursor.x + 1 }) catch return;
563 proto.writeAllFd(t.shared.out_fd, cup) catch {};
564 }
543 } 565 }
544 566
545 /// The one place a wall tile puts an attach on the wire. A tile the user 567 /// The one place a wall tile puts an attach on the wire. A tile the user
@@ -922,6 +944,9 @@ fn pumpTile(t: *Tile) void {
922 // Where this Core's paints land: this tile's rect. The sink admits a 944 // Where this Core's paints land: this tile's rect. The sink admits a
923 // paint whenever the tile has not been forgotten; see `tilePaintBegin`. 945 // paint whenever the tile has not been forgotten; see `tilePaintBegin`.
924 core.sink = .{ .ctx = t, .begin = tilePaintBegin, .end = tilePaintEnd }; 946 core.sink = .{ .ctx = t, .begin = tilePaintBegin, .end = tilePaintEnd };
947 // The paint-end hook reads the core's screen cursor; only this pump
948 // thread dereferences it, and the core outlives the pump.
949 t.core = &core;
925 // This thread does not own the exit and cannot print the stats line — 950 // This thread does not own the exit and cannot print the stats line —
926 // see `Shared.stats`. 951 // see `Shared.stats`.
927 core.owns_stats = false; 952 core.owns_stats = false;
@@ -3361,3 +3386,50 @@ test "a view tile's attach makes no size claim; a tile the user asked for does"
3361 try std.testing.expectEqual(@as(u16, 24), entry_req.rows); 3386 try std.testing.expectEqual(@as(u16, 24), entry_req.rows);
3362 try std.testing.expect(!t.resize_pending.load(.acquire)); 3387 try std.testing.expect(!t.resize_pending.load(.acquire));
3363 } 3388 }
3389
3390 test "the cursor sleeps in the focused tile, whoever painted last" {
3391 // Every painter ends by showing the cursor where its own stripe sits, so
3392 // without an owner the visible cursor lands on whichever pump painted
3393 // last. The fix: a focused paint records its screen cursor in Shared, and
3394 // an unfocused paint's last act is to put the cursor back there. A
3395 // focused tile with no core (the unit-test fixture) records nothing and
3396 // restores nothing — the core is what knows the cursor.
3397 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3398 defer std.posix.close(p[0]);
3399 defer std.posix.close(p[1]);
3400 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
3401 var tiles: [2]Tile = undefined;
3402 tiles[0] = Tile{
3403 .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" },
3404 .stripe = .{ .top = 0, .rows = 12 },
3405 .shared = &shared,
3406 .idx = 0,
3407 .wake_r = -1,
3408 .wake_w = -1,
3409 };
3410 tiles[1] = Tile{
3411 .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" },
3412 .stripe = .{ .top = 12, .rows = 12 },
3413 .shared = &shared,
3414 .idx = 1,
3415 .wake_r = -1,
3416 .wake_w = -1,
3417 };
3418 shared.sel = 0;
3419 shared.cursor = .{ .x = 3, .y = 5 };
3420 var buf: [512]u8 = undefined;
3421
3422 // An UNFOCUSED paint (tile 1, core null) ends by restoring the shared
3423 // cursor — exactly the CUP for the focused tile's stored position, and
3424 // nothing else.
3425 _ = tilePaintBegin(&tiles[1]);
3426 tilePaintEnd(&tiles[1]);
3427 try std.testing.expectEqualStrings("\x1b[6;4H", readAvail(p[0], &buf));
3428
3429 // A FOCUSED paint (tile 0, core null) records nothing and restores
3430 // nothing: no core means the shared cursor is left untouched, and the
3431 // pipe receives no new bytes.
3432 _ = tilePaintBegin(&tiles[0]);
3433 tilePaintEnd(&tiles[0]);
3434 try std.testing.expectEqual(@as(usize, 0), readAvail(p[0], &buf).len);
3435 }
test/e2e.sh
Old New
@@ -419,6 +419,8 @@ D47PID=""
419 SOCK48="${TMPDIR:-/tmp}/muxd-e2e-agentfwd-$$.sock" 419 SOCK48="${TMPDIR:-/tmp}/muxd-e2e-agentfwd-$$.sock"
420 SOCK51="${TMPDIR:-/tmp}/muxd-e2e-inband-$$.sock" 420 SOCK51="${TMPDIR:-/tmp}/muxd-e2e-inband-$$.sock"
421 D51PID="" 421 D51PID=""
422 SOCK52="${TMPDIR:-/tmp}/muxd-e2e-cursor-$$.sock"
423 D52PID=""
422 AGENT48="${TMPDIR:-/tmp}/mux-e2e-agent-$$.sock" 424 AGENT48="${TMPDIR:-/tmp}/mux-e2e-agent-$$.sock"
423 AGENT48PID="" 425 AGENT48PID=""
424 AGENT48KEY="${TMPDIR:-/tmp}/mux-e2e-agentkey-$$" 426 AGENT48KEY="${TMPDIR:-/tmp}/mux-e2e-agentkey-$$"
@@ -1261,6 +1263,7 @@ cleanup() {
1261 [ -n "$D42PID" ] && kill "$D42PID" 2>/dev/null || true 1263 [ -n "$D42PID" ] && kill "$D42PID" 2>/dev/null || true
1262 [ -n "$D43PID" ] && kill "$D43PID" 2>/dev/null || true 1264 [ -n "$D43PID" ] && kill "$D43PID" 2>/dev/null || true
1263 [ -n "${D51PID:-}" ] && kill "$D51PID" 2>/dev/null || true 1265 [ -n "${D51PID:-}" ] && kill "$D51PID" 2>/dev/null || true
1266 [ -n "${D52PID:-}" ] && kill "$D52PID" 2>/dev/null || true
1264 # The ssh-agents the forwarding legs start. Not mux processes and so not 1267 # The ssh-agents the forwarding legs start. Not mux processes and so not
1265 # the leak sweep's business, but they are daemons this file forked: left 1268 # the leak sweep's business, but they are daemons this file forked: left
1266 # alive they outlive the suite holding a private key, which is the one 1269 # alive they outlive the suite holding a private key, which is the one
@@ -1303,6 +1306,7 @@ cleanup() {
1303 [ -S "$SOCK48" ] && "$MUXD" stop --sock "$SOCK48" 2>/dev/null || true 1306 [ -S "$SOCK48" ] && "$MUXD" stop --sock "$SOCK48" 2>/dev/null || true
1304 [ -S "$SOCK49" ] && "$MUXD" stop --sock "$SOCK49" 2>/dev/null || true 1307 [ -S "$SOCK49" ] && "$MUXD" stop --sock "$SOCK49" 2>/dev/null || true
1305 [ -S "$SOCK50" ] && "$MUXD" stop --sock "$SOCK50" 2>/dev/null || true 1308 [ -S "$SOCK50" ] && "$MUXD" stop --sock "$SOCK50" 2>/dev/null || true
1309 [ -S "$SOCK52" ] && "$MUXD" stop --sock "$SOCK52" 2>/dev/null || true
1306 1310
1307 # ---- the leak sweep (hygiene kit, 6a) ---- 1311 # ---- the leak sweep (hygiene kit, 6a) ----
1308 # Here rather than at the bottom of the file, which `set -e` reaches only 1312 # Here rather than at the bottom of the file, which `set -e` reaches only
@@ -1525,6 +1529,12 @@ cleanup() {
1525 "$OUT.zssta" "$OUT.zsstb" "$OUT.zsstop" "$OUT.zswatch" 1529 "$OUT.zssta" "$OUT.zsstb" "$OUT.zsstop" "$OUT.zswatch"
1526 rm -f "$OUT.swcap" "$OUT.swcap.err" "$OUT.swpc" 1530 rm -f "$OUT.swcap" "$OUT.swcap.err" "$OUT.swpc"
1527 rm -f "$OUT.inband" "$OUT.inband.err" "$OUT.inband.log" "$OUT.inband.d" "$OUT.inbstop" 1531 rm -f "$OUT.inband" "$OUT.inband.err" "$OUT.inband.log" "$OUT.inband.d" "$OUT.inbstop"
1532 # The cursor-ownership leg: its daemon socket, the three session pipe
1533 # clients, the ptyclient capture and the stop witness.
1534 rm -f "$SOCK52" "$OUT.cu.d" \
1535 "$OUT.cua" "$OUT.cua.err" "$OUT.cub" "$OUT.cub.err" \
1536 "$OUT.cuc" "$OUT.cuc.err" \
1537 "$OUT.cucap" "$OUT.cucap.err" "$OUT.cupc" "$OUT.custop"
1528 rm -f "$SOCK50" "$OUT.wmse.d" "$OUT.wmsa" "$OUT.wmsa.err" "$OUT.wmsb" \ 1538 rm -f "$SOCK50" "$OUT.wmse.d" "$OUT.wmsa" "$OUT.wmsa.err" "$OUT.wmsb" \
1529 "$OUT.wmsb.err" "$OUT.wmcap" "$OUT.wmcap.err" "$OUT.wmpc" \ 1539 "$OUT.wmsb.err" "$OUT.wmcap" "$OUT.wmcap.err" "$OUT.wmpc" \
1530 "$OUT.wmcap2" "$OUT.wmcap2.err" "$OUT.wmpc2" "$OUT.wmfa" "$OUT.wmfb" \ 1540 "$OUT.wmcap2" "$OUT.wmcap2.err" "$OUT.wmpc2" "$OUT.wmfa" "$OUT.wmfb" \
@@ -7263,6 +7273,76 @@ wait_pid_gone "$D42PID" "agent-mute: the session ended and the daemon should fol
7263 D42PID="" 7273 D42PID=""
7264 ok "agent forwarding: a mute offerer is hung up on, then no longer offered (${MUTE2MS}ms)" 7274 ok "agent forwarding: a mute offerer is hung up on, then no longer offered (${MUTE2MS}ms)"
7265 7275
7276 # --- the cursor rests in the focused tile, whoever painted last ----------
7277 #
7278 # Every painter ends by showing the cursor where its own stripe sits, so
7279 # without an owner the visible cursor lands on whichever pump painted last:
7280 # focus on tile 1, a prompt redraw in tile 2, and the eye is told 2 is live
7281 # while the keys go elsewhere. The focused tile's paint now records its
7282 # screen cursor in Shared, and an unfocused paint's last act is to put the
7283 # cursor back there. This leg focuses tile 3 of a three-tile wall and
7284 # asserts the terminal's final cursor position is in tile 3's stripe —
7285 # deterministic, because every paint now ends at the focused tile's cursor.
7286 #
7287 # Three sessions on a daemon of its own, each marked by its shell before the
7288 # wall attaches (the shell-expanded marker trick: a hit is the shell's work,
7289 # never an echo of anything typed here). The ptyclient wall focuses tile 3,
7290 # settles, and detaches; the capture's last cursor-position escape is the
7291 # witness.
7292 "$MUXD" run --sock "$SOCK52" --shell /bin/sh > "$OUT.cu.d" 2>&1 &
7293 D52PID=$!
7294 wait_sock "$SOCK52" "$OUT.cu.d" "cursor-ownership daemon never bound"
7295
7296 pipe_mux "$OUT.cua" "$OUT.cua.err" timeout 40 "$MUX" --sock "$SOCK52" --session a
7297 pipe_send 'printf "ma-%%s\\n" pin\n'
7298 await_out "$OUT.cua" "ma-pin" "cursor: session a's marker never reached the client"
7299 pipe_detach
7300 wait_grid "$SOCK52" "ma-pin" "cursor: session a's marker" a
7301 pipe_mux "$OUT.cub" "$OUT.cub.err" timeout 40 "$MUX" --sock "$SOCK52" --session b
7302 pipe_send 'printf "mb-%%s\\n" pin\n'
7303 await_out "$OUT.cub" "mb-pin" "cursor: session b's marker never reached the client"
7304 pipe_detach
7305 wait_grid "$SOCK52" "mb-pin" "cursor: session b's marker" b
7306 pipe_mux "$OUT.cuc" "$OUT.cuc.err" timeout 40 "$MUX" --sock "$SOCK52" --session c
7307 pipe_send 'printf "mc-%%s\\n" pin\n'
7308 await_out "$OUT.cuc" "mc-pin" "cursor: session c's marker never reached the client"
7309 pipe_detach
7310 wait_grid "$SOCK52" "mc-pin" "cursor: session c's marker" c
7311
7312 set +e
7313 timeout 40 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.cucap" --err "$OUT.cucap.err" -- \
7314 "$MUX" wall --sock "$SOCK52#a" "--sock $SOCK52#b" "--sock $SOCK52#c" > "$OUT.cupc" 2>&1 <<'EOF'
7315 expect mc-pin 20000
7316 settle 500 15000
7317 send \x1c3
7318 settle 500 15000
7319 send \x1cd
7320 waitexit 10000
7321 EOF
7322 RC=$?
7323 set -e
7324 [ "$RC" -eq 0 ] || {
7325 echo "e2e FAIL: cursor-ownership: ptyclient leg exited $RC:"
7326 cat "$OUT.cupc" "$OUT.cucap.err"; exit 1; }
7327 # Rows 21-30 are tile 3's stripe (3 tiles on 30 rows: stripes at 1/11/21).
7328 # The focused tile's paint records its cursor; an unfocused paint's last act
7329 # is a bare CUP putting it back there, with no show of its own. So the
7330 # terminal's final cursor position — the last CUP in the capture, show or
7331 # none — rests in tile 3's stripe whichever pump painted last. The last
7332 # CUP+show would be the last painter's OWN cursor (racy); the last CUP of
7333 # any kind is the focused tile's, and that is what the fix owns.
7334 _cup_re=$'\x1b\\[[0-9][0-9]*;[0-9][0-9]*H'
7335 _last_cup=$(grep -ao "$_cup_re" "$OUT.cucap" | tail -1)
7336 # _last_cup is ESC[<row>;<col>H; peel the ESC[ prefix and the ;colH suffix.
7337 _cur_row=${_last_cup#??}
7338 _cur_row=${_cur_row%%;*}
7339 [ -n "$_cur_row" ] && [ "$_cur_row" -ge 21 ] && [ "$_cur_row" -le 30 ] || {
7340 echo "e2e FAIL: cursor-ownership: the last CUP was '$_last_cup' (row ${_cur_row:-none}), want tile 3's stripe (rows 21-30):"
7341 cat "$OUT.cucap"; exit 1; }
7342 assert_stopped "$SOCK52" "$D52PID" "cursor-ownership" "$OUT.custop"
7343 D52PID=""
7344 ok "the cursor rests in the focused tile, whichever pump painted last"
7345
7266 # The long-lived daemon has served every scenario that wanted it; stop it 7346 # The long-lived daemon has served every scenario that wanted it; stop it
7267 # NOW so its allocator verdict is written while the suite is still running 7347 # NOW so its allocator verdict is written while the suite is still running
7268 # and can say so. SIGTERM runs the clean-shutdown path, so the defer chain 7348 # and can say so. SIGTERM runs the clean-shutdown path, so the defer chain
@@ -7408,8 +7488,8 @@ DPID=""
7408 # it is the one leg that holds an ssh-agent under SIGSTOP, and a trap that 7488 # it is the one leg that holds an ssh-agent under SIGSTOP, and a trap that
7409 # has to CONT before it kills is cheaper to reason about with nothing 7489 # has to CONT before it kills is cheaper to reason about with nothing
7410 # after it. 7490 # after it.
7411 [ "$OK_COUNT" = "61" ] || { 7491 [ "$OK_COUNT" = "62" ] || {
7412 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 61 —" 7492 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 62 —"
7413 echo " a scenario was added (update the pin) or silently lost" 7493 echo " a scenario was added (update the pin) or silently lost"
7414 exit 1 7494 exit 1
7415 } 7495 }
@@ -7417,4 +7497,4 @@ DPID=""
7417 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 37" 7497 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 37"
7418 exit 1 7498 exit 1
7419 } 7499 }
7420 echo "e2e OK (60 scenarios, 37 convergence points)" 7500 echo "e2e OK (61 scenarios, 37 convergence points)"