From 047dc1ada69ed2a93f471c4065168d5c9ab36b45 Mon Sep 17 00:00:00 2001 From: SmolSmol Date: Mon, 13 Jul 2026 01:47:32 +0800 Subject: [PATCH 1/2] fix(commander): drop mouse-tracking reports so they don't stall typing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code's TUI enables mouse reporting, so every mouse movement over the Commander panel emits an SGR/X10 mouse report. Each report was sent as its own chained HTTP request to /api/commander/input, and keystrokes share that same serial queue — so a burst of mouse reports (measured: 300+ in a short session) piled up ahead of a typed character and delayed it by seconds. The Commander is keyboard-driven, so drop terminal mouse reports in handleTerminalData before they're forwarded. Measured before/after on a real session: mouse reports reaching the server went from ~330 to 0, with keystrokes flowing at ~90ms spacing. The detector is anchored (^) so it only matches actual mouse sequences and leaves arrow keys, bracketed paste, and normal input intact. Co-Authored-By: Claude Opus 4.8 --- client/commander-panel.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/client/commander-panel.js b/client/commander-panel.js index cbfa1660..0cc6ba30 100644 --- a/client/commander-panel.js +++ b/client/commander-panel.js @@ -932,7 +932,21 @@ class CommanderPanel { } } + // Detect terminal mouse-tracking reports: SGR (mode 1006) "ESC[ Date: Sat, 18 Jul 2026 11:47:25 +1000 Subject: [PATCH 2/2] fix: only strip idle-hover mouse motion, keep clicks/drags/scroll flowing Review feedback: the original filter dropped every mouse-tracking report, which silently killed clicks, drags, and scroll-wheel input for mouse-aware apps running in the Commander panel (Claude Code TUI, vim, less). Now only motion-with-no-button reports (the actual flood) are stripped, in both SGR and X10 encodings, and stripping instead of dropping the whole chunk preserves any real keystrokes xterm coalesced into the same data event. Adds a unit test covering motion vs click vs scroll payloads. Co-Authored-By: Claude Fable 5 --- client/commander-panel.js | 28 +++++--- tests/unit/commanderPanel.mouseFilter.test.js | 65 +++++++++++++++++++ 2 files changed, 84 insertions(+), 9 deletions(-) create mode 100644 tests/unit/commanderPanel.mouseFilter.test.js diff --git a/client/commander-panel.js b/client/commander-panel.js index 0cc6ba30..0d2f1556 100644 --- a/client/commander-panel.js +++ b/client/commander-panel.js @@ -932,20 +932,30 @@ class CommanderPanel { } } - // Detect terminal mouse-tracking reports: 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) { - // Drop mouse-tracking reports. Claude Code's TUI enables mouse reporting, so every + // 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). Commander is - // keyboard-driven, so these are safe to drop and it keeps typing responsive. - if (this.isMouseReport(data)) return; + // 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) { 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); + } + }); +});