fix(cli): recover leaked mouse tracking terminal state - #17701
Conversation
Detect leaked SGR mouse-report fragments in CLI input, strip them, and reset terminal modes in-place so scroll and typing recover without reopening the tab. Add regression tests for escaped, visible, and bare leak forms.
Reset sticky mouse/focus/paste terminal modes before the TUI starts and during graceful shutdown paths so stale tab state from prior crashes cannot poison the next session.
There was a problem hiding this comment.
Pull request overview
Extends the CLI’s defensive input sanitization to handle leaked SGR mouse-report fragments and adds a best-effort terminal mode recovery when those leaks are detected, backed by regression tests.
Changes:
- Add SGR mouse report stripping (ESC / visible
^[[/ bare<...M/mfragments) to the leaked-terminal response sanitizer. - Introduce a “with meta” sanitizer variant returning
(cleaned_text, had_mouse_reports)and trigger an in-place terminal mode reset when mouse leaks are detected. - Add regression tests covering escaped, visible, bare, and concatenated mouse-report leak forms while preserving normal angle-bracket text.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
cli.py |
Adds regexes for SGR mouse leaks, returns meta from sanitizer, and performs terminal input-mode recovery when leaks are detected. |
tests/cli/test_cli_terminal_response_sanitizer.py |
Adds test coverage for stripping multiple forms of leaked SGR mouse-report fragments without over-matching normal <...> text. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Handle unbounded SGR mouse report coordinates and avoid regex work on ordinary prompt-buffer edits by short-circuiting before sanitizer passes.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if not any(marker in text for marker in _TERMINAL_RESPONSE_SENTINELS): | ||
| return text, False | ||
|
|
||
| had_mouse_reports = False | ||
|
|
||
| if "\x1b[" in text: | ||
| text = _DSR_CPR_ESC_RE.sub("", text) | ||
| text, count = _SGR_MOUSE_ESC_RE.subn("", text) | ||
| had_mouse_reports = had_mouse_reports or count > 0 | ||
|
|
||
| if "^[" in text: | ||
| text = _DSR_CPR_VISIBLE_RE.sub("", text) | ||
| text, count = _SGR_MOUSE_VISIBLE_RE.subn("", text) | ||
| had_mouse_reports = had_mouse_reports or count > 0 | ||
|
|
||
| if "<" in text: |
There was a problem hiding this comment.
In _strip_leaked_terminal_responses_with_meta, the sentinel fast-path is likely to be bypassed for a lot of normal developer input because _TERMINAL_RESPONSE_SENTINELS includes "<" (common in code/HTML/generics). Also, because "<" is last in the tuple, any(marker in text ...) will scan the entire buffer for "\x1b[" and "^[" before it ever checks for <, and the function then repeats additional in scans in the per-marker blocks. Since this runs from _on_text_changed (per-keystroke), consider restructuring the fast-path to avoid multiple full-string scans (e.g., compute has_esc/has_visible/has_bare once and early-return if all false, and/or make the bare check more specific like requiring ; + M/m before running the bare regex).
| if not any(marker in text for marker in _TERMINAL_RESPONSE_SENTINELS): | |
| return text, False | |
| had_mouse_reports = False | |
| if "\x1b[" in text: | |
| text = _DSR_CPR_ESC_RE.sub("", text) | |
| text, count = _SGR_MOUSE_ESC_RE.subn("", text) | |
| had_mouse_reports = had_mouse_reports or count > 0 | |
| if "^[" in text: | |
| text = _DSR_CPR_VISIBLE_RE.sub("", text) | |
| text, count = _SGR_MOUSE_VISIBLE_RE.subn("", text) | |
| had_mouse_reports = had_mouse_reports or count > 0 | |
| if "<" in text: | |
| has_esc = "\x1b[" in text | |
| has_visible = "^[" in text | |
| has_mouse_suffix = "M" in text or "m" in text | |
| has_bare_mouse = "<" in text and ";" in text and has_mouse_suffix | |
| if not (has_esc or has_visible or has_bare_mouse): | |
| return text, False | |
| had_mouse_reports = False | |
| if has_esc: | |
| text = _DSR_CPR_ESC_RE.sub("", text) | |
| text, count = _SGR_MOUSE_ESC_RE.subn("", text) | |
| had_mouse_reports = had_mouse_reports or count > 0 | |
| if has_visible: | |
| text = _DSR_CPR_VISIBLE_RE.sub("", text) | |
| text, count = _SGR_MOUSE_VISIBLE_RE.subn("", text) | |
| had_mouse_reports = had_mouse_reports or count > 0 | |
| if has_bare_mouse: |
…-self-heal fix(cli): recover leaked mouse tracking terminal state
…-self-heal fix(cli): recover leaked mouse tracking terminal state
…-self-heal fix(cli): recover leaked mouse tracking terminal state
…-self-heal fix(cli): recover leaked mouse tracking terminal state
With the tokenizer reassembling split CSI sequences across a flush (prior commit), no SGR mouse fragment can reach a text token anymore — terminals write a mouse report as one atomic sequence, and any read/flush split now re-joins in the tokenizer buffer instead of leaking. That makes the whole downstream recovery layer dead code: - SGR_MOUSE_FRAGMENT_RE, MOUSE_BURST_NOISE_RE, MOUSE_BURST_RESIDUE_RE - parseTextWithSgrMouseFragments / parseSgrMouseFragment / normalizeSgrMouseFragment - the whole-text mouse-burst noise fast path in parseMultipleKeypresses Remove all of it (~185 lines) and the tests that only exercised it. The narrow legacy X10 wheel-tail resynth stays (distinct mechanism, kept with its own test). This retires the #17701 → #18113 → #26781 → #28463 → #35512 regex hardening chain in favor of the one correct parser fix.
With the tokenizer reassembling split CSI sequences across a flush (prior commit), no SGR mouse fragment can reach a text token anymore — terminals write a mouse report as one atomic sequence, and any read/flush split now re-joins in the tokenizer buffer instead of leaking. That makes the whole downstream recovery layer dead code: - SGR_MOUSE_FRAGMENT_RE, MOUSE_BURST_NOISE_RE, MOUSE_BURST_RESIDUE_RE - parseTextWithSgrMouseFragments / parseSgrMouseFragment / normalizeSgrMouseFragment - the whole-text mouse-burst noise fast path in parseMultipleKeypresses Remove all of it (~185 lines) and the tests that only exercised it. The narrow legacy X10 wheel-tail resynth stays (distinct mechanism, kept with its own test). This retires the NousResearch#17701 → NousResearch#18113 → NousResearch#26781 → NousResearch#28463 → NousResearch#35512 regex hardening chain in favor of the one correct parser fix.
With the tokenizer reassembling split CSI sequences across a flush (prior commit), no SGR mouse fragment can reach a text token anymore — terminals write a mouse report as one atomic sequence, and any read/flush split now re-joins in the tokenizer buffer instead of leaking. That makes the whole downstream recovery layer dead code: - SGR_MOUSE_FRAGMENT_RE, MOUSE_BURST_NOISE_RE, MOUSE_BURST_RESIDUE_RE - parseTextWithSgrMouseFragments / parseSgrMouseFragment / normalizeSgrMouseFragment - the whole-text mouse-burst noise fast path in parseMultipleKeypresses Remove all of it (~185 lines) and the tests that only exercised it. The narrow legacy X10 wheel-tail resynth stays (distinct mechanism, kept with its own test). This retires the NousResearch#17701 → NousResearch#18113 → NousResearch#26781 → NousResearch#28463 → NousResearch#35512 regex hardening chain in favor of the one correct parser fix.
With the tokenizer reassembling split CSI sequences across a flush (prior commit), no SGR mouse fragment can reach a text token anymore — terminals write a mouse report as one atomic sequence, and any read/flush split now re-joins in the tokenizer buffer instead of leaking. That makes the whole downstream recovery layer dead code: - SGR_MOUSE_FRAGMENT_RE, MOUSE_BURST_NOISE_RE, MOUSE_BURST_RESIDUE_RE - parseTextWithSgrMouseFragments / parseSgrMouseFragment / normalizeSgrMouseFragment - the whole-text mouse-burst noise fast path in parseMultipleKeypresses Remove all of it (~185 lines) and the tests that only exercised it. The narrow legacy X10 wheel-tail resynth stays (distinct mechanism, kept with its own test). This retires the NousResearch#17701 → NousResearch#18113 → NousResearch#26781 → NousResearch#28463 → NousResearch#35512 regex hardening chain in favor of the one correct parser fix.
…-self-heal fix(cli): recover leaked mouse tracking terminal state
With the tokenizer reassembling split CSI sequences across a flush (prior commit), no SGR mouse fragment can reach a text token anymore — terminals write a mouse report as one atomic sequence, and any read/flush split now re-joins in the tokenizer buffer instead of leaking. That makes the whole downstream recovery layer dead code: - SGR_MOUSE_FRAGMENT_RE, MOUSE_BURST_NOISE_RE, MOUSE_BURST_RESIDUE_RE - parseTextWithSgrMouseFragments / parseSgrMouseFragment / normalizeSgrMouseFragment - the whole-text mouse-burst noise fast path in parseMultipleKeypresses Remove all of it (~185 lines) and the tests that only exercised it. The narrow legacy X10 wheel-tail resynth stays (distinct mechanism, kept with its own test). This retires the #17701 → #18113 → #26781 → #28463 → #35512 regex hardening chain in favor of the one correct parser fix.
With the tokenizer reassembling split CSI sequences across a flush (prior commit), no SGR mouse fragment can reach a text token anymore — terminals write a mouse report as one atomic sequence, and any read/flush split now re-joins in the tokenizer buffer instead of leaking. That makes the whole downstream recovery layer dead code: - SGR_MOUSE_FRAGMENT_RE, MOUSE_BURST_NOISE_RE, MOUSE_BURST_RESIDUE_RE - parseTextWithSgrMouseFragments / parseSgrMouseFragment / normalizeSgrMouseFragment - the whole-text mouse-burst noise fast path in parseMultipleKeypresses Remove all of it (~185 lines) and the tests that only exercised it. The narrow legacy X10 wheel-tail resynth stays (distinct mechanism, kept with its own test). This retires the NousResearch#17701 → NousResearch#18113 → NousResearch#26781 → NousResearch#28463 → NousResearch#35512 regex hardening chain in favor of the one correct parser fix.
With the tokenizer reassembling split CSI sequences across a flush (prior commit), no SGR mouse fragment can reach a text token anymore — terminals write a mouse report as one atomic sequence, and any read/flush split now re-joins in the tokenizer buffer instead of leaking. That makes the whole downstream recovery layer dead code: - SGR_MOUSE_FRAGMENT_RE, MOUSE_BURST_NOISE_RE, MOUSE_BURST_RESIDUE_RE - parseTextWithSgrMouseFragments / parseSgrMouseFragment / normalizeSgrMouseFragment - the whole-text mouse-burst noise fast path in parseMultipleKeypresses Remove all of it (~185 lines) and the tests that only exercised it. The narrow legacy X10 wheel-tail resynth stays (distinct mechanism, kept with its own test). This retires the NousResearch#17701 → NousResearch#18113 → NousResearch#26781 → NousResearch#28463 → NousResearch#35512 regex hardening chain in favor of the one correct parser fix.
…-self-heal fix(cli): recover leaked mouse tracking terminal state
With the tokenizer reassembling split CSI sequences across a flush (prior commit), no SGR mouse fragment can reach a text token anymore — terminals write a mouse report as one atomic sequence, and any read/flush split now re-joins in the tokenizer buffer instead of leaking. That makes the whole downstream recovery layer dead code: - SGR_MOUSE_FRAGMENT_RE, MOUSE_BURST_NOISE_RE, MOUSE_BURST_RESIDUE_RE - parseTextWithSgrMouseFragments / parseSgrMouseFragment / normalizeSgrMouseFragment - the whole-text mouse-burst noise fast path in parseMultipleKeypresses Remove all of it (~185 lines) and the tests that only exercised it. The narrow legacy X10 wheel-tail resynth stays (distinct mechanism, kept with its own test). This retires the NousResearch#17701 → NousResearch#18113 → NousResearch#26781 → NousResearch#28463 → NousResearch#35512 regex hardening chain in favor of the one correct parser fix.
With the tokenizer reassembling split CSI sequences across a flush (prior commit), no SGR mouse fragment can reach a text token anymore — terminals write a mouse report as one atomic sequence, and any read/flush split now re-joins in the tokenizer buffer instead of leaking. That makes the whole downstream recovery layer dead code: - SGR_MOUSE_FRAGMENT_RE, MOUSE_BURST_NOISE_RE, MOUSE_BURST_RESIDUE_RE - parseTextWithSgrMouseFragments / parseSgrMouseFragment / normalizeSgrMouseFragment - the whole-text mouse-burst noise fast path in parseMultipleKeypresses Remove all of it (~185 lines) and the tests that only exercised it. The narrow legacy X10 wheel-tail resynth stays (distinct mechanism, kept with its own test). This retires the NousResearch#17701 → NousResearch#18113 → NousResearch#26781 → NousResearch#28463 → NousResearch#35512 regex hardening chain in favor of the one correct parser fix.
With the tokenizer reassembling split CSI sequences across a flush (prior commit), no SGR mouse fragment can reach a text token anymore — terminals write a mouse report as one atomic sequence, and any read/flush split now re-joins in the tokenizer buffer instead of leaking. That makes the whole downstream recovery layer dead code: - SGR_MOUSE_FRAGMENT_RE, MOUSE_BURST_NOISE_RE, MOUSE_BURST_RESIDUE_RE - parseTextWithSgrMouseFragments / parseSgrMouseFragment / normalizeSgrMouseFragment - the whole-text mouse-burst noise fast path in parseMultipleKeypresses Remove all of it (~185 lines) and the tests that only exercised it. The narrow legacy X10 wheel-tail resynth stays (distinct mechanism, kept with its own test). This retires the NousResearch#17701 → NousResearch#18113 → NousResearch#26781 → NousResearch#28463 → NousResearch#35512 regex hardening chain in favor of the one correct parser fix.
…-self-heal fix(cli): recover leaked mouse tracking terminal state
With the tokenizer reassembling split CSI sequences across a flush (prior commit), no SGR mouse fragment can reach a text token anymore — terminals write a mouse report as one atomic sequence, and any read/flush split now re-joins in the tokenizer buffer instead of leaking. That makes the whole downstream recovery layer dead code: - SGR_MOUSE_FRAGMENT_RE, MOUSE_BURST_NOISE_RE, MOUSE_BURST_RESIDUE_RE - parseTextWithSgrMouseFragments / parseSgrMouseFragment / normalizeSgrMouseFragment - the whole-text mouse-burst noise fast path in parseMultipleKeypresses Remove all of it (~185 lines) and the tests that only exercised it. The narrow legacy X10 wheel-tail resynth stays (distinct mechanism, kept with its own test). This retires the NousResearch#17701 → NousResearch#18113 → NousResearch#26781 → NousResearch#28463 → NousResearch#35512 regex hardening chain in favor of the one correct parser fix.
With the tokenizer reassembling split CSI sequences across a flush (prior commit), no SGR mouse fragment can reach a text token anymore — terminals write a mouse report as one atomic sequence, and any read/flush split now re-joins in the tokenizer buffer instead of leaking. That makes the whole downstream recovery layer dead code: - SGR_MOUSE_FRAGMENT_RE, MOUSE_BURST_NOISE_RE, MOUSE_BURST_RESIDUE_RE - parseTextWithSgrMouseFragments / parseSgrMouseFragment / normalizeSgrMouseFragment - the whole-text mouse-burst noise fast path in parseMultipleKeypresses Remove all of it (~185 lines) and the tests that only exercised it. The narrow legacy X10 wheel-tail resynth stays (distinct mechanism, kept with its own test). This retires the NousResearch#17701 → NousResearch#18113 → NousResearch#26781 → NousResearch#28463 → NousResearch#35512 regex hardening chain in favor of the one correct parser fix.
Summary
?1006/?1003/?1002/?1000, focus, paste, alt-screen, Kitty keyboard, modifyOtherKeys) when the TUI starts and again on graceful signal/OOM shutdownESC[<...M/m), including visible and bare degraded forms, and recover terminal modes in-place if they are observedTest plan
scripts/run_tests.sh tests/cli/test_cli_terminal_response_sanitizer.pyscripts/run_tests.sh tests/cli/test_cli_bracketed_paste_sanitizer.py tests/cli/test_cli_terminal_response_sanitizer.pynpm test -- --run src/__tests__/terminalModes.test.tsnpm run type-check