a73x

e6fff02f

fix: a tile's application reads mouse reports in its own coordinates

a73x   2026-09-03 05:20

Commit message
fix: a tile's application reads mouse reports in its own coordinates

A tile whose application had asked for the mouse got every SGR report in
the TERMINAL's numbering, so a pane that did not start at the screen's
origin sent its application rows and columns it did not have: on a live
wall a press at terminal row 60 reached a 37-row nvim and a fullscreen
Claude Code, and neither could select at all.

`interact.relocateReports` is now the one place a forwarded report is
spelled: the same filter runs on both sides of the `appMouse()` branch,
and the application's side re-emits each report with `row_off`/`col_off`
taken off, in the read's original order among the keys. A coordinate
outside the pane clamps to its edge, as a terminal reports a drag that
left its window; dropping would leave the application holding a button
the hand released over a neighbour. Pixel reports pass untouched, since
a cell origin cannot come off a pixel.

A wheel report became an event of its own kind so the filter could hand
it back for re-spelling; `dragReports` ignores it, which is where the
old "a wheel is never an event" guarantee lives now.

The two one-tile app-mouse legs were pinning the bug and now expect row
4 for a report on terminal row 5. The new leg stacks two tiles and
presses in the lower one, where terminal row 16 is the application's row
3 and `mux a status` says the stripe is 11 rows. Graded by mutation:
with the row translation capped at one row, only the stacked leg fails.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SakwJEwD9dXBoRP5kWbemW

docs/decisions.md
Old New
@@ -8072,3 +8072,59 @@ leg that a pty run under a fresh state dir leaves no layout behind.
8072 convergence points, up from what the old model needed, and the leg that 8072 convergence points, up from what the old model needed, and the leg that
8073 states the whole change in one sentence is "two walls on the same daemons 8073 states the whole change in one sentence is "two walls on the same daemons
8074 are two layouts; neither learns of the other's panes" in the hosts group. 8074 are two layouts; neither learns of the other's panes" in the hosts group.
8075
8076 ## 2026-09-03 — a tile's application reads mouse reports in its own coordinates
8077
8078 **The escape.** A tile whose application had asked for the mouse got every
8079 SGR report exactly as the terminal wrote it, in the terminal's numbering.
8080 `interact.Core.forward` gated on `appMouse()` and passed the bytes through,
8081 and every pane that did not start at the screen's origin sent its
8082 application rows and columns it did not have: on a live wall (foot,
8083 269x76, four tiles) a press at terminal row 60 reached an nvim of 37 rows
8084 and a fullscreen Claude Code the same way, and neither could select at
8085 all. Found by `strace -f -e trace=read,write` on the wall — the raw
8086 `\e[<0;90;60M` read from fd 0 was the byte string written to the tile's
8087 daemon socket. The same wall's remote tiles selected fine, which is what
8088 made it look like a version split: that daemon held no mouse bits for its
8089 sessions, so mux's own selection ran there, and the alt-screen "box stays
8090 put" behaviour was the tell.
8091
8092 **Why every gate was green.** Each app-mouse leg was a wall of ONE tile —
8093 `no_saved_tree`, a single `--sock` — and every unit test of the forward
8094 path was a pipe client or an origin Core. The mux-selection tests all sit
8095 at `drag_row_off`/`drag_col_off`, so the hit-test had the offset right
8096 from the start; only the hand-over path never met an offset. The working
8097 rule that N=1 and offset=0 are extra cases, never the baseline, was
8098 followed on one side of the `appMouse()` branch and not the other.
8099
8100 **The rule now.** `interact.relocateReports` is the one place a forwarded
8101 report is spelled: the Core runs the same `MouseFilter.feed` on both sides
8102 of the branch, and on the application's side re-emits each report with
8103 `row_off`/`col_off` taken off, in the read's original order among the
8104 keys. A wheel report became an event of its own kind (`Event.Kind.wheel`)
8105 so the filter could hand it back for re-spelling; `dragReports` ignores it,
8106 which is where the old "a wheel is never an event" guarantee lives now.
8107 Two choices in it:
8108
8109 - A coordinate outside the pane CLAMPS to the pane's edge rather than
8110 dropping the report. A real terminal reports a drag that left its window
8111 at the edge it left by, and dropping would leave the application holding
8112 a button the hand released over a neighbour. The edge is what the pane
8113 shows — the smaller of the grid and the clip — like `hitTest`.
8114 - Pixel reports (1016) pass untouched. A cell origin cannot come off a
8115 pixel, and a wrong translation is worse than none. Translating them
8116 needs the terminal's cell size, which the wall does not ask for; open.
8117
8118 The filter's hold now stays armed across the hand-over from selection to
8119 application, so a report split across two reads is whole for whichever
8120 side reads it; the reset survives only for the pipe client, whose reads
8121 hold no reports at all.
8122
8123 **What moved.** A wall on a terminal sits under its label bar, so the two
8124 one-tile app-mouse legs now expect row 4 for a report on terminal row 5 —
8125 they were pinning the bug. The new leg stacks two tiles and presses in the
8126 lower one: terminal row 16 reaches the application as row 3, and `mux a
8127 status` says the stripe is 11 rows so the arithmetic is the layout's, not
8128 the comment's. Graded by mutation: with the row translation capped at one
8129 row every older leg passes and only the stacked leg fails. The suite's
8130 scenario pin goes from 111 to 112.
src/tui/interact.zig
Old New
@@ -450,7 +450,10 @@ pub const MouseFilter = struct {
450 /// `button` is the SGR word verbatim: what a click MEANS differs between 450 /// `button` is the SGR word verbatim: what a click MEANS differs between
451 /// the wall's keyboard and a focused tile, so decoding is the driver's. 451 /// the wall's keyboard and a focused tile, so decoding is the driver's.
452 pub const Event = struct { 452 pub const Event = struct {
453 pub const Kind = enum { press, motion, release }; 453 /// `wheel` is a report a driver re-emits and never selects on: the
454 /// notch it means is already counted in `Out.wheel`, and a
455 /// selection that scrolled as it grew would be one nobody made.
456 pub const Kind = enum { press, motion, release, wheel };
454 457
455 kind: Kind, 458 kind: Kind,
456 button: u16, 459 button: u16,
@@ -543,14 +546,14 @@ pub const MouseFilter = struct {
543 return .{ .forward = out[0..kept], .wheel = wheel, .events = self.events[0..evs] }; 546 return .{ .forward = out[0..kept], .wheel = wheel, .events = self.events[0..evs] };
544 } 547 }
545 548
546 /// The event one complete SGR report means, or null for one that means 549 /// The event one complete SGR report means, or null for one this
547 /// nothing to a driver: a wheel report (already spent as a notch by 550 /// terminal malformed. A wheel report comes back as `.wheel`, its notch
548 /// `wheelNotches` — a driver seeing it here too would scroll and 551 /// already spent by `wheelNotches`: a driver that scrolls on the notch
549 /// select at once) or a report this terminal malformed. 552 /// must not also select on the event, and a driver that hands the
553 /// mouse to an application must still pass the wheel along.
550 fn decodeEvent(seq: []const u8) ?Event { 554 fn decodeEvent(seq: []const u8) ?Event {
551 var it = std.mem.splitScalar(u8, seq[3 .. seq.len - 1], ';'); 555 var it = std.mem.splitScalar(u8, seq[3 .. seq.len - 1], ';');
552 const button = std.fmt.parseInt(u16, it.first(), 10) catch return null; 556 const button = std.fmt.parseInt(u16, it.first(), 10) catch return null;
553 if (button & 0x40 != 0) return null;
554 const col = std.fmt.parseInt(u16, it.next() orelse return null, 10) catch return null; 557 const col = std.fmt.parseInt(u16, it.next() orelse return null, 10) catch return null;
555 const row = std.fmt.parseInt(u16, it.next() orelse return null, 10) catch return null; 558 const row = std.fmt.parseInt(u16, it.next() orelse return null, 10) catch return null;
556 // One-based on the wire. A zero is a terminal talking nonsense, and 559 // One-based on the wire. A zero is a terminal talking nonsense, and
@@ -562,6 +565,8 @@ pub const MouseFilter = struct {
562 // bits claim; only a press can also be a drag (bit 5). 565 // bits claim; only a press can also be a drag (bit 5).
563 .kind = if (seq[seq.len - 1] == 'm') 566 .kind = if (seq[seq.len - 1] == 'm')
564 .release 567 .release
568 else if (button & 0x40 != 0)
569 .wheel
565 else if (button & 0x20 != 0) 570 else if (button & 0x20 != 0)
566 .motion 571 .motion
567 else 572 else
@@ -1224,6 +1229,11 @@ pub const Core = struct {
1224 /// The mouse filter's scratch — sized to hold one read of stdin plus 1229 /// The mouse filter's scratch — sized to hold one read of stdin plus
1225 /// whatever a previous read left mid-report (see MouseFilter.feed). 1230 /// whatever a previous read left mid-report (see MouseFilter.feed).
1226 mouse_buf: [stdin_chunk + MouseFilter.max_held]u8 = undefined, 1231 mouse_buf: [stdin_chunk + MouseFilter.max_held]u8 = undefined,
1232 /// The read rebuilt with its reports in the pane's coordinates, for
1233 /// the application that owns the mouse. Sized like `mouse_buf`: a
1234 /// relocated report is never longer than the one it replaces, since
1235 /// an origin only comes off a coordinate and a clamp only lowers it.
1236 mouse_out: [stdin_chunk + MouseFilter.max_held]u8 = undefined,
1227 1237
1228 /// Born at a size the driver measured: the Engine has to be born at 1238 /// Born at a size the driver measured: the Engine has to be born at
1229 /// the size the first paint clips to. 1239 /// the size the first paint clips to.
@@ -1686,13 +1696,31 @@ pub const Core = struct {
1686 // repaint below is spent only on a highlight that actually moved. 1696 // repaint below is spent only on a highlight that actually moved.
1687 // A drag reports on every cell the pointer crosses. 1697 // A drag reports on every cell the pointer crosses.
1688 const was = self.drag.range(); 1698 const was = self.drag.range();
1689 if (self.claim == .none or self.semantic.terminal_modes.appMouse()) { 1699 if (self.claim == .none) {
1690 self.mouse.reset(); 1700 self.mouse.reset();
1701 self.drag.clear();
1702 } else if (self.semantic.terminal_modes.appMouse()) {
1691 // The application asked for the mouse, so it gets the drag and 1703 // The application asked for the mouse, so it gets the drag and
1692 // there is no mux selection; Shift+drag stays the terminal's own 1704 // there is no mux selection; Shift+drag stays the terminal's own
1693 // escape hatch. CLEARED, not ignored: a selection made before the 1705 // escape hatch. CLEARED, not ignored: a selection made before the
1694 // ask would sit inverted on a screen that is no longer its own. 1706 // ask would sit inverted on a screen that is no longer its own.
1695 self.drag.clear(); 1707 self.drag.clear();
1708 // What it gets is the report in ITS coordinates. The terminal
1709 // numbers the whole screen, and a tile that does not start at
1710 // the screen's origin is a pane whose application has no row 60
1711 // in its 37: passed through as read, every press in such a tile
1712 // landed outside the application's grid and did nothing (found
1713 // on a live wall, 2026-09-02). Pixel reports are left alone —
1714 // a cell origin cannot come off a pixel — and so are the X10,
1715 // UTF-8 and urxvt spellings, which the filter does not read; an
1716 // application that asks for the mouse without 1006 still gets
1717 // the screen's numbering. The filter's hold stays armed across
1718 // the handover from selection, so a report split across two
1719 // reads is whole for whichever side reads it.
1720 if (!self.semantic.terminal_modes.mouse_sgr_pixels) {
1721 const m = self.mouse.feed(typed, &self.mouse_buf);
1722 keys = self.relocateReports(m, &self.mouse_out);
1723 }
1696 } else { 1724 } else {
1697 const m = self.mouse.feed(typed, &self.mouse_buf); 1725 const m = self.mouse.feed(typed, &self.mouse_buf);
1698 keys = m.forward; 1726 keys = m.forward;
@@ -1801,6 +1829,7 @@ pub const Core = struct {
1801 .selection => |r| done = r, 1829 .selection => |r| done = r,
1802 .nothing, .click => {}, 1830 .nothing, .click => {},
1803 }, 1831 },
1832 .wheel => {},
1804 } 1833 }
1805 } 1834 }
1806 return done; 1835 return done;
@@ -1853,6 +1882,37 @@ pub const Core = struct {
1853 return .{ .text = reply.text }; 1882 return .{ .text = reply.text };
1854 } 1883 }
1855 1884
1885 /// The read rebuilt for an application that owns the mouse: the keys as
1886 /// they came, and each report re-spelled with the pane's origin taken
1887 /// off its coordinates. A coordinate outside the pane CLAMPS to its
1888 /// edge rather than dropping the report, as a terminal reports a drag
1889 /// that left its window — dropping would leave the application holding
1890 /// a button the hand released over a neighbour. The edge is what the
1891 /// pane SHOWS, the smaller of the grid and the clip, like `hitTest`.
1892 fn relocateReports(self: *Core, m: MouseFilter.Out, out: []u8) []const u8 {
1893 const rows = @min(@as(u16, @intCast(self.rep.eng.term.rows)), self.size.rows);
1894 const cols = @min(@as(u16, @intCast(self.rep.eng.term.cols)), self.size.cols);
1895 var len: usize = 0;
1896 var from: usize = 0;
1897 for (m.events) |ev| {
1898 @memcpy(out[len..][0 .. ev.at - from], m.forward[from..ev.at]);
1899 len += ev.at - from;
1900 from = ev.at;
1901 const col = @min(ev.col -| self.col_off, cols -| 1);
1902 const row = @min(ev.row -| self.row_off, rows -| 1);
1903 const spelt = std.fmt.bufPrint(out[len..], "\x1b[<{d};{d};{d}{c}", .{
1904 ev.button,
1905 col + 1,
1906 row + 1,
1907 @as(u8, if (ev.kind == .release) 'm' else 'M'),
1908 }) catch unreachable;
1909 len += spelt.len;
1910 }
1911 @memcpy(out[len..][0 .. m.forward.len - from], m.forward[from..]);
1912 len += m.forward.len - from;
1913 return out[0..len];
1914 }
1915
1856 /// Which line of this session a report landed on, or null. The mapping is 1916 /// Which line of this session a report landed on, or null. The mapping is
1857 /// trivial, which is why selection lives in the Core: a terminal cell is a 1917 /// trivial, which is why selection lives in the Core: a terminal cell is a
1858 /// grid cell less the tile's origin, plus the history under it. 1918 /// grid cell less the tile's origin, plus the history under it.
@@ -2615,24 +2675,50 @@ test "interact: a button press becomes an event, still never reaching the pty" {
2615 try std.testing.expectEqual(@as(u16, 11), r.events[0].row); 2675 try std.testing.expectEqual(@as(u16, 11), r.events[0].row);
2616 } 2676 }
2617 2677
2618 test "interact: a wheel report stays a notch and never becomes an event" { 2678 test "interact: a wheel report is a notch and a wheel event, never a press" {
2619 var f: MouseFilter = .{}; 2679 var f: MouseFilter = .{};
2620 var out: [64]u8 = undefined; 2680 var out: [64]u8 = undefined;
2621 2681
2622 // Button 64 is wheel-up. It has always been a notch; what it must not 2682 // Button 64 is wheel-up. It has always been a notch; what it must not
2623 // ALSO be is an event, or a driver would scroll the view and move a 2683 // ALSO be is a press, or a driver would scroll the view and move a
2624 // selection endpoint on the same turn of the wheel. 2684 // selection endpoint on the same turn of the wheel. It is an event of
2685 // its own kind so the driver that hands the mouse to an application
2686 // can re-spell it in the pane's coordinates like any other report.
2625 const up = f.feed("\x1b[<64;1;1M", &out); 2687 const up = f.feed("\x1b[<64;1;1M", &out);
2626 try std.testing.expectEqual(@as(i32, 1), up.wheel); 2688 try std.testing.expectEqual(@as(i32, 1), up.wheel);
2627 try std.testing.expectEqual(@as(usize, 0), up.events.len); 2689 try std.testing.expectEqual(@as(usize, 1), up.events.len);
2690 try std.testing.expectEqual(MouseFilter.Event.Kind.wheel, up.events[0].kind);
2628 2691
2629 // 65 is wheel-down, 66/67 the horizontal pair this client does not 2692 // 65 is wheel-down, 66/67 the horizontal pair this client does not
2630 // handle — all wheel, all silent here. 2693 // handle — all wheel, none of them a press or a motion.
2631 for ([_][]const u8{ "\x1b[<65;1;1M", "\x1b[<66;1;1M", "\x1b[<67;1;1M" }) |seq| { 2694 for ([_][]const u8{ "\x1b[<65;1;1M", "\x1b[<66;1;1M", "\x1b[<67;1;1M" }) |seq| {
2632 try std.testing.expectEqual(@as(usize, 0), f.feed(seq, &out).events.len); 2695 const r = f.feed(seq, &out);
2696 try std.testing.expectEqual(@as(usize, 1), r.events.len);
2697 try std.testing.expectEqual(MouseFilter.Event.Kind.wheel, r.events[0].kind);
2633 } 2698 }
2634 } 2699 }
2635 2700
2701 test "interact: a wheel turned mid-drag moves no endpoint of the selection" {
2702 const alloc = std.testing.allocator;
2703 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
2704 defer std.posix.close(p[0]);
2705 defer std.posix.close(p[1]);
2706 var core = try dragFixture(alloc, p[1]);
2707 defer core.deinit();
2708 var tr: NullTransport = .{};
2709 var buf: [8192]u8 = undefined;
2710 _ = drainPipe(p[0], &buf);
2711
2712 // The wheel is an event now, so this is where the old filter-level
2713 // silence has to be said again: the drag's far end is where the hand
2714 // last moved, not where the wheel turned.
2715 try mouse(&core, &tr, 0, 3, 2, 'M');
2716 try mouse(&core, &tr, 32, 7, 2, 'M');
2717 try mouse(&core, &tr, 64, 12, 4, 'M');
2718 try std.testing.expectEqual(@as(u16, 6), core.drag.range().?.to.col);
2719 try std.testing.expectEqual(@as(u32, 1), core.drag.range().?.to.row);
2720 }
2721
2636 test "interact: one drag is press, motion and release, in the order typed" { 2722 test "interact: one drag is press, motion and release, in the order typed" {
2637 var f: MouseFilter = .{}; 2723 var f: MouseFilter = .{};
2638 var out: [64]u8 = undefined; 2724 var out: [64]u8 = undefined;
@@ -3721,6 +3807,101 @@ test "interact: the application that asked for the mouse gets the drag, and mux
3721 try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf)); 3807 try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf));
3722 } 3808 }
3723 3809
3810 /// A transport that keeps the keystroke frames — what `forward` sends the
3811 /// SESSION — for the tests whose subject is the bytes an application reads.
3812 const InputTransport = struct {
3813 buf: [256]u8 = undefined,
3814 len: usize = 0,
3815
3816 fn writeFrame(self: *InputTransport, ty: proto.MsgType, payload: []const u8) !void {
3817 if (ty != .input) return;
3818 @memcpy(self.buf[self.len..][0..payload.len], payload);
3819 self.len += payload.len;
3820 }
3821
3822 fn take(self: *InputTransport) []const u8 {
3823 defer self.len = 0;
3824 return self.buf[0..self.len];
3825 }
3826 };
3827
3828 /// The fixture with its application holding the mouse, which is the case
3829 /// where the report goes to the session instead of into a selection.
3830 fn appMouseFixture(alloc: std.mem.Allocator, out_fd: std.posix.fd_t) !Core {
3831 var core = try dragFixture(alloc, out_fd);
3832 core.semantic.terminal_modes = .{
3833 .bracketed_paste = false,
3834 .mouse_normal = true,
3835 .mouse_button = true,
3836 .mouse_sgr = true,
3837 };
3838 return core;
3839 }
3840
3841 test "interact: the application's report is in the pane's coordinates, not the screen's" {
3842 const alloc = std.testing.allocator;
3843 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3844 defer std.posix.close(p[0]);
3845 defer std.posix.close(p[1]);
3846 var core = try appMouseFixture(alloc, p[1]);
3847 defer core.deinit();
3848 var tr: InputTransport = .{};
3849
3850 // The terminal reports where the hand went on ITS screen. The
3851 // application has a 20x6 grid that starts four rows down and twelve
3852 // columns in, so a report it can act on names the same cell in the
3853 // grid's own numbering — press, drag, release and wheel alike.
3854 try mouse(&core, &tr, 0, 3, 2, 'M');
3855 try std.testing.expectEqualStrings("\x1b[<0;3;2M", tr.take());
3856 try mouse(&core, &tr, 32, 4, 2, 'M');
3857 try std.testing.expectEqualStrings("\x1b[<32;4;2M", tr.take());
3858 try mouse(&core, &tr, 0, 4, 2, 'm');
3859 try std.testing.expectEqualStrings("\x1b[<0;4;2m", tr.take());
3860 try mouse(&core, &tr, 64, 3, 2, 'M');
3861 try std.testing.expectEqualStrings("\x1b[<64;3;2M", tr.take());
3862
3863 // Keys sharing the read with a report keep their order around it.
3864 _ = try core.forward(&tr, "a\x1b[<0;15;6Mb");
3865 try std.testing.expectEqualStrings("a\x1b[<0;3;2Mb", tr.take());
3866 }
3867
3868 test "interact: a report past the pane's edge clamps to the edge, as a terminal clamps at its own" {
3869 const alloc = std.testing.allocator;
3870 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3871 defer std.posix.close(p[0]);
3872 defer std.posix.close(p[1]);
3873 var core = try appMouseFixture(alloc, p[1]);
3874 defer core.deinit();
3875 var tr: InputTransport = .{};
3876
3877 // A drag that leaves the pane keeps reporting, at the edge it left
3878 // by: a real terminal reports a drag past its window the same way, and
3879 // dropping the report instead would leave the application holding a
3880 // button the hand has let go of. Screen (1,1) is above and left of the
3881 // pane; pane column 30 and row 9 are past its 20x6.
3882 _ = try core.forward(&tr, "\x1b[<32;1;1M");
3883 try std.testing.expectEqualStrings("\x1b[<32;1;1M", tr.take());
3884 try mouse(&core, &tr, 32, 30, 9, 'M');
3885 try std.testing.expectEqualStrings("\x1b[<32;20;6M", tr.take());
3886 }
3887
3888 test "interact: a pixel report is the terminal's to place and passes untouched" {
3889 const alloc = std.testing.allocator;
3890 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
3891 defer std.posix.close(p[0]);
3892 defer std.posix.close(p[1]);
3893 var core = try appMouseFixture(alloc, p[1]);
3894 defer core.deinit();
3895 core.semantic.terminal_modes.mouse_sgr_pixels = true;
3896 var tr: InputTransport = .{};
3897
3898 // Pixel coordinates are not cells, so the pane's cell origin cannot
3899 // come off them; an application that asked for pixels reads the
3900 // screen's. A wrong translation here would be worse than none.
3901 _ = try core.forward(&tr, "\x1b[<0;150;60M");
3902 try std.testing.expectEqualStrings("\x1b[<0;150;60M", tr.take());
3903 }
3904
3724 test "interact: a drag while scrolled back selects nothing" { 3905 test "interact: a drag while scrolled back selects nothing" {
3725 const alloc = std.testing.allocator; 3906 const alloc = std.testing.allocator;
3726 const p = try std.posix.pipe2(.{ .NONBLOCK = true }); 3907 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
test/e2e.sh
Old New
@@ -170,8 +170,8 @@ done
170 # one of those and adds a convergence point would be pinning a fact every 170 # one of those and adds a convergence point would be pinning a fact every
171 # leg above already establishes. 171 # leg above already establishes.
172 172
173 [ "$OK_COUNT" = "111" ] || { 173 [ "$OK_COUNT" = "112" ] || {
174 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 111 —" 174 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 112 —"
175 echo " a scenario was added (update the pin) or silently lost" 175 echo " a scenario was added (update the pin) or silently lost"
176 exit 1 176 exit 1
177 } 177 }
test/e2e_08_mouse.sh
Old New
@@ -54,6 +54,10 @@ SOCK42="${TMPDIR:-/tmp}/muxd-e2e-wallappmouse-$$.sock"
54 defer_sock "$SOCK42" 54 defer_sock "$SOCK42"
55 ZM2STATE="${TMPDIR:-/tmp}/mux-e2e-wallappmouse-state-$$" 55 ZM2STATE="${TMPDIR:-/tmp}/mux-e2e-wallappmouse-state-$$"
56 defer_rm "$ZM2STATE" 56 defer_rm "$ZM2STATE"
57 SOCK77="${TMPDIR:-/tmp}/muxd-e2e-offorigin-$$.sock"
58 defer_sock "$SOCK77"
59 ZOSTATE="${TMPDIR:-/tmp}/mux-e2e-offorigin-state-$$"
60 defer_rm "$ZOSTATE"
57 ZMOUSESH="${TMPDIR:-/tmp}/mux-e2e-wallappmouse-$$.sh" 61 ZMOUSESH="${TMPDIR:-/tmp}/mux-e2e-wallappmouse-$$.sh"
58 defer_rm "$ZMOUSESH" 62 defer_rm "$ZMOUSESH"
59 63
@@ -155,14 +159,14 @@ wait_grid "$SOCK34" "app-holds-the-mouse" "app-mouse session never armed"
155 # The modes are already set when this client attaches, so they arrive in 159 # The modes are already set when this client attaches, so they arrive in
156 # the attach's own term_modes and there is no race to settle for. The echo 160 # the attach's own term_modes and there is no race to settle for. The echo
157 # is the assertion: cat is in canonical mode with ECHOCTL, so bytes that 161 # is the assertion: cat is in canonical mode with ECHOCTL, so bytes that
158 # reach the pty come back as `^[[<64;10;5M` and bytes that do not, do not. 162 # reach the pty come back as `^[[<64;10;4M` (one row up: a wall on a terminal sits under its label bar) and bytes that do not, do not.
159 set +e 163 set +e
160 hostroom appmouse 164 hostroom appmouse
161 XDG_STATE_HOME="$HOSTROOM" timeout 40 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.mse" --err "$OUT.mse.err" \ 165 XDG_STATE_HOME="$HOSTROOM" timeout 40 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.mse" --err "$OUT.mse.err" \
162 -- "$MUX" --sock "$SOCK34" > "$OUT.mse.log" 2>&1 <<'EOF' 166 -- "$MUX" --sock "$SOCK34" > "$OUT.mse.log" 2>&1 <<'EOF'
163 expect app-holds-the-mouse 15000 167 expect app-holds-the-mouse 15000
164 send \x1b[<64;10;5M 168 send \x1b[<64;10;5M
165 expect [<64;10;5M 15000 169 expect [<64;10;4M 15000
166 send \x1cd 170 send \x1cd
167 waitexit 10000 171 waitexit 10000
168 EOF 172 EOF
@@ -172,7 +176,7 @@ rc0 "app mouse: ptyclient leg exited $RC (did the wheel reach the app?):" "$OUT.
172 # The session's own grid, not just the client's screen: the echo is the pty 176 # The session's own grid, not just the client's screen: the echo is the pty
173 # saying it received the bytes. 177 # saying it received the bytes.
174 timeout 20 "$MUX" a capture --sock "$SOCK34" > "$OUT.msecap" 2>&1 178 timeout 20 "$MUX" a capture --sock "$SOCK34" > "$OUT.msecap" 2>&1
175 grep -qF -- "[<64;10;5M" "$OUT.msecap" || { 179 grep -qF -- "[<64;10;4M" "$OUT.msecap" || {
176 echo "e2e FAIL: app mouse: the wheel never reached the application's pty:" 180 echo "e2e FAIL: app mouse: the wheel never reached the application's pty:"
177 cat "$OUT.msecap"; exit 1; } 181 cat "$OUT.msecap"; exit 1; }
178 # The mirror: this terminal was asked for the session's modes, not the 182 # The mirror: this terminal was asked for the session's modes, not the
@@ -528,9 +532,12 @@ start_daemon "$SOCK42" "$OUT.zm2.d" "app-mouse daemon never bound" --shell "$ZMO
528 D39PID=$DPID 532 D39PID=$DPID
529 wait_grid "$SOCK42" "mapp-holds-the-mouse" "app mouse: session never armed" 533 wait_grid "$SOCK42" "mapp-holds-the-mouse" "app mouse: session never armed"
530 # The echo is the assertion: cat is in canonical mode with ECHOCTL, so bytes 534 # The echo is the assertion: cat is in canonical mode with ECHOCTL, so bytes
531 # that reach the pty come back as `^[[<64;10;5M` and bytes that do not, do 535 # that reach the pty come back as `^[[<64;10;4M` and bytes that do not, do
532 # not. The tile claims the terminal on its first pass, so the modes are 536 # not. The tile claims the terminal on its first pass, so the modes are
533 # level-set before the wheel is sent. 537 # level-set before the wheel is sent. Row 5 on the terminal is row 4 of the
538 # application: a tile on a terminal sits under its label bar, and the
539 # report reaches the pty in the application's own numbering, not the
540 # screen's. The leg below moves the tile further from the origin.
534 set +e 541 set +e
535 no_saved_tree "$ZM2STATE" 542 no_saved_tree "$ZM2STATE"
536 XDG_STATE_HOME="$ZM2STATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \ 543 XDG_STATE_HOME="$ZM2STATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \
@@ -539,18 +546,18 @@ XDG_STATE_HOME="$ZM2STATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \
539 expect mapp-holds-the-mouse 20000 546 expect mapp-holds-the-mouse 20000
540 settle 700 20000 547 settle 700 20000
541 send \x1b[<64;10;5M 548 send \x1b[<64;10;5M
542 expect [<64;10;5M 15000 549 expect [<64;10;4M 15000
543 settle 500 15000 550 settle 500 15000
544 send \x1cd 551 send \x1cd
545 waitexit 10000 552 waitexit 10000
546 EOF 553 EOF
547 RC=$? 554 RC=$?
548 set -e 555 set -e
549 rc0 "app mouse: ptyclient leg exited $RC (did the wheel reach the app?):" "$OUT.zm2pc" 556 rc0 "app mouse: ptyclient leg exited $RC (did the wheel reach the app, one row up?):" "$OUT.zm2pc"
550 # The session's own grid, not just this terminal: the echo is the pty saying 557 # The session's own grid, not just this terminal: the echo is the pty saying
551 # it received the bytes. 558 # it received the bytes.
552 timeout 20 "$MUX" a capture --sock "$SOCK42" > "$OUT.zm2capg" 2>&1 559 timeout 20 "$MUX" a capture --sock "$SOCK42" > "$OUT.zm2capg" 2>&1
553 grep -qF -- "[<64;10;5M" "$OUT.zm2capg" || { 560 grep -qF -- "[<64;10;4M" "$OUT.zm2capg" || {
554 echo "e2e FAIL: app mouse: the wheel never reached the application's pty:" 561 echo "e2e FAIL: app mouse: the wheel never reached the application's pty:"
555 cat "$OUT.zm2capg"; exit 1; } 562 cat "$OUT.zm2capg"; exit 1; }
556 # The mirror: this terminal was asked for the SESSION's modes at the claim, 563 # The mirror: this terminal was asked for the SESSION's modes at the claim,
@@ -568,6 +575,63 @@ D39PID=""
568 ok "an application in the focused tile gets the wheel, and the tile does not" 575 ok "an application in the focused tile gets the wheel, and the tile does not"
569 576
570 577
578 # ---- an application in a tile off the origin reads its own rows ---------
579 #
580 # The leg above holds the tile one label bar from the origin; this one puts
581 # it in the LOWER stripe of a stacked wall, where a report passed through
582 # in the terminal's numbering names a row the application does not have.
583 # That is how it shipped: on a live wall every press in a bottom tile
584 # reached vim and Claude Code as a row past their grid, and neither could
585 # select (2026-09-02). Every earlier app-mouse leg was a wall of one tile,
586 # which is the fixture holding the offset at zero.
587 #
588 # 80x24 cut into two stripes is 12 rows each, one of them the label bar, so
589 # session b's grid is 11 rows starting at terminal row 14 — `mux a status`
590 # says the 11, which is what pins the arithmetic to the layout rather than
591 # to this comment. Terminal row 16 is then the application's row 3.
592 start_daemon "$SOCK77" "$OUT.zo.d" "off-origin daemon never bound" --shell "$ZMOUSESH"
593 D77PID=$DPID
594 wait_grid "$SOCK77" "mapp-holds-the-mouse" "off-origin: session 0 never armed"
595 pipe_mux "$OUT.zob" "$OUT.zob.err" env XDG_STATE_HOME="$ZOSTATE" timeout 40 "$MUX" --sock "$SOCK77" --session b
596 await_out "$OUT.zob" "mapp-holds-the-mouse" "off-origin: session b never armed"
597 pipe_detach
598 wait_grid "$SOCK77" "mapp-holds-the-mouse" "off-origin: session b's marker" b
599 set +e
600 seed_layout "$ZOSTATE" stacked "--sock $SOCK77#0" "--sock $SOCK77#b"
601 XDG_STATE_HOME="$ZOSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \
602 --out "$OUT.zocap" --err "$OUT.zocap.err" -- \
603 "$MUX" --sock "$SOCK77" > "$OUT.zopc" 2>&1 <<'EOF'
604 expect mapp-holds-the-mouse 20000
605 settle 700 20000
606 send \x1cn
607 settle 700 20000
608 send \x1b[<0;10;16M
609 expect [<0;10;3M 15000
610 settle 500 15000
611 send \x1cd
612 waitexit 10000
613 EOF
614 RC=$?
615 set -e
616 rc0 "off-origin: ptyclient leg exited $RC (did the press reach the lower tile's app on its own row?):" "$OUT.zopc"
617 timeout 20 "$MUX" a status --sock "$SOCK77" --session b > "$OUT.zost" 2>&1
618 grep -q '"rows":11' "$OUT.zost" || {
619 echo "e2e FAIL: off-origin: session b is not the 11-row lower stripe this leg's arithmetic assumes:"
620 cat "$OUT.zost"; exit 1; }
621 # The session's own grid says which pty the bytes reached, and in what
622 # numbering: the lower tile's, on its row 3, and never the upper tile's.
623 timeout 20 "$MUX" a capture --sock "$SOCK77" --session b > "$OUT.zocapb" 2>&1
624 grep -qF -- "[<0;10;3M" "$OUT.zocapb" || {
625 echo "e2e FAIL: off-origin: the press never reached the lower tile's pty on its own row 3:"
626 cat "$OUT.zocapb"; exit 1; }
627 timeout 20 "$MUX" a capture --sock "$SOCK77" > "$OUT.zocapa" 2>&1
628 grep -qF -- "[<0;10;" "$OUT.zocapa" && {
629 echo "e2e FAIL: off-origin: the press reached the upper tile, which was not focused:"
630 cat "$OUT.zocapa"; exit 1; }
631 assert_stopped "$SOCK77" "$D77PID" "off-origin" "$OUT.zostop"
632 D77PID=""
633 ok "an application in a tile off the origin reads the press on its own row"
634
571 # ---- a click on a dead host's pane must not deafen the wall ------------- 635 # ---- a click on a dead host's pane must not deafen the wall -------------
572 # 636 #
573 # Found live 2026-09-02: a laptop went dark overnight, its pane came back 637 # Found live 2026-09-02: a laptop went dark overnight, its pane came back