From 01c010e23378c318bd96e9ed2de068c698e74779 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 3 Jun 2026 19:05:26 -0500 Subject: [PATCH] fix(hermes-ink): collapse SGR mouse fragment guards into one flush-aware rule When App's 50ms flush watchdog fires mid-CSI during a render stall, an SGR mouse report (ESC[ 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[