Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,4 +133,35 @@ describe('fragmented SGR mouse recovery', () => {

expect(key).toMatchObject({ kind: 'key', sequence: '1234;56;78M9;10;11M' })
})

it('swallows a fully degraded mouse-burst noise blob without leaking prompt text', () => {
// Captured from Windows Terminal during a heavy tool-call render: the event
// loop blocked past App's 50ms flush timer, so a long burst of SGR mouse
// reports (mode 1003 any-motion) arrived as text with prefixes AND
// too degraded for SGR_MOUSE_FRAGMENT_RE (1- and 2-param remnants, a
// stray focus-in `[I`), so without the whole-text noise fast path the entire
// blob types into the composer and locks the user out.
const blob =
'M6M35;220;56M6M35;218;56M169;48M;157;47M;44M20;43M79;40M78;40M0M7M35;49;41M48;41M;47;40M9;15;32M[I;31M5;211;26M35;211;25M7M;220;1MM0M09;25M24M23M3;22MM18M99;26M32MM38M63;44M47MM1;51M M4M54M'
const [events] = parseMultipleKeypresses(INITIAL_STATE, blob)

expect(events).toEqual([])
})

it('keeps plain prose that only contains scattered M and m letters', () => {
const [[key]] = parseMultipleKeypresses(INITIAL_STATE, 'Mmm MMM mmm yummy')

expect(key).toMatchObject({ kind: 'key', sequence: 'Mmm MMM mmm yummy' })
})

it('swallows noise wholesale even when it contains intact recoverable fragments', () => {
// A noise blob can carry a few intact `<b;c;r M` fragments amid the chewed
// shards. The whole-text noise check must run BEFORE fragment recovery —
// otherwise parseTextWithSgrMouseFragments returns non-null and emits a
// pile of recovered mouse events instead of dropping the blob wholesale.
const blob = '<35;159;11M;44M20;43M0M7M<35;124;26M;47;40M9;15;32M5M2M'
const [events] = parseMultipleKeypresses(INITIAL_STATE, blob)

expect(events).toEqual([])
})
})
52 changes: 50 additions & 2 deletions ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,34 @@ const XTVERSION_RE = /^\x1bP>\|(.*?)(?:\x07|\x1b\\)$/s
const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/
const SGR_MOUSE_FRAGMENT_RE = /(?<!\d)(?:\[<|<)?(?:[0-9]|[1-9][0-9]|1\d{2}|2[0-4]\d|25[0-5]);\d+;\d+[Mm]/g

// Whole-text mouse-burst noise fast path. When a heavy render blocks the event
// loop past App's 50ms flush watchdog, a long burst of SGR mouse reports (mode
// 1003 any-motion / 1006 SGR) can arrive as a single text token with prefixes
// AND coordinate digits chewed off across many partial reads. The surviving
// shards (1- and 2-param remnants, stray focus-in `[I`, lone `M`/`m`
// terminators) are too degraded for SGR_MOUSE_FRAGMENT_RE, so the leftover
// tail leaks into the composer and locks the user out (they can't type or exit).
//
// If the ENTIRE text token is drawn only from the mouse-leak alphabet
// (`[ ] < ; I M m`, digits, and the stray spaces a burst can carry) AND it
// carries the structural signature of mouse coordinates — ≥3 `M`/`m`
// terminators, at least one digit, and at least one `;` separator — swallow it
// wholesale. All three constraints together preserve real prose: `Mmm MMM mmm`
// has no digit and no `;`, `see 1;2;3M for details` contains disqualifying
// letters, and `1234;56;78M9;10;11M` has only two terminators.
// eslint-disable-next-line no-control-regex
const MOUSE_BURST_NOISE_RE = /^(?=[\s\S]*\d)(?=[\s\S]*;)(?=(?:[^Mm]*[Mm]){3})[\d;<\[\]IMm \x1b]+$/

// Residual-shard variant for the gaps BETWEEN / AFTER recovered fragments
// inside parseTextWithSgrMouseFragments. A real recovery run leaves degraded
// remnants (e.g. `M6M`, `7M;220;1MM0M`, lone `;157;47M`) that are pure
// mouse-leak alphabet but too short to satisfy the ≥3-terminator whole-text
// rule. Swallow such a residue only when it is pure alphabet AND carries a
// digit AND at least one `M`/`m` — a prose gap like ` for details ` contains
// disqualifying letters and never matches.
// eslint-disable-next-line no-control-regex
const MOUSE_BURST_RESIDUE_RE = /^(?=[^\d]*\d)(?=[^Mm]*[Mm])[\d;<\[\]IMm \x1b]+$/

function createPasteKey(content: string): ParsedKey {
return {
kind: 'key',
Expand Down Expand Up @@ -268,6 +296,16 @@ export function parseMultipleKeypresses(
} else if (token.type === 'text') {
if (inPaste) {
pasteBuffer += token.value
} else if (MOUSE_BURST_NOISE_RE.test(token.value)) {
// Fully degraded mouse-burst noise — a heavy render (e.g. a sudo /
// secret prompt repaint) blocked the event loop past App's 50ms flush
// watchdog, so a long burst of SGR mouse reports arrived as text with
// prefixes AND coordinate digits chewed off. Checked BEFORE fragment
// recovery: a noise blob can still contain a few intact `<b;c;r M`
// fragments, and parseTextWithSgrMouseFragments would then return
// non-null and emit a pile of recovered mouse events instead of
// dropping the blob wholesale. Swallow it here so it never leaks into
// the composer (and we skip the extra fragment-recovery work mid-stall).
} else {
const mouseFragments = parseTextWithSgrMouseFragments(token.value)

Expand Down Expand Up @@ -674,7 +712,12 @@ function parseTextWithSgrMouseFragments(text: string): ParsedInput[] | null {
}

if (first.index! > cursor) {
parsed.push(parseKeypress(text.slice(cursor, first.index!)))
const gap = text.slice(cursor, first.index!)
// Skip pure mouse-leak residue between recovered fragments; only emit
// real text gaps as keypresses.
if (!MOUSE_BURST_RESIDUE_RE.test(gap)) {
parsed.push(parseKeypress(gap))
}
}

for (const match of run) {
Expand All @@ -690,7 +733,12 @@ function parseTextWithSgrMouseFragments(text: string): ParsedInput[] | null {
}

if (cursor < text.length) {
parsed.push(parseKeypress(text.slice(cursor)))
const tail = text.slice(cursor)
// Swallow a pure mouse-leak residue tail (the head fragments recovered, but
// the burst trailed off into chewed-up shards). Emit only real trailing text.
if (!MOUSE_BURST_RESIDUE_RE.test(tail)) {
parsed.push(parseKeypress(tail))
}
}

return parsed
Expand Down
Loading