a73x

c1798e71

docs: the wheel in the key table, and why the app owns the mouse

a73x   2026-08-19 21:02

Commit message
docs: the wheel in the key table, and why the app owns the mouse

The decisions entry carries the rule the design turns on — what the session
asked the terminal for, the client mirrors — and the two costs that came
with it: a report spelled wrong is garbage typed into the application, so
the modes travel one bit per DEC mode rather than as one "wants mouse" bit;
and a terminal that reports the mouse stops selecting text with it, which
is the reason the roadmap had this item banked. Shift+drag is the fallback,
not a fix, so the roadmap keeps the CLI copy mode and says why it is now
wanted more.

README.md
Old New
@@ -40,7 +40,20 @@ Inside a session:
40 | `Ctrl-\` `c` | create a session and switch to it (the old one keeps running) | 40 | `Ctrl-\` `c` | create a session and switch to it (the old one keeps running) |
41 | `Ctrl-\` `n` / `Ctrl-\` `p` | switch to the next / previous session, wrapping | 41 | `Ctrl-\` `n` / `Ctrl-\` `p` | switch to the next / previous session, wrapping |
42 | `Ctrl-\` `w` | show every session as a wall (`j`/`k` select, `Enter` types, `q` comes back) | 42 | `Ctrl-\` `w` | show every session as a wall (`j`/`k` select, `Enter` types, `q` comes back) |
43 | `Shift+PageUp` / `Shift+PageDown` | scrollback (any other key returns to live) | 43 | `Shift+PageUp` / `Shift+PageDown` | scrollback, a screen at a time (any other key returns to live) |
44 | mouse wheel | scrollback, three rows a notch (arrow keys to a full-screen app) |
45
46 The wheel goes to whatever is in front of it. At a shell prompt it moves
47 mux's scrollback, three rows a notch. In a full-screen application that did
48 not ask for the mouse — `less`, `man`, most pagers — it becomes arrow keys,
49 so the application scrolls the way it always does. And an application that
50 DID ask the terminal for mouse reporting — vim with `set mouse=a`, htop —
51 gets every notch, click and drag forwarded to it untouched, with
52 `Shift+PageUp` still reaching mux's own scrollback. Clicks and drags with
53 nothing asking for them are discarded rather than typed at your shell.
54
55 Selecting text with the mouse needs Shift held down while mux is running,
56 because a terminal that is reporting the mouse is not selecting with it.
44 57
45 `mux` again reattaches — full TUI screens included. Kill the client with 58 `mux` again reattaches — full TUI screens included. Kill the client with
46 `kill -9` if you like; the session doesn't care. 59 `kill -9` if you like; the session doesn't care.
docs/decisions.md
Old New
@@ -4137,3 +4137,108 @@ work out is not a reason to end the wall the user is still standing in. The
4137 `--via` case is `unreachable` rather than an error, because the wall grammar has 4137 `--via` case is `unreachable` rather than an error, because the wall grammar has
4138 no `--via` spelling to parse: no tile can hold that target, and pretending 4138 no `--via` spelling to parse: no tile can hold that target, and pretending
4139 otherwise would add an error path nothing can reach. 4139 otherwise would add an error path nothing can reach.
4140
4141 ## 2026-08-19 (the wheel, and who owns the mouse)
4142
4143 The complaint: spinning the wheel in a mux session walked the shell's history
4144 instead of the view. Nothing in mux was reading the wheel — that was the bug.
4145 A terminal that has been asked for no mouse reporting answers the wheel on the
4146 alternate screen by synthesising arrow keys (DEC 1007, "alternate scroll"), and
4147 the client is always on the alternate screen, so every notch arrived as `\x1b[A`
4148 in the stdin stream and went to the pty as input. Shift+PageUp was the only
4149 scrollback control there was.
4150
4151 So the client asks its own terminal for real wheel events — 1000 (button
4152 presses) in 1006 (SGR reports) — and spends them on the scrollback it already
4153 has. Asking is also what stops the synthesis, so the fix removes the symptom
4154 and the cause with the same bytes. The cheaper alternative was considered and
4155 rejected: intercepting the synthesised arrows while already scrolled needs no
4156 mouse reporting at all, but it does nothing for the filed complaint, which is
4157 about the wheel at the LIVE view, where an arrow is a keystroke the shell has
4158 every right to receive.
4159
4160 **An application that asks for the mouse owns it.** This is the rule the design
4161 turns on, and it is the bracketed-paste rule applied to a device the client also
4162 has a use for: what the session asked the terminal for, the client mirrors onto
4163 the real terminal. `term_modes` grew eight bits, one per mouse DEC mode
4164 (9/1000/1002/1003 tracking, 1005/1006/1015/1016 format), so the client can ask
4165 for exactly the set vim asked for and forward every mouse byte verbatim. Not one
4166 "wants the mouse" bit, which was the obvious cheaper shape: a report spelled in
4167 a format the application did not ask for is garbage typed into it, and an
4168 application that asked for drag reports and got only presses is one whose
4169 selection silently does not work. The reserved bits were already documented as
4170 the mouse modes' future home, so this needed no new frame type and no version
4171 check. An old daemon sends them zero, which a new client reads as "nobody wants
4172 the mouse" and keeps the wheel — the pre-mouse behaviour; an old client ignores
4173 them entirely.
4174
4175 Clicks and drags with no application asking are DISCARDED, not forwarded.
4176 There is nobody to send them to, and forwarding them types `[<0;40;12M` at the
4177 user's shell — which is the failure mode the whole feature exists to stop.
4178
4179 There is a third case, and review caught it missing: an application on the
4180 ALTERNATE screen that never asked for the mouse — `less`, `man`, any pager.
4181 The alt screen has no scrollback of ours (`historyRows` is 0 there by
4182 contract), so the notch was consumed by the filter and then dropped by
4183 arithmetic that saturated at a history of zero: the wheel was simply dead in
4184 a pager, which is worse than what it replaced, since the terminal's own
4185 alternate-scroll synthesis had been switched off by our asking for reports.
4186 So mux now does the synthesis itself, as tmux's `alternate-scroll` does: at
4187 the live view, on the alt screen, with nobody holding the mouse, a notch
4188 becomes `wheel_rows` arrow keys sent as input.
4189
4190 And the arrows have to be spelled the way the session reads them. The first
4191 version sent `ESC [ A` and moved `less` not one line: less puts the cursor
4192 keys in APPLICATION mode (DECCKM) and reads `ESC O A`, as every curses
4193 program does. The failure is silent — the escape is ignored and the page
4194 stays put — which is exactly the symptom the review had reported, so a fix
4195 verified only by "arrows reached the pty" would have shipped still broken.
4196 It is verified by a real pager now: 200 lines, `+G`, eight notches, view top
4197 178 to 154. DECCKM is read off the replica's own engine, which carries it in
4198 the snapshot's mode section — no new wire bit.
4199
4200 A keystroke that shares a read with a notch is a third thing again. The
4201 "any other key returns to live" rule is about a key typed AT a history view;
4202 one typed at the live view milliseconds before the wheel moved it was typed
4203 at the shell, and swallowing it loses input to a view the user had not seen
4204 yet. `was_live`, sampled before the scroll is applied, is what tells them
4205 apart.
4206
4207 The filter is gated on `alt_screen` — this client having taken a terminal
4208 over — and not on the mode bits alone. A client whose stdin is a PIPE never
4209 wrote `client_mouse_setup` to anything, so nothing it reads can be a mouse
4210 report, and filtering there is pure loss: measured, `printf 'hello
4211 \x1b[<64;10;5M world\n' | mux` reached the pty with the escape deleted.
4212
4213 Scroll state counts rows, not pages. The two devices disagree about the unit —
4214 the keys move a screenful, the wheel three rows, which is what every terminal's
4215 own scrollback does per notch — and only one of them can be the state's.
4216 `fetch_scrollback` already addressed absolute rows, so no wire changed for this;
4217 `replica.scrollStart` lost its `view_rows` argument and the browser client, which
4218 still scrolls by the page, multiplies.
4219
4220 The stdin parser holds an incomplete report across reads only from `ESC [ <`
4221 onward, and that is a deliberate incompleteness. The complete parse would hold a
4222 bare `ESC` waiting to see whether `[ <` follows, which strands every Escape typed
4223 in vim until the next keystroke — an every-session cost to close a split that
4224 needs a terminal to write a mouse report in two writes and the pty to deliver
4225 under 3 bytes in a 16 KiB read. `ESC [ <` is three bytes no keyboard produces,
4226 so the hold is unambiguous where it exists.
4227
4228 Measured while wiring the e2e: the client's own `\x1b[?1000h` puts the substring
4229 `100` into every capture, and tp1's `expect 100` — the attach snapshot's last row
4230 — matched THAT instead, sending the scroll key before the snapshot had landed.
4231 The needle is now `\x1b[0m100`, anchored on the SGR reset the paint writes in
4232 front of a row. The no-counting rule in that suite assumes needles stay unique to
4233 their phase, and a client that writes new bytes to the terminal can break that
4234 assumption from outside the scenario it breaks.
4235
4236 The cost this pays is the one the roadmap banked the item for: a terminal that
4237 is reporting the mouse stops selecting text with it, so a drag inside mux no
4238 longer marks a native selection — and the CLI still has no copy mode of its own
4239 (the browser client does). Every terminal in use here bypasses reporting while
4240 Shift is held (xterm, ghostty, alacritty, kitty, gnome-terminal all do), so
4241 Shift+drag still selects, and that is the mitigation rather than a fix. It is
4242 taken on purpose: the wheel is used constantly and the drag has a documented
4243 key to fall back on, where the wheel had nothing. A native copy mode is what
4244 would settle it properly, and it stays on the roadmap.
docs/roadmap.md
Old New
@@ -414,9 +414,14 @@ half is mirrored; and `063cec67` now has implementation evidence for its
414 copy. Native CLI keyboard copy mode remains a separate problem. Window-title 414 copy. Native CLI keyboard copy mode remains a separate problem. Window-title
415 state and bells rode the same delivery mechanisms. 415 state and bells rode the same delivery mechanisms.
416 416
417 What remains banked is intentionally narrower: terminal mouse-mode mirroring, 417 What remains banked is intentionally narrower: that native CLI copy mode
418 whose reporting would take away native drag selection until the CLI has its 418 itself, and OSC 10/11 colour queries. Terminal mouse-mode mirroring shipped on
419 own copy mode; that native CLI copy mode itself; and OSC 10/11 colour queries. 419 2026-08-19 with the wheel (decisions.md): the client asks its own terminal for
420 wheel events and mirrors the session's mouse modes when an application asks for
421 them. It was banked because its reporting takes native drag selection away
422 until the CLI has a copy mode — that cost is real and now paid, with Shift+drag
423 (which every terminal in use here honours) as the fallback, which makes the
424 copy mode more wanted rather than less.
420 Browser copy/paste and mouse selection shipped on 2026-08-18: the CLI and 425 Browser copy/paste and mouse selection shipped on 2026-08-18: the CLI and
421 web/WASM consume one shared semantic decoder, browser paste follows the 426 web/WASM consume one shared semantic decoder, browser paste follows the
422 sampled DEC mode 2004 state, accepted OSC 52 payloads reach a guarded 427 sampled DEC mode 2004 state, accepted OSC 52 payloads reach a guarded