Skip to content

Commit f4c77d3

Browse files
authored
Merge pull request xtermjs#5603 from Tyriar/2357
Implement win32-input-mode
2 parents 91c4761 + 9d0beb7 commit f4c77d3

16 files changed

Lines changed: 529 additions & 2 deletions

File tree

demo/client/client.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,10 @@ function createTerminal(): Terminal {
282282
buildNumber: 22621
283283
} : undefined,
284284
fontFamily: '"Fira Code", monospace, "Powerline Extra Symbols"',
285-
theme: { ...xtermjsTheme }
285+
theme: { ...xtermjsTheme },
286+
vtExtensions: {
287+
win32InputMode: isWindows
288+
}
286289
} as ITerminalOptions);
287290

288291
// Load addons

demo/client/components/window/optionsWindow.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,8 @@ export class OptionsWindow extends BaseWindow implements IControlWindow {
125125
];
126126
const nestedBooleanOptions: { label: string, parent: string, prop: string }[] = [
127127
{ label: 'vtExtensions.kittyKeyboard', parent: 'vtExtensions', prop: 'kittyKeyboard' },
128-
{ label: 'vtExtensions.kittySgrBoldFaintControl', parent: 'vtExtensions', prop: 'kittySgrBoldFaintControl' }
128+
{ label: 'vtExtensions.kittySgrBoldFaintControl', parent: 'vtExtensions', prop: 'kittySgrBoldFaintControl' },
129+
{ label: 'vtExtensions.win32InputMode', parent: 'vtExtensions', prop: 'win32InputMode' }
129130
];
130131
const stringOptions: { [key: string]: string[] | null } = {
131132
cursorStyle: ['block', 'underline', 'bar'],

src/browser/public/Terminal.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ export class Terminal extends Disposable implements ITerminalApi {
125125
sendFocusMode: m.sendFocus,
126126
showCursor: !this._core.coreService.isCursorHidden,
127127
synchronizedOutputMode: m.synchronizedOutput,
128+
win32InputMode: m.win32InputMode,
128129
wraparoundMode: m.wraparound
129130
};
130131
}

src/browser/services/KeyboardService.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import { IKeyboardService } from 'browser/services/Services';
77
import { evaluateKeyboardEvent } from 'common/input/Keyboard';
88
import { evaluateKeyboardEventKitty, KittyKeyboardEventType, KittyKeyboardFlags, shouldUseKittyProtocol } from 'common/input/KittyKeyboard';
9+
import { evaluateKeyboardEventWin32 } from 'common/input/Win32InputMode';
910
import { isMac } from 'common/Platform';
1011
import { ICoreService, IOptionsService } from 'common/services/Services';
1112
import { IKeyboardResult } from 'common/Types';
@@ -20,13 +21,21 @@ export class KeyboardService implements IKeyboardService {
2021
}
2122

2223
public evaluateKeyDown(event: KeyboardEvent): IKeyboardResult {
24+
// Win32 input mode takes priority (most raw)
25+
if (this.useWin32InputMode) {
26+
return evaluateKeyboardEventWin32(event, true);
27+
}
2328
const kittyFlags = this._coreService.kittyKeyboard.flags;
2429
return this.useKitty
2530
? evaluateKeyboardEventKitty(event, kittyFlags, event.repeat ? KittyKeyboardEventType.REPEAT : KittyKeyboardEventType.PRESS)
2631
: evaluateKeyboardEvent(event, this._coreService.decPrivateModes.applicationCursorKeys, isMac, this._optionsService.rawOptions.macOptionIsMeta);
2732
}
2833

2934
public evaluateKeyUp(event: KeyboardEvent): IKeyboardResult | undefined {
35+
// Win32 input mode sends key up events
36+
if (this.useWin32InputMode) {
37+
return evaluateKeyboardEventWin32(event, false);
38+
}
3039
const kittyFlags = this._coreService.kittyKeyboard.flags;
3140
if (this.useKitty && (kittyFlags & KittyKeyboardFlags.REPORT_EVENT_TYPES)) {
3241
return evaluateKeyboardEventKitty(event, kittyFlags, KittyKeyboardEventType.RELEASE);
@@ -38,4 +47,8 @@ export class KeyboardService implements IKeyboardService {
3847
const kittyFlags = this._coreService.kittyKeyboard.flags;
3948
return !!(this._optionsService.rawOptions.vtExtensions?.kittyKeyboard && shouldUseKittyProtocol(kittyFlags));
4049
}
50+
51+
public get useWin32InputMode(): boolean {
52+
return !!(this._optionsService.rawOptions.vtExtensions?.win32InputMode && this._coreService.decPrivateModes.win32InputMode);
53+
}
4154
}

src/common/InputHandler.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2026,6 +2026,11 @@ export class InputHandler extends Disposable implements IInputHandler {
20262026
case 2026: // synchronized output (https://github.com/contour-terminal/vt-extensions/blob/master/synchronized-output.md)
20272027
this._coreService.decPrivateModes.synchronizedOutput = true;
20282028
break;
2029+
case 9001: // win32-input-mode (https://github.com/microsoft/terminal/blob/main/doc/specs/%234999%20-%20Improved%20keyboard%20handling%20in%20Conpty.md)
2030+
if (this._optionsService.rawOptions.vtExtensions?.win32InputMode) {
2031+
this._coreService.decPrivateModes.win32InputMode = true;
2032+
}
2033+
break;
20292034
}
20302035
}
20312036
return true;
@@ -2266,6 +2271,11 @@ export class InputHandler extends Disposable implements IInputHandler {
22662271
this._coreService.decPrivateModes.synchronizedOutput = false;
22672272
this._onRequestRefreshRows.fire(undefined);
22682273
break;
2274+
case 9001: // win32-input-mode
2275+
if (this._optionsService.rawOptions.vtExtensions?.win32InputMode) {
2276+
this._coreService.decPrivateModes.win32InputMode = false;
2277+
}
2278+
break;
22692279
}
22702280
}
22712281
return true;
@@ -2361,6 +2371,7 @@ export class InputHandler extends Disposable implements IInputHandler {
23612371
if (p === 47 || p === 1047 || p === 1049) return f(p, b2v(active === alt));
23622372
if (p === 2004) return f(p, b2v(dm.bracketedPasteMode));
23632373
if (p === 2026) return f(p, b2v(dm.synchronizedOutput));
2374+
if (p === 9001) return this._optionsService.rawOptions.vtExtensions?.win32InputMode ? f(p, b2v(dm.win32InputMode)) : f(p, V.NOT_RECOGNIZED);
23642375
return f(p, V.NOT_RECOGNIZED);
23652376
}
23662377

src/common/TestUtils.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ export class MockCoreService implements ICoreService {
112112
reverseWraparound: false,
113113
sendFocus: false,
114114
synchronizedOutput: false,
115+
win32InputMode: false,
115116
wraparound: true
116117
};
117118
public kittyKeyboard = {

src/common/Types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ export interface IDecPrivateModes {
274274
reverseWraparound: boolean;
275275
sendFocus: boolean;
276276
synchronizedOutput: boolean;
277+
win32InputMode: boolean;
277278
wraparound: boolean; // defaults: xterm - true, vt100 - false
278279
}
279280

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
/**
2+
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
3+
* @license MIT
4+
*/
5+
6+
import { assert } from 'chai';
7+
import { evaluateKeyboardEventWin32, Win32ControlKeyState } from 'common/input/Win32InputMode';
8+
import { IKeyboardEvent, KeyboardResultType } from 'common/Types';
9+
10+
type EventOpts = Partial<IKeyboardEvent>;
11+
const ev = (opts: EventOpts): IKeyboardEvent => ({
12+
altKey: false, ctrlKey: false, shiftKey: false, metaKey: false,
13+
keyCode: 0, code: '', key: '', type: 'keydown', ...opts
14+
});
15+
16+
const parse = (seq: string) => {
17+
const m = seq.match(/^\x1b\[(\d+);(\d+);(\d+);(\d+);(\d+);(\d+)_$/);
18+
return m ? { vk: +m[1], sc: +m[2], uc: +m[3], kd: +m[4], cs: +m[5], rc: +m[6] } : null;
19+
};
20+
21+
const test = (opts: EventOpts, isDown: boolean, check: (p: ReturnType<typeof parse>) => void) => {
22+
const result = evaluateKeyboardEventWin32(ev(opts), isDown);
23+
const parsed = parse(result.key!);
24+
assert.ok(parsed);
25+
check(parsed);
26+
};
27+
28+
describe('Win32InputMode', () => {
29+
describe('evaluateKeyboardEventWin32', () => {
30+
describe('basic key encoding', () => {
31+
it('letter key press', () => {
32+
const result = evaluateKeyboardEventWin32(ev({ code: 'KeyA', key: 'a', keyCode: 65 }), true);
33+
assert.strictEqual(result.type, KeyboardResultType.SEND_KEY);
34+
assert.strictEqual(result.cancel, true);
35+
const p = parse(result.key!);
36+
assert.ok(p);
37+
assert.deepStrictEqual([p.vk, p.uc, p.kd, p.rc], [0x41, 97, 1, 1]);
38+
});
39+
it('letter key release', () => test({ code: 'KeyA', key: 'a', keyCode: 65 }, false, p => assert.strictEqual(p!.kd, 0)));
40+
it('digit key', () => test({ code: 'Digit1', key: '1', keyCode: 49 }, true, p => assert.deepStrictEqual([p!.vk, p!.uc], [0x31, 49])));
41+
it('Enter key', () => test({ code: 'Enter', key: 'Enter', keyCode: 13 }, true, p => assert.deepStrictEqual([p!.vk, p!.uc], [0x0D, 0])));
42+
it('Escape key', () => test({ code: 'Escape', key: 'Escape', keyCode: 27 }, true, p => assert.strictEqual(p!.vk, 0x1B)));
43+
it('Space key', () => test({ code: 'Space', key: ' ', keyCode: 32 }, true, p => assert.deepStrictEqual([p!.vk, p!.uc], [0x20, 32])));
44+
});
45+
46+
describe('modifier encoding', () => {
47+
it('shift', () => test({ code: 'KeyA', key: 'A', keyCode: 65, shiftKey: true }, true, p => assert.ok(p!.cs & Win32ControlKeyState.SHIFT_PRESSED)));
48+
it('ctrl left', () => test({ code: 'KeyA', key: 'a', keyCode: 65, ctrlKey: true }, true, p => assert.ok(p!.cs & Win32ControlKeyState.LEFT_CTRL_PRESSED)));
49+
it('ctrl right', () => test({ code: 'ControlRight', key: 'Control', keyCode: 17, ctrlKey: true }, true, p => {
50+
assert.ok(p!.cs & Win32ControlKeyState.RIGHT_CTRL_PRESSED);
51+
assert.ok(p!.cs & Win32ControlKeyState.ENHANCED_KEY);
52+
}));
53+
it('alt left', () => test({ code: 'KeyA', key: 'a', keyCode: 65, altKey: true }, true, p => assert.ok(p!.cs & Win32ControlKeyState.LEFT_ALT_PRESSED)));
54+
it('alt right', () => test({ code: 'AltRight', key: 'Alt', keyCode: 18, altKey: true }, true, p => {
55+
assert.ok(p!.cs & Win32ControlKeyState.RIGHT_ALT_PRESSED);
56+
assert.ok(p!.cs & Win32ControlKeyState.ENHANCED_KEY);
57+
}));
58+
it('multiple modifiers', () => test({ code: 'KeyA', key: 'A', keyCode: 65, shiftKey: true, ctrlKey: true, altKey: true }, true, p => {
59+
assert.ok(p!.cs & Win32ControlKeyState.SHIFT_PRESSED);
60+
assert.ok(p!.cs & Win32ControlKeyState.LEFT_CTRL_PRESSED);
61+
assert.ok(p!.cs & Win32ControlKeyState.LEFT_ALT_PRESSED);
62+
}));
63+
});
64+
65+
describe('function keys', () => {
66+
it('F1', () => test({ code: 'F1', key: 'F1', keyCode: 112 }, true, p => assert.strictEqual(p!.vk, 0x70)));
67+
it('F5', () => test({ code: 'F5', key: 'F5', keyCode: 116 }, true, p => assert.strictEqual(p!.vk, 0x74)));
68+
it('F12', () => test({ code: 'F12', key: 'F12', keyCode: 123 }, true, p => assert.strictEqual(p!.vk, 0x7B)));
69+
it('Ctrl+F1', () => test({ code: 'F1', key: 'F1', keyCode: 112, ctrlKey: true }, true, p => {
70+
assert.strictEqual(p!.vk, 0x70);
71+
assert.ok(p!.cs & Win32ControlKeyState.LEFT_CTRL_PRESSED);
72+
}));
73+
});
74+
75+
describe('navigation keys (ENHANCED_KEY)', () => {
76+
const navKeys: [string, string, number, number][] = [
77+
['ArrowUp', 'ArrowUp', 38, 0x26],
78+
['ArrowDown', 'ArrowDown', 40, 0x28],
79+
['ArrowLeft', 'ArrowLeft', 37, 0x25],
80+
['ArrowRight', 'ArrowRight', 39, 0x27],
81+
['Home', 'Home', 36, 0x24],
82+
['End', 'End', 35, 0x23],
83+
['PageUp', 'PageUp', 33, 0x21],
84+
['PageDown', 'PageDown', 34, 0x22],
85+
['Insert', 'Insert', 45, 0x2D],
86+
['Delete', 'Delete', 46, 0x2E],
87+
];
88+
navKeys.forEach(([code, key, keyCode, vk]) => {
89+
it(code, () => test({ code, key, keyCode }, true, p => {
90+
assert.strictEqual(p!.vk, vk);
91+
assert.ok(p!.cs & Win32ControlKeyState.ENHANCED_KEY);
92+
}));
93+
});
94+
it('Tab', () => test({ code: 'Tab', key: 'Tab', keyCode: 9 }, true, p => assert.strictEqual(p!.vk, 0x09)));
95+
it('Backspace', () => test({ code: 'Backspace', key: 'Backspace', keyCode: 8 }, true, p => assert.strictEqual(p!.vk, 0x08)));
96+
});
97+
98+
describe('numpad keys', () => {
99+
it('Numpad0', () => test({ code: 'Numpad0', key: '0', keyCode: 96 }, true, p => assert.strictEqual(p!.vk, 0x60)));
100+
it('NumpadEnter (ENHANCED)', () => test({ code: 'NumpadEnter', key: 'Enter', keyCode: 13 }, true, p => {
101+
assert.strictEqual(p!.vk, 0x0D);
102+
assert.ok(p!.cs & Win32ControlKeyState.ENHANCED_KEY);
103+
}));
104+
it('NumpadAdd', () => test({ code: 'NumpadAdd', key: '+', keyCode: 107 }, true, p => assert.strictEqual(p!.vk, 0x6B)));
105+
it('NumpadSubtract', () => test({ code: 'NumpadSubtract', key: '-', keyCode: 109 }, true, p => assert.strictEqual(p!.vk, 0x6D)));
106+
it('NumpadMultiply', () => test({ code: 'NumpadMultiply', key: '*', keyCode: 106 }, true, p => assert.strictEqual(p!.vk, 0x6A)));
107+
it('NumpadDivide (ENHANCED)', () => test({ code: 'NumpadDivide', key: '/', keyCode: 111 }, true, p => {
108+
assert.strictEqual(p!.vk, 0x6F);
109+
assert.ok(p!.cs & Win32ControlKeyState.ENHANCED_KEY);
110+
}));
111+
it('NumpadDecimal', () => test({ code: 'NumpadDecimal', key: '.', keyCode: 110 }, true, p => assert.strictEqual(p!.vk, 0x6E)));
112+
});
113+
114+
describe('unicode character', () => {
115+
it('printable', () => test({ code: 'KeyA', key: 'a', keyCode: 65 }, true, p => assert.strictEqual(p!.uc, 97)));
116+
it('shifted', () => test({ code: 'KeyA', key: 'A', keyCode: 65, shiftKey: true }, true, p => assert.strictEqual(p!.uc, 65)));
117+
it('non-printable is 0', () => test({ code: 'ArrowUp', key: 'ArrowUp', keyCode: 38 }, true, p => assert.strictEqual(p!.uc, 0)));
118+
it('extended ASCII', () => test({ code: 'KeyE', key: 'é', keyCode: 69 }, true, p => assert.strictEqual(p!.uc, 233)));
119+
it('symbol', () => test({ code: 'Digit4', key: '$', keyCode: 52, shiftKey: true }, true, p => assert.strictEqual(p!.uc, 36)));
120+
});
121+
122+
describe('scan codes', () => {
123+
it('letter A', () => test({ code: 'KeyA', key: 'a', keyCode: 65 }, true, p => assert.strictEqual(p!.sc, 0x1E)));
124+
it('Escape', () => test({ code: 'Escape', key: 'Escape', keyCode: 27 }, true, p => assert.strictEqual(p!.sc, 0x01)));
125+
});
126+
127+
describe('sequence format', () => {
128+
it('valid CSI format', () => {
129+
const result = evaluateKeyboardEventWin32(ev({ code: 'KeyA', key: 'a', keyCode: 65 }), true);
130+
assert.ok(result.key?.startsWith('\x1b[') && result.key.endsWith('_'));
131+
assert.strictEqual(result.key?.slice(2, -1).split(';').length, 6);
132+
});
133+
});
134+
135+
describe('standalone modifier keys', () => {
136+
it('ShiftLeft', () => test({ code: 'ShiftLeft', key: 'Shift', keyCode: 16, shiftKey: true }, true, p => {
137+
assert.strictEqual(p!.vk, 0x10);
138+
assert.ok(p!.cs & Win32ControlKeyState.SHIFT_PRESSED);
139+
}));
140+
it('ShiftRight', () => test({ code: 'ShiftRight', key: 'Shift', keyCode: 16, shiftKey: true }, true, p => {
141+
assert.strictEqual(p!.vk, 0x10);
142+
assert.ok(p!.cs & Win32ControlKeyState.SHIFT_PRESSED);
143+
}));
144+
it('ControlLeft', () => test({ code: 'ControlLeft', key: 'Control', keyCode: 17, ctrlKey: true }, true, p => {
145+
assert.strictEqual(p!.vk, 0x11);
146+
assert.ok(p!.cs & Win32ControlKeyState.LEFT_CTRL_PRESSED);
147+
}));
148+
it('ControlRight', () => test({ code: 'ControlRight', key: 'Control', keyCode: 17, ctrlKey: true }, true, p => {
149+
assert.strictEqual(p!.vk, 0x11);
150+
assert.ok(p!.cs & Win32ControlKeyState.RIGHT_CTRL_PRESSED);
151+
assert.ok(p!.cs & Win32ControlKeyState.ENHANCED_KEY);
152+
}));
153+
it('AltLeft', () => test({ code: 'AltLeft', key: 'Alt', keyCode: 18, altKey: true }, true, p => {
154+
assert.strictEqual(p!.vk, 0x12);
155+
assert.ok(p!.cs & Win32ControlKeyState.LEFT_ALT_PRESSED);
156+
}));
157+
it('AltRight', () => test({ code: 'AltRight', key: 'Alt', keyCode: 18, altKey: true }, true, p => {
158+
assert.strictEqual(p!.vk, 0x12);
159+
assert.ok(p!.cs & Win32ControlKeyState.RIGHT_ALT_PRESSED);
160+
assert.ok(p!.cs & Win32ControlKeyState.ENHANCED_KEY);
161+
}));
162+
it('modifier release', () => test({ code: 'ShiftLeft', key: 'Shift', keyCode: 16 }, false, p => assert.strictEqual(p!.kd, 0)));
163+
});
164+
165+
describe('problem keys from spec', () => {
166+
it('Ctrl+Space', () => test({ code: 'Space', key: ' ', keyCode: 32, ctrlKey: true }, true, p => {
167+
assert.strictEqual(p!.vk, 0x20);
168+
assert.ok(p!.cs & Win32ControlKeyState.LEFT_CTRL_PRESSED);
169+
}));
170+
it('Shift+Enter', () => test({ code: 'Enter', key: 'Enter', keyCode: 13, shiftKey: true }, true, p => {
171+
assert.strictEqual(p!.vk, 0x0D);
172+
assert.ok(p!.cs & Win32ControlKeyState.SHIFT_PRESSED);
173+
}));
174+
it('Ctrl+Break', () => test({ code: 'Pause', key: 'Pause', keyCode: 19, ctrlKey: true }, true, p => {
175+
assert.strictEqual(p!.vk, 0x13);
176+
assert.ok(p!.cs & Win32ControlKeyState.LEFT_CTRL_PRESSED);
177+
}));
178+
it('Ctrl+Alt+/', () => test({ code: 'Slash', key: '/', keyCode: 191, ctrlKey: true, altKey: true }, true, p => {
179+
assert.ok(p!.cs & Win32ControlKeyState.LEFT_CTRL_PRESSED);
180+
assert.ok(p!.cs & Win32ControlKeyState.LEFT_ALT_PRESSED);
181+
}));
182+
});
183+
184+
describe('meta key', () => {
185+
it('MetaLeft', () => test({ code: 'MetaLeft', key: 'Meta', keyCode: 91, metaKey: true }, true, p => {
186+
assert.strictEqual(p!.vk, 0x5B);
187+
assert.ok(p!.cs & Win32ControlKeyState.ENHANCED_KEY);
188+
}));
189+
it('MetaRight', () => test({ code: 'MetaRight', key: 'Meta', keyCode: 92, metaKey: true }, true, p => {
190+
assert.strictEqual(p!.vk, 0x5C);
191+
assert.ok(p!.cs & Win32ControlKeyState.ENHANCED_KEY);
192+
}));
193+
});
194+
});
195+
});

0 commit comments

Comments
 (0)