Support command/ctrl/alt backspace correctly (#17175)

This commit is contained in:
Tommaso Sciortino
2026-01-21 10:13:26 -08:00
committed by GitHub
parent e894871afc
commit f190b87223
27 changed files with 487 additions and 298 deletions
@@ -101,9 +101,9 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'return',
ctrl: false,
meta: false,
shift: false,
ctrl: false,
cmd: false,
}),
);
});
@@ -116,9 +116,9 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'return',
ctrl: false,
meta: false,
shift: true,
ctrl: false,
cmd: false,
}),
);
});
@@ -127,17 +127,17 @@ describe('KeypressContext', () => {
{
modifier: 'Shift',
sequence: '\x1b[57414;2u',
expected: { ctrl: false, meta: false, shift: true },
expected: { shift: true, ctrl: false, cmd: false },
},
{
modifier: 'Ctrl',
sequence: '\x1b[57414;5u',
expected: { ctrl: true, meta: false, shift: false },
expected: { shift: false, ctrl: true, cmd: false },
},
{
modifier: 'Alt',
sequence: '\x1b[57414;3u',
expected: { ctrl: false, meta: true, shift: false },
expected: { shift: false, alt: true, ctrl: false, cmd: false },
},
])(
'should handle numpad enter with $modifier modifier',
@@ -163,9 +163,9 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'j',
ctrl: true,
meta: false,
shift: false,
ctrl: true,
cmd: false,
}),
);
});
@@ -178,9 +178,10 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'return',
ctrl: false,
meta: true,
shift: false,
alt: true,
ctrl: false,
cmd: false,
}),
);
});
@@ -202,7 +203,13 @@ describe('KeypressContext', () => {
act(() => stdin.write('a'));
expect(keyHandler).toHaveBeenLastCalledWith(
expect.objectContaining({ name: 'a' }),
expect.objectContaining({
name: 'a',
shift: false,
alt: false,
ctrl: false,
cmd: false,
}),
);
act(() => stdin.write('\r'));
@@ -212,6 +219,10 @@ describe('KeypressContext', () => {
name: 'return',
sequence: '\r',
insertable: true,
shift: false,
alt: false,
ctrl: false,
cmd: false,
}),
);
});
@@ -228,6 +239,10 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenLastCalledWith(
expect.objectContaining({
name: 'return',
shift: false,
alt: false,
ctrl: false,
cmd: false,
}),
);
});
@@ -245,6 +260,10 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'escape',
shift: false,
alt: false,
ctrl: false,
cmd: false,
}),
);
});
@@ -266,11 +285,21 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ name: 'escape', meta: true }),
expect.objectContaining({
name: 'escape',
shift: false,
alt: true,
cmd: false,
}),
);
expect(keyHandler).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ name: 'escape', meta: true }),
expect.objectContaining({
name: 'escape',
shift: false,
alt: true,
cmd: false,
}),
);
});
});
@@ -296,7 +325,9 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'escape',
meta: true,
shift: false,
alt: true,
cmd: false,
}),
);
});
@@ -318,17 +349,17 @@ describe('KeypressContext', () => {
{
name: 'Backspace',
inputSequence: '\x1b[127u',
expected: { name: 'backspace', meta: false },
expected: { name: 'backspace', alt: false, cmd: false },
},
{
name: 'Option+Backspace',
name: 'Alt+Backspace',
inputSequence: '\x1b[127;3u',
expected: { name: 'backspace', meta: true },
expected: { name: 'backspace', alt: true, cmd: false },
},
{
name: 'Ctrl+Backspace',
inputSequence: '\x1b[127;5u',
expected: { name: 'backspace', ctrl: true },
expected: { name: 'backspace', alt: false, ctrl: true, cmd: false },
},
{
name: 'Shift+Space',
@@ -612,14 +643,17 @@ describe('KeypressContext', () => {
{ sequence: `\x1b[27;5;9~`, expected: { name: 'tab', ctrl: true } },
{
sequence: `\x1b[27;6;9~`,
expected: { name: 'tab', ctrl: true, shift: true },
expected: { name: 'tab', shift: true, ctrl: true },
},
// XTerm Function Key
{ sequence: `\x1b[1;129A`, expected: { name: 'up' } },
{ sequence: `\x1b[1;2H`, expected: { name: 'home', shift: true } },
{ sequence: `\x1b[1;5F`, expected: { name: 'end', ctrl: true } },
{ sequence: `\x1b[1;1P`, expected: { name: 'f1' } },
{ sequence: `\x1b[1;3Q`, expected: { name: 'f2', meta: true } },
{
sequence: `\x1b[1;3Q`,
expected: { name: 'f2', alt: true, cmd: false },
},
// Tilde Function Keys
{ sequence: `\x1b[3~`, expected: { name: 'delete' } },
{ sequence: `\x1b[5~`, expected: { name: 'pageup' } },
@@ -637,33 +671,75 @@ describe('KeypressContext', () => {
// Legacy Arrows
{
sequence: `\x1b[A`,
expected: { name: 'up', ctrl: false, meta: false, shift: false },
expected: {
name: 'up',
shift: false,
alt: false,
ctrl: false,
cmd: false,
},
},
{
sequence: `\x1b[B`,
expected: { name: 'down', ctrl: false, meta: false, shift: false },
expected: {
name: 'down',
shift: false,
alt: false,
ctrl: false,
cmd: false,
},
},
{
sequence: `\x1b[C`,
expected: { name: 'right', ctrl: false, meta: false, shift: false },
expected: {
name: 'right',
shift: false,
alt: false,
ctrl: false,
cmd: false,
},
},
{
sequence: `\x1b[D`,
expected: { name: 'left', ctrl: false, meta: false, shift: false },
expected: {
name: 'left',
shift: false,
alt: false,
ctrl: false,
cmd: false,
},
},
// Legacy Home/End
{
sequence: `\x1b[H`,
expected: { name: 'home', ctrl: false, meta: false, shift: false },
expected: {
name: 'home',
shift: false,
alt: false,
ctrl: false,
cmd: false,
},
},
{
sequence: `\x1b[F`,
expected: { name: 'end', ctrl: false, meta: false, shift: false },
expected: {
name: 'end',
shift: false,
alt: false,
ctrl: false,
cmd: false,
},
},
{
sequence: `\x1b[5H`,
expected: { name: 'home', ctrl: true, meta: false, shift: false },
expected: {
name: 'home',
shift: false,
alt: false,
ctrl: true,
cmd: false,
},
},
])(
'should recognize sequence "$sequence" as $expected.name',
@@ -690,11 +766,23 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ name: 'delete' }),
expect.objectContaining({
name: 'delete',
shift: false,
alt: false,
ctrl: false,
cmd: false,
}),
);
expect(keyHandler).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ name: 'delete' }),
expect.objectContaining({
name: 'delete',
shift: false,
alt: false,
ctrl: false,
cmd: false,
}),
);
});
@@ -751,9 +839,10 @@ describe('KeypressContext', () => {
chunk: `\x1b[${keycode};3u`,
expected: {
name: key,
ctrl: false,
meta: true,
shift: false,
alt: true,
ctrl: false,
cmd: false,
},
};
} else if (terminal === 'MacTerminal') {
@@ -766,24 +855,26 @@ describe('KeypressContext', () => {
expected: {
sequence: `\x1b${key}`,
name: key,
ctrl: false,
meta: true,
shift: false,
alt: true,
ctrl: false,
cmd: false,
},
};
} else {
// iTerm2 and VSCode send accented characters (å, ø, µ)
// Note: µ (mu) is sent with meta:false on iTerm2/VSCode but
// gets converted to m with meta:true
// Note: µ (mu) is sent with alt:false on iTerm2/VSCode but
// gets converted to m with alt:true
return {
terminal,
key,
chunk: accentedChar,
expected: {
name: key,
ctrl: false,
meta: true, // Always expect meta:true after conversion
shift: false,
alt: true, // Always expect alt:true after conversion
ctrl: false,
cmd: false,
sequence: accentedChar,
},
};
@@ -825,7 +916,10 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
sequence: '\\',
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
}),
);
});
@@ -858,6 +952,10 @@ describe('KeypressContext', () => {
expect.objectContaining({
name: 'undefined',
sequence: INCOMPLETE_KITTY_SEQUENCE,
shift: false,
alt: false,
ctrl: false,
cmd: false,
}),
);
});
@@ -876,6 +974,10 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
sequence: '\x1b[m',
shift: false,
alt: false,
ctrl: false,
cmd: false,
}),
);
});
@@ -1048,6 +1150,10 @@ describe('KeypressContext', () => {
expect.objectContaining({
name: 'a',
sequence: 'a',
shift: false,
alt: false,
ctrl: false,
cmd: false,
}),
);
});
@@ -1162,7 +1268,14 @@ describe('KeypressContext', () => {
});
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({ name: 'f12', sequence: '\u001b[24~' }),
expect.objectContaining({
name: 'f12',
sequence: '\u001b[24~',
shift: false,
alt: false,
ctrl: false,
cmd: false,
}),
);
});
});
@@ -251,9 +251,10 @@ function bufferPaste(keypressHandler: KeypressHandler): KeypressHandler {
if (buffer.length > 0) {
keypressHandler({
name: 'paste',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: true,
sequence: buffer,
});
@@ -300,9 +301,10 @@ function* emitKeys(
let escaped = false;
let name = undefined;
let ctrl = false;
let meta = false;
let shift = false;
let alt = false;
let ctrl = false;
let cmd = false;
let code = undefined;
let insertable = false;
@@ -353,9 +355,10 @@ function* emitKeys(
const decoded = Buffer.from(base64Data, 'base64').toString('utf-8');
keypressHandler({
name: 'paste',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: true,
sequence: decoded,
});
@@ -490,9 +493,10 @@ function* emitKeys(
}
// Parse the key modifier
ctrl = !!(modifier & 4);
meta = !!(modifier & 10); // use 10 to catch both alt (2) and meta (8).
shift = !!(modifier & 1);
alt = !!(modifier & 2);
ctrl = !!(modifier & 4);
cmd = !!(modifier & 8);
const keyInfo = KEY_INFO_MAP[code];
if (keyInfo) {
@@ -503,13 +507,16 @@ function* emitKeys(
if (keyInfo.ctrl) {
ctrl = true;
}
if (name === 'space' && !ctrl && !meta) {
if (name === 'space' && !ctrl && !cmd && !alt) {
sequence = ' ';
insertable = true;
}
} else {
name = 'undefined';
if ((ctrl || meta) && (code.endsWith('u') || code.endsWith('~'))) {
if (
(ctrl || cmd || alt) &&
(code.endsWith('u') || code.endsWith('~'))
) {
// CSI-u or tilde-coded functional keys: ESC [ <code> ; <mods> (u|~)
const codeNumber = parseInt(code.slice(1, -1), 10);
if (
@@ -523,26 +530,26 @@ function* emitKeys(
} else if (ch === '\r') {
// carriage return
name = 'return';
meta = escaped;
alt = escaped;
} else if (escaped && ch === '\n') {
// Alt+Enter (linefeed), should be consistent with carriage return
name = 'return';
meta = escaped;
alt = escaped;
} else if (ch === '\t') {
// tab
name = 'tab';
meta = escaped;
alt = escaped;
} else if (ch === '\b' || ch === '\x7f') {
// backspace or ctrl+h
name = 'backspace';
meta = escaped;
alt = escaped;
} else if (ch === ESC) {
// escape key
name = 'escape';
meta = escaped;
alt = escaped;
} else if (ch === ' ') {
name = 'space';
meta = escaped;
alt = escaped;
insertable = true;
} else if (!escaped && ch <= '\x1a') {
// ctrl+letter
@@ -552,29 +559,30 @@ function* emitKeys(
// Letter, number, shift+letter
name = ch.toLowerCase();
shift = /^[A-Z]$/.exec(ch) !== null;
meta = escaped;
alt = escaped;
insertable = true;
} else if (MAC_ALT_KEY_CHARACTER_MAP[ch] && process.platform === 'darwin') {
name = MAC_ALT_KEY_CHARACTER_MAP[ch];
meta = true;
alt = true;
} else if (sequence === `${ESC}${ESC}`) {
// Double escape
name = 'escape';
meta = true;
alt = true;
// Emit first escape key here, then continue processing
keypressHandler({
name: 'escape',
ctrl,
meta,
shift,
alt,
ctrl,
cmd,
insertable: false,
sequence: ESC,
});
} else if (escaped) {
// Escape sequence timeout
name = ch.length ? undefined : 'escape';
meta = true;
alt = true;
} else {
// Any other character is considered printable.
insertable = true;
@@ -586,9 +594,10 @@ function* emitKeys(
) {
keypressHandler({
name: name || '',
ctrl,
meta,
shift,
alt,
ctrl,
cmd,
insertable,
sequence,
});
@@ -599,9 +608,10 @@ function* emitKeys(
export interface Key {
name: string;
ctrl: boolean;
meta: boolean;
shift: boolean;
alt: boolean;
ctrl: boolean;
cmd: boolean; // Command/Windows/Super key
insertable: boolean;
sequence: string;
}
@@ -139,63 +139,63 @@ describe('MouseContext', () => {
sequence: '\x1b[<0;10;20M',
expected: {
name: 'left-press',
shift: false,
ctrl: false,
meta: false,
shift: false,
},
},
{
sequence: '\x1b[<0;10;20m',
expected: {
name: 'left-release',
shift: false,
ctrl: false,
meta: false,
shift: false,
},
},
{
sequence: '\x1b[<2;10;20M',
expected: {
name: 'right-press',
shift: false,
ctrl: false,
meta: false,
shift: false,
},
},
{
sequence: '\x1b[<1;10;20M',
expected: {
name: 'middle-press',
shift: false,
ctrl: false,
meta: false,
shift: false,
},
},
{
sequence: '\x1b[<64;10;20M',
expected: {
name: 'scroll-up',
shift: false,
ctrl: false,
meta: false,
shift: false,
},
},
{
sequence: '\x1b[<65;10;20M',
expected: {
name: 'scroll-down',
shift: false,
ctrl: false,
meta: false,
shift: false,
},
},
{
sequence: '\x1b[<32;10;20M',
expected: {
name: 'move',
shift: false,
ctrl: false,
meta: false,
shift: false,
},
},
{
@@ -208,7 +208,7 @@ describe('MouseContext', () => {
}, // Alt + left press
{
sequence: '\x1b[<20;10;20M',
expected: { name: 'left-press', ctrl: true, shift: true },
expected: { name: 'left-press', shift: true, ctrl: true },
}, // Ctrl + Shift + left press
{
sequence: '\x1b[<68;10;20M',