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 58745b8c40..89c842c015 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 @@ -39,3 +39,60 @@ describe('parseMultipleKeypresses bracketed paste recovery', () => { expect(state.pasteBuffer).toBe('') }) }) + +describe('mouse wheel modifier decoding', () => { + // SGR mouse format: ESC [ < button ; col ; row M + // Wheel up = 64 (0x40), wheel down = 65 (0x41). + // Modifier bits: shift = 0x04, meta = 0x08, ctrl = 0x10. + const sgrWheel = (button: number) => `\x1b[<${button};10;10M` + + it('plain wheel up has no modifiers', () => { + const [[key]] = parseMultipleKeypresses(INITIAL_STATE, sgrWheel(0x40)) + + expect(key).toMatchObject({ name: 'wheelup', ctrl: false, meta: false, shift: false }) + }) + + it('plain wheel down has no modifiers', () => { + const [[key]] = parseMultipleKeypresses(INITIAL_STATE, sgrWheel(0x41)) + + expect(key).toMatchObject({ name: 'wheeldown', ctrl: false, meta: false, shift: false }) + }) + + it('decodes meta (Alt/Option) on wheel up', () => { + const [[key]] = parseMultipleKeypresses(INITIAL_STATE, sgrWheel(0x40 | 0x08)) + + expect(key).toMatchObject({ name: 'wheelup', ctrl: false, meta: true, shift: false }) + }) + + it('decodes meta (Alt/Option) on wheel down', () => { + const [[key]] = parseMultipleKeypresses(INITIAL_STATE, sgrWheel(0x41 | 0x08)) + + expect(key).toMatchObject({ name: 'wheeldown', ctrl: false, meta: true, shift: false }) + }) + + it('decodes ctrl on wheel events', () => { + const [[key]] = parseMultipleKeypresses(INITIAL_STATE, sgrWheel(0x40 | 0x10)) + + expect(key).toMatchObject({ name: 'wheelup', ctrl: true, meta: false, shift: false }) + }) + + it('decodes shift on wheel events', () => { + const [[key]] = parseMultipleKeypresses(INITIAL_STATE, sgrWheel(0x41 | 0x04)) + + expect(key).toMatchObject({ name: 'wheeldown', ctrl: false, meta: false, shift: true }) + }) + + it('decodes combined modifiers', () => { + const [[key]] = parseMultipleKeypresses(INITIAL_STATE, sgrWheel(0x40 | 0x08 | 0x10)) + + expect(key).toMatchObject({ name: 'wheelup', ctrl: true, meta: true, shift: false }) + }) + + it('decodes meta on legacy X10 wheel encoding', () => { + // X10: ESC [ M Cb Cx Cy where each byte is value+32. + const x10 = `\x1b[M${String.fromCharCode(0x40 + 0x08 + 32)}${String.fromCharCode(10 + 32)}${String.fromCharCode(10 + 32)}` + const [[key]] = parseMultipleKeypresses(INITIAL_STATE, x10) + + expect(key).toMatchObject({ name: 'wheelup', meta: true }) + }) +}) 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 56976d8a84..3a21aa2646 100644 --- a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts +++ b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts @@ -697,16 +697,17 @@ function parseKeypress(s: string = ''): ParsedKey { // never reach here. Mask with 0x43 (bits 6+1+0) to check wheel-flag // + direction while ignoring modifier bits (Shift=0x04, Meta=0x08, // Ctrl=0x10) — modified wheel events (e.g. Ctrl+scroll, button=80) - // should still be recognized as wheelup/wheeldown. + // should still be recognized as wheelup/wheeldown. Preserve those + // modifier bits for callers that bind modified wheel gestures. if ((match = SGR_MOUSE_RE.exec(s))) { const button = parseInt(match[1]!, 10) if ((button & 0x43) === 0x40) { - return createNavKey(s, 'wheelup', false) + return createWheelKey(s, 'wheelup', button) } if ((button & 0x43) === 0x41) { - return createNavKey(s, 'wheeldown', false) + return createWheelKey(s, 'wheeldown', button) } // Shouldn't reach here (parseMouseEvent catches non-wheel) but be safe @@ -722,11 +723,11 @@ function parseKeypress(s: string = ''): ParsedKey { const button = s.charCodeAt(3) - 32 if ((button & 0x43) === 0x40) { - return createNavKey(s, 'wheelup', false) + return createWheelKey(s, 'wheelup', button) } if ((button & 0x43) === 0x41) { - return createNavKey(s, 'wheeldown', false) + return createWheelKey(s, 'wheeldown', button) } return createNavKey(s, 'mouse', false) @@ -834,3 +835,19 @@ function createNavKey(s: string, name: string, ctrl: boolean): ParsedKey { isPasted: false } } + +function createWheelKey(s: string, name: 'wheelup' | 'wheeldown', button: number): ParsedKey { + return { + kind: 'key', + name, + ctrl: !!(button & 0x10), + meta: !!(button & 0x08), + shift: !!(button & 0x04), + option: false, + super: false, + fn: false, + sequence: s, + raw: s, + isPasted: false + } +}