diff --git a/src/client/__tests__/useTerminal.test.tsx b/src/client/__tests__/useTerminal.test.tsx index 2a1aac5..33f4ce6 100644 --- a/src/client/__tests__/useTerminal.test.tsx +++ b/src/client/__tests__/useTerminal.test.tsx @@ -444,6 +444,59 @@ describe('useTerminal', () => { expect(sendCalls.some((call) => call.type === 'terminal-resize')).toBe(true) }) + test('batches wheel scroll steps into a single terminal-input message', () => { + globalAny.navigator = { + userAgent: 'Chrome', + platform: 'MacIntel', + maxTouchPoints: 0, + clipboard: { writeText: () => Promise.resolve() }, + } as unknown as Navigator + + const sendCalls: Array> = [] + const listeners: Array<(message: ServerMessage) => void> = [] + const { container } = createContainerMock() + + act(() => { + TestRenderer.create( + sendCalls.push(message)} + subscribe={(listener) => { + listeners.push(listener) + return () => {} + }} + theme={{ background: '#000' }} + fontSize={12} + onScrollChange={() => {}} + />, + { + createNodeMock: () => container, + } + ) + }) + + const terminal = TerminalMock.instances[0] + if (!terminal) { + throw new Error('Expected terminal instance') + } + + // Ignore attach/resize chatter from mount. + sendCalls.length = 0 + + act(() => { + terminal.emitWheel({ deltaY: -90 } as WheelEvent) + }) + + const terminalInputs = sendCalls.filter((call) => call.type === 'terminal-input') + expect(terminalInputs).toHaveLength(1) + expect(terminalInputs[0]).toEqual({ + type: 'terminal-input', + sessionId: 'session-1', + data: `\x1b[<64;40;12M`.repeat(3), + }) + }) + test('detaches previous session and cleans up on unmount', async () => { globalAny.navigator = { userAgent: 'Chrome', diff --git a/src/client/hooks/useTerminal.ts b/src/client/hooks/useTerminal.ts index af36069..13c00f1 100644 --- a/src/client/hooks/useTerminal.ts +++ b/src/client/hooks/useTerminal.ts @@ -619,20 +619,24 @@ export function useTerminal({ const col = Math.floor(cols / 2) const row = Math.floor(rows / 2) + // NOTE: batch multiple steps into one terminal-input message. This reduces + // WebSocket churn (JSON stringify/send) dramatically on trackpads. + const steps = Math.trunc(wheelAccumRef.current / STEP) let scrolledUp = false let didScroll = false - while (Math.abs(wheelAccumRef.current) >= STEP) { + if (steps !== 0) { didScroll = true - const down = wheelAccumRef.current > 0 - wheelAccumRef.current += down ? -STEP : STEP + const down = steps > 0 + wheelAccumRef.current -= steps * STEP // SGR mouse wheel: button 64 = scroll up, 65 = scroll down const button = down ? 65 : 64 if (!down) scrolledUp = true + const sequence = `\x1b[<${button};${col};${row}M` sendMessageRef.current({ type: 'terminal-input', sessionId: attached, - data: `\x1b[<${button};${col};${row}M` + data: sequence.repeat(Math.abs(steps)), }) }