@@ -63,6 +63,7 @@ const XTVERSION_RE = /^\x1bP>\|(.*?)(?:\x07|\x1b\\)$/s
6363// Button 32=left-drag (0x20 | motion-bit). Plain 0/1/2 = left/mid/right click.
6464// eslint-disable-next-line no-control-regex
6565const SGR_MOUSE_RE = / ^ \x1b \[ < ( \d + ) ; ( \d + ) ; ( \d + ) ( [ M m ] ) $ /
66+ const SGR_MOUSE_FRAGMENT_RE = / (?< ! \d ) (?: \[ < | < ) ? (?: [ 0 - 9 ] | [ 1 - 9 ] [ 0 - 9 ] | 1 \d { 2 } | 2 [ 0 - 4 ] \d | 2 5 [ 0 - 5 ] ) ; \d + ; \d + [ M m ] / g
6667
6768function createPasteKey ( content : string ) : ParsedKey {
6869 return {
@@ -267,23 +268,22 @@ export function parseMultipleKeypresses(
267268 } else if ( token . type === 'text' ) {
268269 if ( inPaste ) {
269270 pasteBuffer += token . value
270- } else if ( / ^ \[ < \d + ; \d + ; \d + [ M m ] $ / . test ( token . value ) || / ^ \[ M [ \x60 - \x7f ] [ \x20 - \uffff ] { 2 } $ / . test ( token . value ) ) {
271- // Orphaned SGR/X10 mouse tail (fullscreen only — mouse tracking is off
272- // otherwise). A heavy render blocked the event loop past App's 50ms
273- // flush timer, so the buffered ESC was flushed as a lone Escape and
274- // the continuation `[<btn;col;rowM` arrived as text. Re-synthesize
275- // with the ESC prefix so the scroll event still fires instead of
276- // leaking into the prompt. The spurious Escape is gone; App.tsx's
277- // readableLength check prevents it. The X10 Cb slot is narrowed to
278- // the wheel range [\x60-\x7f] (0x40|modifiers + 32) — a full [\x20-]
279- // range would match typed input like `[MAX]` batched into one read
280- // and silently drop it as a phantom click. Click/drag orphans leak
281- // as visible garbage instead; deletable garbage beats silent loss.
282- const resynthesized = '\x1b' + token . value
283- const mouse = parseMouseEvent ( resynthesized )
284- keys . push ( mouse ?? parseKeypress ( resynthesized ) )
285271 } else {
286- keys . push ( parseKeypress ( token . value ) )
272+ const mouseFragments = parseTextWithSgrMouseFragments ( token . value )
273+
274+ if ( mouseFragments ) {
275+ keys . push ( ...mouseFragments )
276+ } else if ( / ^ \[ M [ \x60 - \x7f ] [ \x20 - \uffff ] { 2 } $ / . test ( token . value ) ) {
277+ // Orphaned X10 wheel tail (fullscreen only — mouse tracking is off
278+ // otherwise). A heavy render blocked the event loop past App's 50ms
279+ // flush timer, so the buffered ESC was flushed as a lone Escape and
280+ // the continuation arrived as text. Re-synthesize with ESC so the
281+ // scroll event still fires instead of leaking into the prompt.
282+ const resynthesized = '\x1b' + token . value
283+ keys . push ( parseKeypress ( resynthesized ) )
284+ } else {
285+ keys . push ( parseKeypress ( token . value ) )
286+ }
287287 }
288288 }
289289 }
@@ -625,6 +625,77 @@ function parseMouseEvent(s: string): ParsedMouse | null {
625625 }
626626}
627627
628+ function normalizeSgrMouseFragment ( fragment : string ) : string {
629+ if ( fragment . startsWith ( '[<' ) ) {
630+ return `\x1b${ fragment } `
631+ }
632+
633+ if ( fragment . startsWith ( '<' ) ) {
634+ return `\x1b[${ fragment } `
635+ }
636+
637+ return `\x1b[<${ fragment } `
638+ }
639+
640+ function parseSgrMouseFragment ( fragment : string ) : ParsedInput {
641+ const sequence = normalizeSgrMouseFragment ( fragment )
642+ return parseMouseEvent ( sequence ) ?? parseKeypress ( sequence )
643+ }
644+
645+ function parseTextWithSgrMouseFragments ( text : string ) : ParsedInput [ ] | null {
646+ SGR_MOUSE_FRAGMENT_RE . lastIndex = 0
647+
648+ const matches = [ ...text . matchAll ( SGR_MOUSE_FRAGMENT_RE ) ]
649+ if ( matches . length === 0 ) {
650+ return null
651+ }
652+
653+ const parsed : ParsedInput [ ] = [ ]
654+ let cursor = 0
655+ let consumedAny = false
656+
657+ for ( let i = 0 ; i < matches . length ; ) {
658+ const first = matches [ i ] !
659+ const run : RegExpMatchArray [ ] = [ first ]
660+ let runEnd = first . index ! + first [ 0 ] . length
661+ i ++
662+
663+ while ( i < matches . length && matches [ i ] ! . index === runEnd ) {
664+ run . push ( matches [ i ] ! )
665+ runEnd = matches [ i ] ! . index ! + matches [ i ] ! [ 0 ] . length
666+ i ++
667+ }
668+
669+ const hasExplicitMousePrefix = run . some ( match => match [ 0 ] . startsWith ( '[<' ) || match [ 0 ] . startsWith ( '<' ) )
670+ const isFragmentBurst = run . length > 1
671+
672+ if ( ! hasExplicitMousePrefix && ! isFragmentBurst ) {
673+ continue
674+ }
675+
676+ if ( first . index ! > cursor ) {
677+ parsed . push ( parseKeypress ( text . slice ( cursor , first . index ! ) ) )
678+ }
679+
680+ for ( const match of run ) {
681+ parsed . push ( parseSgrMouseFragment ( match [ 0 ] ) )
682+ }
683+
684+ cursor = runEnd
685+ consumedAny = true
686+ }
687+
688+ if ( ! consumedAny ) {
689+ return null
690+ }
691+
692+ if ( cursor < text . length ) {
693+ parsed . push ( parseKeypress ( text . slice ( cursor ) ) )
694+ }
695+
696+ return parsed
697+ }
698+
628699function parseKeypress ( s : string = '' ) : ParsedKey {
629700 let parts
630701
0 commit comments