From 5700f3dfee3d34ca4f4ea48689ab09edd663553b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:56:01 -0700 Subject: [PATCH] feat(terminal): Ctrl+Left/Right word-nav on Windows/Linux (#857) --- .../terminal-shortcut-ctrl-arrow.test.ts | 79 +++++++++++++++++++ .../terminal-shortcut-policy.test.ts | 16 ++-- .../terminal-pane/terminal-shortcut-policy.ts | 20 +++++ tests/e2e/terminal-shortcuts.spec.ts | 7 ++ 4 files changed, 114 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-shortcut-ctrl-arrow.test.ts diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-ctrl-arrow.test.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-ctrl-arrow.test.ts new file mode 100644 index 000000000..7e7559db5 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-ctrl-arrow.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import { + resolveTerminalShortcutAction, + type TerminalShortcutEvent +} from './terminal-shortcut-policy' + +function event(overrides: Partial): TerminalShortcutEvent { + return { + key: '', + code: '', + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + repeat: false, + ...overrides + } +} + +describe('non-mac Ctrl+Left/Right word-nav', () => { + // Windows Terminal, GNOME Terminal, and Konsole all bind Ctrl+←/→ to + // word-nav. xterm.js emits \e[1;5D / \e[1;5C which default readline doesn't + // map, so translate to \eb / \ef (same bytes as our Alt+Arrow rule). + it('translates Ctrl+←/→ on Windows/Linux to readline \\eb / \\ef', () => { + expect( + resolveTerminalShortcutAction( + event({ key: 'ArrowLeft', code: 'ArrowLeft', ctrlKey: true }), + false + ) + ).toEqual({ type: 'sendInput', data: '\x1bb' }) + expect( + resolveTerminalShortcutAction( + event({ key: 'ArrowRight', code: 'ArrowRight', ctrlKey: true }), + false + ) + ).toEqual({ type: 'sendInput', data: '\x1bf' }) + }) + + it('does not translate Ctrl+Arrow on macOS (reserved by OS)', () => { + // Mac uses Cmd+Arrow for line-nav and Option+Arrow for word-nav. + // Ctrl+Arrow is the macOS Mission Control / Spaces chord. + expect( + resolveTerminalShortcutAction( + event({ key: 'ArrowLeft', code: 'ArrowLeft', ctrlKey: true }), + true + ) + ).toBeNull() + expect( + resolveTerminalShortcutAction( + event({ key: 'ArrowRight', code: 'ArrowRight', ctrlKey: true }), + true + ) + ).toBeNull() + }) + + it('does not intercept Ctrl+Shift+Arrow (selection passthrough)', () => { + expect( + resolveTerminalShortcutAction( + event({ key: 'ArrowLeft', code: 'ArrowLeft', ctrlKey: true, shiftKey: true }), + false + ) + ).toBeNull() + expect( + resolveTerminalShortcutAction( + event({ key: 'ArrowRight', code: 'ArrowRight', ctrlKey: true, shiftKey: true }), + false + ) + ).toBeNull() + }) + + it('does not intercept Ctrl+Alt+Arrow (different chord)', () => { + expect( + resolveTerminalShortcutAction( + event({ key: 'ArrowLeft', code: 'ArrowLeft', ctrlKey: true, altKey: true }), + false + ) + ).toBeNull() + }) +}) 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 b636a323c..74884d7c6 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 @@ -110,14 +110,6 @@ describe('resolveTerminalShortcutAction', () => { true ) ).toBeNull() - - // Non-Mac Ctrl+Arrow must pass through unchanged (readline's word-nav there). - expect( - resolveTerminalShortcutAction( - event({ key: 'ArrowLeft', code: 'ArrowLeft', ctrlKey: true }), - false - ) - ).toBeNull() }) it('uses ctrl as the non-mac pane modifier but still requires shift for tab-safe chords', () => { @@ -217,6 +209,14 @@ describe('resolveTerminalShortcutAction', () => { ) ).toBeNull() + // Ctrl+Alt+Arrow (Linux workspace switching on some desktops) must pass through on non-Mac. + expect( + resolveTerminalShortcutAction( + event({ key: 'ArrowLeft', code: 'ArrowLeft', ctrlKey: true, altKey: true }), + false + ) + ).toBeNull() + // Regression guard: plain ArrowLeft must still pass through untouched. expect( resolveTerminalShortcutAction(event({ key: 'ArrowLeft', code: 'ArrowLeft' }), true) 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 5c36c80ca..7664dfad5 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts @@ -176,6 +176,26 @@ export function resolveTerminalShortcutAction( return { type: 'sendInput', data: event.key === 'ArrowLeft' ? '\x1bb' : '\x1bf' } } + if ( + !isMac && + !event.metaKey && + event.ctrlKey && + !event.altKey && + !event.shiftKey && + (event.key === 'ArrowLeft' || event.key === 'ArrowRight') + ) { + // Why: Windows Terminal, GNOME Terminal, and Konsole all bind Ctrl+←/→ for + // word navigation on Linux/Windows — but xterm.js emits \e[1;5D / \e[1;5C, + // which default readline (bash, zsh) does not bind to backward-word / + // forward-word. Translate to \eb / \ef (same bytes as our Alt+Arrow rule) + // so Ctrl+←/→ works for word-nav matching user expectations on those + // platforms without requiring a custom inputrc. + // + // Mac-gated: Ctrl+Arrow on macOS is reserved for Mission Control / Spaces + // navigation at the OS level and should never reach the app. + return { type: 'sendInput', data: event.key === 'ArrowLeft' ? '\x1bb' : '\x1bf' } + } + // Why: with macOptionIsMeta disabled (to let non-US keyboard layouts compose // characters like @ and €), xterm.js no longer translates Option+letter into // Esc+letter automatically. We match on event.code (physical key) rather than diff --git a/tests/e2e/terminal-shortcuts.spec.ts b/tests/e2e/terminal-shortcuts.spec.ts index 156ba2485..9f53ac557 100644 --- a/tests/e2e/terminal-shortcuts.spec.ts +++ b/tests/e2e/terminal-shortcuts.spec.ts @@ -159,6 +159,13 @@ test.describe('Terminal Shortcuts', () => { await pressAndExpectWrite(orcaPage, electronApp, 'Alt+ArrowLeft', '\x1bb') await pressAndExpectWrite(orcaPage, electronApp, 'Alt+ArrowRight', '\x1bf') + // Ctrl+←/→ on non-mac → readline backward-word / forward-word (\eb / \ef). + // Mac-gated: Ctrl+Arrow on macOS is reserved for Mission Control / Spaces. + if (!isMac) { + await pressAndExpectWrite(orcaPage, electronApp, 'Control+ArrowLeft', '\x1bb') + await pressAndExpectWrite(orcaPage, electronApp, 'Control+ArrowRight', '\x1bf') + } + // Alt+Backspace → Esc+DEL (readline backward-kill-word). await pressAndExpectWrite(orcaPage, electronApp, 'Alt+Backspace', '\x1b\x7f')