From 88537ca6f3fa8d0114124d5faac0cacebf22c4d5 Mon Sep 17 00:00:00 2001 From: Marty Date: Tue, 3 Feb 2026 21:18:42 +0000 Subject: [PATCH 1/3] 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/3] 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/3] 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}'],