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 89c842c0..cee7ab39 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,41 @@ 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' }) + }) + + it('does not match prefixless fragments inside longer digit runs', () => { + const [[key]] = parseMultipleKeypresses(INITIAL_STATE, '1234;56;78M9;10;11M') + + expect(key).toMatchObject({ kind: 'key', sequence: '1234;56;78M9;10;11M' }) + }) +}) 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 3a21aa26..a92a72b5 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 = /(? 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 38ad8fe6..27699134 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 7add5998..79d6981f 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