This commit is contained in:
Brennan Benson 2026-04-21 10:44:37 +00:00 committed by GitHub
commit 47894503bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 846 additions and 303 deletions

View file

@ -0,0 +1,72 @@
# Terminal Shortcuts
This document is **generated from** `src/renderer/src/components/terminal-pane/terminal-shortcut-table.ts` and is enforced by `terminal-shortcut-table.docs.test.ts`. If the table changes, this file must be regenerated: run the parity test in `terminal-shortcut-table.docs.test.ts`, then replace the rows inside the `BEGIN:MAC`/`END:MAC` and `BEGIN:NONMAC`/`END:NONMAC` HTML-comment markers below with the `expected` value from the assertion diff.
Notation:
- `Mod` = Cmd on macOS, Ctrl on Windows/Linux
- `Ctrl` / `Meta` / `Alt` / `Shift` are literal modifier keys
- `→ sendInput(X)` means we write bytes X to the PTY
- `→ <action>` means we fire an app-level action
Shortcuts that depend on the Mac "Option as Alt" setting (Option+letter composing vs. Esc+letter emitting) are **not** in this table — they live in `resolveMacOptionAsAltAction` because their matching depends on runtime state.
## Mac (macOS)
| Chord | Action |
| --- | --- |
<!-- BEGIN:MAC -->
| `Mod+Shift+C` | Copy terminal selection |
| `Mod+F` | Toggle terminal search overlay |
| `Mod+K` | Clear active pane screen and scrollback |
| `Mod+W` | Close active split pane (or the tab if only one pane) |
| `Mod+]` | Cycle focus to next split pane |
| `Mod+[` | Cycle focus to previous split pane |
| `Mod+Shift+Enter` | Expand/collapse active split pane to fill the terminal area |
| `Mod+Shift+D` | Split active pane downward (Mac) |
| `Mod+D` | Split active pane to the right (Mac) |
| `Mod+ArrowLeft` | → sendInput(Ctrl+A) — Move cursor to start of line (Ctrl+A) |
| `Mod+ArrowRight` | → sendInput(Ctrl+E) — Move cursor to end of line (Ctrl+E) |
| `Mod+Backspace` | → sendInput(Ctrl+U) — Kill from cursor to start of line (Ctrl+U) |
| `Mod+Delete` | → sendInput(Ctrl+K) — Kill from cursor to end of line (Ctrl+K) |
| `Alt+ArrowLeft` | → sendInput(\eb) — Move cursor backward one word (\eb) |
| `Alt+ArrowRight` | → sendInput(\ef) — Move cursor forward one word (\ef) |
| `Alt+Backspace` | → sendInput(Esc+DEL) — Delete word before cursor (Esc+DEL) |
| `Ctrl+Backspace` | → sendInput(\x17) — Delete word before cursor (unix-word-rubout \x17) |
| `Shift+Enter` | → sendInput(\e[13;2u) — Shift+Enter as CSI-u (\e[13;2u) so agents can distinguish from Enter |
<!-- END:MAC -->
## Windows / Linux
| Chord | Action |
| --- | --- |
<!-- BEGIN:NONMAC -->
| `Mod+Shift+C` | Copy terminal selection |
| `Mod+F` | Toggle terminal search overlay |
| `Mod+K` | Clear active pane screen and scrollback |
| `Mod+W` | Close active split pane (or the tab if only one pane) |
| `Mod+]` | Cycle focus to next split pane |
| `Mod+[` | Cycle focus to previous split pane |
| `Mod+Shift+Enter` | Expand/collapse active split pane to fill the terminal area |
| `Mod+Shift+D` | Split active pane to the right (Linux/Windows) |
| `Alt+Shift+D` | Split active pane downward (Linux/Windows, Windows Terminal convention) |
| `Alt+ArrowLeft` | → sendInput(\eb) — Move cursor backward one word (\eb) |
| `Alt+ArrowRight` | → sendInput(\ef) — Move cursor forward one word (\ef) |
| `Alt+Backspace` | → sendInput(Esc+DEL) — Delete word before cursor (Esc+DEL) |
| `Ctrl+Backspace` | → sendInput(\x17) — Delete word before cursor (unix-word-rubout \x17) |
| `Shift+Enter` | → sendInput(\e[13;2u) — Shift+Enter as CSI-u (\e[13;2u) so agents can distinguish from Enter |
<!-- END:NONMAC -->
## Reserved shell chords (never intercepted)
(This section is editorial and is not enforced by the parity test — it describes chords intentionally left out of the table.)
These fall through to xterm.js and the shell regardless of platform:
- `Ctrl+C` — SIGINT
- `Ctrl+D` — EOF (see #586 — this is why our non-Mac split chord requires Shift)
- `Ctrl+Z` — SIGTSTP
- `Ctrl+\` — SIGQUIT
- `Home` / `End` — start/end of line (xterm's terminfo sequences)
- `Ctrl+R` / `Ctrl+U` / `Ctrl+A` / `Ctrl+E` — readline
Note: on Linux/Windows, `Ctrl+K` and `Ctrl+W` ARE intercepted by the app (they are `Mod+K` / `Mod+W` — see the Windows/Linux table above). On macOS, `Mod` is Cmd, so `Ctrl+K` and `Ctrl+W` fall through to readline there.

View file

@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest'
import {
resolveTerminalShortcutAction,
type TerminalShortcutEvent
} from './terminal-shortcut-policy'
function event(overrides: Partial<TerminalShortcutEvent>): TerminalShortcutEvent {
return {
key: '',
code: '',
metaKey: false,
ctrlKey: false,
altKey: false,
shiftKey: false,
repeat: false,
...overrides
}
}
// These cases exercise `resolveMacOptionAsAltAction` — the one branch kept
// imperative because it depends on runtime state (macOptionAsAlt setting +
// which physical Option key is held) and matches on event.code rather than
// event.key (macOS composition replaces event.key with the composed glyph).
describe('mac option-as-alt compensation', () => {
it('translates macOS Option+B/F/D to readline escape sequences in compose mode', () => {
// With macOptionAsAlt='false' (compose), xterm.js doesn't translate these.
// Matches on event.code because macOS composition replaces event.key.
expect(
resolveTerminalShortcutAction(event({ key: '∫', code: 'KeyB', altKey: true }), true, 'false')
).toEqual({ type: 'sendInput', data: '\x1bb' })
expect(
resolveTerminalShortcutAction(event({ key: 'ƒ', code: 'KeyF', altKey: true }), true, 'false')
).toEqual({ type: 'sendInput', data: '\x1bf' })
expect(
resolveTerminalShortcutAction(event({ key: '∂', code: 'KeyD', altKey: true }), true, 'false')
).toEqual({ type: 'sendInput', data: '\x1bd' })
// On Linux/Windows, Alt+B/F/D must still pass through (the table's Alt+Arrow
// word-nav rule is specific to ArrowLeft/Right, not letters).
expect(
resolveTerminalShortcutAction(event({ key: 'b', code: 'KeyB', altKey: true }), false)
).toBeNull()
// Option+Shift+B/F/D is a different chord (selection/capitalization); do
// not intercept.
expect(
resolveTerminalShortcutAction(
event({ key: 'B', code: 'KeyB', altKey: true, shiftKey: true }),
true,
'false'
)
).toBeNull()
})
it('sends Esc+letter for any Option+letter when left Option acts as alt', () => {
// Left Option (location=1) in 'left' mode: full Meta for any letter key.
expect(
resolveTerminalShortcutAction(
event({ key: '¬', code: 'KeyL', altKey: true }),
true,
'left',
1
)
).toEqual({ type: 'sendInput', data: '\x1bl' })
expect(
resolveTerminalShortcutAction(
event({ key: '†', code: 'KeyT', altKey: true }),
true,
'left',
1
)
).toEqual({ type: 'sendInput', data: '\x1bt' })
// Right Option (location=2) in 'left' mode is the compose side; only B/F/D
// still get patched so core readline word-nav works regardless.
expect(
resolveTerminalShortcutAction(
event({ key: '∫', code: 'KeyB', altKey: true }),
true,
'left',
2
)
).toEqual({ type: 'sendInput', data: '\x1bb' })
// Right Option+L must pass through (its composed glyph).
expect(
resolveTerminalShortcutAction(
event({ key: '¬', code: 'KeyL', altKey: true }),
true,
'left',
2
)
).toBeNull()
})
it('sends Esc+letter for any Option+letter when right Option acts as alt', () => {
// Right Option (location=2) in 'right' mode: full Meta including punctuation.
expect(
resolveTerminalShortcutAction(
event({ key: '≥', code: 'Period', altKey: true }),
true,
'right',
2
)
).toEqual({ type: 'sendInput', data: '\x1b.' })
expect(
resolveTerminalShortcutAction(
event({ key: '¬', code: 'KeyL', altKey: true }),
true,
'right',
2
)
).toEqual({ type: 'sendInput', data: '\x1bl' })
// Left Option (location=1) in 'right' mode is the compose side.
expect(
resolveTerminalShortcutAction(
event({ key: '¬', code: 'KeyL', altKey: true }),
true,
'right',
1
)
).toBeNull()
})
it('does not intercept Option+letter in true mode (xterm handles it natively)', () => {
expect(
resolveTerminalShortcutAction(event({ key: 'b', code: 'KeyB', altKey: true }), true, 'true')
).toBeNull()
})
})

View file

@ -0,0 +1,21 @@
export type TerminalShortcutEvent = {
key: string
code?: string
metaKey: boolean
ctrlKey: boolean
altKey: boolean
shiftKey: boolean
repeat?: boolean
}
export type MacOptionAsAlt = 'true' | 'false' | 'left' | 'right'
export type TerminalShortcutAction =
| { type: 'copySelection' }
| { type: 'toggleSearch' }
| { type: 'clearActivePane' }
| { type: 'focusPane'; direction: 'next' | 'previous' }
| { type: 'toggleExpandActivePane' }
| { type: 'closeActivePane' }
| { type: 'splitActivePane'; direction: 'vertical' | 'horizontal' }
| { type: 'sendInput'; data: string }

View file

@ -223,112 +223,6 @@ describe('resolveTerminalShortcutAction', () => {
).toBeNull()
})
it('translates macOS Option+B/F/D to readline escape sequences in compose mode', () => {
// With macOptionAsAlt='false' (compose), xterm.js doesn't translate these.
// Matches on event.code because macOS composition replaces event.key.
expect(
resolveTerminalShortcutAction(event({ key: '∫', code: 'KeyB', altKey: true }), true, 'false')
).toEqual({ type: 'sendInput', data: '\x1bb' })
expect(
resolveTerminalShortcutAction(event({ key: 'ƒ', code: 'KeyF', altKey: true }), true, 'false')
).toEqual({ type: 'sendInput', data: '\x1bf' })
expect(
resolveTerminalShortcutAction(event({ key: '∂', code: 'KeyD', altKey: true }), true, 'false')
).toEqual({ type: 'sendInput', data: '\x1bd' })
// On Linux/Windows, Alt+B/F/D must still pass through
expect(
resolveTerminalShortcutAction(event({ key: 'b', code: 'KeyB', altKey: true }), false)
).toBeNull()
// Option+Shift+B/F/D should not be intercepted (different chord)
expect(
resolveTerminalShortcutAction(
event({ key: 'B', code: 'KeyB', altKey: true, shiftKey: true }),
true,
'false'
)
).toBeNull()
})
it('sends Esc+letter for any Option+letter when left Option acts as alt', () => {
// Left Option (optionKeyLocation=1) in 'left' mode: full Meta for any letter key
expect(
resolveTerminalShortcutAction(
event({ key: '¬', code: 'KeyL', altKey: true }),
true,
'left',
1
)
).toEqual({ type: 'sendInput', data: '\x1bl' })
expect(
resolveTerminalShortcutAction(
event({ key: '†', code: 'KeyT', altKey: true }),
true,
'left',
1
)
).toEqual({ type: 'sendInput', data: '\x1bt' })
// Right Option (optionKeyLocation=2) in 'left' mode: compose side, only B/F/D patched
expect(
resolveTerminalShortcutAction(
event({ key: '∫', code: 'KeyB', altKey: true }),
true,
'left',
2
)
).toEqual({ type: 'sendInput', data: '\x1bb' })
// Right Option+L should pass through (compose character)
expect(
resolveTerminalShortcutAction(
event({ key: '¬', code: 'KeyL', altKey: true }),
true,
'left',
2
)
).toBeNull()
})
it('sends Esc+letter for any Option+letter when right Option acts as alt', () => {
// Right Option (optionKeyLocation=2) in 'right' mode: full Meta, including punctuation
expect(
resolveTerminalShortcutAction(
event({ key: '≥', code: 'Period', altKey: true }),
true,
'right',
2
)
).toEqual({ type: 'sendInput', data: '\x1b.' })
expect(
resolveTerminalShortcutAction(
event({ key: '¬', code: 'KeyL', altKey: true }),
true,
'right',
2
)
).toEqual({ type: 'sendInput', data: '\x1bl' })
// Left Option (optionKeyLocation=1) in 'right' mode: compose side, only B/F/D patched
expect(
resolveTerminalShortcutAction(
event({ key: '¬', code: 'KeyL', altKey: true }),
true,
'right',
1
)
).toBeNull()
})
it('does not intercept Option+letter in true mode (xterm handles it)', () => {
// In 'true' mode, macOptionIsMeta is enabled in xterm, so no compensation needed
// Our handler still fires but is gated by macOptionAsAlt !== 'true'
expect(
resolveTerminalShortcutAction(event({ key: 'b', code: 'KeyB', altKey: true }), true, 'true')
).toBeNull()
})
it('keeps Cmd+D and Cmd+Shift+D for split on macOS', () => {
expect(
resolveTerminalShortcutAction(event({ key: 'd', code: 'KeyD', metaKey: true }), true)

View file

@ -1,14 +1,16 @@
export type TerminalShortcutEvent = {
key: string
code?: string
metaKey: boolean
ctrlKey: boolean
altKey: boolean
shiftKey: boolean
repeat?: boolean
}
import {
TERMINAL_SHORTCUTS,
type ChordMatch,
type Modifier,
type ShortcutEntry
} from './terminal-shortcut-table'
import type {
MacOptionAsAlt,
TerminalShortcutAction,
TerminalShortcutEvent
} from './terminal-shortcut-policy-types'
export type MacOptionAsAlt = 'true' | 'false' | 'left' | 'right'
export type { MacOptionAsAlt, TerminalShortcutAction, TerminalShortcutEvent }
// Why: macOS composition replaces event.key for punctuation, so we map
// event.code to the unmodified character for Esc+ sequences.
@ -26,15 +28,141 @@ const PUNCTUATION_CODE_MAP: Record<string, string> = {
Backquote: '`'
}
export type TerminalShortcutAction =
| { type: 'copySelection' }
| { type: 'toggleSearch' }
| { type: 'clearActivePane' }
| { type: 'focusPane'; direction: 'next' | 'previous' }
| { type: 'toggleExpandActivePane' }
| { type: 'closeActivePane' }
| { type: 'splitActivePane'; direction: 'vertical' | 'horizontal' }
| { type: 'sendInput'; data: string }
function chordMatches(event: TerminalShortcutEvent, match: ChordMatch, isMac: boolean): boolean {
const required = new Set<Modifier>(match.modifiers)
// `mod` is the primary app modifier: Meta on Mac, Ctrl elsewhere. Expand it
// into the platform-specific literal modifier so exclusivity checks below
// correctly reject events with the wrong modifier.
if (required.has('mod')) {
required.delete('mod')
required.add(isMac ? 'meta' : 'ctrl')
}
const has = {
meta: event.metaKey,
ctrl: event.ctrlKey,
alt: event.altKey,
shift: event.shiftKey
}
// Every required modifier must be present.
for (const mod of required) {
if (mod === 'meta' && !has.meta) {
return false
}
if (mod === 'ctrl' && !has.ctrl) {
return false
}
if (mod === 'alt' && !has.alt) {
return false
}
if (mod === 'shift' && !has.shift) {
return false
}
}
// No non-required modifier may be present. Keeps Ctrl+Shift+D from matching
// a rule that only required Ctrl, and protects cross-platform chords from
// accidentally firing when an extra modifier is held.
if (has.meta && !required.has('meta')) {
return false
}
if (has.ctrl && !required.has('ctrl')) {
return false
}
if (has.alt && !required.has('alt')) {
return false
}
if (has.shift && !required.has('shift')) {
return false
}
if (match.key !== undefined && event.key !== match.key) {
return false
}
if (match.keyLower !== undefined && event.key.toLowerCase() !== match.keyLower) {
return false
}
if (match.code !== undefined) {
const codes = typeof match.code === 'string' ? [match.code] : match.code
if (!event.code || !codes.includes(event.code)) {
return false
}
}
return true
}
function entryAppliesToPlatform(entry: ShortcutEntry, isMac: boolean): boolean {
return isMac ? entry.mac : entry.nonMac
}
/**
* Mac Option-as-Alt compensation. Kept imperative because its matching logic
* depends on runtime state (the `macOptionAsAlt` setting and which physical
* Option key is held) and on event.code rather than event.key, which doesn't
* fit the declarative table's shape.
*
* With macOptionIsMeta disabled in xterm (so non-US keyboard layouts can
* compose characters like @ and ), xterm.js no longer translates Option+letter
* into Esc+letter automatically. We match on event.code (physical key) because
* macOS composition replaces event.key with the composed character
* (e.g. Option+B reports key='∫', not key='b').
*
* Modes (mirrors Ghostty):
* - 'true': xterm handles all Option as Meta natively; nothing to do here.
* - 'false': compensate only the three most critical readline shortcuts (B/F/D).
* - 'left'/'right': the designated Option key acts as full Meta (emit Esc+
* for any letter/digit/punctuation); the other Option key composes, with
* B/F/D compensated.
*/
function resolveMacOptionAsAltAction(
event: TerminalShortcutEvent,
macOptionAsAlt: MacOptionAsAlt,
optionKeyLocation: number
): TerminalShortcutAction | null {
if (event.metaKey || event.ctrlKey || !event.altKey || event.shiftKey) {
return null
}
// Why: event.location on a character key reports that key's position (always
// 0 for standard keys), NOT which modifier is held. The caller must track
// the Option key's own keydown location and pass it as optionKeyLocation.
const isLeftOption = optionKeyLocation === 1
const isRightOption = optionKeyLocation === 2
const shouldActAsMeta =
(macOptionAsAlt === 'left' && isLeftOption) || (macOptionAsAlt === 'right' && isRightOption)
if (shouldActAsMeta) {
if (event.code?.startsWith('Key') && event.code.length === 4) {
const letter = event.code.charAt(3).toLowerCase()
return { type: 'sendInput', data: `\x1b${letter}` }
}
if (event.code?.startsWith('Digit') && event.code.length === 6) {
return { type: 'sendInput', data: `\x1b${event.code.charAt(5)}` }
}
const punct = event.code ? PUNCTUATION_CODE_MAP[event.code] : undefined
if (punct) {
return { type: 'sendInput', data: `\x1b${punct}` }
}
}
// In 'false', 'left', or 'right' mode, the compose-side Option key still
// needs the three most critical readline shortcuts patched.
if (macOptionAsAlt !== 'true' && !shouldActAsMeta) {
if (event.code === 'KeyB') {
return { type: 'sendInput', data: '\x1bb' }
}
if (event.code === 'KeyF') {
return { type: 'sendInput', data: '\x1bf' }
}
if (event.code === 'KeyD') {
return { type: 'sendInput', data: '\x1bd' }
}
}
return null
}
export function resolveTerminalShortcutAction(
event: TerminalShortcutEvent,
@ -42,190 +170,27 @@ export function resolveTerminalShortcutAction(
macOptionAsAlt: MacOptionAsAlt = 'false',
optionKeyLocation: number = 0
): TerminalShortcutAction | null {
const mod = isMac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
if (!event.repeat && mod && !event.altKey) {
const lowerKey = event.key.toLowerCase()
if (event.shiftKey && lowerKey === 'c') {
return { type: 'copySelection' }
// Key-repeat events should never fire app actions (split, close, etc.) but
// ARE allowed to emit PTY bytes (holding Alt+Left to jump back several
// words). The table's entries cover both kinds, so repeat gating happens in
// the keyboard handler, not here — except that the original policy applied
// a repeat guard to most chords. Preserve that behavior: reject repeats for
// everything except sendInput entries.
for (const entry of TERMINAL_SHORTCUTS) {
if (!entryAppliesToPlatform(entry, isMac)) {
continue
}
if (!event.shiftKey && lowerKey === 'f') {
return { type: 'toggleSearch' }
if (!chordMatches(event, entry.match, isMac)) {
continue
}
if (!event.shiftKey && lowerKey === 'k') {
return { type: 'clearActivePane' }
}
if (!event.shiftKey && (event.code === 'BracketLeft' || event.code === 'BracketRight')) {
return {
type: 'focusPane',
direction: event.code === 'BracketRight' ? 'next' : 'previous'
}
}
if (
event.shiftKey &&
event.key === 'Enter' &&
(event.code === 'Enter' || event.code === 'NumpadEnter')
) {
return { type: 'toggleExpandActivePane' }
}
if (!event.shiftKey && lowerKey === 'w') {
return { type: 'closeActivePane' }
}
if (lowerKey === 'd') {
if (isMac) {
return {
type: 'splitActivePane',
direction: event.shiftKey ? 'horizontal' : 'vertical'
}
}
// Why: on Windows/Linux, Ctrl+D is the standard EOF signal for terminals.
// Binding Ctrl+D to split-pane would swallow EOF and break shell workflows
// (see #586). Only Ctrl+Shift+D triggers split on non-Mac platforms;
// Ctrl+D (without Shift) falls through to the terminal as normal input.
if (event.shiftKey) {
return { type: 'splitActivePane', direction: 'vertical' }
}
if (event.repeat && entry.action.type !== 'sendInput') {
return null
}
return entry.action
}
// Why: on Windows/Linux, Alt+Shift+D splits the pane down (horizontal).
// This lives outside the mod+!alt block above because it uses Alt instead
// of Ctrl, following the Windows Terminal convention for split shortcuts
// and avoiding the Ctrl+D / EOF conflict (see #586).
if (
!isMac &&
!event.repeat &&
!event.metaKey &&
!event.ctrlKey &&
event.altKey &&
event.shiftKey &&
event.key.toLowerCase() === 'd'
) {
return { type: 'splitActivePane', direction: 'horizontal' }
}
if (
!event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
event.shiftKey &&
event.key === 'Enter'
) {
return { type: 'sendInput', data: '\x1b[13;2u' }
}
if (
event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!event.shiftKey &&
event.key === 'Backspace'
) {
return { type: 'sendInput', data: '\x17' }
}
if (isMac && event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey) {
if (event.key === 'Backspace') {
return { type: 'sendInput', data: '\x15' }
}
if (event.key === 'Delete') {
return { type: 'sendInput', data: '\x0b' }
}
// Why: Cmd+←/→ on macOS conventionally moves to start/end of line in
// terminals (iTerm2, Ghostty). xterm.js has no default mapping for
// Cmd+Arrow, so we translate to readline's Ctrl+A (\x01) / Ctrl+E (\x05),
// which work universally across bash/zsh/fish and most TUI editors.
if (event.key === 'ArrowLeft') {
return { type: 'sendInput', data: '\x01' }
}
if (event.key === 'ArrowRight') {
return { type: 'sendInput', data: '\x05' }
}
}
if (
!event.metaKey &&
!event.ctrlKey &&
event.altKey &&
!event.shiftKey &&
event.key === 'Backspace'
) {
return { type: 'sendInput', data: '\x1b\x7f' }
}
if (
!event.metaKey &&
!event.ctrlKey &&
event.altKey &&
!event.shiftKey &&
(event.key === 'ArrowLeft' || event.key === 'ArrowRight')
) {
// Why: xterm.js would otherwise emit \e[1;3D / \e[1;3C for option/alt+arrow,
// which default readline (bash, zsh) does not bind to backward-word /
// forward-word — so word navigation silently doesn't work without a custom
// inputrc. Translate to \eb / \ef (readline's default word-nav bindings) so
// option+←/→ on macOS and alt+←/→ on Linux/Windows behave like they do in
// iTerm2's "Esc+" option-key mode. Platform-agnostic: both produce altKey.
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
// event.key because macOS composition replaces event.key with the composed
// character (e.g. Option+B reports key='∫', not key='b').
//
// The handling depends on the macOptionAsAlt setting (mirrors Ghostty):
// - 'true': xterm handles all Option as Meta natively; nothing to do here.
// - 'false': compensate the three most critical readline shortcuts (B/F/D).
// - 'left'/'right': the designated Option key acts as full Meta (emit Esc+
// for any single letter); the other key composes, with B/F/D compensated.
if (isMac && !event.metaKey && !event.ctrlKey && event.altKey && !event.shiftKey) {
// Why: event.location on a character key reports that key's position (always
// 0 for standard keys), NOT which modifier is held. The caller must track
// the Option key's own keydown location and pass it as optionKeyLocation.
const isLeftOption = optionKeyLocation === 1
const isRightOption = optionKeyLocation === 2
const shouldActAsMeta =
(macOptionAsAlt === 'left' && isLeftOption) || (macOptionAsAlt === 'right' && isRightOption)
if (shouldActAsMeta) {
// Emit Esc+key for letter keys (e.g. Option+B → \x1bb)
if (event.code?.startsWith('Key') && event.code.length === 4) {
const letter = event.code.charAt(3).toLowerCase()
return { type: 'sendInput', data: `\x1b${letter}` }
}
// Emit Esc+digit for number keys (e.g. Option+1 → \x1b1)
if (event.code?.startsWith('Digit') && event.code.length === 6) {
return { type: 'sendInput', data: `\x1b${event.code.charAt(5)}` }
}
const punct = event.code ? PUNCTUATION_CODE_MAP[event.code] : undefined
if (punct) {
return { type: 'sendInput', data: `\x1b${punct}` }
}
}
// In 'false', 'left', or 'right' mode, the compose-side Option key still
// needs the three most critical readline shortcuts patched.
if (macOptionAsAlt !== 'true' && !shouldActAsMeta) {
if (event.code === 'KeyB') {
return { type: 'sendInput', data: '\x1bb' }
}
if (event.code === 'KeyF') {
return { type: 'sendInput', data: '\x1bf' }
}
if (event.code === 'KeyD') {
return { type: 'sendInput', data: '\x1bd' }
}
}
if (isMac) {
return resolveMacOptionAsAltAction(event, macOptionAsAlt, optionKeyLocation)
}
return null

View file

@ -0,0 +1,150 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { TERMINAL_SHORTCUTS, type ShortcutEntry } from './terminal-shortcut-table'
/**
* Parity test: the human-readable reference doc at docs/terminal-shortcuts.md
* must stay in sync with the TERMINAL_SHORTCUTS table. If this test fails,
* regenerate the MAC/NONMAC tables in the doc by replacing the content
* between the <!-- BEGIN:X --> / <!-- END:X --> markers with the output
* printed in the assertion diff.
*/
function formatModifiers(modifiers: readonly string[]): string {
return modifiers
.map((m) => {
if (m === 'mod') {
return 'Mod'
}
if (m === 'ctrl') {
return 'Ctrl'
}
if (m === 'meta') {
return 'Meta'
}
if (m === 'alt') {
return 'Alt'
}
if (m === 'shift') {
return 'Shift'
}
return m
})
.join('+')
}
function formatKeyLabel(entry: ShortcutEntry): string {
const { match } = entry
if (match.keyLower) {
return match.keyLower.toUpperCase()
}
if (match.key) {
return match.key
}
if (match.code) {
const codes = typeof match.code === 'string' ? [match.code] : match.code
const first = codes[0]
if (first === 'BracketLeft') {
return '['
}
if (first === 'BracketRight') {
return ']'
}
return first
}
// Surface malformed entries at the parity test rather than corrupting the
// generated doc with a '?' placeholder that would silently pass review.
throw new Error(`ShortcutEntry '${entry.id}' has no key, keyLower, or code`)
}
function formatChord(entry: ShortcutEntry): string {
const mods = formatModifiers(entry.match.modifiers)
const key = formatKeyLabel(entry)
return mods ? `${mods}+${key}` : key
}
function formatAction(entry: ShortcutEntry): string {
const a = entry.action
if (a.type === 'sendInput') {
return `→ sendInput(${describeBytes(a.data)}) — ${entry.description}`
}
return entry.description
}
function describeBytes(data: string): string {
// Human-readable renderings for the byte sequences we emit, so the doc is
// scannable without decoding control codes mentally.
const map: Record<string, string> = {
'\x01': 'Ctrl+A',
'\x05': 'Ctrl+E',
'\x0b': 'Ctrl+K',
'\x15': 'Ctrl+U',
'\x17': '\\x17',
'\x1bb': '\\eb',
'\x1bf': '\\ef',
'\x1bd': '\\ed',
'\x1b\x7f': 'Esc+DEL',
'\x1b[13;2u': '\\e[13;2u'
}
return map[data] ?? JSON.stringify(data)
}
function renderTableRows(forMac: boolean): string[] {
return TERMINAL_SHORTCUTS.filter((e) => (forMac ? e.mac : e.nonMac)).map(
(e) => `| \`${formatChord(e)}\` | ${formatAction(e)} |`
)
}
function extractSection(doc: string, marker: string): string[] {
const begin = `<!-- BEGIN:${marker} -->`
const end = `<!-- END:${marker} -->`
const start = doc.indexOf(begin)
const stop = doc.indexOf(end)
if (start === -1 || stop === -1) {
throw new Error(`Missing ${marker} markers in terminal-shortcuts.md`)
}
return doc
.slice(start + begin.length, stop)
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 0)
}
describe('terminal-shortcuts.md parity with TERMINAL_SHORTCUTS', () => {
// __dirname-equivalent for this test: five levels up to repo root.
const docPath = join(__dirname, '..', '..', '..', '..', '..', 'docs', 'terminal-shortcuts.md')
const doc = readFileSync(docPath, 'utf8')
it('mac table matches the TERMINAL_SHORTCUTS entries where mac=true', () => {
const expected = renderTableRows(true)
const actual = extractSection(doc, 'MAC')
expect(actual).toEqual(expected)
})
it('non-mac table matches the TERMINAL_SHORTCUTS entries where nonMac=true', () => {
const expected = renderTableRows(false)
const actual = extractSection(doc, 'NONMAC')
expect(actual).toEqual(expected)
})
it('every entry applies to at least one platform', () => {
// An entry with mac=false && nonMac=false is dead code — it can never fire.
for (const e of TERMINAL_SHORTCUTS) {
expect(e.mac || e.nonMac, `ShortcutEntry '${e.id}' fires on no platform`).toBe(true)
}
})
it('formatKeyLabel throws on an entry with no key, keyLower, or code', () => {
const malformed: ShortcutEntry = {
id: 'malformed-test',
description: 'test',
mac: true,
nonMac: true,
match: { modifiers: [] },
action: { type: 'toggleSearch' }
}
expect(() => formatKeyLabel(malformed)).toThrow(/malformed-test/)
})
})

View file

@ -0,0 +1,246 @@
import type { TerminalShortcutAction } from './terminal-shortcut-policy-types'
/**
* Declarative table of terminal keyboard shortcuts.
*
* Each entry describes a chord (modifiers + key or code) per platform and the
* action to fire. Platform semantics:
* - `mac: true` entry applies on macOS (mod = Meta/Cmd)
* - `nonMac: true` entry applies on Windows/Linux (mod = Ctrl)
* - both platform-agnostic chord
*
* Entries are mutually exclusive by construction: `chordMatches` (in
* terminal-shortcut-policy.ts) rejects any event whose modifier set isn't
* exactly what the entry requires, so a base chord (e.g. Mod+D) cannot match
* a Shift-variant event regardless of where it sits in this array. Ordering
* is not load-bearing for correctness keep entries grouped by feature for
* readability only.
*
* Chords that require dynamic runtime state (Mac Option-as-Alt composition,
* which branches on `optionKeyLocation` and the `macOptionAsAlt` setting) are
* NOT in this table they live in `resolveMacOptionAsAltAction` because their
* matching logic is genuinely non-tabular.
*/
/**
* Modifier tokens:
* - `mod` Meta on Mac, Ctrl on non-Mac (the "primary app modifier")
* - `ctrl` literal Ctrl key on both platforms (distinct from mod on Mac)
* - `meta` literal Meta/Cmd key on both platforms (rarely needed)
* - `alt` Alt/Option
* - `shift` Shift
*
* Any modifier NOT listed on an entry must be ABSENT from the event. This
* prevents Alt+ArrowLeft from accidentally matching a rule intended for
* Ctrl+ArrowLeft just because altKey happened to also be true.
*/
export type Modifier = 'mod' | 'ctrl' | 'meta' | 'alt' | 'shift'
export type ChordMatch = {
modifiers: readonly Modifier[]
/** Match on event.key (for Arrow/Enter/Backspace/Delete). */
key?: string
/** Match on event.key, case-insensitively (letters). */
keyLower?: string
/** Match on event.code (physical key) — used for BracketLeft/Right. */
code?: string | readonly string[]
}
export type ShortcutEntry = {
id: string
description: string
mac: boolean
nonMac: boolean
match: ChordMatch
action: TerminalShortcutAction
}
const MOD = 'mod' as const
const CTRL = 'ctrl' as const
const ALT = 'alt' as const
const SHIFT = 'shift' as const
export const TERMINAL_SHORTCUTS: readonly ShortcutEntry[] = [
// ===== App actions (Mod = Cmd on Mac, Ctrl elsewhere) =====
{
id: 'copy-selection',
description: 'Copy terminal selection',
mac: true,
nonMac: true,
match: { modifiers: [MOD, SHIFT], keyLower: 'c' },
action: { type: 'copySelection' }
},
{
id: 'toggle-search',
description: 'Toggle terminal search overlay',
mac: true,
nonMac: true,
match: { modifiers: [MOD], keyLower: 'f' },
action: { type: 'toggleSearch' }
},
{
id: 'clear-active-pane',
description: 'Clear active pane screen and scrollback',
mac: true,
nonMac: true,
match: { modifiers: [MOD], keyLower: 'k' },
action: { type: 'clearActivePane' }
},
{
id: 'close-active-pane',
description: 'Close active split pane (or the tab if only one pane)',
mac: true,
nonMac: true,
match: { modifiers: [MOD], keyLower: 'w' },
action: { type: 'closeActivePane' }
},
{
id: 'focus-next-pane',
description: 'Cycle focus to next split pane',
mac: true,
nonMac: true,
match: { modifiers: [MOD], code: 'BracketRight' },
action: { type: 'focusPane', direction: 'next' }
},
{
id: 'focus-previous-pane',
description: 'Cycle focus to previous split pane',
mac: true,
nonMac: true,
match: { modifiers: [MOD], code: 'BracketLeft' },
action: { type: 'focusPane', direction: 'previous' }
},
{
id: 'toggle-expand-pane',
description: 'Expand/collapse active split pane to fill the terminal area',
mac: true,
nonMac: true,
match: { modifiers: [MOD, SHIFT], key: 'Enter', code: ['Enter', 'NumpadEnter'] },
action: { type: 'toggleExpandActivePane' }
},
// ===== Split chords — platform-divergent =====
// Ctrl+D is EOF on Linux/Win (#586), so non-Mac split requires Shift.
{
id: 'split-horizontal-mac',
description: 'Split active pane downward (Mac)',
mac: true,
nonMac: false,
match: { modifiers: [MOD, SHIFT], keyLower: 'd' },
action: { type: 'splitActivePane', direction: 'horizontal' }
},
{
id: 'split-vertical-mac',
description: 'Split active pane to the right (Mac)',
mac: true,
nonMac: false,
match: { modifiers: [MOD], keyLower: 'd' },
action: { type: 'splitActivePane', direction: 'vertical' }
},
{
id: 'split-vertical-nonmac',
description: 'Split active pane to the right (Linux/Windows)',
mac: false,
nonMac: true,
match: { modifiers: [MOD, SHIFT], keyLower: 'd' },
action: { type: 'splitActivePane', direction: 'vertical' }
},
{
id: 'split-horizontal-nonmac',
description: 'Split active pane downward (Linux/Windows, Windows Terminal convention)',
mac: false,
nonMac: true,
match: { modifiers: [ALT, SHIFT], keyLower: 'd' },
action: { type: 'splitActivePane', direction: 'horizontal' }
},
// ===== PTY byte emits: Mac Cmd+Arrow line navigation =====
// Cmd+Left/Right → readline Ctrl+A / Ctrl+E (iTerm2/Ghostty convention).
// xterm.js has no default Cmd+Arrow mapping; without this the chord is dropped.
{
id: 'mac-cmd-left-line-start',
description: 'Move cursor to start of line (Ctrl+A)',
mac: true,
nonMac: false,
match: { modifiers: [MOD], key: 'ArrowLeft' },
action: { type: 'sendInput', data: '\x01' }
},
{
id: 'mac-cmd-right-line-end',
description: 'Move cursor to end of line (Ctrl+E)',
mac: true,
nonMac: false,
match: { modifiers: [MOD], key: 'ArrowRight' },
action: { type: 'sendInput', data: '\x05' }
},
// ===== PTY byte emits: Mac Cmd+Backspace/Delete line-kill =====
{
id: 'mac-cmd-backspace-kill-line',
description: 'Kill from cursor to start of line (Ctrl+U)',
mac: true,
nonMac: false,
match: { modifiers: [MOD], key: 'Backspace' },
action: { type: 'sendInput', data: '\x15' }
},
{
id: 'mac-cmd-delete-kill-to-eol',
description: 'Kill from cursor to end of line (Ctrl+K)',
mac: true,
nonMac: false,
match: { modifiers: [MOD], key: 'Delete' },
action: { type: 'sendInput', data: '\x0b' }
},
// ===== PTY byte emits: cross-platform word navigation =====
// Alt+Left/Right → readline backward-word / forward-word. Both platforms
// produce altKey=true (Option on Mac, Alt on Linux/Win). xterm.js emits
// \e[1;3D / \e[1;3C by default, which readline doesn't bind to word-nav.
{
id: 'alt-left-backward-word',
description: 'Move cursor backward one word (\\eb)',
mac: true,
nonMac: true,
match: { modifiers: [ALT], key: 'ArrowLeft' },
action: { type: 'sendInput', data: '\x1bb' }
},
{
id: 'alt-right-forward-word',
description: 'Move cursor forward one word (\\ef)',
mac: true,
nonMac: true,
match: { modifiers: [ALT], key: 'ArrowRight' },
action: { type: 'sendInput', data: '\x1bf' }
},
// ===== PTY byte emits: word delete =====
{
id: 'alt-backspace-backward-kill-word',
description: 'Delete word before cursor (Esc+DEL)',
mac: true,
nonMac: true,
match: { modifiers: [ALT], key: 'Backspace' },
action: { type: 'sendInput', data: '\x1b\x7f' }
},
{
id: 'ctrl-backspace-unix-word-rubout',
description: 'Delete word before cursor (unix-word-rubout \\x17)',
mac: true,
nonMac: true,
// Why: uses literal `ctrl` (not `mod`) so this is Ctrl+Backspace on both
// platforms. On Mac, Cmd+Backspace is a different chord (kill-line, \x15)
// handled by the mac-cmd-backspace-kill-line entry above.
match: { modifiers: [CTRL], key: 'Backspace' },
action: { type: 'sendInput', data: '\x17' }
},
// ===== CSI-u Shift+Enter =====
{
id: 'shift-enter-csi-u',
description: 'Shift+Enter as CSI-u (\\e[13;2u) so agents can distinguish from Enter',
mac: true,
nonMac: true,
match: { modifiers: [SHIFT], key: 'Enter' },
action: { type: 'sendInput', data: '\x1b[13;2u' }
}
]

View file

@ -64,6 +64,52 @@ async function getPtyWrites(app: ElectronApplication): Promise<string[]> {
})
}
// Why: copySelection writes through the `clipboard:writeText` ipcMain.handle
// channel. Replace the handler (instead of spying on Electron's clipboard
// object) so the test both captures the call and avoids mutating the real
// system clipboard on headful runs.
async function installMainProcessClipboardSpy(app: ElectronApplication): Promise<void> {
await app.evaluate(({ ipcMain }) => {
const g = globalThis as unknown as {
__clipboardTextLog?: string[]
__clipboardSpyInstalled?: boolean
}
if (g.__clipboardSpyInstalled) {
return
}
g.__clipboardTextLog = []
g.__clipboardSpyInstalled = true
ipcMain.removeHandler('clipboard:writeText')
ipcMain.handle('clipboard:writeText', (_event, text: string) => {
g.__clipboardTextLog!.push(text)
})
})
}
async function getClipboardWrites(app: ElectronApplication): Promise<string[]> {
return app.evaluate(() => {
const g = globalThis as unknown as { __clipboardTextLog?: string[] }
return g.__clipboardTextLog ?? []
})
}
async function selectAllActiveTerminal(page: Page): Promise<void> {
await page.evaluate(() => {
const paneManagers = window.__paneManagers
if (!paneManagers) {
return
}
const state = window.__store?.getState()
const tabId = state?.activeTabId
if (!tabId) {
return
}
const manager = paneManagers.get(tabId)
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0]
pane?.terminal.selectAll()
})
}
// Why: the window-level keydown handler is gated on non-editable targets; the
// xterm helper textarea is treated as non-editable on purpose. Focusing it
// guarantees each chord reaches the shortcut policy through the real DOM path.
@ -146,6 +192,7 @@ test.describe('Terminal Shortcuts', () => {
electronApp
}) => {
await installMainProcessPtyWriteSpy(electronApp)
await installMainProcessClipboardSpy(electronApp)
// Seed the buffer so Cmd+K has something to clear.
const ptyId = await discoverActivePtyId(orcaPage)
@ -153,6 +200,23 @@ test.describe('Terminal Shortcuts', () => {
await execInTerminal(orcaPage, ptyId, `echo ${marker}`)
await waitForTerminalOutput(orcaPage, marker)
// --- copy-selection chord (Mod+Shift+C) ---
// Why: must run before Cmd+K clears the buffer; selectAll + Mod+Shift+C
// must route through the shortcut policy and write the selection through
// the clipboard:writeText IPC (which the spy captured).
// Why: focus must happen BEFORE selectAll — focusing the xterm helper
// textarea after a programmatic selection can collapse the selection in
// some xterm configurations, so we focus first and then select.
await focusActiveTerminal(orcaPage)
await selectAllActiveTerminal(orcaPage)
await orcaPage.keyboard.press(`${mod}+Shift+c`)
await expect
.poll(async () => (await getClipboardWrites(electronApp)).some((t) => t.includes(marker)), {
timeout: 5_000,
message: 'Mod+Shift+C did not write the terminal selection to the clipboard'
})
.toBe(true)
// --- send-input chords (platform-agnostic) ---
// Alt+←/→ → readline backward-word / forward-word (\eb / \ef).