From 71b685aee0e7f2f7c7dbdbd3e9b91ece66a701a4 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Apr 2026 17:43:21 -0500 Subject: [PATCH] fix(tui): recover fragmented SGR mouse reports --- .../hermes-ink/src/ink/parse-keypress.test.ts | 32 ++++++ .../hermes-ink/src/ink/parse-keypress.ts | 103 +++++++++++++++--- ui-tui/src/__tests__/terminalModes.test.ts | 10 +- ui-tui/src/lib/terminalModes.ts | 8 ++ 4 files changed, 136 insertions(+), 17 deletions(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts index 89c842c015..893eb42ed2 100644 --- a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts @@ -96,3 +96,35 @@ describe('mouse wheel modifier decoding', () => { 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' }) + }) +}) diff --git a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts index 3a21aa2646..b79b839e85 100644 --- a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts +++ b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts @@ -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. // eslint-disable-next-line no-control-regex 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 { return { @@ -267,23 +268,22 @@ export function parseMultipleKeypresses( } else if (token.type === 'text') { if (inPaste) { 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 `[ 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 { let parts diff --git a/ui-tui/src/__tests__/terminalModes.test.ts b/ui-tui/src/__tests__/terminalModes.test.ts index 38ad8fe6a2..6b721b37f1 100644 --- a/ui-tui/src/__tests__/terminalModes.test.ts +++ b/ui-tui/src/__tests__/terminalModes.test.ts @@ -3,11 +3,19 @@ import { describe, expect, it, vi } from 'vitest' import { resetTerminalModes, TERMINAL_MODE_RESET } from '../lib/terminalModes.js' 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[?1005l') expect(TERMINAL_MODE_RESET).toContain('\x1b[?1003l') 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[?9l') expect(TERMINAL_MODE_RESET).toContain('\x1b[?1004l') expect(TERMINAL_MODE_RESET).toContain('\x1b[?2004l') expect(TERMINAL_MODE_RESET).toContain('\x1b[?1049l') diff --git a/ui-tui/src/lib/terminalModes.ts b/ui-tui/src/lib/terminalModes.ts index 7add599892..84d3f0850b 100644 --- a/ui-tui/src/lib/terminalModes.ts +++ b/ui-tui/src/lib/terminalModes.ts @@ -1,10 +1,18 @@ import { writeSync } from 'node:fs' 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[?1005l' + // UTF-8 extended mouse '\x1b[?1003l' + // any-motion mouse '\x1b[?1002l' + // button-motion mouse + '\x1b[?1001l' + // highlight mouse '\x1b[?1000l' + // click mouse + '\x1b[?9l' + // X10 mouse '\x1b[?1004l' + // focus events '\x1b[?2004l' + // bracketed paste '\x1b[?1049l' + // alternate screen