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..e217492 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,71 @@ 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/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 }