mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 06:29:42 +02:00
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:
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user