Compare commits

...

1 Commits

Author SHA1 Message Date
teknium1
978ea9051d fix(tui): stop X10 mouse motion/hover reports leaking into the prompt
On long sessions, moving the mouse filled the prompt with garbage and made
it impossible to type. The terminal emits a mouse report on every cursor move
(default tracking is wheel+click+drag+hover); a heavy transcript render blocks
Node's event loop past App's 50ms input-flush timer, so the report gets split:
the leading ESC is flushed alone and the rest arrives as plain text.

parse-keypress already re-synthesizes split reports, but its X10 button-byte
filter was [\x60-\x7f] — wheel events only. Every motion/hover/click/drag
report (Cb 0-35, bytes \x20-'C') fell through and dumped its '[MC..' payload
into the prompt. mode-1003 hover (byte 'C') fires on every move, hence the
'input grows when I move the mouse' symptom on terminals like tmux that honor
1000/1002 but ignore SGR 1006.

- Widen the orphaned-X10-tail range to the full button byte (\x20-\x7f) so
  split motion/hover/click/drag re-parse as mouse events; wheel still scrolls.
- Suppress a dangling mouse-report prefix (\x1b[, \x1b[<, \x1b[<35;80) on the
  watchdog flush so the rarer SGR-split case stops leaking a bare '['.

PR #30084 only added DEC-mode presets (what the terminal emits); this fixes the
read-back path, which is why cases persisted with tracking on.
2026-05-30 21:03:06 -07:00
2 changed files with 85 additions and 3 deletions

View File

@@ -165,3 +165,63 @@ describe('fragmented SGR mouse recovery', () => {
expect(events).toEqual([])
})
})
describe('mouse report split across the App watchdog flush', () => {
// A heavy render on a long session blocks Node's event loop past App's 50ms
// flush timer, splitting a mouse report: the leading ESC is flushed alone and
// the remainder arrives as a text token on the next readable event. Without
// recovery, the tail leaks into the prompt as the cursor moves.
// X10 (legacy 1000/1002/1003) payload byte = value + 32. No-button motion
// under mode 1003 is Cb=35 -> byte 'C' (0x43). tmux and other terminals that
// honor 1000/1002 but ignore SGR 1006 emit this on every cursor move.
const x10 = (cb: number, col: number, row: number) =>
`[M${String.fromCharCode(cb + 32, col + 32, row + 32)}`
it('recovers a split X10 hover report instead of leaking [MC.. into the prompt', () => {
// Lone ESC buffered (could begin a longer sequence), then the [M tail
// arrives as text on the next readable event. Pre-fix this leaked the full
// "[MCxy" payload into the prompt.
const [escKeys, afterEsc] = parseMultipleKeypresses(INITIAL_STATE, '\x1b')
expect(escKeys).toEqual([]) // ESC is held until the next chunk resolves it
const [tailKeys] = parseMultipleKeypresses(afterEsc, x10(35, 80, 24))
expect(tailKeys).toHaveLength(1)
expect(tailKeys[0]).toMatchObject({ kind: 'key', name: 'mouse' })
})
it('still recovers a split X10 wheel report (scroll keeps working)', () => {
const [, afterEsc] = parseMultipleKeypresses(INITIAL_STATE, '\x1b')
const [tailKeys] = parseMultipleKeypresses(afterEsc, x10(64, 80, 24))
expect(tailKeys).toHaveLength(1)
expect(tailKeys[0]).toMatchObject({ kind: 'key', name: 'wheelup' })
})
it('suppresses a dangling SGR mouse prefix on flush instead of leaking "["', () => {
// ESC[ flushed by the watchdog (incomplete CSI), then the <...M tail
// arrives as text. Pre-fix the flushed "\x1b[" leaked a bare "[".
const [feedKeys, pending] = parseMultipleKeypresses(INITIAL_STATE, '\x1b[')
expect(feedKeys).toEqual([])
const [flushKeys, flushed] = parseMultipleKeypresses(pending, null)
expect(flushKeys).toEqual([]) // the dangling "\x1b[" is dropped, not leaked
const [tailKeys] = parseMultipleKeypresses(flushed, '<35;80;24M')
expect(tailKeys).toEqual([expect.objectContaining({ kind: 'mouse', button: 35, col: 80, row: 24 })])
})
it('drops a dangling "\\x1b[<" prefix flushed by the watchdog', () => {
const [, pending] = parseMultipleKeypresses(INITIAL_STATE, '\x1b[<')
const [flushKeys] = parseMultipleKeypresses(pending, null)
expect(flushKeys).toEqual([])
})
it('still flushes a lone ESC as the Escape key (not suppressed)', () => {
const [, pending] = parseMultipleKeypresses(INITIAL_STATE, '\x1b')
const [flushKeys] = parseMultipleKeypresses(pending, null)
expect(flushKeys).toEqual([expect.objectContaining({ kind: 'key', name: 'escape' })])
})
})

View File

@@ -64,6 +64,12 @@ const XTVERSION_RE = /^\x1bP>\|(.*?)(?:\x07|\x1b\\)$/s
// eslint-disable-next-line no-control-regex
const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/
const SGR_MOUSE_FRAGMENT_RE = /(?<!\d)(?:\[<|<)?(?:[0-9]|[1-9][0-9]|1\d{2}|2[0-4]\d|25[0-5]);\d+;\d+[Mm]/g
// A truncated mouse-report prefix with no final byte yet. Matches partial SGR
// (`\x1b[`, `\x1b[<`, `\x1b[<35;80`) and partial X10 (`\x1b[M`, `\x1b[M`+1-2
// payload bytes). Used to suppress these on flush so the watchdog doesn't leak
// `[`, `[<`, etc. into the prompt when a heavy render splits a mouse report.
// eslint-disable-next-line no-control-regex
const DANGLING_MOUSE_PREFIX_RE = /^\x1b\[(?:<[\d;]*|M[\x20-\uffff]{0,2})?$/
// Whole-text mouse-burst noise fast path. When a heavy render blocks the event
// loop past App's 50ms flush watchdog, a long burst of SGR mouse reports (mode
@@ -278,6 +284,15 @@ export function parseMultipleKeypresses(
} else if (inPaste) {
// Sequences inside paste are treated as literal text
pasteBuffer += token.value
} else if (isFlush && DANGLING_MOUSE_PREFIX_RE.test(token.value)) {
// Truncated mouse-report prefix flushed by App's 50ms watchdog. A
// heavy render blocked the event loop while a report was mid-arrival,
// so the tokenizer holds an incomplete CSI like `\x1b[`, `\x1b[<`, or
// `\x1b[<35;80` with no final byte yet. No physical keypress produces
// a dangling mouse prefix (Alt+[ arrives complete in one read), so on
// flush we drop it rather than leak `[`, `[<`, etc. into the prompt.
// The report's tail (`<35;80;24M`) arrives next as a text token and is
// recovered by parseTextWithSgrMouseFragments.
} else {
const response = parseTerminalResponse(token.value)
@@ -311,12 +326,19 @@ export function parseMultipleKeypresses(
if (mouseFragments) {
keys.push(...mouseFragments)
} else if (/^\[M[\x60-\x7f][\x20-\uffff]{2}$/.test(token.value)) {
// Orphaned X10 wheel tail (fullscreen only — mouse tracking is off
} else if (/^\[M[\x20-\x7f][\x20-\uffff]{2}$/.test(token.value)) {
// Orphaned X10 mouse tail (fullscreen only — mouse tracking is off
// otherwise). A heavy render blocked the event loop past App's 50ms
// flush timer, so the buffered ESC was flushed as a lone Escape and
// the continuation arrived as text. Re-synthesize with ESC so the
// scroll event still fires instead of leaking into the prompt.
// event is parsed as a mouse event instead of leaking into the prompt.
//
// The button-byte range is the full \x20-\x7f (X10 Cb = byte 32,
// values 095): wheel events (Cb 64/65 → byte \x60/\x61) AND motion,
// drag, click, and mode-1003 hover events (Cb 035 → byte \x20-\x43).
// The original \x60-\x7f range only re-synthesized wheel tails, so on
// long sessions every split hover/motion report (Cb 35 → byte 'C')
// leaked its `[MCxy` payload into the prompt row as the cursor moved.
const resynthesized = '\x1b' + token.value
keys.push(parseKeypress(resynthesized))
} else {