mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
Merge 9e8375708f into 8c2554ab28
This commit is contained in:
commit
47894503bd
8 changed files with 846 additions and 303 deletions
72
docs/terminal-shortcuts.md
Normal file
72
docs/terminal-shortcuts.md
Normal 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.
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/)
|
||||
})
|
||||
})
|
||||
|
|
@ -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' }
|
||||
}
|
||||
]
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Reference in a new issue