Skip to content

Commit 42441a1

Browse files
committed
readline: support kitty keyboard protocol
1 parent ae228c1 commit 42441a1

File tree

6 files changed

+197
-23
lines changed

6 files changed

+197
-23
lines changed

lib/internal/readline/interface.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ const ESCAPE_CODE_TIMEOUT = 500;
9898
const kMaxLengthOfKillRing = 32;
9999

100100
const kMultilinePrompt = Symbol('| ');
101+
const kKeyboardProtocolWasEnabled = Symbol('_keyboardProtocolWasEnabled');
101102

102103
const kAddHistory = Symbol('_addHistory');
103104
const kBeforeEdit = Symbol('_beforeEdit');
@@ -281,6 +282,8 @@ function InterfaceConstructor(input, output, completer, terminal) {
281282
}
282283

283284
function onkeypress(s, key) {
285+
if (key?.eventType === 'release')
286+
return;
284287
self[kTtyWrite](s, key);
285288
if (key?.sequence) {
286289
// If the key.sequence is half of a surrogate pair
@@ -333,6 +336,13 @@ function InterfaceConstructor(input, output, completer, terminal) {
333336
// Cursor position on the line.
334337
this.cursor = 0;
335338

339+
if (typeof output?.setKeyboardProtocol === 'function' && output.isTTY) {
340+
output.setKeyboardProtocol('kitty');
341+
this[kKeyboardProtocolWasEnabled] = true;
342+
} else {
343+
this[kKeyboardProtocolWasEnabled] = false;
344+
}
345+
336346
if (output !== null && output !== undefined)
337347
output.on('resize', onresize);
338348

@@ -549,6 +559,9 @@ class Interface extends InterfaceConstructor {
549559
if (this.closed) return;
550560
this.pause();
551561
if (this.terminal) {
562+
if (this[kKeyboardProtocolWasEnabled]) {
563+
this.output.setKeyboardProtocol('legacy');
564+
}
552565
this[kSetRawMode](false);
553566
}
554567
this.closed = true;

lib/internal/readline/utils.js

Lines changed: 101 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const {
44
ArrayPrototypeToSorted,
55
RegExpPrototypeExec,
66
StringFromCharCode,
7+
StringFromCodePoint,
78
StringPrototypeCharCodeAt,
89
StringPrototypeCodePointAt,
910
StringPrototypeSlice,
@@ -15,6 +16,26 @@ const {
1516
const kUTF16SurrogateThreshold = 0x10000; // 2 ** 16
1617
const kEscape = '\x1b';
1718
const kSubstringSearch = Symbol('kSubstringSearch');
19+
const kKittyModifierShift = 1;
20+
const kKittyModifierAlt = 2;
21+
const kKittyModifierCtrl = 4;
22+
const kKittyModifierRightAlt = 8;
23+
24+
const kittyEventTypes = {
25+
__proto__: null,
26+
1: 'press',
27+
2: 'repeat',
28+
3: 'release',
29+
};
30+
31+
const kittySpecialKeyNames = {
32+
__proto__: null,
33+
9: 'tab',
34+
13: 'return',
35+
27: 'escape',
36+
32: 'space',
37+
127: 'backspace',
38+
};
1839

1940
function CSI(strings, ...args) {
2041
let ret = `${kEscape}[`;
@@ -56,6 +77,77 @@ function charLengthAt(str, i) {
5677
return StringPrototypeCodePointAt(str, i) >= kUTF16SurrogateThreshold ? 2 : 1;
5778
}
5879

80+
function decodeKittyCodePoints(text) {
81+
if (text === undefined || text === '')
82+
return undefined;
83+
const chars = StringPrototypeSplit(text, ':');
84+
let ret = '';
85+
for (let i = 0; i < chars.length; i++) {
86+
const code = Number(chars[i]);
87+
if (!Number.isInteger(code))
88+
return undefined;
89+
ret += StringFromCodePoint(code);
90+
}
91+
return ret;
92+
}
93+
94+
function getKittyBaseName(codepoint, text) {
95+
if (kittySpecialKeyNames[codepoint] !== undefined)
96+
return kittySpecialKeyNames[codepoint];
97+
98+
const source = text?.length ? text :
99+
(codepoint > 0 ? StringFromCodePoint(codepoint) : '');
100+
if (RegExpPrototypeExec(/^[0-9A-Za-z]$/, source) !== null)
101+
return StringPrototypeToLowerCase(source);
102+
return undefined;
103+
}
104+
105+
function parseKittySequence(code, key) {
106+
const match = RegExpPrototypeExec(
107+
/^(\d+(?::\d+)*)((?:;(?:\d*(?::\d+)?))?)(?:;(\d+(?::\d+)*))?u$/,
108+
code,
109+
);
110+
if (match === null)
111+
return false;
112+
113+
const codepoints = StringPrototypeSplit(match[1], ':');
114+
const primaryCodepoint = Number(codepoints[0]);
115+
if (!Number.isInteger(primaryCodepoint))
116+
return false;
117+
118+
let modifiers = 1;
119+
let eventType = 1;
120+
if (match[2] !== '') {
121+
const modifierField = StringPrototypeSlice(match[2], 1);
122+
if (modifierField !== '') {
123+
const modifierParts = StringPrototypeSplit(modifierField, ':');
124+
modifiers = Number(modifierParts[0] || '1');
125+
if (!Number.isInteger(modifiers))
126+
return false;
127+
if (modifierParts.length > 1) {
128+
eventType = Number(modifierParts[1] || '1');
129+
if (!Number.isInteger(eventType))
130+
return false;
131+
}
132+
}
133+
}
134+
135+
const modifierFlags = modifiers - 1;
136+
key.ctrl = !!(modifierFlags & kKittyModifierCtrl);
137+
key.meta = !!(modifierFlags & (kKittyModifierAlt | kKittyModifierRightAlt));
138+
key.shift = !!(modifierFlags & kKittyModifierShift);
139+
key.modifiers = modifierFlags;
140+
key.eventType = kittyEventTypes[eventType] || 'press';
141+
key.code = `[${code}`;
142+
143+
const text = decodeKittyCodePoints(match[3]);
144+
if (text !== undefined)
145+
key.text = text;
146+
147+
key.name = getKittyBaseName(primaryCodepoint, text);
148+
return true;
149+
}
150+
59151
/*
60152
Some patterns seen in terminal key escape codes, derived from combos seen
61153
at http://www.midnight-commander.org/browser/lib/tty/key.c
@@ -165,27 +257,8 @@ function* emitKeys(stream) {
165257
*
166258
*/
167259
const cmdStart = s.length - 1;
168-
169-
// Skip one or two leading digits
170-
if (ch >= '0' && ch <= '9') {
260+
while (ch >= '0' && ch <= '9' || ch === ';' || ch === ':') {
171261
s += (ch = yield);
172-
173-
if (ch >= '0' && ch <= '9') {
174-
s += (ch = yield);
175-
176-
if (ch >= '0' && ch <= '9') {
177-
s += (ch = yield);
178-
}
179-
}
180-
}
181-
182-
// skip modifier
183-
if (ch === ';') {
184-
s += (ch = yield);
185-
186-
if (ch >= '0' && ch <= '9') {
187-
s += yield;
188-
}
189262
}
190263

191264
/*
@@ -202,6 +275,9 @@ function* emitKeys(stream) {
202275
code += match[1] + match[3];
203276
modifier = (match[2] || 1) - 1;
204277
}
278+
} else if (cmd.endsWith('u') && parseKittySequence(cmd, key)) {
279+
code += cmd;
280+
modifier = key.modifiers;
205281
} else if (
206282
(match = RegExpPrototypeExec(/^((\d;)?(\d))?([A-Za-z])$/, cmd))
207283
) {
@@ -216,10 +292,10 @@ function* emitKeys(stream) {
216292
key.ctrl = !!(modifier & 4);
217293
key.meta = !!(modifier & 10);
218294
key.shift = !!(modifier & 1);
219-
key.code = code;
295+
key.code ??= code;
220296

221297
// Parse the key itself
222-
switch (code) {
298+
if (key.name === undefined) switch (code) {
223299
/* xterm/gnome ESC [ letter (with modifier) */
224300
case '[P': key.name = 'f1'; break;
225301
case '[Q': key.name = 'f2'; break;
@@ -366,9 +442,11 @@ function* emitKeys(stream) {
366442

367443
key.sequence = s;
368444

445+
const keypress = escaped && typeof key.text === 'string' ? key.text : s;
446+
369447
if (s.length !== 0 && (key.name !== undefined || escaped)) {
370448
/* Named character or sequence */
371-
stream.emit('keypress', escaped ? undefined : s, key);
449+
stream.emit('keypress', escaped ? keypress : s, key);
372450
} else if (charLengthAt(s, 0) === s.length) {
373451
/* Single unnamed character, e.g. "." */
374452
stream.emit('keypress', s, key);

lib/tty.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ const {
4343
// Lazy loaded for startup performance.
4444
let readline;
4545

46+
const kKittyKeyboardProtocolEnhancementDisambiguateEscapeCodes = 1;
47+
const kKittyKeyboardProtocolRestore = '\x1b[<u';
48+
4649
function isatty(fd) {
4750
return NumberIsInteger(fd) && fd >= 0 && fd <= 2147483647 &&
4851
isTTY(fd);
@@ -144,6 +147,31 @@ WriteStream.prototype._refreshSize = function() {
144147
}
145148
};
146149

150+
WriteStream.prototype.setKeyboardProtocol = function(protocol, options = {}) {
151+
if (protocol === 'legacy') {
152+
this.write(kKittyKeyboardProtocolRestore);
153+
return this;
154+
}
155+
156+
if (protocol !== 'kitty') {
157+
throw new ERR_INVALID_ARG_VALUE('protocol', protocol);
158+
}
159+
160+
let enhancements =
161+
kKittyKeyboardProtocolEnhancementDisambiguateEscapeCodes;
162+
if (options !== null && typeof options === 'object' &&
163+
options.enhancements !== undefined) {
164+
enhancements = options.enhancements;
165+
}
166+
167+
if (!NumberIsInteger(enhancements) || enhancements < 0) {
168+
throw new ERR_INVALID_ARG_VALUE('options.enhancements', enhancements);
169+
}
170+
171+
this.write(`\x1b[>${enhancements}u`);
172+
return this;
173+
};
174+
147175
// Backwards-compat
148176
WriteStream.prototype.cursorTo = function(x, y, callback) {
149177
if (readline === undefined) readline = require('readline');

test/parallel/test-readline-emit-keypress-events.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,28 @@ const expectedKeys = [
1414
{ sequence: 'o', name: 'o', ctrl: false, meta: false, shift: false },
1515
{ sequence: 'o', name: 'o', ctrl: false, meta: false, shift: false },
1616
];
17+
const kittyExpectedKeys = [
18+
{
19+
sequence: '\x1b[127u',
20+
name: 'backspace',
21+
ctrl: false,
22+
meta: false,
23+
shift: false,
24+
modifiers: 0,
25+
eventType: 'press',
26+
code: '[127u',
27+
},
28+
{
29+
sequence: '\x1b[99;5u',
30+
name: 'c',
31+
ctrl: true,
32+
meta: false,
33+
shift: false,
34+
modifiers: 4,
35+
eventType: 'press',
36+
code: '[99;5u',
37+
},
38+
];
1739

1840
{
1941
const stream = new PassThrough();
@@ -31,6 +53,22 @@ const expectedKeys = [
3153
assert.deepStrictEqual(keys, expectedKeys);
3254
}
3355

56+
{
57+
const stream = new PassThrough();
58+
const sequence = [];
59+
const keys = [];
60+
61+
readline.emitKeypressEvents(stream);
62+
stream.on('keypress', (s, k) => {
63+
sequence.push(s);
64+
keys.push(k);
65+
});
66+
stream.write('\x1b[127u\x1b[99;5u');
67+
68+
assert.deepStrictEqual(sequence, ['\x1b[127u', '\x1b[99;5u']);
69+
assert.deepStrictEqual(keys, kittyExpectedKeys);
70+
}
71+
3472
{
3573
const stream = new PassThrough();
3674
const sequence = [];

test/parallel/test-readline-keys.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,15 @@ addTest('\x01\x0b\x10', [
127127
{ name: 'p', sequence: '\x10', ctrl: true },
128128
]);
129129

130+
// kitty keyboard protocol (`CSI ... u`)
131+
addTest('\x1b[127u\x1b[107;5u\x1b[97;3u\x1b[97;2;65u\x1b[97;5:3u', [
132+
{ name: 'backspace', sequence: '\x1b[127u', code: '[127u', modifiers: 0, eventType: 'press' },
133+
{ name: 'k', sequence: '\x1b[107;5u', code: '[107;5u', ctrl: true, modifiers: 4, eventType: 'press' },
134+
{ name: 'a', sequence: '\x1b[97;3u', code: '[97;3u', meta: true, modifiers: 2, eventType: 'press' },
135+
{ name: 'a', sequence: '\x1b[97;2;65u', code: '[97;2;65u', shift: true, modifiers: 1, eventType: 'press', text: 'A' },
136+
{ name: 'a', sequence: '\x1b[97;5:3u', code: '[97;5:3u', ctrl: true, modifiers: 4, eventType: 'release' },
137+
]);
138+
130139
// Alt keys
131140
addTest('a\x1baA\x1bA', [
132141
{ name: 'a', sequence: 'a' },

test/parallel/test-readline-set-raw-mode.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,17 @@ let expectedRawMode = true;
3030
let rawModeCalled = false;
3131
let resumeCalled = false;
3232
let pauseCalled = false;
33+
const keyboardProtocols = [];
34+
35+
stream.isTTY = true;
3336

3437
stream.setRawMode = common.mustCallAtLeast(function(mode) {
3538
rawModeCalled = true;
3639
assert.strictEqual(mode, expectedRawMode);
3740
});
41+
stream.setKeyboardProtocol = common.mustCall(function(protocol) {
42+
keyboardProtocols.push(protocol);
43+
}, 2);
3844
stream.resume = function() {
3945
resumeCalled = true;
4046
};
@@ -53,6 +59,7 @@ assert(rli.terminal);
5359
assert(rawModeCalled);
5460
assert(resumeCalled);
5561
assert(!pauseCalled);
62+
assert.deepStrictEqual(keyboardProtocols, ['kitty']);
5663

5764

5865
// pause() should call *not* call setRawMode()
@@ -84,6 +91,7 @@ rli.close();
8491
assert(rawModeCalled);
8592
assert(!resumeCalled);
8693
assert(pauseCalled);
94+
assert.deepStrictEqual(keyboardProtocols, ['kitty', 'legacy']);
8795

8896
assert.deepStrictEqual(stream.listeners('keypress'), []);
8997
// One data listener for the keypress events.

0 commit comments

Comments
 (0)