mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 15:39:41 +02:00
feat(web): forward wheel + guard clicks for desktop stripped-mouse sessions
Desktop click-to-position-cursor died under the server's mouse-DECSET strip (same root cause as the mobile touchend tap regression): xterm's native mouse encoder only emits SGR while mouseTrackingMode is ON, but the server strips the enabling DECSETs from claude/codex/gemini output. Hand-encode the report for plain left-clicks (_handleDesktopTerminalClick), skipping every click that already means something else (synthetic/compat, modified, double/triple, drag-selection, off-grid, xterm encoder live). Also widen forwarding to the wheel: Claude Code 2.1.187+ scrolls its own transcript on SGR wheel reports and no longer captures wheel as select-menu navigation (verified against 2.1.202), so forward the wheel to the TUI for strip-mode sessions at the buffer bottom (40ms-coalesced to avoid a tmux send-keys storm). Shift+wheel and any scrolled-up viewport stay on xterm's local scrollback. Guard synthetic taps/clicks on viewport-at-bottom so a scrolled-up report can't hit-test the wrong row. Tests: 12 cases in test/terminal-touch-tap.test.ts. Verified E2E via Playwright against the live instance (wheel up/down forward, Shift+wheel local, click).
This commit is contained in:
@@ -144,6 +144,189 @@ describe('terminal touch tap mouse guard', () => {
|
||||
expect(app._sessionUsesServerMouseStrip()).toBe(false);
|
||||
});
|
||||
|
||||
it('desktop click: encodes SGR press+release for a plain left-click in strip mode', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
const sent: Array<{ id: string; data: string }> = [];
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app._sendInputAsync = (id: string, data: string) => sent.push({ id, data });
|
||||
app.terminal = {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
modes: { mouseTrackingMode: 'none' },
|
||||
hasSelection: () => false,
|
||||
element: {
|
||||
querySelector: () => ({ getBoundingClientRect: () => ({ left: 10, top: 20 }) }),
|
||||
},
|
||||
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } },
|
||||
};
|
||||
|
||||
app._handleDesktopTerminalClick({
|
||||
isTrusted: true,
|
||||
button: 0,
|
||||
detail: 1,
|
||||
clientX: 171,
|
||||
clientY: 101,
|
||||
target: { closest: (sel: string) => (sel === '.xterm-screen' ? {} : null) },
|
||||
});
|
||||
|
||||
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[<0;21;6M\x1b[<0;21;6m' }]);
|
||||
});
|
||||
|
||||
it('desktop click: skips clicks that already have a meaning elsewhere', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
const sent: string[] = [];
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app._sendInputAsync = (_id: string, data: string) => sent.push(data);
|
||||
const terminal = () => ({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
modes: { mouseTrackingMode: 'none' } as { mouseTrackingMode: string },
|
||||
hasSelection: () => false,
|
||||
element: {
|
||||
querySelector: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }),
|
||||
},
|
||||
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } },
|
||||
});
|
||||
const click = (overrides: Record<string, unknown> = {}) => ({
|
||||
isTrusted: true,
|
||||
button: 0,
|
||||
detail: 1,
|
||||
clientX: 50,
|
||||
clientY: 50,
|
||||
target: { closest: (sel: string) => (sel === '.xterm-screen' ? {} : null) },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
app.terminal = terminal();
|
||||
app._handleDesktopTerminalClick(click({ isTrusted: false })); // synthetic
|
||||
app._handleDesktopTerminalClick(click({ button: 1 })); // middle button
|
||||
app._handleDesktopTerminalClick(click({ detail: 2 })); // double-click word select
|
||||
app._handleDesktopTerminalClick(click({ shiftKey: true })); // selection override
|
||||
app._handleDesktopTerminalClick(click({ target: { closest: () => null } })); // outside grid
|
||||
|
||||
app.terminal = { ...terminal(), hasSelection: () => true }; // drag-selection just ended
|
||||
app._handleDesktopTerminalClick(click());
|
||||
|
||||
app.terminal = { ...terminal(), modes: { mouseTrackingMode: 'vt200' } }; // xterm encoder live
|
||||
app._handleDesktopTerminalClick(click());
|
||||
|
||||
app.terminal = terminal();
|
||||
app.sessions = new Map([['sess-1', { mode: 'shell' }]]); // not a strip mode
|
||||
app._handleDesktopTerminalClick(click());
|
||||
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
|
||||
it('desktop click: skips the compat click that follows a touch tap', () => {
|
||||
const { app, setNow } = loadTerminalUiHarness();
|
||||
const sent: string[] = [];
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app._sendInputAsync = (_id: string, data: string) => sent.push(data);
|
||||
app.terminal = {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
modes: { mouseTrackingMode: 'none' },
|
||||
hasSelection: () => false,
|
||||
element: {
|
||||
querySelector: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }),
|
||||
},
|
||||
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } },
|
||||
};
|
||||
const click = {
|
||||
isTrusted: true,
|
||||
button: 0,
|
||||
detail: 1,
|
||||
clientX: 50,
|
||||
clientY: 50,
|
||||
target: { closest: (sel: string) => (sel === '.xterm-screen' ? {} : null) },
|
||||
};
|
||||
|
||||
app._suppressTrustedTapMouseEvents(); // touchend just handled the tap
|
||||
app._handleDesktopTerminalClick(click);
|
||||
expect(sent).toEqual([]);
|
||||
|
||||
setNow(10_000); // window expired — a genuine mouse click reports again
|
||||
app._handleDesktopTerminalClick(click);
|
||||
expect(sent).toEqual(['\x1b[<0;7;4M\x1b[<0;7;4m']);
|
||||
});
|
||||
|
||||
it('tap: does nothing while the viewport is scrolled up into local scrollback', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
const sent: string[] = [];
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app._sendInputAsync = (_id: string, data: string) => sent.push(data);
|
||||
app.terminal = {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
buffer: { active: { viewportY: 10, baseY: 50 } }, // scrolled up
|
||||
element: {
|
||||
querySelector: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }),
|
||||
},
|
||||
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } },
|
||||
};
|
||||
|
||||
app._sendSyntheticSgrTap(50, 50);
|
||||
expect(sent).toEqual([]);
|
||||
|
||||
app.terminal.buffer.active.viewportY = 50; // back at the bottom
|
||||
app._sendSyntheticSgrTap(50, 50);
|
||||
expect(sent).toEqual(['\x1b[<0;7;4M\x1b[<0;7;4m']);
|
||||
});
|
||||
|
||||
it('wheel: forwards to the app only for strip-mode sessions at the buffer bottom without Shift', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app.terminal = {
|
||||
modes: { mouseTrackingMode: 'none' },
|
||||
buffer: { active: { viewportY: 50, baseY: 50 } },
|
||||
};
|
||||
|
||||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true);
|
||||
expect(app._shouldForwardWheelToApp({ shiftKey: true })).toBe(false); // Shift = local scrollback
|
||||
|
||||
app.terminal.buffer.active.viewportY = 10; // browsing local scrollback
|
||||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||||
app.terminal.buffer.active.viewportY = 50;
|
||||
|
||||
app.terminal.modes.mouseTrackingMode = 'vt200'; // xterm's own encoder live
|
||||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||||
app.terminal.modes.mouseTrackingMode = 'none';
|
||||
|
||||
app.sessions = new Map([['sess-1', { mode: 'shell' }]]); // not a strip mode
|
||||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('wheel: encodes SGR 64/65 ticks, caps per event, and coalesces into one flush', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
const sent: Array<{ id: string; data: string }> = [];
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||
app._sendInputAsync = (id: string, data: string) => sent.push({ id, data });
|
||||
app.terminal = {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
element: {
|
||||
querySelector: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }),
|
||||
},
|
||||
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } },
|
||||
};
|
||||
|
||||
app._sendSyntheticSgrWheel(50, 50, -2); // 2 ticks up
|
||||
app._sendSyntheticSgrWheel(50, 50, 9); // capped at 5 ticks down
|
||||
expect(sent).toEqual([]); // nothing until the flush timer fires
|
||||
|
||||
app._flushWheelSgrQueue();
|
||||
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[<64;7;4M'.repeat(2) + '\x1b[<65;7;4M'.repeat(5) }]);
|
||||
|
||||
app._flushWheelSgrQueue(); // queue drained — no duplicate send
|
||||
expect(sent).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('allows trusted mouse events after the tap window expires', () => {
|
||||
const { app, setNow } = loadTerminalUiHarness();
|
||||
const { element, dispatch } = createElementHarness();
|
||||
|
||||
Reference in New Issue
Block a user