Skip to content
Open
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
24 changes: 24 additions & 0 deletions client/commander-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -932,7 +932,31 @@ class CommanderPanel {
}
}

// Strip mouse-tracking MOTION reports (idle hover, no button held) from an input
// chunk: SGR (mode 1006) "ESC[<btn;col;rowM/m" and X10-encoded (modes 1002/1003)
// "ESC[M" + 3 bytes. Only motion-with-no-button reports are noise; clicks, drags,
// and scroll-wheel reports are meaningful to mouse-aware apps (Claude Code's TUI,
// vim, less) and must keep flowing. Stripping (vs dropping the whole chunk) also
// preserves any real keystrokes xterm coalesced into the same data event.
stripMouseMotionReports(data) {
const s = String(data == null ? '' : data);
if (!s.includes('\x1b[')) return s;
const isIdleMotion = (btnCode) => (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') {
Expand Down
65 changes: 65 additions & 0 deletions tests/unit/commanderPanel.mouseFilter.test.js
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
Loading