Fix Ctrl+Alt terminal input on Windows by repairing xterm's AltGr misclassification (#8810)
* fix(terminal): forward Windows Ctrl Alt chords * fix(terminal): route rescued Windows Ctrl+Alt chords through xterm's own key encoders Replace the hand-rolled Alt-prefix encoder in the custom key handler with a narrow repair of xterm's third-level-shift classification. xterm's keyboard service already computes the correct bytes for every input protocol (legacy ESC-prefixed, kitty CSI-u, win32-input-mode) before _isThirdLevelShift discards them on Windows Ctrl+Alt; rescuing only provably-genuine chords (Chromium's layout-wide AltGraph simulation, crbug 762557) lets those encoders deliver protocol-correct, layout-aware bytes with no duplicated encoding knowledge in Orca. Fixes vs the previous approach: kitty-mode TUIs now receive CSI-u instead of legacy bytes, digits/punctuation no longer alias to plain Alt chords, letters follow the logical layout (Dvorak/Colemak), Ctrl+Alt+Shift and Ctrl+Alt+F-keys gain Linux parity, and handled keys get xterm's stock preventDefault/stopPropagation. Firefox web clients keep stock behavior; a real-Terminal contract test fails loudly if an xterm upgrade removes the seam, degrading at runtime to the historical dropped-chord behavior. Co-authored-by: Orca <help@stably.ai> * Refactor Windows Ctrl+Alt chord test helpers and clarify AltGraph commen - Extract a shared getCore() helper in the test file to dedupe repeated `_core` casts across third-level-shift and keyboard-service lookups. - Correct the AltGraph comment: Chromium simulates AltGraph per composing keypress, not for the whole Ctrl+Alt press duration. - Warn via console when xterm no longer exposes `_core._isThirdLevelShift`, so a silent classification-repair failure is diagnosable in the wild. * Clarify comment explaining why Windows Ctrl+Alt chords bypass AltGraph c The comment previously implied Chromium always sets AltGraph=true for composable chords; the revised wording states the actual mechanism (Alt+Ctrl modifiers get replaced by AltGraph) so the inverse case is unambiguous. --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
dd0f4c39c8
commit
8461df2139
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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> = {}): 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<string, unknown>): Record<string, unknown> {
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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<KeyboardEvent, 'ctrlKey' | 'altKey' | 'metaKey'> & {
|
||||
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
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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<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)
|
||||
})
|
||||
}
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in New Issue