fix(tui): keep ssh-mangled mouse reports out of the composer - #297
Merged
Conversation
Reported on Discord: ssh from a Mac into the TUI and the mouse layer "spams coordinates" as visible text while the app looks hung. Four layers of hardening: - mouse-stdin: hold a chunk-final lone ESC for 10ms. An ssh hop re-chunks the stream, and a report split right after its ESC used to type `[<64;3;9M` into the composer — the reported spam. If the rest of a report follows it rejoins and decodes; if nothing does, it was the Escape key and flushes (under Ink's own ~20ms lone-Esc deferral). - decoder: consume urxvt/1015 reports instead of dropping them through as text, buffer truncated CSI heads so split sequences reach Ink whole, and document + cover that 1005 already lands in the X10 branch. - leak breaker: report-shaped text about to reach Ink (a burst in one read, or three drips across the session, counted across read boundaries) trips once — strips the shapes, disables tracking for the session only, and posts a warn notice naming /mouse and --no-mouse. - signals: a second SIGINT/SIGTERM/SIGHUP now restores the terminal (mouse reporting off, alt screen left) and exits 130 instead of Node's default kill that skips exit hooks and leaves the shell printing coordinates on every click. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial review proved a hole in the tripped path: forwardText ran the remnant replace per-chunk, so a straggler split across reads — the exact ssh re-chunking this PR exists to survive — still leaked into the composer after the breaker tripped (write `[<0;9` then `;9M` and Ink received `[<0;9;9M` verbatim), contradicting the "keeps stripping the in-flight stragglers" contract. Unlike the pre-trip counter, which scans an already-forwarded tail, the stripper has to keep the bytes out of Ink — so it withholds a chunk-final remnant *prefix* until the rest arrives (stragglers trail each other by well under a millisecond) or a 10ms timer rules it ordinary typing, mirroring the ESC-split hold. Four new tests cover the split straggler, a byte-at-a-time straggler, a partial straggler on the tripping chunk itself, and the timer releasing withheld typing; all four fail without the fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
ssh from a Mac (iTerm2, then Terminal.app) into the TUI and the mouse layer "spams coordinates" as visible text while the app looks hung. Four layers of hardening, from most to least likely cause:
mouse-stdin.ts) — an ssh hop re-chunks the byte stream, so during a wheel/drag flood some read eventually ends exactly on a report's ESC. The decoder forwarded a chunk-final lone ESC as text (that is how the Escape key arrives), which typed the rest of the report —[<64;3;9M— straight into the composer. A chunk-final ESC is now held 10ms: if the rest of a report follows, they rejoin and decode; if nothing follows, it was the Escape key and flushes (well under Ink's own ~20ms lone-Esc deferral, so Escape feel is unchanged).parse-mouse-events.ts) — urxvt/1015 reports (ESC [ 32;62;21 M) are now consumed instead of dropping through as text, with a button-code floor (≥32) so no keyboard CSI can be misread as one; truncated three-param CSI heads are buffered until their final byte so split sequences reach Ink whole; and 1005/UTF-8-extended is documented + covered — stdin is UTF-8-decoded before the parser, so it already lands in the X10 branch.mouse-stdin.ts+tui-command.ts) — if report-shaped text is about to reach Ink anyway (an encoding or mangling nobody anticipated), the shapes are stripped and mouse tracking is disabled for this session only (persisted preference untouched), with a one-line warn notice naming/mouseand--no-mouse. Trips on a burst (2 remnants in one read) or a slow drip (3 across the session, counted across read boundaries); a single report-shaped paste fragment does not trip it, and it never arms while the mouse is off. Post-trip stripping joins reads too (adversarial review caught it running per-chunk, which let a straggler split across reads —[<0;9then;9M— leak through after the trip): a chunk-final remnant prefix is withheld until the rest arrives or a 10ms timer rules it ordinary typing, mirroring the ESC-split hold.signal-escalation.ts) — the handlers wereprocess.once, so a second SIGINT/SIGTERM/SIGHUP fell through to Node's default kill, which skipsexithooks and left the shell in mouse-reporting mode — every click printing[<0;64;21Mat the prompt — and on the alt screen. Over ssh, a hang is exactly when people reach for a second Ctrl-C. The second signal now restores the terminal (via the existingterminal-restore.tsLIFO net) and exits 130.Test evidence
parse-mouse-events.test.ts,mouse-stdin.test.ts,signal-escalation.test.ts,tui-command.mouse.test.ts(the original 18 — 11 proven to fail with the src files reverted to the old main, plus 2 pinning pre-existing behavior, 3 signal-escalation units and 2 negatives — and 4 from the review fix, all 4 proven to fail without the post-trip joining).src/tui/tree on Node 25.9, after merging current main (v0.5.0, shift-drag selection): 238 files, 2581 tests, all pass (one unrelated runner-level EACCES from a local-models fixture spawning a fake llama-server binary; that file's tests pass).npm run lintclean.Known tradeoff (by design, called out in review): a bracketed paste containing two or more report-shaped fragments — say, a log of this very bug — trips the breaker, strips those substrings and disables the mouse for the session. The notice tells the operator and
/mouse onturns tracking back on; treating paste markers specially was judged not worth the extra state for a paste that specific.Reported on Discord: https://discord.com/channels/1515649306781155428/1515650562430079048/1542210929297461248