Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 5 additions & 11 deletions src/client/components/Terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
}

Expand Down
14 changes: 4 additions & 10 deletions src/client/hooks/useTerminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,26 +510,20 @@ 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) {
didScroll = true
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
})
}

Expand Down
68 changes: 68 additions & 0 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1091,6 +1094,71 @@ function handleCheckCopyMode(sessionId: string, ws: ServerWebSocket<WSData>) {
}
}

function handleTmuxScroll(
sessionId: string,
direction: 'up' | 'down',
lines: number,
ws: ServerWebSocket<WSData>
) {
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<WSData>) {
const session = registry.get(sessionId)
if (!session) {
Expand Down
1 change: 1 addition & 0 deletions src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Loading