From 88537ca6f3fa8d0114124d5faac0cacebf22c4d5 Mon Sep 17 00:00:00 2001 From: Marty Date: Tue, 3 Feb 2026 21:18:42 +0000 Subject: [PATCH 1/6] fix: use tmux copy-mode commands instead of SGR sequences for mouse wheel scrolling When scrolling with the mouse wheel in the browser, the previous implementation sent SGR mouse sequences (\x1b[<64;..M) via `tmux send-keys -l`, which has two issues: 1. The `-l` flag sends keys literally, which doesn't trigger tmux's WheelUpPane binding 2. Even without `-l`, `send-keys` sends input TO the pane, not to tmux itself This caused the shell to receive the sequences instead of tmux entering copy-mode, resulting in command history scrolling instead of scrollback buffer scrolling. The fix: - Added new `tmux-scroll` message type to bypass send-keys entirely - Directly invoke `tmux copy-mode` and `tmux send-keys -X scroll-up/down` - Works consistently with both desktop mouse wheel and touch scrolling - Matches native tmux behavior when connected directly This ensures mouse wheel scrolling in the browser enters tmux copy-mode and scrolls the scrollback buffer, just like scrolling in native tmux. Co-Authored-By: Claude Sonnet 4.5 --- src/client/components/Terminal.tsx | 16 ++++------- src/client/hooks/useTerminal.ts | 14 +++------- src/server/index.ts | 45 ++++++++++++++++++++++++++++++ src/shared/types.ts | 1 + 4 files changed, 55 insertions(+), 21 deletions(-) diff --git a/src/client/components/Terminal.tsx b/src/client/components/Terminal.tsx index 0375475..0c7e89d 100644 --- a/src/client/components/Terminal.tsx +++ b/src/client/components/Terminal.tsx @@ -549,22 +549,16 @@ export default function Terminal({ const currentSessionId = sessionIdRef.current if (!currentSessionId || lines === 0) return false - const terminal = terminalRef.current - const cols = terminal?.cols ?? 80 - const rows = terminal?.rows ?? 24 - const col = Math.floor(cols / 2) - const row = Math.floor(rows / 2) - - // SGR mouse wheel: button 64 = scroll up, 65 = scroll down - const button = lines > 0 ? 65 : 64 - const count = Math.abs(lines) const scrolledUp = lines < 0 + const count = Math.abs(lines) + // Send tmux scroll command directly to enter copy-mode and scroll for (let i = 0; i < count; i++) { sendMessageRef.current({ - type: 'terminal-input', + type: 'tmux-scroll', sessionId: currentSessionId, - data: `\x1b[<${button};${col};${row}M` + direction: lines > 0 ? 'down' : 'up', + lines: 1 }) } diff --git a/src/client/hooks/useTerminal.ts b/src/client/hooks/useTerminal.ts index cc4e90e..8fa7a1a 100644 --- a/src/client/hooks/useTerminal.ts +++ b/src/client/hooks/useTerminal.ts @@ -510,12 +510,6 @@ export function useTerminal({ const STEP = 30 wheelAccumRef.current += ev.deltaY - // Get approximate cell position for SGR mouse event - const cols = terminal.cols - const rows = terminal.rows - const col = Math.floor(cols / 2) - const row = Math.floor(rows / 2) - let scrolledUp = false let didScroll = false while (Math.abs(wheelAccumRef.current) >= STEP) { @@ -523,13 +517,13 @@ export function useTerminal({ const down = wheelAccumRef.current > 0 wheelAccumRef.current += down ? -STEP : STEP - // SGR mouse wheel: button 64 = scroll up, 65 = scroll down - const button = down ? 65 : 64 + // Send tmux scroll command directly to enter copy-mode and scroll if (!down) scrolledUp = true sendMessageRef.current({ - type: 'terminal-input', + type: 'tmux-scroll', sessionId: attached, - data: `\x1b[<${button};${col};${row}M` + direction: down ? 'down' : 'up', + lines: 1 }) } diff --git a/src/server/index.ts b/src/server/index.ts index 043e9c2..cadbc5d 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1031,6 +1031,9 @@ function handleMessage( case 'tmux-check-copy-mode': handleCheckCopyMode(message.sessionId, ws) return + case 'tmux-scroll': + handleTmuxScroll(message.sessionId, message.direction, message.lines, ws) + return case 'session-resume': handleSessionResume(message, ws) return @@ -1091,6 +1094,48 @@ function handleCheckCopyMode(sessionId: string, ws: ServerWebSocket) { } } +function handleTmuxScroll( + sessionId: string, + direction: 'up' | 'down', + lines: number, + ws: ServerWebSocket +) { + const session = registry.get(sessionId) + if (!session) return + if (session.remote) return + + try { + const target = resolveCopyModeTarget(sessionId, ws, session) + + // Enter copy-mode if not already in it + Bun.spawnSync(['tmux', 'copy-mode', '-t', target], { + stdout: 'ignore', + stderr: 'ignore', + }) + + // Scroll in copy-mode using tmux's native scroll commands + const scrollCmd = direction === 'up' ? 'scroll-up' : 'scroll-down' + Bun.spawnSync( + ['tmux', 'send-keys', '-X', '-t', target, '-N', lines.toString(), scrollCmd], + { + stdout: 'ignore', + stderr: 'ignore', + } + ) + + // Optimistically report copy-mode status (scrolling up enters it) + if (direction === 'up') { + send(ws, { + type: 'tmux-copy-mode-status', + sessionId, + inCopyMode: true, + }) + } + } catch { + // Silently ignore scroll errors + } +} + function handleKill(sessionId: string, ws: ServerWebSocket) { const session = registry.get(sessionId) if (!session) { diff --git a/src/shared/types.ts b/src/shared/types.ts index b6c712f..0888761 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -115,6 +115,7 @@ export type ClientMessage = | { type: 'terminal-detach'; sessionId: string } | { type: 'terminal-input'; sessionId: string; data: string } | { type: 'terminal-resize'; sessionId: string; cols: number; rows: number } + | { type: 'tmux-scroll'; sessionId: string; direction: 'up' | 'down'; lines: number } | { type: 'session-create'; projectPath: string; name?: string; command?: string } | { type: 'session-kill'; sessionId: string } | { type: 'session-rename'; sessionId: string; newName: string } From d7573b5703923f446519fba8d86cedeb320605e6 Mon Sep 17 00:00:00 2001 From: Marty Date: Tue, 3 Feb 2026 21:30:13 +0000 Subject: [PATCH 2/6] feat: auto-exit copy-mode when scrolling reaches bottom After scrolling, check tmux's pane_in_mode status and report it to the client. When scrolling down reaches the bottom, tmux automatically exits copy-mode, and we now detect this and update the UI accordingly. This eliminates the need to press 'q' to exit copy-mode - just scroll down to the bottom and it exits automatically, matching native tmux behavior. Co-Authored-By: Claude Sonnet 4.5 --- src/server/index.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/server/index.ts b/src/server/index.ts index cadbc5d..8b43739 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1123,14 +1123,19 @@ function handleTmuxScroll( } ) - // Optimistically report copy-mode status (scrolling up enters it) - if (direction === 'up') { - send(ws, { - type: 'tmux-copy-mode-status', - sessionId, - inCopyMode: true, - }) - } + // Check copy-mode status after scrolling + const checkResult = Bun.spawnSync( + ['tmux', 'display-message', '-p', '-t', target, '#{pane_in_mode}'], + { stdout: 'pipe', stderr: 'ignore' } + ) + const inCopyMode = checkResult.stdout.toString().trim() === '1' + + // Report current copy-mode status to client + send(ws, { + type: 'tmux-copy-mode-status', + sessionId, + inCopyMode, + }) } catch { // Silently ignore scroll errors } From 8652aeeac055c5a475bfdeb3ffe3733275f6e360 Mon Sep 17 00:00:00 2001 From: Marty Date: Tue, 3 Feb 2026 21:38:00 +0000 Subject: [PATCH 3/6] fix: explicitly exit copy-mode when scroll position reaches 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tmux doesn't auto-exit copy-mode when reaching the bottom - it stays in copy-mode until explicitly exited. Now we check #{scroll_position} after scrolling down, and if it's 0 (at the bottom), we explicitly send the cancel command to exit copy-mode. This provides the expected behavior: scroll down to bottom → automatically exits copy-mode and returns to live terminal. Co-Authored-By: Claude Sonnet 4.5 --- src/server/index.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/server/index.ts b/src/server/index.ts index 8b43739..e217492 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1123,6 +1123,24 @@ function handleTmuxScroll( } ) + // When scrolling down, check if we've reached the bottom (scroll position 0) + // If so, explicitly exit copy-mode + if (direction === 'down') { + const scrollPosResult = Bun.spawnSync( + ['tmux', 'display-message', '-p', '-t', target, '#{scroll_position}'], + { stdout: 'pipe', stderr: 'ignore' } + ) + const scrollPosition = Number.parseInt(scrollPosResult.stdout.toString().trim(), 10) + + // At position 0 (bottom), exit copy-mode automatically + if (scrollPosition === 0) { + Bun.spawnSync(['tmux', 'send-keys', '-X', '-t', target, 'cancel'], { + stdout: 'ignore', + stderr: 'ignore', + }) + } + } + // Check copy-mode status after scrolling const checkResult = Bun.spawnSync( ['tmux', 'display-message', '-p', '-t', target, '#{pane_in_mode}'], From 17d56dbb51a0aa5f73f53cca82f8672a3d8b142f Mon Sep 17 00:00:00 2001 From: Gary Basin Date: Tue, 3 Feb 2026 19:41:20 -0500 Subject: [PATCH 4/6] refactor: handle scroll in pipe-pane mode only Instead of intercepting scroll events client-side and sending a separate tmux-scroll message, handle SGR mouse scroll sequences directly in PipePaneTerminalProxy.write(). This approach: - Only affects pipe-pane mode (where send-keys -l doesn't trigger tmux mouse handling) - Leaves PTY mode unchanged (native tmux mouse handling works correctly) - Reduces complexity by keeping scroll logic server-side - Avoids extra WebSocket message round-trips The fix intercepts ESC[<64;col;rowM (scroll-up) and ESC[<65;col;rowM (scroll-down) sequences before they're sent as literal text to the shell, and instead: 1. Enters tmux copy-mode (idempotent) 2. Sends scroll-up or scroll-down via send-keys -X Co-Authored-By: Claude Opus 4.5 --- src/client/components/Terminal.tsx | 16 +- src/client/hooks/useTerminal.ts | 14 +- .../__tests__/pipePaneTerminalProxy.test.ts | 164 ++++++++++++++++++ src/server/index.ts | 68 -------- src/server/terminal/PipePaneTerminalProxy.ts | 15 ++ src/shared/types.ts | 1 - 6 files changed, 200 insertions(+), 78 deletions(-) diff --git a/src/client/components/Terminal.tsx b/src/client/components/Terminal.tsx index 0c7e89d..0375475 100644 --- a/src/client/components/Terminal.tsx +++ b/src/client/components/Terminal.tsx @@ -549,16 +549,22 @@ export default function Terminal({ const currentSessionId = sessionIdRef.current if (!currentSessionId || lines === 0) return false - const scrolledUp = lines < 0 + const terminal = terminalRef.current + const cols = terminal?.cols ?? 80 + const rows = terminal?.rows ?? 24 + const col = Math.floor(cols / 2) + const row = Math.floor(rows / 2) + + // SGR mouse wheel: button 64 = scroll up, 65 = scroll down + const button = lines > 0 ? 65 : 64 const count = Math.abs(lines) + const scrolledUp = lines < 0 - // Send tmux scroll command directly to enter copy-mode and scroll for (let i = 0; i < count; i++) { sendMessageRef.current({ - type: 'tmux-scroll', + type: 'terminal-input', sessionId: currentSessionId, - direction: lines > 0 ? 'down' : 'up', - lines: 1 + data: `\x1b[<${button};${col};${row}M` }) } diff --git a/src/client/hooks/useTerminal.ts b/src/client/hooks/useTerminal.ts index 8fa7a1a..cc4e90e 100644 --- a/src/client/hooks/useTerminal.ts +++ b/src/client/hooks/useTerminal.ts @@ -510,6 +510,12 @@ export function useTerminal({ const STEP = 30 wheelAccumRef.current += ev.deltaY + // Get approximate cell position for SGR mouse event + const cols = terminal.cols + const rows = terminal.rows + const col = Math.floor(cols / 2) + const row = Math.floor(rows / 2) + let scrolledUp = false let didScroll = false while (Math.abs(wheelAccumRef.current) >= STEP) { @@ -517,13 +523,13 @@ export function useTerminal({ const down = wheelAccumRef.current > 0 wheelAccumRef.current += down ? -STEP : STEP - // Send tmux scroll command directly to enter copy-mode and scroll + // SGR mouse wheel: button 64 = scroll up, 65 = scroll down + const button = down ? 65 : 64 if (!down) scrolledUp = true sendMessageRef.current({ - type: 'tmux-scroll', + type: 'terminal-input', sessionId: attached, - direction: down ? 'down' : 'up', - lines: 1 + data: `\x1b[<${button};${col};${row}M` }) } diff --git a/src/server/__tests__/pipePaneTerminalProxy.test.ts b/src/server/__tests__/pipePaneTerminalProxy.test.ts index a4fb4e8..9486639 100644 --- a/src/server/__tests__/pipePaneTerminalProxy.test.ts +++ b/src/server/__tests__/pipePaneTerminalProxy.test.ts @@ -248,4 +248,168 @@ describe('PipePaneTerminalProxy', () => { await proxy.dispose() }) + + test('handles SGR scroll-up sequence with tmux copy-mode instead of send-keys -l', async () => { + const harness = createPipeHarness() + + const proxy = new PipePaneTerminalProxy({ + connectionId: 'conn-scroll-1', + sessionName: 'agentboard-ws-conn-scroll-1', + baseSession: 'agentboard', + onData: () => {}, + spawn: harness.spawn, + spawnSync: harness.spawnSync, + monitorTargets: false, + }) + + await proxy.start() + await proxy.switchTo('agentboard:@1') + harness.tmuxCalls.length = 0 // Clear setup calls + + // Send SGR scroll-up sequence (button 64) + proxy.write('\x1b[<64;40;12M') + + // Should call copy-mode first + expect(harness.tmuxCalls).toContainEqual([ + 'tmux', + 'copy-mode', + '-t', + 'agentboard:@1', + ]) + + // Should call scroll-up command + expect(harness.tmuxCalls).toContainEqual([ + 'tmux', + 'send-keys', + '-X', + '-t', + 'agentboard:@1', + 'scroll-up', + ]) + + // Should NOT use send-keys -l (the broken approach) + const literalSendKeys = harness.tmuxCalls.find( + (call) => call.includes('-l') && call.includes('\x1b') + ) + expect(literalSendKeys).toBeUndefined() + + await proxy.dispose() + }) + + test('handles SGR scroll-down sequence with tmux copy-mode', async () => { + const harness = createPipeHarness() + + const proxy = new PipePaneTerminalProxy({ + connectionId: 'conn-scroll-2', + sessionName: 'agentboard-ws-conn-scroll-2', + baseSession: 'agentboard', + onData: () => {}, + spawn: harness.spawn, + spawnSync: harness.spawnSync, + monitorTargets: false, + }) + + await proxy.start() + await proxy.switchTo('agentboard:@1') + harness.tmuxCalls.length = 0 + + // Send SGR scroll-down sequence (button 65) + proxy.write('\x1b[<65;40;12M') + + // Should call copy-mode + expect(harness.tmuxCalls).toContainEqual([ + 'tmux', + 'copy-mode', + '-t', + 'agentboard:@1', + ]) + + // Should call scroll-down command + expect(harness.tmuxCalls).toContainEqual([ + 'tmux', + 'send-keys', + '-X', + '-t', + 'agentboard:@1', + 'scroll-down', + ]) + + await proxy.dispose() + }) + + test('regular input still uses send-keys -l', async () => { + const harness = createPipeHarness() + + const proxy = new PipePaneTerminalProxy({ + connectionId: 'conn-scroll-3', + sessionName: 'agentboard-ws-conn-scroll-3', + baseSession: 'agentboard', + onData: () => {}, + spawn: harness.spawn, + spawnSync: harness.spawnSync, + monitorTargets: false, + }) + + await proxy.start() + await proxy.switchTo('agentboard:@1') + harness.tmuxCalls.length = 0 + + // Send regular input + proxy.write('hello') + + // Should use send-keys -l for regular input + expect(harness.tmuxCalls).toContainEqual([ + 'tmux', + 'send-keys', + '-t', + 'agentboard:@1', + '-l', + '--', + 'hello', + ]) + + // Should NOT call copy-mode for regular input + const copyModeCall = harness.tmuxCalls.find((call) => call.includes('copy-mode')) + expect(copyModeCall).toBeUndefined() + + await proxy.dispose() + }) + + test('handles other mouse sequences (non-scroll) with send-keys -l', async () => { + const harness = createPipeHarness() + + const proxy = new PipePaneTerminalProxy({ + connectionId: 'conn-scroll-4', + sessionName: 'agentboard-ws-conn-scroll-4', + baseSession: 'agentboard', + onData: () => {}, + spawn: harness.spawn, + spawnSync: harness.spawnSync, + monitorTargets: false, + }) + + await proxy.start() + await proxy.switchTo('agentboard:@1') + harness.tmuxCalls.length = 0 + + // Send SGR mouse click sequence (button 0 = left click) + proxy.write('\x1b[<0;40;12M') + + // Should use send-keys -l for non-scroll mouse events + expect(harness.tmuxCalls).toContainEqual([ + 'tmux', + 'send-keys', + '-t', + 'agentboard:@1', + '-l', + '--', + '\x1b[<0;40;12M', + ]) + + // Should NOT call copy-mode for clicks + const copyModeCall = harness.tmuxCalls.find((call) => call.includes('copy-mode')) + expect(copyModeCall).toBeUndefined() + + await proxy.dispose() + }) }) diff --git a/src/server/index.ts b/src/server/index.ts index e217492..043e9c2 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1031,9 +1031,6 @@ function handleMessage( case 'tmux-check-copy-mode': handleCheckCopyMode(message.sessionId, ws) return - case 'tmux-scroll': - handleTmuxScroll(message.sessionId, message.direction, message.lines, ws) - return case 'session-resume': handleSessionResume(message, ws) return @@ -1094,71 +1091,6 @@ function handleCheckCopyMode(sessionId: string, ws: ServerWebSocket) { } } -function handleTmuxScroll( - sessionId: string, - direction: 'up' | 'down', - lines: number, - ws: ServerWebSocket -) { - const session = registry.get(sessionId) - if (!session) return - if (session.remote) return - - try { - const target = resolveCopyModeTarget(sessionId, ws, session) - - // Enter copy-mode if not already in it - Bun.spawnSync(['tmux', 'copy-mode', '-t', target], { - stdout: 'ignore', - stderr: 'ignore', - }) - - // Scroll in copy-mode using tmux's native scroll commands - const scrollCmd = direction === 'up' ? 'scroll-up' : 'scroll-down' - Bun.spawnSync( - ['tmux', 'send-keys', '-X', '-t', target, '-N', lines.toString(), scrollCmd], - { - stdout: 'ignore', - stderr: 'ignore', - } - ) - - // When scrolling down, check if we've reached the bottom (scroll position 0) - // If so, explicitly exit copy-mode - if (direction === 'down') { - const scrollPosResult = Bun.spawnSync( - ['tmux', 'display-message', '-p', '-t', target, '#{scroll_position}'], - { stdout: 'pipe', stderr: 'ignore' } - ) - const scrollPosition = Number.parseInt(scrollPosResult.stdout.toString().trim(), 10) - - // At position 0 (bottom), exit copy-mode automatically - if (scrollPosition === 0) { - Bun.spawnSync(['tmux', 'send-keys', '-X', '-t', target, 'cancel'], { - stdout: 'ignore', - stderr: 'ignore', - }) - } - } - - // Check copy-mode status after scrolling - const checkResult = Bun.spawnSync( - ['tmux', 'display-message', '-p', '-t', target, '#{pane_in_mode}'], - { stdout: 'pipe', stderr: 'ignore' } - ) - const inCopyMode = checkResult.stdout.toString().trim() === '1' - - // Report current copy-mode status to client - send(ws, { - type: 'tmux-copy-mode-status', - sessionId, - inCopyMode, - }) - } catch { - // Silently ignore scroll errors - } -} - function handleKill(sessionId: string, ws: ServerWebSocket) { const session = registry.get(sessionId) if (!session) { diff --git a/src/server/terminal/PipePaneTerminalProxy.ts b/src/server/terminal/PipePaneTerminalProxy.ts index 6d8d49e..0341ef8 100644 --- a/src/server/terminal/PipePaneTerminalProxy.ts +++ b/src/server/terminal/PipePaneTerminalProxy.ts @@ -43,6 +43,21 @@ class PipePaneTerminalProxy extends TerminalProxyBase { if (!data) { return } + + // Check for SGR mouse scroll sequences: ESC[<64;col;rowM (up) or ESC[<65;col;rowM (down) + // These need special handling in pipe-pane mode since send-keys -l sends them to the shell + // instead of tmux's mouse handler + // eslint-disable-next-line no-control-regex + const scrollMatch = data.match(/^\x1b\[<(64|65);\d+;\d+M$/) + if (scrollMatch) { + const direction = scrollMatch[1] === '64' ? 'scroll-up' : 'scroll-down' + // Enter copy-mode if not already (idempotent) + this.runTmux(['copy-mode', '-t', this.currentTarget]) + // Send scroll command + this.runTmux(['send-keys', '-X', '-t', this.currentTarget, direction]) + return + } + const lines = data.split('\n') for (let index = 0; index < lines.length; index += 1) { const line = lines[index] diff --git a/src/shared/types.ts b/src/shared/types.ts index 0888761..b6c712f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -115,7 +115,6 @@ export type ClientMessage = | { type: 'terminal-detach'; sessionId: string } | { type: 'terminal-input'; sessionId: string; data: string } | { type: 'terminal-resize'; sessionId: string; cols: number; rows: number } - | { type: 'tmux-scroll'; sessionId: string; direction: 'up' | 'down'; lines: number } | { type: 'session-create'; projectPath: string; name?: string; command?: string } | { type: 'session-kill'; sessionId: string } | { type: 'session-rename'; sessionId: string; newName: string } From 97c21d7fb97a638e5e9b719a23249a15e40f18bb Mon Sep 17 00:00:00 2001 From: Gary Basin Date: Tue, 3 Feb 2026 19:53:12 -0500 Subject: [PATCH 5/6] fix: handle scroll modifier keys and batched input Improvements to SGR mouse scroll handling in pipe-pane mode: - Support modifier keys (Shift/Alt/Ctrl) by masking off modifier bits (button & 67) to extract the base wheel button (64=up, 65=down) - Handle batched scroll sequences in a single write() call - Only enter copy-mode once per write batch for efficiency - Properly handle mixed scroll + text input - Ignore release events (lowercase 'm') Added tests for: - Shift+scroll, Ctrl+scroll, Alt+Shift+scroll variants - Multiple scroll sequences in one write - Mixed scroll and text input - Release event handling Co-Authored-By: Claude Opus 4.5 --- .../__tests__/pipePaneTerminalProxy.test.ts | 164 ++++++++++++++++++ src/server/terminal/PipePaneTerminalProxy.ts | 61 +++++-- 2 files changed, 214 insertions(+), 11 deletions(-) diff --git a/src/server/__tests__/pipePaneTerminalProxy.test.ts b/src/server/__tests__/pipePaneTerminalProxy.test.ts index 9486639..f5f449f 100644 --- a/src/server/__tests__/pipePaneTerminalProxy.test.ts +++ b/src/server/__tests__/pipePaneTerminalProxy.test.ts @@ -412,4 +412,168 @@ describe('PipePaneTerminalProxy', () => { await proxy.dispose() }) + + test('handles scroll with modifier keys (shift, ctrl, alt)', async () => { + const harness = createPipeHarness() + + const proxy = new PipePaneTerminalProxy({ + connectionId: 'conn-scroll-mod', + sessionName: 'agentboard-ws-conn-scroll-mod', + baseSession: 'agentboard', + onData: () => {}, + spawn: harness.spawn, + spawnSync: harness.spawnSync, + monitorTargets: false, + }) + + await proxy.start() + await proxy.switchTo('agentboard:@1') + + // Test Shift+scroll-up (64 + 4 = 68) + harness.tmuxCalls.length = 0 + proxy.write('\x1b[<68;40;12M') + expect(harness.tmuxCalls).toContainEqual(['tmux', 'copy-mode', '-t', 'agentboard:@1']) + expect(harness.tmuxCalls).toContainEqual([ + 'tmux', + 'send-keys', + '-X', + '-t', + 'agentboard:@1', + 'scroll-up', + ]) + + // Test Ctrl+scroll-down (65 + 16 = 81) + harness.tmuxCalls.length = 0 + proxy.write('\x1b[<81;40;12M') + expect(harness.tmuxCalls).toContainEqual([ + 'tmux', + 'send-keys', + '-X', + '-t', + 'agentboard:@1', + 'scroll-down', + ]) + + // Test Alt+Shift+scroll-up (64 + 4 + 8 = 76) + harness.tmuxCalls.length = 0 + proxy.write('\x1b[<76;40;12M') + expect(harness.tmuxCalls).toContainEqual([ + 'tmux', + 'send-keys', + '-X', + '-t', + 'agentboard:@1', + 'scroll-up', + ]) + + await proxy.dispose() + }) + + test('handles batched scroll sequences in single write', async () => { + const harness = createPipeHarness() + + const proxy = new PipePaneTerminalProxy({ + connectionId: 'conn-scroll-batch', + sessionName: 'agentboard-ws-conn-scroll-batch', + baseSession: 'agentboard', + onData: () => {}, + spawn: harness.spawn, + spawnSync: harness.spawnSync, + monitorTargets: false, + }) + + await proxy.start() + await proxy.switchTo('agentboard:@1') + harness.tmuxCalls.length = 0 + + // Send multiple scroll-up sequences in one write (simulates WS batching) + proxy.write('\x1b[<64;40;12M\x1b[<64;40;12M\x1b[<64;40;12M') + + // Should only call copy-mode once + const copyModeCalls = harness.tmuxCalls.filter((call) => call.includes('copy-mode')) + expect(copyModeCalls.length).toBe(1) + + // Should call scroll-up three times + const scrollUpCalls = harness.tmuxCalls.filter((call) => call.includes('scroll-up')) + expect(scrollUpCalls.length).toBe(3) + + await proxy.dispose() + }) + + test('handles mixed scroll and text input in single write', async () => { + const harness = createPipeHarness() + + const proxy = new PipePaneTerminalProxy({ + connectionId: 'conn-scroll-mixed', + sessionName: 'agentboard-ws-conn-scroll-mixed', + baseSession: 'agentboard', + onData: () => {}, + spawn: harness.spawn, + spawnSync: harness.spawnSync, + monitorTargets: false, + }) + + await proxy.start() + await proxy.switchTo('agentboard:@1') + harness.tmuxCalls.length = 0 + + // Send text, then scroll, then more text + proxy.write('hello\x1b[<64;40;12M world') + + // Should call copy-mode and scroll-up for the scroll sequence + expect(harness.tmuxCalls).toContainEqual(['tmux', 'copy-mode', '-t', 'agentboard:@1']) + expect(harness.tmuxCalls).toContainEqual([ + 'tmux', + 'send-keys', + '-X', + '-t', + 'agentboard:@1', + 'scroll-up', + ]) + + // Should also send the non-scroll text via send-keys -l + expect(harness.tmuxCalls).toContainEqual([ + 'tmux', + 'send-keys', + '-t', + 'agentboard:@1', + '-l', + '--', + 'hello world', + ]) + + await proxy.dispose() + }) + + test('ignores scroll release events (lowercase m)', async () => { + const harness = createPipeHarness() + + const proxy = new PipePaneTerminalProxy({ + connectionId: 'conn-scroll-release', + sessionName: 'agentboard-ws-conn-scroll-release', + baseSession: 'agentboard', + onData: () => {}, + spawn: harness.spawn, + spawnSync: harness.spawnSync, + monitorTargets: false, + }) + + await proxy.start() + await proxy.switchTo('agentboard:@1') + harness.tmuxCalls.length = 0 + + // Send scroll release event (lowercase 'm' = release) + proxy.write('\x1b[<64;40;12m') + + // Should NOT call copy-mode for release events + const copyModeCall = harness.tmuxCalls.find((call) => call.includes('copy-mode')) + expect(copyModeCall).toBeUndefined() + + // Should pass through as regular input (or be stripped - either is acceptable) + // The key is that it doesn't trigger scroll handling + const scrollCall = harness.tmuxCalls.find((call) => call.includes('scroll-up')) + expect(scrollCall).toBeUndefined() + + await proxy.dispose() + }) }) diff --git a/src/server/terminal/PipePaneTerminalProxy.ts b/src/server/terminal/PipePaneTerminalProxy.ts index 0341ef8..0957704 100644 --- a/src/server/terminal/PipePaneTerminalProxy.ts +++ b/src/server/terminal/PipePaneTerminalProxy.ts @@ -44,21 +44,60 @@ class PipePaneTerminalProxy extends TerminalProxyBase { return } - // Check for SGR mouse scroll sequences: ESC[<64;col;rowM (up) or ESC[<65;col;rowM (down) - // These need special handling in pipe-pane mode since send-keys -l sends them to the shell - // instead of tmux's mouse handler + // Handle SGR mouse scroll sequences that may be batched with other input. + // In pipe-pane mode, send-keys -l passes escape sequences to the pane program + // (shell/app) instead of tmux's mouse handler, so we intercept scroll events + // and translate them to tmux copy-mode commands. + // + // SGR mouse encoding: ESC[ lastIndex) { + nonScrollParts.push(data.slice(lastIndex, match.index)) + } + lastIndex = match.index + match[0].length + + if (!hasScrollEvents) { + // Enter copy-mode once (idempotent, but avoid repeated calls) + this.runTmux(['copy-mode', '-t', this.currentTarget]) + hasScrollEvents = true + } + + const direction = baseButton === 64 ? 'scroll-up' : 'scroll-down' + this.runTmux(['send-keys', '-X', '-t', this.currentTarget, direction]) + } + } + + // Collect any remaining text after the last scroll sequence + if (lastIndex < data.length) { + nonScrollParts.push(data.slice(lastIndex)) + } + + // If we only had scroll events with no other input, we're done + remaining = nonScrollParts.join('') + if (!remaining) { return } - const lines = data.split('\n') + const lines = remaining.split('\n') for (let index = 0; index < lines.length; index += 1) { const line = lines[index] if (line) { From 9c389d52902a1b31af0da2dca9538bd9c0a185b6 Mon Sep 17 00:00:00 2001 From: Gary Basin Date: Tue, 3 Feb 2026 20:14:27 -0500 Subject: [PATCH 6/6] fix: exit copy-mode when scroll-down reaches bottom After scroll-down events in pipe-pane mode, check #{scroll_position} and cancel copy-mode when at position 0 (bottom). This prevents getting stuck in copy-mode from incidental scroll-down input (especially common with trackpads that generate many down deltas). Added tests for: - Copy-mode exit when scroll_position=0 - No exit when scrolled up (position > 0) - Scroll-up does not trigger position check Co-Authored-By: Brenner Spear Co-Authored-By: Claude Opus 4.5 --- .../__tests__/pipePaneTerminalProxy.test.ts | 100 ++++++++++++++++++ src/server/terminal/PipePaneTerminalProxy.ts | 23 ++++ 2 files changed, 123 insertions(+) diff --git a/src/server/__tests__/pipePaneTerminalProxy.test.ts b/src/server/__tests__/pipePaneTerminalProxy.test.ts index f5f449f..ea51e75 100644 --- a/src/server/__tests__/pipePaneTerminalProxy.test.ts +++ b/src/server/__tests__/pipePaneTerminalProxy.test.ts @@ -576,4 +576,104 @@ describe('PipePaneTerminalProxy', () => { await proxy.dispose() }) + + test('exits copy-mode when scroll-down reaches bottom (scroll_position=0)', async () => { + let scrollPosition = '5' // Start scrolled up + const harness = createPipeHarness() + + // Override spawnSync to return scroll position + const originalSpawnSync = harness.spawnSync + const spawnSyncWithScrollPos = ( + args: string[], + options?: Parameters[1] + ) => { + if (args[1] === 'display-message' && args.includes('#{scroll_position}')) { + return { + exitCode: 0, + stdout: Buffer.from(scrollPosition), + stderr: Buffer.from(''), + } as ReturnType + } + return originalSpawnSync(args, options) + } + + const proxy = new PipePaneTerminalProxy({ + connectionId: 'conn-scroll-exit', + sessionName: 'agentboard-ws-conn-scroll-exit', + baseSession: 'agentboard', + onData: () => {}, + spawn: harness.spawn, + spawnSync: spawnSyncWithScrollPos, + monitorTargets: false, + }) + + await proxy.start() + await proxy.switchTo('agentboard:@1') + harness.tmuxCalls.length = 0 + + // Scroll down when NOT at bottom - should NOT cancel + proxy.write('\x1b[<65;40;12M') + let cancelCalls = harness.tmuxCalls.filter((call) => call.includes('cancel')) + expect(cancelCalls.length).toBe(0) + + // Now simulate being at bottom + scrollPosition = '0' + harness.tmuxCalls.length = 0 + + // Scroll down at bottom - should cancel copy-mode + proxy.write('\x1b[<65;40;12M') + cancelCalls = harness.tmuxCalls.filter((call) => call.includes('cancel')) + expect(cancelCalls.length).toBe(1) + expect(harness.tmuxCalls).toContainEqual([ + 'tmux', + 'send-keys', + '-X', + '-t', + 'agentboard:@1', + 'cancel', + ]) + + await proxy.dispose() + }) + + test('scroll-up does not check scroll position or cancel', async () => { + const harness = createPipeHarness() + + // Override spawnSync to track display-message calls + const originalSpawnSync = harness.spawnSync + let displayMessageCalled = false + const spawnSyncTracking = ( + args: string[], + options?: Parameters[1] + ) => { + if (args[1] === 'display-message') { + displayMessageCalled = true + } + return originalSpawnSync(args, options) + } + + const proxy = new PipePaneTerminalProxy({ + connectionId: 'conn-scroll-up-no-cancel', + sessionName: 'agentboard-ws-conn-scroll-up-no-cancel', + baseSession: 'agentboard', + onData: () => {}, + spawn: harness.spawn, + spawnSync: spawnSyncTracking, + monitorTargets: false, + }) + + await proxy.start() + await proxy.switchTo('agentboard:@1') + harness.tmuxCalls.length = 0 + displayMessageCalled = false + + // Scroll up - should NOT check scroll position + proxy.write('\x1b[<64;40;12M') + + expect(displayMessageCalled).toBe(false) + const cancelCalls = harness.tmuxCalls.filter((call) => call.includes('cancel')) + expect(cancelCalls.length).toBe(0) + + await proxy.dispose() + }) }) diff --git a/src/server/terminal/PipePaneTerminalProxy.ts b/src/server/terminal/PipePaneTerminalProxy.ts index 0957704..54b207b 100644 --- a/src/server/terminal/PipePaneTerminalProxy.ts +++ b/src/server/terminal/PipePaneTerminalProxy.ts @@ -58,6 +58,7 @@ class PipePaneTerminalProxy extends TerminalProxyBase { const sgrMousePattern = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g let remaining = data let hasScrollEvents = false + let hasScrollDown = false const nonScrollParts: string[] = [] let lastIndex = 0 @@ -82,10 +83,32 @@ class PipePaneTerminalProxy extends TerminalProxyBase { } const direction = baseButton === 64 ? 'scroll-up' : 'scroll-down' + if (baseButton === 65) { + hasScrollDown = true + } this.runTmux(['send-keys', '-X', '-t', this.currentTarget, direction]) } } + // After scroll-down events, check if we've reached the bottom and exit copy-mode + // This prevents getting stuck in copy-mode from incidental scroll-down input + if (hasScrollDown) { + try { + const scrollPos = this.runTmux([ + 'display-message', + '-t', + this.currentTarget, + '-p', + '#{scroll_position}', + ]).trim() + if (scrollPos === '0') { + this.runTmux(['send-keys', '-X', '-t', this.currentTarget, 'cancel']) + } + } catch { + // Ignore errors checking scroll position + } + } + // Collect any remaining text after the last scroll sequence if (lastIndex < data.length) { nonScrollParts.push(data.slice(lastIndex))