diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts index 365bfd801..9d1038c45 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts @@ -13,6 +13,7 @@ import { buildDefaultTerminalOptions } from './pane-terminal-options' import { shouldFocusTerminalFromPanePointerDown } from './pane-pointer-focus' import { ENABLE_WEBGL_RENDERER } from './pane-webgl-renderer' import { installGuardedLinkProviderRegistration } from './terminal-link-provider-guard' +import { installWindowsCtrlAltChordRepair } from './terminal-windows-ctrl-alt-chord-classification' function getTerminalUrlOpenHint(): string { return navigator.userAgent.includes('Mac') @@ -55,6 +56,7 @@ export function createPaneDOM( // line) escapes to window.onerror and gets the renderer killed. Guard every // provider registered after this point — addon-internal and Orca's own. installGuardedLinkProviderRegistration(terminal) + installWindowsCtrlAltChordRepair(terminal) const fitAddon = new FitAddon() const searchAddon = new SearchAddon() const unicode11Addon = new Unicode11Addon() diff --git a/src/renderer/src/lib/pane-manager/terminal-windows-ctrl-alt-chord-classification.test.ts b/src/renderer/src/lib/pane-manager/terminal-windows-ctrl-alt-chord-classification.test.ts new file mode 100644 index 000000000..d0d9f92ef --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-windows-ctrl-alt-chord-classification.test.ts @@ -0,0 +1,185 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from 'vitest' +import { Terminal } from '@xterm/xterm' +import { + installWindowsCtrlAltChordRepair, + isGenuineWindowsCtrlAltChord, + shouldRepairWindowsCtrlAltChords +} from './terminal-windows-ctrl-alt-chord-classification' + +const WINDOWS_ELECTRON_UA = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) ' + + 'orca/1.0.0 Chrome/126.0.0.0 Electron/31.0.0 Safari/537.36' +const WINDOWS_FIREFOX_UA = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0' +const MAC_ELECTRON_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) ' + + 'orca/1.0.0 Chrome/126.0.0.0 Electron/31.0.0 Safari/537.36' + +type ClassificationEvent = { + type: string + keyCode: number + ctrlKey: boolean + altKey: boolean + metaKey: boolean + shiftKey?: boolean + getModifierState?: (keyArg: string) => boolean +} + +function chord(overrides: Partial = {}): ClassificationEvent { + return { + type: 'keydown', + keyCode: 85, + ctrlKey: true, + altKey: true, + metaKey: false, + getModifierState: () => false, + ...overrides + } +} + +type CoreThirdLevelShift = ( + browser: { isMac?: boolean; isWindows?: boolean }, + event: ClassificationEvent +) => boolean + +type XtermCoreInternals = { + _isThirdLevelShift?: CoreThirdLevelShift + _keyboardService?: { evaluateKeyDown?: (event: unknown) => { key?: string } | undefined } + coreService?: { kittyKeyboard?: { flags: number } } +} + +function getCore(terminal: Terminal): XtermCoreInternals { + return (terminal as unknown as { _core?: XtermCoreInternals })._core ?? {} +} + +function getThirdLevelShift(terminal: Terminal): CoreThirdLevelShift { + const core = getCore(terminal) + const classify = core._isThirdLevelShift + if (typeof classify !== 'function') { + throw new Error('xterm no longer exposes _core._isThirdLevelShift') + } + return classify.bind(core) +} + +describe('isGenuineWindowsCtrlAltChord', () => { + it('accepts Ctrl+Alt chords whose AltGraph state is false', () => { + expect(isGenuineWindowsCtrlAltChord(chord())).toBe(true) + expect(isGenuineWindowsCtrlAltChord(chord({ shiftKey: true }))).toBe(true) + // Synthetic events without getModifierState cannot be AltGr composition. + expect(isGenuineWindowsCtrlAltChord(chord({ getModifierState: undefined }))).toBe(true) + }) + + it('rejects AltGr composition and non-Ctrl+Alt chords', () => { + expect( + isGenuineWindowsCtrlAltChord(chord({ getModifierState: (key) => key === 'AltGraph' })) + ).toBe(false) + expect(isGenuineWindowsCtrlAltChord(chord({ metaKey: true }))).toBe(false) + expect(isGenuineWindowsCtrlAltChord(chord({ altKey: false }))).toBe(false) + expect(isGenuineWindowsCtrlAltChord(chord({ ctrlKey: false }))).toBe(false) + }) +}) + +describe('shouldRepairWindowsCtrlAltChords', () => { + it('repairs only Windows Chromium clients', () => { + expect(shouldRepairWindowsCtrlAltChords(WINDOWS_ELECTRON_UA)).toBe(true) + // Why: Firefox does not rewrite composing Ctrl+Alt presses to AltGraph, so + // a false AltGraph state there does not prove the chord is genuine. + expect(shouldRepairWindowsCtrlAltChords(WINDOWS_FIREFOX_UA)).toBe(false) + expect(shouldRepairWindowsCtrlAltChords(MAC_ELECTRON_UA)).toBe(false) + }) +}) + +describe('installWindowsCtrlAltChordRepair', () => { + it('finds the internal classification seam on the real Terminal', () => { + // Upgrade tripwire: if a future xterm rename removes the seam, this fails + // loudly instead of silently reverting to dropped Ctrl+Alt chords. + const terminal = new Terminal() + try { + expect(getThirdLevelShift(terminal)({ isWindows: true }, chord())).toBe(true) + expect(installWindowsCtrlAltChordRepair(terminal, WINDOWS_ELECTRON_UA)).toBe(true) + } finally { + terminal.dispose() + } + }) + + it('reclassifies only genuine Windows Ctrl+Alt chords', () => { + const terminal = new Terminal() + try { + installWindowsCtrlAltChordRepair(terminal, WINDOWS_ELECTRON_UA) + const classify = getThirdLevelShift(terminal) + const windows = { isWindows: true } + expect(classify(windows, chord())).toBe(false) + expect(classify(windows, chord({ shiftKey: true }))).toBe(false) + // AltGr composition keeps xterm's third-level-shift handling. + expect(classify(windows, chord({ getModifierState: (key) => key === 'AltGraph' }))).toBe(true) + // macOS option-as-third-level-shift is untouched. + expect( + classify({ isMac: true }, chord({ ctrlKey: false, getModifierState: () => false })) + ).toBe(true) + } finally { + terminal.dispose() + } + }) + + it('declines on clients without trustworthy AltGraph state', () => { + const terminal = new Terminal() + try { + expect(installWindowsCtrlAltChordRepair(terminal, WINDOWS_FIREFOX_UA)).toBe(false) + expect(getThirdLevelShift(terminal)({ isWindows: true }, chord())).toBe(true) + } finally { + terminal.dispose() + } + }) +}) + +// Why: the repair intentionally adds no encoder — rescued chords must produce +// whatever bytes xterm's own keyboard service computes for the protocol the +// foreground app negotiated. These pin that contract for both protocol tiers. +describe('rescued chords are encoded by xterm, not Orca', () => { + function keyDownChord(overrides: Record): Record { + return { ...chord(), repeat: false, ...overrides } + } + + function getEvaluateKeyDown( + terminal: Terminal + ): (event: unknown) => { key?: string } | undefined { + const service = getCore(terminal)._keyboardService + if (typeof service?.evaluateKeyDown !== 'function') { + throw new Error('xterm no longer exposes _core._keyboardService.evaluateKeyDown') + } + return service.evaluateKeyDown.bind(service) + } + + it('legacy encoder emits Alt-prefixed bytes matching the Windows E2E', () => { + const terminal = new Terminal() + try { + installWindowsCtrlAltChordRepair(terminal, WINDOWS_ELECTRON_UA) + const evaluate = getEvaluateKeyDown(terminal) + expect(evaluate(keyDownChord({ key: 'u', code: 'KeyU', keyCode: 85 }))?.key).toBe('\x1b\x15') + expect(evaluate(keyDownChord({ key: '2', code: 'Digit2', keyCode: 50 }))?.key).toBe('\x1b2') + expect(evaluate(keyDownChord({ key: ';', code: 'Semicolon', keyCode: 186 }))?.key).toBe( + '\x1b;' + ) + } finally { + terminal.dispose() + } + }) + + it('kitty encoder takes over once the app negotiates progressive flags', () => { + const terminal = new Terminal({ vtExtensions: { kittyKeyboard: true } }) + try { + installWindowsCtrlAltChordRepair(terminal, WINDOWS_ELECTRON_UA) + const kitty = getCore(terminal).coreService?.kittyKeyboard + expect(kitty).toBeTruthy() + kitty!.flags = 1 + const evaluate = getEvaluateKeyDown(terminal) + expect(evaluate(keyDownChord({ key: 'u', code: 'KeyU', keyCode: 85 }))?.key).toBe( + '\x1b[117;7u' + ) + } finally { + terminal.dispose() + } + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-windows-ctrl-alt-chord-classification.ts b/src/renderer/src/lib/pane-manager/terminal-windows-ctrl-alt-chord-classification.ts new file mode 100644 index 000000000..f73da741a --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-windows-ctrl-alt-chord-classification.ts @@ -0,0 +1,76 @@ +import type { Terminal } from '@xterm/xterm' + +// Why: xterm misclassifies Windows Ctrl+Alt chords as AltGr and drops the ones +// that never compose a keypress (#8734); repairing the classification lets +// xterm's own protocol-aware key encoders emit the bytes. +type ThirdLevelShiftBrowserInfo = { isWindows?: boolean } + +type ThirdLevelShiftKeyboardEvent = Pick & { + getModifierState?: (keyArg: string) => boolean +} + +type TerminalWithThirdLevelShift = { + _core?: { + _isThirdLevelShift?: ( + browser: ThirdLevelShiftBrowserInfo, + event: ThirdLevelShiftKeyboardEvent + ) => boolean + } +} + +/** + * Returns whether a Windows Ctrl+Alt chord is genuine keyboard input rather + * than AltGr composition, and must therefore reach xterm's key encoders. + * + * When a Ctrl+Alt keydown composes a printable character on the active + * layout, Chromium replaces the Control+Alt modifiers with AltGraph + * (crbug 762557), so a chord still reporting Ctrl+Alt without AltGraph + * cannot compose text. + */ +export function isGenuineWindowsCtrlAltChord(event: ThirdLevelShiftKeyboardEvent): boolean { + return ( + event.ctrlKey && event.altKey && !event.metaKey && event.getModifierState?.('AltGraph') !== true + ) +} + +/** Returns whether this client's AltGraph modifier state is trustworthy. */ +export function shouldRepairWindowsCtrlAltChords(userAgent: string): boolean { + // Why: only Chromium rewrites composing Ctrl+Alt presses to AltGraph. Paired + // web clients on Firefox keep stock classification so Ctrl+Alt-alias AltGr + // typing there is never misread as a chord. + return userAgent.includes('Windows') && userAgent.includes('Chrome/') +} + +/** + * Narrow xterm's Windows third-level-shift classification so genuine + * Ctrl+Alt chords flow into its protocol-aware key encoders instead of + * being dropped. Only ever flips a third-level verdict to false — AltGr, + * macOS option handling, and every non-Windows path are untouched. + * + * Returns false when the internal seam is unavailable (e.g. after an xterm + * upgrade), degrading to the historical drop-the-chord behavior. + */ +export function installWindowsCtrlAltChordRepair( + terminal: Terminal, + userAgent: string = navigator.userAgent +): boolean { + if (!shouldRepairWindowsCtrlAltChords(userAgent)) { + return false + } + const core = (terminal as unknown as TerminalWithThirdLevelShift)._core + const stockClassification = core?._isThirdLevelShift + if (!core || typeof stockClassification !== 'function') { + console.warn( + 'xterm no longer exposes _core._isThirdLevelShift; Windows Ctrl+Alt chords will be dropped' + ) + return false + } + core._isThirdLevelShift = function (browser, event) { + const thirdLevel = stockClassification.call(this, browser, event) + if (!thirdLevel || browser?.isWindows !== true) { + return thirdLevel + } + return !isGenuineWindowsCtrlAltChord(event) + } + return true +} diff --git a/tests/e2e/terminal-shortcuts.spec.ts b/tests/e2e/terminal-shortcuts.spec.ts index f120d53ff..5c921376c 100644 --- a/tests/e2e/terminal-shortcuts.spec.ts +++ b/tests/e2e/terminal-shortcuts.spec.ts @@ -29,60 +29,11 @@ import { focusActiveTerminalInput } 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 { - await app.evaluate(({ ipcMain }) => { - const g = globalThis as unknown as { - __ptyWriteLog?: { id: string; data: string }[] - __ptyWriteSpyInstalled?: boolean - __ptyWriteAcceptedSpyInstalled?: 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 }) - }) - const invokeHandlers = ( - ipcMain as unknown as { - _invokeHandlers?: Map< - string, - (event: unknown, args: { id: string; data: string }) => unknown - > - } - )._invokeHandlers - const writeAcceptedHandler = invokeHandlers?.get('pty:writeAccepted') - if (writeAcceptedHandler && !g.__ptyWriteAcceptedSpyInstalled) { - g.__ptyWriteAcceptedSpyInstalled = true - invokeHandlers?.set('pty:writeAccepted', (event, args) => { - g.__ptyWriteLog!.push({ id: args.id, data: args.data }) - return writeAcceptedHandler(event, args) - }) - } - }) -} - -async function clearPtyWriteLog(app: ElectronApplication): Promise { - 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 { - return app.evaluate(() => { - const g = globalThis as unknown as { __ptyWriteLog?: { id: string; data: string }[] } - return (g.__ptyWriteLog ?? []).map((e) => e.data) - }) -} +import { + clearTerminalPtyWriteLog as clearPtyWriteLog, + installTerminalPtyWriteSpy as installMainProcessPtyWriteSpy, + readTerminalPtyWrites as getPtyWrites +} from './helpers/terminal-pty-write-spy' async function setActivePaneForegroundAgent( page: Page, @@ -562,6 +513,19 @@ test.describe('Terminal Shortcuts', () => { } }) + test('Windows forwards genuine Ctrl+Alt text chords to the PTY', async ({ + orcaPage, + electronApp + }) => { + test.skip(process.platform !== 'win32', 'Windows xterm AltGr classification regression') + await installMainProcessPtyWriteSpy(electronApp) + await waitForActivePanePtyId(orcaPage) + + await pressAndExpectWrite(orcaPage, electronApp, 'Control+Alt+u', '\x1b\x15') + await pressAndExpectWrite(orcaPage, electronApp, 'Control+Alt+2', '\x1b2') + await pressAndExpectWrite(orcaPage, electronApp, 'Control+Alt+;', '\x1b;') + }) + test('Ctrl+Enter writes the kitty modified-enter chord for terminal TUIs', async ({ orcaPage, electronApp