From b978fd8b269d9711e0f023ffc4b92b57a1c75baf Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 29 Apr 2026 20:39:39 -0500 Subject: [PATCH] feat(tui): preserve modifiers on mouse wheel events Decode Shift, Meta, and Ctrl bits from SGR and legacy X10 wheel event button bytes so TUI input handlers can distinguish modified wheel gestures from plain scrolling. --- .../hermes-ink/src/ink/parse-keypress.test.ts | 57 +++++++++++++++++++ .../hermes-ink/src/ink/parse-keypress.ts | 27 +++++++-- 2 files changed, 79 insertions(+), 5 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 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 + } +}