diff --git a/ui-tui/packages/hermes-ink/src/ink/events/input-event.test.ts b/ui-tui/packages/hermes-ink/src/ink/events/input-event.test.ts new file mode 100644 index 000000000..771294f0a --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/events/input-event.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' + +import { INITIAL_STATE, type ParsedInput, type ParsedKey, parseMultipleKeypresses } from '../parse-keypress.js' + +import { InputEvent } from './input-event.js' + +/** + * Drive the real input pipeline (tokenizer → parseKeypress → InputEvent) for a + * sequence of stdin chunks. `null` chunks simulate App's 50ms flush watchdog + * firing mid-sequence. Returns the `.input` of the first key-kind token — i.e. + * what would actually be typed into the composer. + */ +function pipelineInput(...chunks: (string | null)[]): string { + let state = INITIAL_STATE + const all: ParsedInput[] = [] + + for (const chunk of chunks) { + const [keys, next] = parseMultipleKeypresses(state, chunk) + all.push(...keys) + state = next + } + + const key = all.find((k): k is ParsedKey => k.kind === 'key') + + return key ? new InputEvent(key).input : '' +} + +describe('InputEvent SGR mouse fragment suppression', () => { + it('suppresses the buffered CSI prefix force-emitted by a mid-sequence flush', () => { + // The tokenizer buffers an incomplete CSI mouse sequence; the flush + // force-emits it as a nameless sequence token (ESC still attached). Intact + // `[ { + // These are the cases the older `/^\[<\d+;\d+;\d+[Mm]/` guard missed — + // the prefix was lost to the flush, only the tail reaches us as text. + for (const tail of ['46M', '6M', '35;46M', '0;35;46M']) { + expect(pipelineInput(tail)).toBe('') + } + }) + + it('suppresses leading-semicolon tails from a split at a `;` boundary', () => { + for (const tail of [';46M', ';35;46M']) { + expect(pipelineInput(tail)).toBe('') + } + }) + + it('suppresses both halves of a `ESC[<0; / 35;46M` split end to end', () => { + expect(pipelineInput('\x1b[<0;', null)).toBe('') // flushed prefix + expect(pipelineInput('35;46M')).toBe('') // continuation + }) + + it('suppresses release (`m`) terminators as well as press (`M`)', () => { + expect(pipelineInput('35;46m')).toBe('') + expect(pipelineInput('\x1b[<0;35;', null)).toBe('') + }) +}) + +describe('InputEvent SGR mouse fragment guard does not eat real input', () => { + it('passes through lone bracket/angle/semicolon characters', () => { + // No coordinate digit → the `(?=…\d)` lookahead fails, so typing these + // characters is never swallowed. + expect(pipelineInput('<')).toBe('<') + expect(pipelineInput('[')).toBe('[') + expect(pipelineInput(';')).toBe(';') + }) + + it('passes through digits and the literal letter M', () => { + // These parse to a named key (number / m), so the `!keypress.name` gate + // skips suppression entirely. + expect(pipelineInput('5')).toBe('5') + expect(pipelineInput('M')).toBe('M') + }) + + it('passes through ordinary text', () => { + expect(pipelineInput('hello')).toBe('hello') + }) + + it('keeps two stuck-together fragments / coordinate-like prose intact', () => { + // An embedded M/m breaks the `[\d;]+...$` anchor, so a run like this is + // left for the upstream burst/recovery logic rather than blanked here. + expect(pipelineInput('1234;56;78M9;10;11M')).toBe('1234;56;78M9;10;11M') + }) +}) diff --git a/ui-tui/packages/hermes-ink/src/ink/events/input-event.ts b/ui-tui/packages/hermes-ink/src/ink/events/input-event.ts index 19031402b..f88146be9 100644 --- a/ui-tui/packages/hermes-ink/src/ink/events/input-event.ts +++ b/ui-tui/packages/hermes-ink/src/ink/events/input-event.ts @@ -5,6 +5,32 @@ import { Event } from './event.js' const inputForSpecialSequence = (name: string): string => name === 'space' ? ' ' : name === 'return' || name === 'escape' ? '' : name +// SGR mouse-report fragment that leaked into a nameless text/sequence token. +// In alt-screen Ink enables MOUSE_ANY (DEC 1003), so every pixel of motion +// emits a CSI mouse report (ESC[