diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts index 5c35d605741..de9fdfbe081 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts @@ -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() diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts index fbb5dfa4a36..75b44ebbe7b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts @@ -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) diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts index 5b648f2fabe..d2d84976983 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts @@ -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 ( diff --git a/tests/e2e/terminal-shortcuts.spec.ts b/tests/e2e/terminal-shortcuts.spec.ts index 39a200aa0ac..de15695de16 100644 --- a/tests/e2e/terminal-shortcuts.spec.ts +++ b/tests/e2e/terminal-shortcuts.spec.ts @@ -230,6 +230,31 @@ async function getActiveBackgroundTerminalTabId(page: Page): Promise { + 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 { 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