diff --git a/client/commander-panel.js b/client/commander-panel.js index cbfa1660..0d2f1556 100644 --- a/client/commander-panel.js +++ b/client/commander-panel.js @@ -932,7 +932,31 @@ class CommanderPanel { } } + // Strip mouse-tracking MOTION reports (idle hover, no button held) from an input + // chunk: SGR (mode 1006) "ESC[ (btnCode & 0x20) !== 0 && (btnCode & 0x03) === 3 && (btnCode & 0x40) === 0; + return s + .replace(/\x1b\[<(\d+);\d+;\d+[Mm]/g, (match, btn) => (isIdleMotion(Number(btn)) ? '' : match)) + .replace(/\x1b\[M([\s\S]{3})/g, (match, payload) => (isIdleMotion(payload.charCodeAt(0) - 32) ? '' : match)); + } + handleTerminalData(data) { + // Filter mouse-motion noise. Claude Code's TUI enables mouse reporting, so every + // mouse move over the panel emits a report — and each was sent as its own chained + // HTTP request, flooding the input queue and stalling real keystrokes (measured: + // hundreds of mouse reports queued ahead of a single typed character). Hover + // motion carries no meaning for the panel, so it's stripped; clicks/drags/scroll + // still reach the PTY for apps that use them. + data = this.stripMouseMotionReports(data); + if (!data) return; + // If we're currently capturing a command, don't forward to Commander PTY. if (this.commandCapture) { if (data === '\r' || data === '\n') { diff --git a/tests/unit/commanderPanel.mouseFilter.test.js b/tests/unit/commanderPanel.mouseFilter.test.js new file mode 100644 index 00000000..f1513272 --- /dev/null +++ b/tests/unit/commanderPanel.mouseFilter.test.js @@ -0,0 +1,65 @@ +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +// client/commander-panel.js is a browser script (assigns window.CommanderPanel at the +// top level), so evaluate it in a sandbox instead of require()-ing it. Only the class +// declaration runs at load time; no DOM access happens until instantiation. +const loadCommanderPanelClass = () => { + const source = fs.readFileSync(path.join(__dirname, '..', '..', 'client', 'commander-panel.js'), 'utf8'); + const sandbox = { window: { location: { origin: 'http://localhost' } } }; + vm.createContext(sandbox); + vm.runInContext(source, sandbox); + return sandbox.window.CommanderPanel; +}; + +describe('CommanderPanel.stripMouseMotionReports', () => { + const ESC = '\x1b'; + let strip; + + beforeAll(() => { + const CommanderPanel = loadCommanderPanelClass(); + strip = CommanderPanel.prototype.stripMouseMotionReports; + }); + + const x10 = (btnCode) => `${ESC}[M${String.fromCharCode(32 + btnCode, 42, 52)}`; + + test('strips SGR idle-hover motion reports (the flood)', () => { + expect(strip.call({}, `${ESC}[<35;10;20M`)).toBe(''); + expect(strip.call({}, `${ESC}[<51;10;20M`)).toBe(''); // motion + ctrl modifier + expect(strip.call({}, `${ESC}[<35;1;1M`.repeat(50))).toBe(''); + }); + + test('strips X10-encoded idle-hover motion reports', () => { + expect(strip.call({}, x10(35))).toBe(''); + }); + + test('forwards clicks, releases, drags, and scroll-wheel reports', () => { + for (const report of [ + `${ESC}[<0;10;20M`, // left press + `${ESC}[<0;10;20m`, // left release + `${ESC}[<32;10;20M`, // left-button drag motion + `${ESC}[<64;10;20M`, // scroll up + `${ESC}[<65;10;20M`, // scroll down + x10(0) // X10 click + ]) { + expect(strip.call({}, report)).toBe(report); + } + }); + + test('preserves real input coalesced into the same chunk as motion noise', () => { + expect(strip.call({}, `${ESC}[<35;1;1Mabc`)).toBe('abc'); + expect(strip.call({}, `a${ESC}[<35;1;1Mb`)).toBe('ab'); + }); + + test('leaves keyboard escape sequences and plain text untouched', () => { + for (const input of [ + 'hello', + `${ESC}[A`, // arrow up + `${ESC}[1;5C`, // ctrl+right + `${ESC}[200~line1\rline2${ESC}[201~` // bracketed paste + ]) { + expect(strip.call({}, input)).toBe(input); + } + }); +});