c1798e71
docs: the wheel in the key table, and why the app owns the mouse
a73x 2026-08-19 21:02
Commit message
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 |