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:
BingZ 2026-07-17 07:38:20 +08:00 committed by GitHub
parent dd0f4c39c8
commit 8461df2139
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 281 additions and 54 deletions

View File

@ -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()

View File

@ -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()
}
})
})

View File

@ -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
}

View File

@ -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