a73x

bd8f4571

fix: the unzoomed wall filters mouse reports instead of typing them

a73x   2026-08-22 13:13

Commit message
fix: the unzoomed wall filters mouse reports instead of typing them

The wall asks for mouse modes now, so reports arrive at the key loop —
and an SGR report is made of characters the wall acts on. Its parameters
are decimal digits, and a digit is a jump key in `selectKey`; column 88
spelled the legacy way is `x`, which forgets a tile. A wheel spin over a
stripe was one read away from walking the selection at random.

A `MouseFilter` of the wall's own goes in front of the byte loop. It is
the second instance in the process and not an alternative to the first:
a promoted tile's `Core` runs one over the bytes it is handed zoomed,
this one runs over the bytes that arrive unzoomed. It resets where the
prefix filter does, because a held candidate belongs to the read a zoom
throws away.

Nothing consumes the events or the notch yet, and they are discarded
where a reader can see it happen rather than by omission. The doc that
said no report could reach here is rewritten: it is now a routing fact —
this path is the zoomed branch, so a report on it belongs to a session
by construction — rather than the absence it used to be.

The e2e gate is a press, a drag, a release and a wheel notch at the
unzoomed wall, carrying the digits that would jump the selection; the
marker typed after the next Enter has to land in the session that was
selected before them. Read off the DAEMON, not off the label bar — the
assertion is about where input went, not about a paint — and its
control types a bare `2` in their place and requires the opposite
answer, because a negative that cannot fail is not a test. The reports
end on row 2, and that is the leg: written first as `\x1b[<0;2;1M` it
passed against a key loop with the filter deleted, because the loop
acts on every digit as it goes, so the `2` jumped to b and the `1`
behind it jumped back to a and the selection ended where it started.

Three existing legs move with the mode set. The zoom-skip handover
decode was reading zero claims — its needle was the old two-escape
claim, and the claim is three escapes now. The two mirror legs were
about to pass having proved nothing: both asserted `?1002h` and said in
prose that only the session could have set it, which stopped being true
the moment 1002 entered the client's own set. They ask for `?1003h`
instead — motion with no button down is a mode no client set will ever
contain.

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

src/interact.zig
Old New
@@ -141,10 +141,16 @@ const stdin_chunk = 16 * 1024;
141 const wheel_rows: u32 = 3; 141 const wheel_rows: u32 = 3;
142 142
143 /// Pulls SGR mouse reports out of the stdin stream and turns the wheel ones 143 /// Pulls SGR mouse reports out of the stdin stream and turns the wheel ones
144 /// into scrollback movement. Only runs while no application in the session 144 /// into scrollback movement.
145 /// has asked for the mouse — when one has, its bytes are its own and this 145 ///
146 /// filter is bypassed entirely (and reset, so a report split across that 146 /// TWO instances exist, and they are not alternatives. A tile's `Core` owns
147 /// transition cannot be half-eaten). 147 /// one for the bytes a ZOOMED session's terminal delivers; it runs only
148 /// while no application in that session has asked for the mouse, because
149 /// when one has, the bytes are its own and the filter is bypassed entirely
150 /// (and reset, so a report split across that transition cannot be
151 /// half-eaten). The CLI wall owns the other, in front of its UNZOOMED key
152 /// loop, where there is no session to bypass it for — the modes are the
153 /// wall's own (`wall_mouse_claim`) and every report is the wall's.
148 /// 154 ///
149 /// Only the SGR form (`ESC [ < b ; x ; y M|m`) is recognised, because it is 155 /// Only the SGR form (`ESC [ < b ; x ; y M|m`) is recognised, because it is
150 /// the only form the client ever asks its terminal for (`client_mouse_setup`). 156 /// the only form the client ever asks its terminal for (`client_mouse_setup`).
@@ -158,10 +164,13 @@ const wheel_rows: u32 = 3;
158 /// leaks through as input. A terminal writes a report with one write, and 164 /// leaks through as input. A terminal writes a report with one write, and
159 /// the pty delivers up to 16 KiB per read, so that split is a theoretical 165 /// the pty delivers up to 16 KiB per read, so that split is a theoretical
160 /// one; a delayed Escape would be an every-session one. 166 /// one; a delayed Escape would be an every-session one.
161 const MouseFilter = struct { 167 pub const MouseFilter = struct {
162 /// Longest report worth holding: `ESC [ <` plus three parameters. A 168 /// Longest report worth holding: `ESC [ <` plus three parameters. A
163 /// candidate that outgrows it was never a mouse report. 169 /// candidate that outgrows it was never a mouse report.
164 const max_held = 24; 170 ///
171 /// Public because a caller has to SIZE the buffer it passes `feed`, and
172 /// the size is `in.len + max_held`.
173 pub const max_held = 24;
165 174
166 /// One report the filter understood, in this client's coordinates. 175 /// One report the filter understood, in this client's coordinates.
167 /// 176 ///
@@ -189,7 +198,7 @@ const MouseFilter = struct {
189 /// and strand the drag state machine mid-drag. 198 /// and strand the drag state machine mid-drag.
190 const max_events = stdin_chunk / 10 + 1; 199 const max_events = stdin_chunk / 10 + 1;
191 200
192 const Out = struct { 201 pub const Out = struct {
193 /// The bytes that were not mouse reports, in order. 202 /// The bytes that were not mouse reports, in order.
194 forward: []const u8, 203 forward: []const u8,
195 /// Net wheel notches: positive is up, into history. 204 /// Net wheel notches: positive is up, into history.
@@ -203,7 +212,7 @@ const MouseFilter = struct {
203 len: usize = 0, 212 len: usize = 0,
204 events: [max_events]Event = undefined, 213 events: [max_events]Event = undefined,
205 214
206 fn reset(self: *MouseFilter) void { 215 pub fn reset(self: *MouseFilter) void {
207 self.len = 0; 216 self.len = 0;
208 } 217 }
209 218
@@ -211,7 +220,7 @@ const MouseFilter = struct {
211 /// `in.len + max_held` — a candidate held from the previous read is 220 /// `in.len + max_held` — a candidate held from the previous read is
212 /// handed back ahead of this chunk's bytes when it turns out not to 221 /// handed back ahead of this chunk's bytes when it turns out not to
213 /// have been a report after all. 222 /// have been a report after all.
214 fn feed(self: *MouseFilter, in: []const u8, out: []u8) Out { 223 pub fn feed(self: *MouseFilter, in: []const u8, out: []u8) Out {
215 var kept: usize = 0; 224 var kept: usize = 0;
216 var wheel: i32 = 0; 225 var wheel: i32 = 0;
217 var evs: usize = 0; 226 var evs: usize = 0;
src/wallview.zig
Old New
@@ -791,12 +791,18 @@ fn endWith(t: *Tile, reason: EndReason, code: u8) void {
791 /// So the mailbox carries whatever the terminal wrote, minus the chord, and 791 /// So the mailbox carries whatever the terminal wrote, minus the chord, and
792 /// `interact.Core.forward` takes it apart on the other side. 792 /// `interact.Core.forward` takes it apart on the other side.
793 /// 793 ///
794 /// Nothing arriving here while the wall is UNZOOMED can be a mouse report: 794 /// Nothing arriving here is an UNZOOMED wall's mouse report, and that is a
795 /// the wall asks its own terminal for no mouse mode, and a promoted tile's 795 /// routing fact rather than an absence now that there are such reports. The
796 /// modes come off at the demote (`Core.releaseTerminal`). A report in 796 /// wall holds mouse modes of its own for its whole life
797 /// flight across that boundary lands in the unzoomed key loop instead, 797 /// (`interact.wall_mouse_claim`) and reads what they produce itself, in the
798 /// where its bytes are selection keys or nothing — the wall's input, never 798 /// key loop, through a `MouseFilter` of its own. This function is reached
799 /// a session's. 799 /// only on the zoomed branch of that loop, so a report that gets here
800 /// belongs to a session by construction.
801 ///
802 /// A report in flight across the boundary lands on whichever side the zoom
803 /// was on when the read returned: the wall's filter eats it, or the
804 /// session's does. Neither side is ever handed the other's, which is what
805 /// keeps a wall drag from becoming tile input.
800 /// 806 ///
801 /// A chunk that does not fit is dropped WHOLE. The obvious alternative — 807 /// A chunk that does not fit is dropped WHOLE. The obvious alternative —
802 /// copy what fits — splices: the head of one read lands in the mailbox, the 808 /// copy what fits — splices: the head of one read lands in the mailbox, the
@@ -2601,6 +2607,14 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2601 // nothing); zoomed IN the terminal belongs to the session and only the 2607 // nothing); zoomed IN the terminal belongs to the session and only the
2602 // `Ctrl-\` chord layer is held back. 2608 // `Ctrl-\` chord layer is held back.
2603 var prefix: interact.PrefixFilter = .{}; 2609 var prefix: interact.PrefixFilter = .{};
2610 // The wall's own mouse filter — the second in the process, and not an
2611 // alternative to the first: a promoted tile's `Core` runs one over the
2612 // bytes it is handed zoomed, this one runs over the bytes that arrive
2613 // UNZOOMED, which the wall's own modes are what produce.
2614 var mouse: interact.MouseFilter = .{};
2615 // `feed` hands a candidate held across the previous read back ahead of
2616 // this chunk, so its room is a whole chunk plus that hold.
2617 var mouse_out: [mailbox_max + interact.MouseFilter.max_held]u8 = undefined;
2604 // Where `Ctrl-\ l` goes back to. Keyboard-thread state: no pump reads 2618 // Where `Ctrl-\ l` goes back to. Keyboard-thread state: no pump reads
2605 // it, and no lock guards it, because nothing else writes it. 2619 // it, and no lock guards it, because nothing else writes it.
2606 var last_zoom: ?usize = null; 2620 var last_zoom: ?usize = null;
@@ -2768,7 +2782,23 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2768 continue; 2782 continue;
2769 } 2783 }
2770 2784
2771 for (b[0..n]) |key| { 2785 // Unzoomed, this terminal reports the mouse on the WALL's own claim,
2786 // so a read here can carry SGR reports as well as keys. They come
2787 // out AHEAD of the byte loop because a report is made of characters
2788 // the wall acts on: its parameters are decimal digits, and a digit
2789 // is a jump key in `selectKey`. One wheel spin over a stripe would
2790 // otherwise walk the selection at random.
2791 const report = mouse.feed(b[0..n], &mouse_out);
2792 // Both halves are dropped, by decision and not by omission. There
2793 // is no wall-level scrollback for a notch to move, and a press, a
2794 // drag or a release has nothing behind it yet — a user who sees the
2795 // wall answer the mouse at all will spin the wheel over a stripe,
2796 // and until something is listening, nothing happening is the honest
2797 // answer to that.
2798 _ = report.wheel;
2799 _ = report.events;
2800
2801 for (report.forward) |key| {
2772 if (key == 'q' or key == 0x1c) break :keys; 2802 if (key == 'q' or key == 0x1c) break :keys;
2773 // Both spellings of Enter: ICRNL is off, so a Return arrives 2803 // Both spellings of Enter: ICRNL is off, so a Return arrives
2774 // as CR, but a script or a paste can just as easily send LF. 2804 // as CR, but a script or a paste can just as easily send LF.
@@ -2784,8 +2814,11 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2784 setZoom(tiles[0..live], &shared, shared.sel); 2814 setZoom(tiles[0..live], &shared, shared.sel);
2785 // The rest of this read was typed at the WALL, before the 2815 // The rest of this read was typed at the WALL, before the
2786 // terminal changed hands — it is not the session's input. 2816 // terminal changed hands — it is not the session's input.
2787 // The prefix layer starts clean for the same reason. 2817 // The prefix layer starts clean for the same reason, and so
2818 // does the mouse filter: anything it is still holding is
2819 // the head of a report from the read being dropped here.
2788 prefix = .{}; 2820 prefix = .{};
2821 mouse.reset();
2789 continue :keys; 2822 continue :keys;
2790 } 2823 }
2791 // `x` forgets the selected tile: off the wall file, off this 2824 // `x` forgets the selected tile: off the wall file, off this
@@ -2888,6 +2921,40 @@ fn allPresent(comptime n: usize) [n]bool {
2888 return [_]bool{true} ** n; 2921 return [_]bool{true} ** n;
2889 } 2922 }
2890 2923
2924 test "a mouse report at the unzoomed wall is filtered out, never typed as a wall key" {
2925 // The hazard first, so the filter is measured against something real
2926 // rather than asserted into existence. An SGR report's parameters are
2927 // decimal digits and its final byte is a letter; on the wall a digit is
2928 // a jump key and `x` forgets a tile. Fed raw, this one report — button
2929 // 0 pressed at column 3, row 1 — moves the selection twice.
2930 const report = "\x1b[<0;3;1M";
2931 const present = [_]bool{ true, true, true };
2932 var jumped: usize = 0;
2933 for (report) |k| {
2934 if (selectKey(&present, 0, k)) |_| jumped += 1;
2935 }
2936 try std.testing.expect(jumped > 0);
2937
2938 // ...and through the filter the wall now runs its unzoomed reads
2939 // through, the same bytes reach the byte loop as nothing at all.
2940 var mouse: interact.MouseFilter = .{};
2941 var out: [64]u8 = undefined;
2942 const got = mouse.feed(report, &out);
2943 try std.testing.expectEqual(@as(usize, 0), got.forward.len);
2944 // Seen, not merely swallowed. A filter that dropped the bytes and
2945 // reported nothing would pass the line above and leave the slice that
2946 // turns a click into a stripe selection with nothing to build on.
2947 try std.testing.expectEqual(@as(usize, 1), got.events.len);
2948 try std.testing.expectEqual(@as(u16, 2), got.events[0].col);
2949 try std.testing.expectEqual(@as(u16, 0), got.events[0].row);
2950
2951 // Ordinary keys are untouched by the same call — the filter has to be
2952 // safe to put in front of EVERY unzoomed read, not just the ones
2953 // carrying a report.
2954 const mixed = mouse.feed("1\x1b[<0;3;1Mj", &out);
2955 try std.testing.expectEqualStrings("1j", mixed.forward);
2956 }
2957
2891 test "selectKey: j/k and n/p wrap at both ends" { 2958 test "selectKey: j/k and n/p wrap at both ends" {
2892 const p3 = allPresent(3); 2959 const p3 = allPresent(3);
2893 try std.testing.expectEqual(@as(?usize, 1), selectKey(&p3, 0, 'j')); 2960 try std.testing.expectEqual(@as(?usize, 1), selectKey(&p3, 0, 'j'));
test/e2e.sh
Old New
@@ -464,6 +464,12 @@ AGENT49BKEY="${TMPDIR:-/tmp}/mux-e2e-agent2bkey-$$"
464 D43PID="" 464 D43PID=""
465 FLIPAPID="" 465 FLIPAPID=""
466 466
467 # The wall's own mouse claim needs a daemon of its own for the same reason
468 # every other leg does: the wall scenario above stops its daemon on the way
469 # out, so there is nothing left to reuse.
470 SOCK50="${TMPDIR:-/tmp}/muxd-e2e-wallmouse-$$.sock"
471 D48PID=""
472
467 # One counter out of a MUX_PREDICT_STATS line. The client prints exactly one 473 # One counter out of a MUX_PREDICT_STATS line. The client prints exactly one
468 # such line on exit; every field is a key=value pair, so a rename or reorder 474 # such line on exit; every field is a key=value pair, so a rename or reorder
469 # in the client shows up here as an empty read rather than a wrong number. 475 # in the client shows up here as an empty read rather than a wrong number.
@@ -1316,6 +1322,7 @@ cleanup() {
1316 [ -S "$SOCK47" ] && "$MUXD" stop --sock "$SOCK47" 2>/dev/null || true 1322 [ -S "$SOCK47" ] && "$MUXD" stop --sock "$SOCK47" 2>/dev/null || true
1317 [ -S "$SOCK48" ] && "$MUXD" stop --sock "$SOCK48" 2>/dev/null || true 1323 [ -S "$SOCK48" ] && "$MUXD" stop --sock "$SOCK48" 2>/dev/null || true
1318 [ -S "$SOCK49" ] && "$MUXD" stop --sock "$SOCK49" 2>/dev/null || true 1324 [ -S "$SOCK49" ] && "$MUXD" stop --sock "$SOCK49" 2>/dev/null || true
1325 [ -S "$SOCK50" ] && "$MUXD" stop --sock "$SOCK50" 2>/dev/null || true
1319 1326
1320 # ---- the leak sweep (hygiene kit, 6a) ---- 1327 # ---- the leak sweep (hygiene kit, 6a) ----
1321 # Here rather than at the bottom of the file, which `set -e` reaches only 1328 # Here rather than at the bottom of the file, which `set -e` reaches only
@@ -1542,6 +1549,10 @@ cleanup() {
1542 rm -f "$OUT.zsa" "$OUT.zsa.err" "$OUT.zsb" "$OUT.zsb.err" "$OUT.zscap" \ 1549 rm -f "$OUT.zsa" "$OUT.zsa.err" "$OUT.zsb" "$OUT.zsb.err" "$OUT.zscap" \
1543 "$OUT.zscap.err" "$OUT.zs.d" "$OUT.zsfa" "$OUT.zsfb" "$OUT.zspc" \ 1550 "$OUT.zscap.err" "$OUT.zs.d" "$OUT.zsfa" "$OUT.zsfb" "$OUT.zspc" \
1544 "$OUT.zssta" "$OUT.zsstb" "$OUT.zsstop" "$OUT.zswatch" 1551 "$OUT.zssta" "$OUT.zsstb" "$OUT.zsstop" "$OUT.zswatch"
1552 rm -f "$SOCK50" "$OUT.wmse.d" "$OUT.wmsa" "$OUT.wmsa.err" "$OUT.wmsb" \
1553 "$OUT.wmsb.err" "$OUT.wmcap" "$OUT.wmcap.err" "$OUT.wmpc" \
1554 "$OUT.wmcap2" "$OUT.wmcap2.err" "$OUT.wmpc2" "$OUT.wmfa" "$OUT.wmfb" \
1555 "$OUT.wmstop"
1545 # The convergence files a FAILING assert_converged leaves behind 1556 # The convergence files a FAILING assert_converged leaves behind
1546 # (.render/.dump/.rvt/.dvt/.diff for that capture) are deliberately not 1557 # (.render/.dump/.rvt/.dvt/.diff for that capture) are deliberately not
1547 # chased here: on a failing run they are the evidence. 1558 # chased here: on a failing run they are the evidence.
@@ -5672,13 +5683,17 @@ ok "the wheel scrolls back and returns to live, and never reaches the pty"
5672 # `set mouse=a` set — before becoming cat, so every wheel byte is the 5683 # `set mouse=a` set — before becoming cat, so every wheel byte is the
5673 # application's and the client must hand them over untouched. 5684 # application's and the client must hand them over untouched.
5674 # 5685 #
5675 # 1002 is the mode that makes this leg sharp: the client's OWN capture 5686 # 1003 is the mode that makes this leg sharp, and it is 1003 rather
5676 # set is 1000+1006, so a `?1002h` on this terminal can only have come 5687 # than 1002 because the client's own capture set is 1000+1002+1006 —
5677 # from mirroring what the session asked for. 5688 # a `?1002h` here would be written whether the session asked or not,
5689 # and asserting on it would be asserting nothing. 1003 (report motion
5690 # with no button down) is a mode the client never wants for itself, so
5691 # a `?1003h` on this terminal can only be the session's, mirrored. The
5692 # script asks for it on top of vim's `set mouse=a` set.
5678 cat > "$MOUSESH" <<'EOF' 5693 cat > "$MOUSESH" <<'EOF'
5679 #!/bin/sh 5694 #!/bin/sh
5680 seq 1 100 5695 seq 1 100
5681 printf '\033[?1000h\033[?1002h\033[?1006h' 5696 printf '\033[?1000h\033[?1002h\033[?1003h\033[?1006h'
5682 printf 'app-holds-the-mouse\n' 5697 printf 'app-holds-the-mouse\n'
5683 exec /bin/cat 5698 exec /bin/cat
5684 EOF 5699 EOF
@@ -5716,9 +5731,10 @@ grep -qF -- "[<64;10;5M" "$OUT.msecap" || {
5716 echo "e2e FAIL: app mouse: the wheel never reached the application's pty:" 5731 echo "e2e FAIL: app mouse: the wheel never reached the application's pty:"
5717 cat "$OUT.msecap"; exit 1; } 5732 cat "$OUT.msecap"; exit 1; }
5718 # The mirror: this terminal was asked for the session's modes, not the 5733 # The mirror: this terminal was asked for the session's modes, not the
5719 # client's own set. 5734 # client's own set — and 1003 is outside that set, so this cannot pass on
5720 grep -qaF "$(printf '\033[?1002h')" "$OUT.mse" || { 5735 # the client's own behalf.
5721 echo "e2e FAIL: app mouse: the client never mirrored the session's drag mode"; exit 1; } 5736 grep -qaF "$(printf '\033[?1003h')" "$OUT.mse" || {
5737 echo "e2e FAIL: app mouse: the client never mirrored the session's any-motion mode"; exit 1; }
5722 # And the negative that makes the pair a pair: this session has the same 5738 # And the negative that makes the pair a pair: this session has the same
5723 # 77 rows of history as (a), and the same wheel byte moved none of it. 5739 # 77 rows of history as (a), and the same wheel byte moved none of it.
5724 grep -qF -- "60" "$OUT.mse" && { 5740 grep -qF -- "60" "$OUT.mse" && {
@@ -5985,10 +6001,10 @@ done
5985 # (wallview `setZoom`), so the order is structural rather than lucky. 6001 # (wallview `setZoom`), so the order is structural rather than lucky.
5986 # 6002 #
5987 # Both needles are exact byte strings that nothing else emits. The claim is 6003 # Both needles are exact byte strings that nothing else emits. The claim is
5988 # the only place those two escapes are adjacent — the level-set that follows 6004 # the only place those three escapes are adjacent — the level-set that
5989 # it spells the same modes with three others in between — and the release is 6005 # follows it puts `?1003l` and `?1005l` between the last two — and the
5990 # a nine-escape run written whole. 6006 # release is a nine-escape run written whole.
5991 _ZS_CLAIM=$(printf '\033[?1000h\033[?1006h') 6007 _ZS_CLAIM=$(printf '\033[?1000h\033[?1002h\033[?1006h')
5992 _ZS_REL=$(printf '\033[?2004l\033[?9l\033[?1000l\033[?1002l\033[?1003l\033[?1005l\033[?1006l\033[?1015l\033[?1016l') 6008 _ZS_REL=$(printf '\033[?2004l\033[?9l\033[?1000l\033[?1002l\033[?1003l\033[?1005l\033[?1006l\033[?1015l\033[?1016l')
5993 # Byte offsets, merged and sorted, then read as a word. `grep -oba` is what 6009 # Byte offsets, merged and sorted, then read as a word. `grep -oba` is what
5994 # makes this possible at all: the capture is binary, these are escape 6010 # makes this possible at all: the capture is binary, these are escape
@@ -5996,13 +6012,20 @@ _ZS_REL=$(printf '\033[?2004l\033[?9l\033[?1000l\033[?1002l\033[?1003l\033[?1005
5996 _zs_order=$( { grep -obaF -- "$_ZS_CLAIM" "$OUT.zscap" | sed 's/:.*/ C/' || true; \ 6012 _zs_order=$( { grep -obaF -- "$_ZS_CLAIM" "$OUT.zscap" | sed 's/:.*/ C/' || true; \
5997 grep -obaF -- "$_ZS_REL" "$OUT.zscap" | sed 's/:.*/ R/' || true; } \ 6013 grep -obaF -- "$_ZS_REL" "$OUT.zscap" | sed 's/:.*/ R/' || true; } \
5998 | sort -n | awk '{printf "%s", $2}' ) 6014 | sort -n | awk '{printf "%s", $2}' )
5999 # CRCRCR: three handovers, each release before the next claim. The trailing 6015 # CCRCRCRCR, and every letter is accounted for. The WALL claims these modes
6000 # R the wall's own exit writes is not asserted here — the no-leaked-mode leg 6016 # for itself now — it reads mouse reports while unzoomed — so the run opens
6001 # owns that one. 6017 # with its claim (C) before Enter's promote claims on top (C). Then the
6018 # three handovers, each release before the next claim (RC RC), then the
6019 # unzoom's release followed by the wall taking its own modes back (RC), and
6020 # last the R of the wall's exit.
6021 #
6022 # The property under test is unchanged and is the adjacency: no C may sit
6023 # where an R belongs, because a claim landing before the outgoing release
6024 # is a session that spends its turn with the mouse off.
6002 case "$_zs_order" in 6025 case "$_zs_order" in
6003 CRCRCR*) ;; 6026 CCRCRCRCR*) ;;
6004 *) 6027 *)
6005 echo "e2e FAIL: zoom skip: the terminal handover was $_zs_order, not CRCRCR —" 6028 echo "e2e FAIL: zoom skip: the terminal handover was $_zs_order, not CCRCRCRCR —"
6006 echo " an incoming tile claimed the terminal before the outgoing one" 6029 echo " an incoming tile claimed the terminal before the outgoing one"
6007 echo " released it, so the release unset the modes the claim had just" 6030 echo " released it, so the release unset the modes the claim had just"
6008 echo " set and the zoomed session lost its mouse:" 6031 echo " set and the zoomed session lost its mouse:"
@@ -6197,11 +6220,13 @@ ok "a zoomed tile scrolls its session's history on the wheel, and the pty sees n
6197 grep -q "wallwheel" "$OUT.zwcapg" && { 6220 grep -q "wallwheel" "$OUT.zwcapg" && {
6198 echo "e2e FAIL: zoom wheel: the DEMOTED wall forwarded keystrokes to the session:" 6221 echo "e2e FAIL: zoom wheel: the DEMOTED wall forwarded keystrokes to the session:"
6199 cat "$OUT.zwcapg"; exit 1; } 6222 cat "$OUT.zwcapg"; exit 1; }
6200 # ...and the terminal was given back its mouse. `?1000l` is written exactly 6223 # ...and the SESSION's mouse was taken off it. `?1000l` is written exactly
6201 # twice on this run — once by the demote and once by the wall's own exit — 6224 # twice on this run — once by the demote and once by the wall's own exit —
6202 # and the promote writes only `h`s, so a count of one is a demote that left 6225 # and the promote writes only `h`s, so a count of one is a demote that left
6203 # the user's terminal reporting clicks into their shell for the rest of the 6226 # the session's modes on the user's terminal for the rest of the wall's
6204 # wall's life. Counted with `grep -o`: these are escape sequences inside a 6227 # life. What the demote then puts back is the WALL's own capture set, which
6228 # is the wall reading mouse reports on its own account and not a leak; the
6229 # exit's teardown is what has to leave the terminal clean. Counted with `grep -o`: these are escape sequences inside a
6205 # paint, with no newlines to make a line count mean anything. 6230 # paint, with no newlines to make a line count mean anything.
6206 _zw_off=$(grep -oa "$(printf '\033')\[?1000l" "$OUT.zwcap" | wc -l) 6231 _zw_off=$(grep -oa "$(printf '\033')\[?1000l" "$OUT.zwcap" | wc -l)
6207 [ "$_zw_off" -ge 2 ] || { 6232 [ "$_zw_off" -ge 2 ] || {
@@ -6210,7 +6235,7 @@ _zw_off=$(grep -oa "$(printf '\033')\[?1000l" "$OUT.zwcap" | wc -l)
6210 cat "$OUT.zwpc"; exit 1; } 6235 cat "$OUT.zwpc"; exit 1; }
6211 assert_stopped "$SOCK41" "$D38PID" "zoom wheel" "$OUT.zwstop" 6236 assert_stopped "$SOCK41" "$D38PID" "zoom wheel" "$OUT.zwstop"
6212 D38PID="" 6237 D38PID=""
6213 ok "a demoted tile forwards nothing and leaves no mouse mode on the wall's terminal" 6238 ok "a demoted tile forwards nothing and leaves no SESSION mouse mode on the wall's terminal"
6214 6239
6215 # ---- an application in a ZOOMED tile owns the mouse --------------------- 6240 # ---- an application in a ZOOMED tile owns the mouse ---------------------
6216 # 6241 #
@@ -6221,16 +6246,18 @@ ok "a demoted tile forwards nothing and leaves no mouse mode on the wall's termi
6221 # — and the promote is answered by `resyncSnapshot`, which carries no modes 6246 # — and the promote is answered by `resyncSnapshot`, which carries no modes
6222 # at all. So the handover cannot come from a frame: it has to come from what 6247 # at all. So the handover cannot come from a frame: it has to come from what
6223 # the Core already knew, level-set onto the terminal at the moment of the 6248 # the Core already knew, level-set onto the terminal at the moment of the
6224 # claim. Measured before that existed: `?1002h` never reached this capture 6249 # claim. Measured before that existed: the session's own mode never reached
6225 # and the wheel was eaten as scrollback. 6250 # this capture and the wheel was eaten as scrollback.
6226 # 6251 #
6227 # 1002 is what makes the leg sharp. The wall asks its terminal for NO mouse 6252 # 1003 is what makes the leg sharp, and it has to be 1003: the wall claims
6228 # mode, and a promoted tile's own capture set is 1000+1006, so a `?1002h` on 6253 # 1000+1002+1006 for itself and a promoted tile claims the same set, so
6229 # this terminal can only be the session's, mirrored. 6254 # either of those would appear on this terminal with no session involved.
6255 # 1003 is in nobody's own set, so a `?1003h` here can only be the session's,
6256 # mirrored.
6230 cat > "$ZMOUSESH" <<'EOF' 6257 cat > "$ZMOUSESH" <<'EOF'
6231 #!/bin/sh 6258 #!/bin/sh
6232 seq 1 100 | sed 's/^/mln/' 6259 seq 1 100 | sed 's/^/mln/'
6233 printf '\033[?1000h\033[?1002h\033[?1006h' 6260 printf '\033[?1000h\033[?1002h\033[?1003h\033[?1006h'
6234 printf 'mapp-holds-the-mouse\n' 6261 printf 'mapp-holds-the-mouse\n'
6235 exec /bin/cat 6262 exec /bin/cat
6236 EOF 6263 EOF
@@ -6273,9 +6300,9 @@ grep -qF -- "[<64;10;5M" "$OUT.zm2capg" || {
6273 echo "e2e FAIL: zoom app mouse: the wheel never reached the application's pty:" 6300 echo "e2e FAIL: zoom app mouse: the wheel never reached the application's pty:"
6274 cat "$OUT.zm2capg"; exit 1; } 6301 cat "$OUT.zm2capg"; exit 1; }
6275 # The mirror: this terminal was asked for the SESSION's modes at the 6302 # The mirror: this terminal was asked for the SESSION's modes at the
6276 # promote, not for the tile's own set. 6303 # promote, not for the tile's own set — and 1003 is outside that set.
6277 grep -qaF "$(printf '\033')[?1002h" "$OUT.zm2cap" || { 6304 grep -qaF "$(printf '\033')[?1003h" "$OUT.zm2cap" || {
6278 echo "e2e FAIL: zoom app mouse: the promote never mirrored the session's drag mode:" 6305 echo "e2e FAIL: zoom app mouse: the promote never mirrored the session's any-motion mode:"
6279 cat "$OUT.zm2pc"; exit 1; } 6306 cat "$OUT.zm2pc"; exit 1; }
6280 # And the negative that makes the pair a pair: this session has the same 77 6307 # And the negative that makes the pair a pair: this session has the same 77
6281 # rows of history as the leg above, and the same wheel byte moved none of it. 6308 # rows of history as the leg above, and the same wheel byte moved none of it.
@@ -7510,6 +7537,105 @@ kill "$AGENT49BPID" 2>/dev/null || true
7510 AGENT49BPID="" 7537 AGENT49BPID=""
7511 ok "agent forwarding: the agent that answers is whoever typed last" 7538 ok "agent forwarding: the agent that answers is whoever typed last"
7512 7539
7540 # ---- a mouse report at the UNZOOMED wall is discarded, not typed --------
7541 #
7542 # The wall asks its own terminal for mouse reporting now, so reports reach
7543 # the unzoomed key loop where nothing used to be able to. That loop reads
7544 # every byte as a wall key, and an SGR report is made of characters it acts
7545 # on: `\x1b[<0;2;1M` carries a `2`, and a digit jumps the selection to that
7546 # tile. Left unfiltered, one click or one wheel spin over a stripe walks the
7547 # selection somewhere the user did not put it, and the next Enter zooms the
7548 # wrong session.
7549 #
7550 # Which tile the selection is ON is not read off the label bar here — that
7551 # would be asserting against a paint. It is read off the DAEMON: Enter zooms
7552 # whatever is selected, so a marker typed afterwards lands in the selected
7553 # session and `muxa capture` says which one that was. The wall painting is
7554 # not in the loop at all.
7555 #
7556 # Two runs, because a negative that cannot fail is not a test. The first
7557 # sends the reports and the second sends a bare `2` in their place; if `2`
7558 # did not move the selection, the pair would pass having proved nothing
7559 # about the filter.
7560 "$MUXD" run --sock "$SOCK50" --shell /bin/sh > "$OUT.wmse.d" 2>&1 &
7561 D48PID=$!
7562 wait_sock "$SOCK50" "$OUT.wmse.d" "wall-mouse daemon never bound"
7563
7564 { printf 'printf "wma-%%s\\n" pin\n'; sleep 2; printf '\034\034'; } | \
7565 timeout 40 "$MUX" --sock "$SOCK50" --session a > "$OUT.wmsa" 2> "$OUT.wmsa.err"
7566 wait_grid "$SOCK50" "wma-pin" "wall mouse: session a's marker" a
7567 { printf 'printf "wmb-%%s\\n" pin\n'; sleep 2; printf '\034\034'; } | \
7568 timeout 40 "$MUX" --sock "$SOCK50" --session b > "$OUT.wmsb" 2> "$OUT.wmsb.err"
7569 wait_grid "$SOCK50" "wmb-pin" "wall mouse: session b's marker" b
7570
7571 # A press, a drag and a release at column 3, then a wheel notch — every
7572 # shape the filter has to swallow. The wall opens with tile 1 (session a)
7573 # selected, and each report ENDS on a `2`: that is not decoration. The key
7574 # loop acts on every digit as it passes, so a report ending on `1` would
7575 # jump to b and back to a and leave the selection where it started —
7576 # passing this leg with the filter deleted. Measured, not reasoned about:
7577 # with `\x1b[<0;2;1M` this leg passed against a loop that read the reports
7578 # as keys. Row 2 is the only parameter free to carry that `2`, since
7579 # column 3 and the button words have to spell something else.
7580 set +e
7581 timeout 60 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.wmcap" --err "$OUT.wmcap.err" -- \
7582 "$MUX" wall "--sock $SOCK50#a" "--sock $SOCK50#b" > "$OUT.wmpc" 2>&1 <<'EOF'
7583 expect wmb-pin 20000
7584 settle 700 20000
7585 send \x1b[<0;3;2M\x1b[<32;3;2M\x1b[<0;3;2m\x1b[<64;3;2M
7586 settle 700 20000
7587 send \r
7588 settle 700 20000
7589 send printf 'wm-%s\\n' one\n
7590 expect wm-one 15000
7591 settle 400 15000
7592 send \x1cd
7593 waitexit 10000
7594 EOF
7595 RC=$?
7596 set -e
7597 [ "$RC" -eq 0 ] || {
7598 echo "e2e FAIL: wall mouse: ptyclient leg exited $RC (did the wall survive a mouse report?):"
7599 cat "$OUT.wmpc"; exit 1; }
7600 timeout 20 "$MUXA" capture --sock "$SOCK50" --session a > "$OUT.wmfa" 2>&1
7601 timeout 20 "$MUXA" capture --sock "$SOCK50" --session b > "$OUT.wmfb" 2>&1
7602 grep -q "wm-one" "$OUT.wmfa" || {
7603 echo "e2e FAIL: wall mouse: the selection left session a, so a report was read as keys:"
7604 cat "$OUT.wmfa"; exit 1; }
7605 grep -q "wm-one" "$OUT.wmfb" && {
7606 echo "e2e FAIL: wall mouse: a mouse report jumped the selection to session b:"
7607 cat "$OUT.wmfb"; exit 1; }
7608
7609 # The control. Same wall, same Enter, and a `2` where the reports were: this
7610 # one MUST land in b, or the negative above was a digit that never worked.
7611 set +e
7612 timeout 60 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.wmcap2" --err "$OUT.wmcap2.err" -- \
7613 "$MUX" wall "--sock $SOCK50#a" "--sock $SOCK50#b" > "$OUT.wmpc2" 2>&1 <<'EOF'
7614 expect wmb-pin 20000
7615 settle 700 20000
7616 send 2
7617 settle 700 20000
7618 send \r
7619 settle 700 20000
7620 send printf 'wm-%s\\n' two\n
7621 expect wm-two 15000
7622 settle 400 15000
7623 send \x1cd
7624 waitexit 10000
7625 EOF
7626 RC=$?
7627 set -e
7628 [ "$RC" -eq 0 ] || {
7629 echo "e2e FAIL: wall mouse: control leg exited $RC (did a bare 2 move the selection?):"
7630 cat "$OUT.wmpc2"; exit 1; }
7631 timeout 20 "$MUXA" capture --sock "$SOCK50" --session b > "$OUT.wmfb" 2>&1
7632 grep -q "wm-two" "$OUT.wmfb" || {
7633 echo "e2e FAIL: wall mouse: a bare 2 did not move the selection, so the leg above proves nothing:"
7634 cat "$OUT.wmfb"; exit 1; }
7635 assert_stopped "$SOCK50" "$D48PID" "wall mouse" "$OUT.wmstop"
7636 D48PID=""
7637 ok "a mouse report at the unzoomed wall is swallowed; the same digit typed alone still jumps"
7638
7513 7639
7514 # The long-lived daemon has served every scenario that wanted it; stop it 7640 # The long-lived daemon has served every scenario that wanted it; stop it
7515 # NOW so its allocator verdict is written while the suite is still running 7641 # NOW so its allocator verdict is written while the suite is still running
@@ -7618,9 +7744,16 @@ DPID=""
7618 # since both replicate the same session and hold both answers alike. The 7744 # since both replicate the same session and hold both answers alike. The
7619 # 59th is the `-A` preflight, and no convergence point because it never 7745 # 59th is the `-A` preflight, and no convergence point because it never
7620 # attaches: what it asserts on is an exit code and a message, before any 7746 # attaches: what it asserts on is an exit code and a message, before any
7621 # transport exists to converge. 7747 # transport exists to converge. The 61st is the mouse report at the
7622 [ "$OK_COUNT" = "60" ] || { 7748 # unzoomed wall, and no convergence point because its subject is WHICH
7623 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 60 —" 7749 # SESSION a keystroke reached — read off two daemon captures, one of which
7750 # must not contain it — and a client grid compared against the daemon's
7751 # says nothing about where the wall decided to send input. It is last in
7752 # the file rather than beside the other wall legs on purpose: the ordinals
7753 # in this paragraph are positions, so a scenario inserted in the middle
7754 # renumbers every sentence after it.
7755 [ "$OK_COUNT" = "61" ] || {
7756 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 61 —"
7624 echo " a scenario was added (update the pin) or silently lost" 7757 echo " a scenario was added (update the pin) or silently lost"
7625 exit 1 7758 exit 1
7626 } 7759 }
@@ -7628,4 +7761,4 @@ DPID=""
7628 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 35" 7761 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 35"
7629 exit 1 7762 exit 1
7630 } 7763 }
7631 echo "e2e OK (60 scenarios, 35 convergence points)" 7764 echo "e2e OK (61 scenarios, 35 convergence points)"