Skip to content

fix(tui): keep ssh-mangled mouse reports out of the composer - #297

Merged
plombeer31 merged 3 commits into
mainfrom
fix/discord-ssh-mouse-hardening
Aug 31, 2026
Merged

fix(tui): keep ssh-mangled mouse reports out of the composer#297
plombeer31 merged 3 commits into
mainfrom
fix/discord-ssh-mouse-hardening

Conversation

@plombeer31

@plombeer31 plombeer31 commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

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:

  1. ESC-split hold (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).
  2. Decoder coverage (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.
  3. Leak breaker (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 /mouse and --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;9 then ;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.
  4. Signal escalation (signal-escalation.ts) — the handlers were process.once, so a second SIGINT/SIGTERM/SIGHUP fell through to Node's default kill, which skips exit hooks and left the shell in mouse-reporting mode — every click printing [<0;64;21M at 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 existing terminal-restore.ts LIFO net) and exits 130.

Test evidence

  • 22 new tests across 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).
  • Full 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 lint clean.
  • No in-repo PTY test: the failure needs an ssh hop's re-chunking, which a local PTY doesn't reproduce deterministically; the chunk-boundary behavior is instead pinned by the unit tests feeding the exact split byte sequences.

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 on turns 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

Valerii and others added 3 commits August 31, 2026 17:38
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>
@plombeer31
plombeer31 merged commit 5378a11 into main Aug 31, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant