mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
fix(terminal): Cmd+Left/Right jump to start/end of line (#851)
This commit is contained in:
parent
ac39899e90
commit
0ef3a65635
3 changed files with 282 additions and 0 deletions
|
|
@ -89,6 +89,37 @@ describe('resolveTerminalShortcutAction', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('translates Cmd+←/→ on macOS to readline start/end-of-line (Ctrl+A/E)', () => {
|
||||
expect(
|
||||
resolveTerminalShortcutAction(
|
||||
event({ key: 'ArrowLeft', code: 'ArrowLeft', metaKey: true }),
|
||||
true
|
||||
)
|
||||
).toEqual({ type: 'sendInput', data: '\x01' })
|
||||
expect(
|
||||
resolveTerminalShortcutAction(
|
||||
event({ key: 'ArrowRight', code: 'ArrowRight', metaKey: true }),
|
||||
true
|
||||
)
|
||||
).toEqual({ type: 'sendInput', data: '\x05' })
|
||||
|
||||
// Cmd+Shift+Arrow is a different chord (selection) — don't intercept.
|
||||
expect(
|
||||
resolveTerminalShortcutAction(
|
||||
event({ key: 'ArrowLeft', code: 'ArrowLeft', metaKey: true, shiftKey: true }),
|
||||
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', () => {
|
||||
expect(
|
||||
resolveTerminalShortcutAction(event({ key: 'f', code: 'KeyF', ctrlKey: true }), false)
|
||||
|
|
|
|||
|
|
@ -138,6 +138,16 @@ export function resolveTerminalShortcutAction(
|
|||
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 (
|
||||
|
|
|
|||
241
tests/e2e/terminal-shortcuts.spec.ts
Normal file
241
tests/e2e/terminal-shortcuts.spec.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
/**
|
||||
* E2E test for terminal keyboard shortcuts.
|
||||
*
|
||||
* Verifies every chord resolved by resolveTerminalShortcutAction end-to-end:
|
||||
* real DOM keydown → window capture handler → policy → transport → IPC.
|
||||
*
|
||||
* sendInput chords are verified by intercepting pty:write in the Electron main
|
||||
* process so the test proves the bytes actually leave the renderer, without
|
||||
* depending on the shell's readline behaving identically across OSes. Action
|
||||
* chords (split, close, search, clear) are verified via their user-visible
|
||||
* side effect (pane count, search overlay, terminal buffer).
|
||||
*
|
||||
* Platform-specific chords (Cmd+Arrow, Cmd+Backspace on macOS only) are
|
||||
* skipped on the other platform since they'd never fire there at runtime.
|
||||
*/
|
||||
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
|
||||
import {
|
||||
discoverActivePtyId,
|
||||
execInTerminal,
|
||||
countVisibleTerminalPanes,
|
||||
waitForActiveTerminalManager,
|
||||
waitForTerminalOutput,
|
||||
waitForPaneCount,
|
||||
getTerminalContent
|
||||
} from './helpers/terminal'
|
||||
import { waitForSessionReady, waitForActiveWorktree, ensureTerminalVisible } from './helpers/store'
|
||||
|
||||
// Why: contextBridge freezes window.api so the renderer cannot spy on
|
||||
// pty.write directly. Intercept in the main process instead — pty:write is an
|
||||
// ipcMain.on listener, so prepending a listener lets us capture every call
|
||||
// without disturbing the real handler.
|
||||
async function installMainProcessPtyWriteSpy(app: ElectronApplication): Promise<void> {
|
||||
await app.evaluate(({ ipcMain }) => {
|
||||
const g = globalThis as unknown as {
|
||||
__ptyWriteLog?: { id: string; data: string }[]
|
||||
__ptyWriteSpyInstalled?: boolean
|
||||
}
|
||||
if (g.__ptyWriteSpyInstalled) {
|
||||
return
|
||||
}
|
||||
g.__ptyWriteLog = []
|
||||
g.__ptyWriteSpyInstalled = true
|
||||
ipcMain.prependListener('pty:write', (_event: unknown, args: { id: string; data: string }) => {
|
||||
g.__ptyWriteLog!.push({ id: args.id, data: args.data })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function clearPtyWriteLog(app: ElectronApplication): Promise<void> {
|
||||
await app.evaluate(() => {
|
||||
const g = globalThis as unknown as { __ptyWriteLog?: { id: string; data: string }[] }
|
||||
if (g.__ptyWriteLog) {
|
||||
g.__ptyWriteLog.length = 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function getPtyWrites(app: ElectronApplication): Promise<string[]> {
|
||||
return app.evaluate(() => {
|
||||
const g = globalThis as unknown as { __ptyWriteLog?: { id: string; data: string }[] }
|
||||
return (g.__ptyWriteLog ?? []).map((e) => e.data)
|
||||
})
|
||||
}
|
||||
|
||||
// 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.
|
||||
async function focusActiveTerminal(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const textarea = document.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement | null
|
||||
textarea?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
async function pressAndExpectWrite(
|
||||
page: Page,
|
||||
app: ElectronApplication,
|
||||
chord: string,
|
||||
expectedData: string
|
||||
): Promise<void> {
|
||||
await clearPtyWriteLog(app)
|
||||
await focusActiveTerminal(page)
|
||||
await page.keyboard.press(chord)
|
||||
|
||||
// Why: assert exact equality, not substring match. Short control codes like
|
||||
// \x01 (Ctrl+A) and \x05 (Ctrl+E) are single bytes that can appear inside
|
||||
// unrelated writes (shell prompt redraws, bracketed-paste sequences), so a
|
||||
// substring match would produce false positives.
|
||||
await expect
|
||||
.poll(async () => (await getPtyWrites(app)).some((w) => w === expectedData), {
|
||||
timeout: 5_000,
|
||||
message: `Expected chord "${chord}" to write ${JSON.stringify(expectedData)}`
|
||||
})
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
const isMac = process.platform === 'darwin'
|
||||
const mod = isMac ? 'Meta' : 'Control'
|
||||
|
||||
// Why: split chords differ by platform. On macOS Cmd+D splits vertically and
|
||||
// Cmd+Shift+D horizontally. On Linux/Windows Ctrl+D is reserved for EOF
|
||||
// (see terminal-shortcut-policy.ts and #586), so vertical is Ctrl+Shift+D
|
||||
// and horizontal is Alt+Shift+D (Windows Terminal convention).
|
||||
const splitVerticalChord = isMac ? `${mod}+d` : `${mod}+Shift+d`
|
||||
const splitHorizontalChord = isMac ? `${mod}+Shift+d` : 'Alt+Shift+d'
|
||||
|
||||
// Why: serial mode is load-bearing. Tests mutate shared Electron app state
|
||||
// (pane layout, terminal buffer, expand toggle) and the pty:write spy log is
|
||||
// a single main-process singleton. Parallel execution would interleave chord
|
||||
// effects and corrupt assertions.
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
test.describe('Terminal Shortcuts', () => {
|
||||
test.beforeEach(async ({ orcaPage }) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
const hasPaneManager = await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
test.skip(
|
||||
!hasPaneManager,
|
||||
'Electron automation in this environment never mounts the live TerminalPane manager.'
|
||||
)
|
||||
await waitForPaneCount(orcaPage, 1, 30_000)
|
||||
})
|
||||
|
||||
test('all terminal chords reach the PTY or fire their action', async ({
|
||||
orcaPage,
|
||||
electronApp
|
||||
}) => {
|
||||
await installMainProcessPtyWriteSpy(electronApp)
|
||||
|
||||
// Seed the buffer so Cmd+K has something to clear.
|
||||
const ptyId = await discoverActivePtyId(orcaPage)
|
||||
const marker = `SHORTCUT_TEST_${Date.now()}`
|
||||
await execInTerminal(orcaPage, ptyId, `echo ${marker}`)
|
||||
await waitForTerminalOutput(orcaPage, marker)
|
||||
|
||||
// --- send-input chords (platform-agnostic) ---
|
||||
|
||||
// Alt+←/→ → readline backward-word / forward-word (\eb / \ef).
|
||||
await pressAndExpectWrite(orcaPage, electronApp, 'Alt+ArrowLeft', '\x1bb')
|
||||
await pressAndExpectWrite(orcaPage, electronApp, 'Alt+ArrowRight', '\x1bf')
|
||||
|
||||
// Alt+Backspace → Esc+DEL (readline backward-kill-word).
|
||||
await pressAndExpectWrite(orcaPage, electronApp, 'Alt+Backspace', '\x1b\x7f')
|
||||
|
||||
// Ctrl+Backspace → \x17 (unix-word-rubout).
|
||||
await pressAndExpectWrite(orcaPage, electronApp, 'Control+Backspace', '\x17')
|
||||
|
||||
// Shift+Enter → CSI-u so agents can distinguish from plain Enter.
|
||||
await pressAndExpectWrite(orcaPage, electronApp, 'Shift+Enter', '\x1b[13;2u')
|
||||
|
||||
// --- send-input chords (macOS-only) ---
|
||||
|
||||
if (isMac) {
|
||||
// Cmd+←/→ → Ctrl+A / Ctrl+E (beginning/end of line).
|
||||
await pressAndExpectWrite(orcaPage, electronApp, 'Meta+ArrowLeft', '\x01')
|
||||
await pressAndExpectWrite(orcaPage, electronApp, 'Meta+ArrowRight', '\x05')
|
||||
|
||||
// Cmd+Backspace → Ctrl+U (kill line). Cmd+Delete → Ctrl+K (kill to EOL).
|
||||
await pressAndExpectWrite(orcaPage, electronApp, 'Meta+Backspace', '\x15')
|
||||
await pressAndExpectWrite(orcaPage, electronApp, 'Meta+Delete', '\x0b')
|
||||
}
|
||||
|
||||
// --- action chords (no PTY byte; assert via visible effect) ---
|
||||
|
||||
// Cmd/Ctrl+K clears the pane.
|
||||
await focusActiveTerminal(orcaPage)
|
||||
await orcaPage.keyboard.press(`${mod}+k`)
|
||||
await expect
|
||||
.poll(async () => (await getTerminalContent(orcaPage)).includes(marker), {
|
||||
timeout: 5_000,
|
||||
message: 'Cmd+K did not clear the terminal buffer'
|
||||
})
|
||||
.toBe(false)
|
||||
|
||||
// Split vertically (chord varies by platform — see splitVerticalChord).
|
||||
const panesBeforeSplit = await countVisibleTerminalPanes(orcaPage)
|
||||
await focusActiveTerminal(orcaPage)
|
||||
await orcaPage.keyboard.press(splitVerticalChord)
|
||||
await waitForPaneCount(orcaPage, panesBeforeSplit + 1)
|
||||
|
||||
// Cmd/Ctrl+] and Cmd/Ctrl+[ cycle focus (no pane-count change).
|
||||
await focusActiveTerminal(orcaPage)
|
||||
await orcaPage.keyboard.press(`${mod}+BracketRight`)
|
||||
await focusActiveTerminal(orcaPage)
|
||||
await orcaPage.keyboard.press(`${mod}+BracketLeft`)
|
||||
expect(await countVisibleTerminalPanes(orcaPage)).toBe(panesBeforeSplit + 1)
|
||||
|
||||
// Cmd/Ctrl+Shift+Enter toggles expand on the active pane. Requires >1 pane,
|
||||
// so it runs while the vertical split from above is still open.
|
||||
const readExpanded = async (): Promise<boolean> =>
|
||||
orcaPage.evaluate(() => {
|
||||
const state = window.__store?.getState()
|
||||
const tabId = state?.activeTabId
|
||||
if (!state || !tabId) {
|
||||
return false
|
||||
}
|
||||
return state.expandedPaneByTabId[tabId] === true
|
||||
})
|
||||
expect(await readExpanded()).toBe(false)
|
||||
await focusActiveTerminal(orcaPage)
|
||||
await orcaPage.keyboard.press(`${mod}+Shift+Enter`)
|
||||
await expect
|
||||
.poll(readExpanded, { timeout: 3_000, message: 'Cmd+Shift+Enter did not expand pane' })
|
||||
.toBe(true)
|
||||
await focusActiveTerminal(orcaPage)
|
||||
await orcaPage.keyboard.press(`${mod}+Shift+Enter`)
|
||||
await expect
|
||||
.poll(readExpanded, { timeout: 3_000, message: 'Cmd+Shift+Enter did not collapse pane' })
|
||||
.toBe(false)
|
||||
|
||||
// Cmd/Ctrl+W closes the active split pane (not the whole tab: >1 pane).
|
||||
await focusActiveTerminal(orcaPage)
|
||||
await orcaPage.keyboard.press(`${mod}+w`)
|
||||
await waitForPaneCount(orcaPage, panesBeforeSplit)
|
||||
|
||||
// Split horizontally (chord varies by platform — see splitHorizontalChord).
|
||||
const panesBeforeHSplit = await countVisibleTerminalPanes(orcaPage)
|
||||
await focusActiveTerminal(orcaPage)
|
||||
await orcaPage.keyboard.press(splitHorizontalChord)
|
||||
await waitForPaneCount(orcaPage, panesBeforeHSplit + 1)
|
||||
await focusActiveTerminal(orcaPage)
|
||||
await orcaPage.keyboard.press(`${mod}+w`)
|
||||
await waitForPaneCount(orcaPage, panesBeforeHSplit)
|
||||
|
||||
// Cmd/Ctrl+F toggles the search overlay.
|
||||
await focusActiveTerminal(orcaPage)
|
||||
await orcaPage.keyboard.press(`${mod}+f`)
|
||||
await expect(orcaPage.locator('[data-terminal-search-root]').first()).toBeVisible({
|
||||
timeout: 3_000
|
||||
})
|
||||
await orcaPage.keyboard.press('Escape')
|
||||
await expect(orcaPage.locator('[data-terminal-search-root]').first()).toBeHidden({
|
||||
timeout: 3_000
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue