Skip to content

Commit 8fed969

Browse files
authored
Merge pull request #18113 from NousResearch/bb/tui-sgr-mouse-fragments
fix(tui): recover fragmented SGR mouse reports
2 parents bbbce92 + ded011c commit 8fed969

4 files changed

Lines changed: 142 additions & 17 deletions

File tree

ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,41 @@ describe('mouse wheel modifier decoding', () => {
9696
expect(key).toMatchObject({ name: 'wheelup', meta: true })
9797
})
9898
})
99+
100+
describe('fragmented SGR mouse recovery', () => {
101+
it('re-synthesizes bracket-only SGR mouse tails as mouse events', () => {
102+
const [[mouse]] = parseMultipleKeypresses(INITIAL_STATE, '[<35;159;11M')
103+
104+
expect(mouse).toMatchObject({ kind: 'mouse', button: 35, col: 159, row: 11, action: 'press' })
105+
})
106+
107+
it('re-synthesizes angle-only SGR mouse tails as mouse events', () => {
108+
const [[mouse]] = parseMultipleKeypresses(INITIAL_STATE, '<35;159;11M')
109+
110+
expect(mouse).toMatchObject({ kind: 'mouse', button: 35, col: 159, row: 11, action: 'press' })
111+
})
112+
113+
it('re-synthesizes degraded SGR mouse bursts without leaking prompt text', () => {
114+
const [events] = parseMultipleKeypresses(INITIAL_STATE, '5;142;11M<35;159;11M35;124;26M35;119;26Mtyped')
115+
116+
expect(events.slice(0, 4)).toEqual([
117+
expect.objectContaining({ kind: 'mouse', button: 5, col: 142, row: 11 }),
118+
expect.objectContaining({ kind: 'mouse', button: 35, col: 159, row: 11 }),
119+
expect.objectContaining({ kind: 'mouse', button: 35, col: 124, row: 26 }),
120+
expect.objectContaining({ kind: 'mouse', button: 35, col: 119, row: 26 })
121+
])
122+
expect(events[4]).toMatchObject({ kind: 'key', sequence: 'typed' })
123+
})
124+
125+
it('keeps isolated semicolon text that only resembles a prefixless mouse report', () => {
126+
const [[key]] = parseMultipleKeypresses(INITIAL_STATE, 'see 1;2;3M for details')
127+
128+
expect(key).toMatchObject({ kind: 'key', sequence: 'see 1;2;3M for details' })
129+
})
130+
131+
it('does not match prefixless fragments inside longer digit runs', () => {
132+
const [[key]] = parseMultipleKeypresses(INITIAL_STATE, '1234;56;78M9;10;11M')
133+
134+
expect(key).toMatchObject({ kind: 'key', sequence: '1234;56;78M9;10;11M' })
135+
})
136+
})

ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts

Lines changed: 87 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ const XTVERSION_RE = /^\x1bP>\|(.*?)(?:\x07|\x1b\\)$/s
6363
// Button 32=left-drag (0x20 | motion-bit). Plain 0/1/2 = left/mid/right click.
6464
// eslint-disable-next-line no-control-regex
6565
const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/
66+
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
6667

6768
function createPasteKey(content: string): ParsedKey {
6869
return {
@@ -267,23 +268,22 @@ export function parseMultipleKeypresses(
267268
} else if (token.type === 'text') {
268269
if (inPaste) {
269270
pasteBuffer += token.value
270-
} else if (/^\[<\d+;\d+;\d+[Mm]$/.test(token.value) || /^\[M[\x60-\x7f][\x20-\uffff]{2}$/.test(token.value)) {
271-
// Orphaned SGR/X10 mouse tail (fullscreen only — mouse tracking is off
272-
// otherwise). A heavy render blocked the event loop past App's 50ms
273-
// flush timer, so the buffered ESC was flushed as a lone Escape and
274-
// the continuation `[<btn;col;rowM` arrived as text. Re-synthesize
275-
// with the ESC prefix so the scroll event still fires instead of
276-
// leaking into the prompt. The spurious Escape is gone; App.tsx's
277-
// readableLength check prevents it. The X10 Cb slot is narrowed to
278-
// the wheel range [\x60-\x7f] (0x40|modifiers + 32) — a full [\x20-]
279-
// range would match typed input like `[MAX]` batched into one read
280-
// and silently drop it as a phantom click. Click/drag orphans leak
281-
// as visible garbage instead; deletable garbage beats silent loss.
282-
const resynthesized = '\x1b' + token.value
283-
const mouse = parseMouseEvent(resynthesized)
284-
keys.push(mouse ?? parseKeypress(resynthesized))
285271
} else {
286-
keys.push(parseKeypress(token.value))
272+
const mouseFragments = parseTextWithSgrMouseFragments(token.value)
273+
274+
if (mouseFragments) {
275+
keys.push(...mouseFragments)
276+
} else if (/^\[M[\x60-\x7f][\x20-\uffff]{2}$/.test(token.value)) {
277+
// Orphaned X10 wheel tail (fullscreen only — mouse tracking is off
278+
// otherwise). A heavy render blocked the event loop past App's 50ms
279+
// flush timer, so the buffered ESC was flushed as a lone Escape and
280+
// the continuation arrived as text. Re-synthesize with ESC so the
281+
// scroll event still fires instead of leaking into the prompt.
282+
const resynthesized = '\x1b' + token.value
283+
keys.push(parseKeypress(resynthesized))
284+
} else {
285+
keys.push(parseKeypress(token.value))
286+
}
287287
}
288288
}
289289
}
@@ -625,6 +625,77 @@ function parseMouseEvent(s: string): ParsedMouse | null {
625625
}
626626
}
627627

628+
function normalizeSgrMouseFragment(fragment: string): string {
629+
if (fragment.startsWith('[<')) {
630+
return `\x1b${fragment}`
631+
}
632+
633+
if (fragment.startsWith('<')) {
634+
return `\x1b[${fragment}`
635+
}
636+
637+
return `\x1b[<${fragment}`
638+
}
639+
640+
function parseSgrMouseFragment(fragment: string): ParsedInput {
641+
const sequence = normalizeSgrMouseFragment(fragment)
642+
return parseMouseEvent(sequence) ?? parseKeypress(sequence)
643+
}
644+
645+
function parseTextWithSgrMouseFragments(text: string): ParsedInput[] | null {
646+
SGR_MOUSE_FRAGMENT_RE.lastIndex = 0
647+
648+
const matches = [...text.matchAll(SGR_MOUSE_FRAGMENT_RE)]
649+
if (matches.length === 0) {
650+
return null
651+
}
652+
653+
const parsed: ParsedInput[] = []
654+
let cursor = 0
655+
let consumedAny = false
656+
657+
for (let i = 0; i < matches.length;) {
658+
const first = matches[i]!
659+
const run: RegExpMatchArray[] = [first]
660+
let runEnd = first.index! + first[0].length
661+
i++
662+
663+
while (i < matches.length && matches[i]!.index === runEnd) {
664+
run.push(matches[i]!)
665+
runEnd = matches[i]!.index! + matches[i]![0].length
666+
i++
667+
}
668+
669+
const hasExplicitMousePrefix = run.some(match => match[0].startsWith('[<') || match[0].startsWith('<'))
670+
const isFragmentBurst = run.length > 1
671+
672+
if (!hasExplicitMousePrefix && !isFragmentBurst) {
673+
continue
674+
}
675+
676+
if (first.index! > cursor) {
677+
parsed.push(parseKeypress(text.slice(cursor, first.index!)))
678+
}
679+
680+
for (const match of run) {
681+
parsed.push(parseSgrMouseFragment(match[0]))
682+
}
683+
684+
cursor = runEnd
685+
consumedAny = true
686+
}
687+
688+
if (!consumedAny) {
689+
return null
690+
}
691+
692+
if (cursor < text.length) {
693+
parsed.push(parseKeypress(text.slice(cursor)))
694+
}
695+
696+
return parsed
697+
}
698+
628699
function parseKeypress(s: string = ''): ParsedKey {
629700
let parts
630701

ui-tui/src/__tests__/terminalModes.test.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,19 @@ import { describe, expect, it, vi } from 'vitest'
33
import { resetTerminalModes, TERMINAL_MODE_RESET } from '../lib/terminalModes.js'
44

55
describe('terminal mode reset', () => {
6-
it('includes the sticky input modes Hermes enables', () => {
6+
it('includes common sticky input modes', () => {
7+
expect(TERMINAL_MODE_RESET).toContain('\x1b[0\'z')
8+
expect(TERMINAL_MODE_RESET).toContain('\x1b[0\'{')
9+
expect(TERMINAL_MODE_RESET).toContain('\x1b[?2029l')
10+
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1016l')
11+
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1015l')
712
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1006l')
13+
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1005l')
814
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1003l')
915
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1002l')
16+
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1001l')
1017
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1000l')
18+
expect(TERMINAL_MODE_RESET).toContain('\x1b[?9l')
1119
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1004l')
1220
expect(TERMINAL_MODE_RESET).toContain('\x1b[?2004l')
1321
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1049l')

ui-tui/src/lib/terminalModes.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
import { writeSync } from 'node:fs'
22

33
export const TERMINAL_MODE_RESET =
4+
'\x1b[0\'z' + // DEC locator reporting
5+
'\x1b[0\'{' + // selectable locator events
6+
'\x1b[?2029l' + // passive mouse
7+
'\x1b[?1016l' + // SGR-pixels mouse
8+
'\x1b[?1015l' + // urxvt decimal mouse
49
'\x1b[?1006l' + // SGR mouse
10+
'\x1b[?1005l' + // UTF-8 extended mouse
511
'\x1b[?1003l' + // any-motion mouse
612
'\x1b[?1002l' + // button-motion mouse
13+
'\x1b[?1001l' + // highlight mouse
714
'\x1b[?1000l' + // click mouse
15+
'\x1b[?9l' + // X10 mouse
816
'\x1b[?1004l' + // focus events
917
'\x1b[?2004l' + // bracketed paste
1018
'\x1b[?1049l' + // alternate screen

0 commit comments

Comments
 (0)