fix(cli): prevent SGR mouse events from being swallowed as paste on Windows (#7988)

On Windows (pasteWorkaround path), shouldFlushRawDataAsPaste
misclassifies raw stdin data containing both a carriage return (0x0d)
and an SGR mouse sequence as pasted content. The synthetic paste
wrapper sets isPaste=true in handleKeypress, which discards SGR mouse
data via resetSgrMouse(), breaking wheel scrolling in VP mode.

This commonly triggers when Enter (\r) and a subsequent mouse wheel
event arrive in the same stdin chunk — a frequent occurrence on
Windows Terminal. macOS is unaffected because handleStdinData does
not use this heuristic.

Skip the paste heuristic when the buffer contains ANSI escape bytes
(0x1b) so SGR mouse sequences always reach readline for proper
parsing and dispatch to mouse subscribers.

Closes #7964

Co-authored-by: 秦奇 <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-07-29 06:41:34 +00:00
1 parent 81f1ffb94c
commit 4c11e21bcd
2 files changed
+112

No files matched your search

@@ -1031,6 +1031,111 @@ describe('KeypressContext - Kitty Protocol', () => {
expect(mouseHandler).not.toHaveBeenCalled();
});
it('dispatches a standalone SGR wheel event to mouse subscribers (pasteWorkaround path)', async () => {
const mouseHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), {
wrapper: ({ children }) =>
wrapper({ children, pasteWorkaround: true }),
});
act(() => {
result.current.subscribeMouse(mouseHandler);
});
// A pure SGR scroll-down event (button 65) arriving alone.
act(() => {
stdin.emit('data', Buffer.from('\x1b[<65;10;20M'));
});
await waitFor(() => {
expect(mouseHandler).toHaveBeenCalledTimes(1);
});
expect(mouseHandler).toHaveBeenCalledWith(
expect.objectContaining({ name: 'scroll-down', col: 10, row: 20 }),
);
});
it('dispatches SGR wheel event arriving in fragmented chunks (pasteWorkaround path)', async () => {
const mouseHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), {
wrapper: ({ children }) =>
wrapper({ children, pasteWorkaround: true }),
});
act(() => {
result.current.subscribeMouse(mouseHandler);
});
// SGR sequence split across two stdin chunks.
act(() => {
stdin.emit('data', Buffer.from('\x1b[<64;5'));
stdin.emit('data', Buffer.from(';15M'));
});
await waitFor(() => {
expect(mouseHandler).toHaveBeenCalledTimes(1);
});
expect(mouseHandler).toHaveBeenCalledWith(
expect.objectContaining({ name: 'scroll-up', col: 5, row: 15 }),
);
});
it('dispatches SGR wheel event when \\r arrives in the same chunk (pasteWorkaround path)', async () => {
const mouseHandler = vi.fn();
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), {
wrapper: ({ children }) =>
wrapper({ children, pasteWorkaround: true }),
});
act(() => {
result.current.subscribe(keyHandler);
result.current.subscribeMouse(mouseHandler);
});
// Windows Terminal may deliver a preceding Enter (\r) in the same
// stdin chunk as the SGR mouse sequence. The \r must not cause
// shouldFlushRawDataAsPaste to misclassify the SGR data as paste.
act(() => {
stdin.emit('data', Buffer.from('\r\x1b[<65;10;20M'));
});
await waitFor(() => {
expect(mouseHandler).toHaveBeenCalledTimes(1);
});
expect(mouseHandler).toHaveBeenCalledWith(
expect.objectContaining({ name: 'scroll-down', col: 10, row: 20 }),
);
});
it('dispatches SGR wheel event when \\r\\n arrives in the same chunk (pasteWorkaround path)', async () => {
const mouseHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), {
wrapper: ({ children }) =>
wrapper({ children, pasteWorkaround: true }),
});
act(() => {
result.current.subscribeMouse(mouseHandler);
});
// Windows-style \r\n followed by SGR in one chunk.
act(() => {
stdin.emit('data', Buffer.from('\r\n\x1b[<65;3;7M'));
});
await waitFor(() => {
expect(mouseHandler).toHaveBeenCalledTimes(1);
});
expect(mouseHandler).toHaveBeenCalledWith(
expect.objectContaining({ name: 'scroll-down', col: 3, row: 7 }),
);
});
it('should handle empty paste sequence', async () => {
const keyHandler = vi.fn();
@@ -1201,6 +1201,13 @@ export function KeypressProvider({
});
const shouldFlushRawDataAsPaste = (data: Buffer) => {
// ANSI escape sequences (SGR mouse events, cursor keys) must reach
// readline for proper parsing. Wrapping them in synthetic paste events
// causes handleKeypress's isPaste guard to discard SGR mouse data,
// breaking wheel scrolling on Windows where \r from Enter commonly
// shares a stdin chunk with a subsequent mouse event.
if (data.includes(0x1b)) return false;
const hasReturn = data.includes(0x0d);
const hasEmbeddedTab = data.length > 1 && data.includes(0x09);
const isSingleReturn = data.length <= 2 && hasReturn;