fix: handle macOS terminal command arrow scrolling (#5846)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-06-19 15:48:03 -07:00
committed by GitHub
co-authored by Orca
parent c9460ed636
commit abbfb6d53f
4 changed files with 123 additions and 0 deletions
@@ -302,6 +302,21 @@ export function useTerminalKeyboardShortcuts({
return
}
if (action.type === 'scrollViewport') {
e.preventDefault()
e.stopImmediatePropagation()
const pane = manager.getActivePane() ?? manager.getPanes()[0]
if (!pane) {
return
}
if (action.position === 'top') {
pane.terminal.scrollToLine(0)
} else {
pane.terminal.scrollToBottom()
}
return
}
// Cmd+[ / Cmd+] cycles active split pane focus.
if (action.type === 'focusPane') {
const panes = manager.getPanes()
@@ -130,6 +130,26 @@ describe('resolveTerminalShortcutAction', () => {
).toBeNull()
})
it('maps Cmd+↑/↓ on macOS to terminal scrollback top/bottom navigation', () => {
expect(
resolveTerminalShortcutAction(event({ key: 'ArrowUp', code: 'ArrowUp', metaKey: true }), true)
).toEqual({ type: 'scrollViewport', position: 'top' })
expect(
resolveTerminalShortcutAction(
event({ key: 'ArrowDown', code: 'ArrowDown', metaKey: true }),
true
)
).toEqual({ type: 'scrollViewport', position: 'bottom' })
// Cmd+Shift+Arrow is selection territory; leave it to focused apps/shells.
expect(
resolveTerminalShortcutAction(
event({ key: 'ArrowUp', code: 'ArrowUp', metaKey: true, shiftKey: true }),
true
)
).toBeNull()
})
it('preserves existing non-Mac terminal pane shortcuts', () => {
expect(
resolveTerminalShortcutAction(event({ key: 'f', code: 'KeyF', ctrlKey: true }), false)
@@ -37,6 +37,7 @@ export type TerminalShortcutAction =
| { type: 'toggleExpandActivePane' }
| { type: 'closeActivePane' }
| { type: 'splitActivePane'; direction: 'vertical' | 'horizontal' }
| { type: 'scrollViewport'; position: 'top' | 'bottom' }
| { type: 'sendInput'; data: string }
export function resolveTerminalShortcutAction(
@@ -129,6 +130,14 @@ export function resolveTerminalShortcutAction(
if (event.key === 'ArrowRight') {
return { type: 'sendInput', data: '\x05' }
}
// Why: macOS terminal users expect Cmd+↑/↓ to jump through scrollback
// without writing escape bytes into the shell.
if (event.key === 'ArrowUp') {
return { type: 'scrollViewport', position: 'top' }
}
if (event.key === 'ArrowDown') {
return { type: 'scrollViewport', position: 'bottom' }
}
}
if (
+79
View File
@@ -230,6 +230,31 @@ async function getActiveBackgroundTerminalTabId(page: Page): Promise<string | nu
})
}
async function getActiveTerminalViewport(
page: Page
): Promise<{ viewportY: number; baseY: number }> {
return page.evaluate(() => {
const state = window.__store?.getState()
const worktreeId = state?.activeWorktreeId
const tabId =
state?.activeTabType === 'terminal'
? state.activeTabId
: worktreeId
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
: null
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
const buffer = pane?.terminal.buffer.active
if (!buffer) {
throw new Error('No active terminal buffer')
}
return {
viewportY: buffer.viewportY,
baseY: buffer.baseY
}
})
}
async function enableKittyKeyboardReporting(page: Page, flags: number): Promise<void> {
await page.evaluate(async (flags) => {
const state = window.__store?.getState()
@@ -782,6 +807,60 @@ test.describe('Terminal Shortcuts', () => {
})
})
test('Cmd+Up/Down scrolls terminal viewport without writing to the PTY on macOS', async ({
orcaPage,
electronApp
}) => {
test.skip(!isMac, 'Cmd+Up/Down terminal scroll navigation is macOS-only')
await installMainProcessPtyWriteSpy(electronApp)
const ptyId = await waitForActivePanePtyId(orcaPage)
const marker = `CMD_ARROW_SCROLL_${Date.now()}`
await execInTerminal(orcaPage, ptyId, `for i in {1..120}; do echo ${marker}_$i; done`)
await waitForTerminalOutput(orcaPage, `${marker}_120`)
await expect
.poll(
async () => {
const viewport = await getActiveTerminalViewport(orcaPage)
return viewport.baseY > 0 && viewport.viewportY === viewport.baseY
},
{
timeout: 5_000,
message: 'terminal did not settle at the bottom with scrollback for Cmd+Up/Down repro'
}
)
.toBe(true)
await clearPtyWriteLog(electronApp)
await focusActiveTerminal(orcaPage)
await orcaPage.keyboard.press('Meta+ArrowUp')
await expect
.poll(async () => getActiveTerminalViewport(orcaPage), {
timeout: 5_000,
message: 'Cmd+Up did not scroll the terminal viewport to the top'
})
.toMatchObject({ viewportY: 0 })
expect(await getPtyWrites(electronApp)).toEqual([])
await focusActiveTerminal(orcaPage)
await orcaPage.keyboard.press('Meta+ArrowDown')
await expect
.poll(
async () => {
const viewport = await getActiveTerminalViewport(orcaPage)
return viewport.viewportY === viewport.baseY
},
{
timeout: 5_000,
message: 'Cmd+Down did not scroll the terminal viewport to the bottom'
}
)
.toBe(true)
expect(await getPtyWrites(electronApp)).toEqual([])
})
test('Shift with Russian layout text reaches the PTY as Cyrillic under kitty keyboard reporting', async ({
orcaPage,
electronApp