fix(tui): recover fragmented SGR mouse reports
This commit is contained in:
@@ -96,3 +96,35 @@ describe('mouse wheel modifier decoding', () => {
|
|||||||
expect(key).toMatchObject({ name: 'wheelup', meta: true })
|
expect(key).toMatchObject({ name: 'wheelup', meta: true })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('fragmented SGR mouse recovery', () => {
|
||||||
|
it('re-synthesizes bracket-only SGR mouse tails as mouse events', () => {
|
||||||
|
const [[mouse]] = parseMultipleKeypresses(INITIAL_STATE, '[<35;159;11M')
|
||||||
|
|
||||||
|
expect(mouse).toMatchObject({ kind: 'mouse', button: 35, col: 159, row: 11, action: 'press' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('re-synthesizes angle-only SGR mouse tails as mouse events', () => {
|
||||||
|
const [[mouse]] = parseMultipleKeypresses(INITIAL_STATE, '<35;159;11M')
|
||||||
|
|
||||||
|
expect(mouse).toMatchObject({ kind: 'mouse', button: 35, col: 159, row: 11, action: 'press' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('re-synthesizes degraded SGR mouse bursts without leaking prompt text', () => {
|
||||||
|
const [events] = parseMultipleKeypresses(INITIAL_STATE, '5;142;11M<35;159;11M35;124;26M35;119;26Mtyped')
|
||||||
|
|
||||||
|
expect(events.slice(0, 4)).toEqual([
|
||||||
|
expect.objectContaining({ kind: 'mouse', button: 5, col: 142, row: 11 }),
|
||||||
|
expect.objectContaining({ kind: 'mouse', button: 35, col: 159, row: 11 }),
|
||||||
|
expect.objectContaining({ kind: 'mouse', button: 35, col: 124, row: 26 }),
|
||||||
|
expect.objectContaining({ kind: 'mouse', button: 35, col: 119, row: 26 })
|
||||||
|
])
|
||||||
|
expect(events[4]).toMatchObject({ kind: 'key', sequence: 'typed' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps isolated semicolon text that only resembles a prefixless mouse report', () => {
|
||||||
|
const [[key]] = parseMultipleKeypresses(INITIAL_STATE, 'see 1;2;3M for details')
|
||||||
|
|
||||||
|
expect(key).toMatchObject({ kind: 'key', sequence: 'see 1;2;3M for details' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ const XTVERSION_RE = /^\x1bP>\|(.*?)(?:\x07|\x1b\\)$/s
|
|||||||
// Button 32=left-drag (0x20 | motion-bit). Plain 0/1/2 = left/mid/right click.
|
// Button 32=left-drag (0x20 | motion-bit). Plain 0/1/2 = left/mid/right click.
|
||||||
// eslint-disable-next-line no-control-regex
|
// eslint-disable-next-line no-control-regex
|
||||||
const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/
|
const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/
|
||||||
|
const SGR_MOUSE_FRAGMENT_RE = /(?:\[<|<)?(?:[0-9]|[1-9][0-9]|1\d{2}|2[0-4]\d|25[0-5]);\d+;\d+[Mm]/g
|
||||||
|
|
||||||
function createPasteKey(content: string): ParsedKey {
|
function createPasteKey(content: string): ParsedKey {
|
||||||
return {
|
return {
|
||||||
@@ -267,23 +268,22 @@ export function parseMultipleKeypresses(
|
|||||||
} else if (token.type === 'text') {
|
} else if (token.type === 'text') {
|
||||||
if (inPaste) {
|
if (inPaste) {
|
||||||
pasteBuffer += token.value
|
pasteBuffer += token.value
|
||||||
} else if (/^\[<\d+;\d+;\d+[Mm]$/.test(token.value) || /^\[M[\x60-\x7f][\x20-\uffff]{2}$/.test(token.value)) {
|
|
||||||
// Orphaned SGR/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 `[<btn;col;rowM` arrived as text. Re-synthesize
|
|
||||||
// with the ESC prefix so the scroll event still fires instead of
|
|
||||||
// leaking into the prompt. The spurious Escape is gone; App.tsx's
|
|
||||||
// readableLength check prevents it. The X10 Cb slot is narrowed to
|
|
||||||
// the wheel range [\x60-\x7f] (0x40|modifiers + 32) — a full [\x20-]
|
|
||||||
// range would match typed input like `[MAX]` batched into one read
|
|
||||||
// and silently drop it as a phantom click. Click/drag orphans leak
|
|
||||||
// as visible garbage instead; deletable garbage beats silent loss.
|
|
||||||
const resynthesized = '\x1b' + token.value
|
|
||||||
const mouse = parseMouseEvent(resynthesized)
|
|
||||||
keys.push(mouse ?? parseKeypress(resynthesized))
|
|
||||||
} else {
|
} else {
|
||||||
keys.push(parseKeypress(token.value))
|
const mouseFragments = parseTextWithSgrMouseFragments(token.value)
|
||||||
|
|
||||||
|
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
|
||||||
|
// 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.
|
||||||
|
const resynthesized = '\x1b' + token.value
|
||||||
|
keys.push(parseKeypress(resynthesized))
|
||||||
|
} else {
|
||||||
|
keys.push(parseKeypress(token.value))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -625,6 +625,77 @@ function parseMouseEvent(s: string): ParsedMouse | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeSgrMouseFragment(fragment: string): string {
|
||||||
|
if (fragment.startsWith('[<')) {
|
||||||
|
return `\x1b${fragment}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fragment.startsWith('<')) {
|
||||||
|
return `\x1b[${fragment}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `\x1b[<${fragment}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSgrMouseFragment(fragment: string): ParsedInput {
|
||||||
|
const sequence = normalizeSgrMouseFragment(fragment)
|
||||||
|
return parseMouseEvent(sequence) ?? parseKeypress(sequence)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTextWithSgrMouseFragments(text: string): ParsedInput[] | null {
|
||||||
|
SGR_MOUSE_FRAGMENT_RE.lastIndex = 0
|
||||||
|
|
||||||
|
const matches = [...text.matchAll(SGR_MOUSE_FRAGMENT_RE)]
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed: ParsedInput[] = []
|
||||||
|
let cursor = 0
|
||||||
|
let consumedAny = false
|
||||||
|
|
||||||
|
for (let i = 0; i < matches.length;) {
|
||||||
|
const first = matches[i]!
|
||||||
|
const run: RegExpMatchArray[] = [first]
|
||||||
|
let runEnd = first.index! + first[0].length
|
||||||
|
i++
|
||||||
|
|
||||||
|
while (i < matches.length && matches[i]!.index === runEnd) {
|
||||||
|
run.push(matches[i]!)
|
||||||
|
runEnd = matches[i]!.index! + matches[i]![0].length
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasExplicitMousePrefix = run.some(match => match[0].startsWith('[<') || match[0].startsWith('<'))
|
||||||
|
const isFragmentBurst = run.length > 1
|
||||||
|
|
||||||
|
if (!hasExplicitMousePrefix && !isFragmentBurst) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (first.index! > cursor) {
|
||||||
|
parsed.push(parseKeypress(text.slice(cursor, first.index!)))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const match of run) {
|
||||||
|
parsed.push(parseSgrMouseFragment(match[0]))
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = runEnd
|
||||||
|
consumedAny = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!consumedAny) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursor < text.length) {
|
||||||
|
parsed.push(parseKeypress(text.slice(cursor)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
function parseKeypress(s: string = ''): ParsedKey {
|
function parseKeypress(s: string = ''): ParsedKey {
|
||||||
let parts
|
let parts
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,19 @@ import { describe, expect, it, vi } from 'vitest'
|
|||||||
import { resetTerminalModes, TERMINAL_MODE_RESET } from '../lib/terminalModes.js'
|
import { resetTerminalModes, TERMINAL_MODE_RESET } from '../lib/terminalModes.js'
|
||||||
|
|
||||||
describe('terminal mode reset', () => {
|
describe('terminal mode reset', () => {
|
||||||
it('includes the sticky input modes Hermes enables', () => {
|
it('includes common sticky input modes', () => {
|
||||||
|
expect(TERMINAL_MODE_RESET).toContain("\x1b[0'z")
|
||||||
|
expect(TERMINAL_MODE_RESET).toContain("\x1b[0'{")
|
||||||
|
expect(TERMINAL_MODE_RESET).toContain('\x1b[?2029l')
|
||||||
|
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1016l')
|
||||||
|
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1015l')
|
||||||
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1006l')
|
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1006l')
|
||||||
|
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1005l')
|
||||||
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1003l')
|
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1003l')
|
||||||
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1002l')
|
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1002l')
|
||||||
|
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1001l')
|
||||||
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1000l')
|
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1000l')
|
||||||
|
expect(TERMINAL_MODE_RESET).toContain('\x1b[?9l')
|
||||||
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1004l')
|
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1004l')
|
||||||
expect(TERMINAL_MODE_RESET).toContain('\x1b[?2004l')
|
expect(TERMINAL_MODE_RESET).toContain('\x1b[?2004l')
|
||||||
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1049l')
|
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1049l')
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import { writeSync } from 'node:fs'
|
import { writeSync } from 'node:fs'
|
||||||
|
|
||||||
export const TERMINAL_MODE_RESET =
|
export const TERMINAL_MODE_RESET =
|
||||||
|
"\x1b[0'z" + // DEC locator reporting
|
||||||
|
"\x1b[0'{" + // selectable locator events
|
||||||
|
'\x1b[?2029l' + // passive mouse
|
||||||
|
'\x1b[?1016l' + // SGR-pixels mouse
|
||||||
|
'\x1b[?1015l' + // urxvt decimal mouse
|
||||||
'\x1b[?1006l' + // SGR mouse
|
'\x1b[?1006l' + // SGR mouse
|
||||||
|
'\x1b[?1005l' + // UTF-8 extended mouse
|
||||||
'\x1b[?1003l' + // any-motion mouse
|
'\x1b[?1003l' + // any-motion mouse
|
||||||
'\x1b[?1002l' + // button-motion mouse
|
'\x1b[?1002l' + // button-motion mouse
|
||||||
|
'\x1b[?1001l' + // highlight mouse
|
||||||
'\x1b[?1000l' + // click mouse
|
'\x1b[?1000l' + // click mouse
|
||||||
|
'\x1b[?9l' + // X10 mouse
|
||||||
'\x1b[?1004l' + // focus events
|
'\x1b[?1004l' + // focus events
|
||||||
'\x1b[?2004l' + // bracketed paste
|
'\x1b[?2004l' + // bracketed paste
|
||||||
'\x1b[?1049l' + // alternate screen
|
'\x1b[?1049l' + // alternate screen
|
||||||
|
|||||||
Reference in New Issue
Block a user