feat: add the TUI raw-mode key parser

Decodes printable UTF-8, the control keys, arrows in both CSI and SS3 forms and
SGR mouse reports out of a byte stream that can tear anywhere, so a sequence
split across two reads decodes the same as one that arrives whole.

A lone ESC cannot be told from the start of an arrow key by looking at bytes,
so the parser holds it and the caller resolves it with flush() once its
disambiguation timer fires. Unknown sequences are swallowed: a stray CSI must
never reach a prompt composer as typed text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-22 14:13:57 +02:00
parent 64cf8384f2
commit 5d7fdb528b
2 changed files with 432 additions and 0 deletions
+210
View File
@@ -0,0 +1,210 @@
/**
* @fileoverview Unit tests for the raw-mode key parser.
*
* The two failure modes that matter are covered explicitly: a sequence that
* arrives split across reads must decode identically at EVERY split position
* (a terminal is free to break a chunk anywhere), and an unknown sequence must
* be swallowed rather than leaked as typed text.
*/
import { describe, it, expect } from 'vitest';
import { createKeyParser, type TuiInputEvent } from '../../src/tui/tui-keys.js';
/** Feed a whole sequence in one go. */
function decode(input: string | Buffer): TuiInputEvent[] {
return createKeyParser().feed(input);
}
/** Feed the same bytes split at `at`, so a torn read must not change the result. */
function decodeSplit(bytes: Buffer, at: number): TuiInputEvent[] {
const parser = createKeyParser();
return [...parser.feed(bytes.subarray(0, at)), ...parser.feed(bytes.subarray(at))];
}
describe('printable input', () => {
it('emits one event per code point', () => {
expect(decode('ab')).toEqual([
{ type: 'char', value: 'a' },
{ type: 'char', value: 'b' },
]);
});
it('decodes multi-byte UTF-8', () => {
expect(decode('é中')).toEqual([
{ type: 'char', value: 'é' },
{ type: 'char', value: '中' },
]);
expect(decode('\u{1f600}')).toEqual([{ type: 'char', value: '\u{1f600}' }]);
});
it('holds a UTF-8 character split across chunks', () => {
const bytes = Buffer.from('中', 'utf8');
const parser = createKeyParser();
expect(parser.feed(bytes.subarray(0, 1))).toEqual([]);
expect(parser.pending()).toBe(1);
expect(parser.feed(bytes.subarray(1, 2))).toEqual([]);
expect(parser.feed(bytes.subarray(2))).toEqual([{ type: 'char', value: '中' }]);
expect(parser.pending()).toBe(0);
});
it('decodes a 4-byte character at every split position', () => {
const bytes = Buffer.from('\u{1f600}', 'utf8');
for (let at = 0; at <= bytes.length; at++) {
expect(decodeSplit(bytes, at)).toEqual([{ type: 'char', value: '\u{1f600}' }]);
}
});
it('swallows invalid UTF-8 rather than typing a replacement character', () => {
expect(decode(Buffer.from([0xc3, 0x28]))).toEqual([{ type: 'char', value: '(' }]);
});
});
describe('control keys', () => {
it('maps Enter, Tab and Backspace', () => {
expect(decode('\r')).toEqual([{ type: 'enter' }]);
expect(decode('\n')).toEqual([{ type: 'enter' }]);
expect(decode('\t')).toEqual([{ type: 'tab' }]);
expect(decode('\x7f')).toEqual([{ type: 'backspace' }]);
expect(decode('\x08')).toEqual([{ type: 'backspace' }]);
});
it('maps Ctrl+letter, keeping Ctrl+I and Ctrl+M as Tab and Enter', () => {
expect(decode('\x03')).toEqual([{ type: 'ctrl', key: 'c' }]);
expect(decode('\x17')).toEqual([{ type: 'ctrl', key: 'w' }]);
expect(decode('\x01')).toEqual([{ type: 'ctrl', key: 'a' }]);
expect(decode('\x09')).toEqual([{ type: 'tab' }]);
expect(decode('\x0d')).toEqual([{ type: 'enter' }]);
expect(decode('\x00')).toEqual([{ type: 'ctrl', key: '@' }]);
});
});
describe('escape sequences', () => {
it('decodes CSI arrows, Home and End', () => {
expect(decode('\x1b[A')).toEqual([{ type: 'key', name: 'up' }]);
expect(decode('\x1b[B')).toEqual([{ type: 'key', name: 'down' }]);
expect(decode('\x1b[C')).toEqual([{ type: 'key', name: 'right' }]);
expect(decode('\x1b[D')).toEqual([{ type: 'key', name: 'left' }]);
expect(decode('\x1b[H')).toEqual([{ type: 'key', name: 'home' }]);
expect(decode('\x1b[F')).toEqual([{ type: 'key', name: 'end' }]);
});
it('decodes the SS3 variants application-cursor mode sends', () => {
expect(decode('\x1bOA')).toEqual([{ type: 'key', name: 'up' }]);
expect(decode('\x1bOD')).toEqual([{ type: 'key', name: 'left' }]);
expect(decode('\x1bOH')).toEqual([{ type: 'key', name: 'home' }]);
expect(decode('\x1bOP')).toEqual([]);
});
it('decodes the numbered CSI keys', () => {
expect(decode('\x1b[2~')).toEqual([{ type: 'key', name: 'insert' }]);
expect(decode('\x1b[3~')).toEqual([{ type: 'key', name: 'delete' }]);
expect(decode('\x1b[5~')).toEqual([{ type: 'key', name: 'pageup' }]);
expect(decode('\x1b[6~')).toEqual([{ type: 'key', name: 'pagedown' }]);
expect(decode('\x1b[1~')).toEqual([{ type: 'key', name: 'home' }]);
expect(decode('\x1b[4~')).toEqual([{ type: 'key', name: 'end' }]);
});
it('ignores modifiers on an arrow rather than dropping the key', () => {
expect(decode('\x1b[1;5A')).toEqual([{ type: 'key', name: 'up' }]);
});
it('swallows unknown sequences instead of leaking them as text', () => {
expect(decode('\x1b[Z')).toEqual([]);
expect(decode('\x1b[999~')).toEqual([]);
expect(decode('\x1b[?1049h')).toEqual([]);
expect(decode('\x1b[200~hi\x1b[201~')).toEqual([
{ type: 'char', value: 'h' },
{ type: 'char', value: 'i' },
]);
});
it('consumes the payload of an X10 mouse report', () => {
expect(decode('\x1b[M !!x')).toEqual([{ type: 'char', value: 'x' }]);
});
it('reads ESC followed by a letter as Escape then that letter', () => {
expect(decode('\x1bx')).toEqual([{ type: 'escape' }, { type: 'char', value: 'x' }]);
});
});
describe('lone escape', () => {
it('holds a trailing ESC until the caller flushes', () => {
const parser = createKeyParser();
expect(parser.feed('\x1b')).toEqual([]);
expect(parser.pending()).toBe(1);
expect(parser.flush()).toEqual([{ type: 'escape' }]);
expect(parser.pending()).toBe(0);
});
it('completes the sequence instead when the rest arrives', () => {
const parser = createKeyParser();
expect(parser.feed('\x1b')).toEqual([]);
expect(parser.feed('[A')).toEqual([{ type: 'key', name: 'up' }]);
expect(parser.flush()).toEqual([]);
});
it('turns a half-typed sequence into Escape plus its characters', () => {
const parser = createKeyParser();
expect(parser.feed('\x1b[')).toEqual([]);
expect(parser.flush()).toEqual([{ type: 'escape' }, { type: 'char', value: '[' }]);
});
});
describe('SGR mouse', () => {
it('decodes press and release with 1-based coordinates', () => {
expect(decode('\x1b[<0;12;34M')).toEqual([{ type: 'mouse', kind: 'press', x: 12, y: 34, button: 0 }]);
expect(decode('\x1b[<0;12;34m')).toEqual([{ type: 'mouse', kind: 'release', x: 12, y: 34, button: 0 }]);
});
it('decodes the wheel', () => {
expect(decode('\x1b[<64;3;4M')).toEqual([{ type: 'mouse', kind: 'wheel-up', x: 3, y: 4, button: 64 }]);
expect(decode('\x1b[<65;3;4M')).toEqual([{ type: 'mouse', kind: 'wheel-down', x: 3, y: 4, button: 65 }]);
});
it('swallows drag/motion reports', () => {
expect(decode('\x1b[<32;5;6M')).toEqual([]);
});
it('swallows a malformed report', () => {
expect(decode('\x1b[<0;12M')).toEqual([]);
});
});
describe('torn reads', () => {
const cases: Array<[string, TuiInputEvent[]]> = [
['\x1b[A', [{ type: 'key', name: 'up' }]],
['\x1b[6~', [{ type: 'key', name: 'pagedown' }]],
['\x1b[<64;3;4M', [{ type: 'mouse', kind: 'wheel-up', x: 3, y: 4, button: 64 }]],
['\x1bOB', [{ type: 'key', name: 'down' }]],
['\x1b[1;5C', [{ type: 'key', name: 'right' }]],
];
for (const [sequence, expected] of cases) {
it(`decodes ${JSON.stringify(sequence)} at every split position`, () => {
const bytes = Buffer.from(sequence, 'utf8');
for (let at = 0; at <= bytes.length; at++) {
expect(decodeSplit(bytes, at)).toEqual(expected);
}
});
}
it('decodes a mixed burst split anywhere', () => {
const bytes = Buffer.from('a\x1b[Bx\r\x1b[<65;1;1M', 'utf8');
const expected: TuiInputEvent[] = [
{ type: 'char', value: 'a' },
{ type: 'key', name: 'down' },
{ type: 'char', value: 'x' },
{ type: 'enter' },
{ type: 'mouse', kind: 'wheel-down', x: 1, y: 1, button: 65 },
];
for (let at = 0; at <= bytes.length; at++) {
expect(decodeSplit(bytes, at)).toEqual(expected);
}
});
it('drops a garbage burst whole instead of wedging or leaking it', () => {
const parser = createKeyParser();
expect(parser.feed(`\x1b[${'1'.repeat(200)}`)).toEqual([]);
expect(parser.pending()).toBe(0);
expect(parser.feed('\x1b[A')).toEqual([{ type: 'key', name: 'up' }]);
});
});