mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Alt+1..9 switches to that session, and `[` / `]` / Tab step through them, so the
muscle memory from the web UI carries over.
Alt+N SELECTS rather than attaches, which is what the web UI's Alt+N does:
switching which tab you look at is cheap and reversible, and the terminal
equivalent is moving the selection and its preview, not handing the whole
terminal to a pane. Bare 1-9 keeps its documented jump-and-attach meaning.
Two of the web UI's chords cannot cross into a terminal, so the nearest
transmittable keys carry them instead:
Alt+[ / Alt+] ESC+[ IS the CSI introducer every arrow key arrives on, and
ESC+] is OSC, so neither chord is distinguishable from a
sequence. Bare `[` and `]` do the job.
Ctrl+Tab a terminal cannot report the Ctrl, so plain Tab carries it.
⚠️ The parser now decodes ESC + a printable character in ONE read as an Alt
chord, and the app replays every chord it does not claim as `escape` then that
character. That fallback is load-bearing, not tidiness: a real Esc landing in
the same read as the next keystroke is byte-identical to a chord, and without
the replay "Esc then q" typed quickly decoded as Alt+Q, matched nothing and was
swallowed. The e2e suite caught exactly that as the dashboard refusing to quit.
A lone Esc is still held and flushed on the caller's timer, which is what keeps
the two separable at all.
246 lines
9.7 KiB
TypeScript
246 lines
9.7 KiB
TypeScript
/**
|
|
* @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 the Alt chord it is', () => {
|
|
// Changed deliberately: this used to decode as Escape + `x`, which made
|
|
// Alt+N unreachable. A lone Esc is still separable because it is HELD until
|
|
// the caller's timer flushes it (see the 'lone escape' suite).
|
|
expect(decode('\x1bx')).toEqual([{ type: 'alt', 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' }]);
|
|
});
|
|
});
|
|
|
|
describe('Alt chords', () => {
|
|
it('reads ESC + a printable character in one read as Alt+that key', () => {
|
|
expect(decode('\x1b1')).toEqual([{ type: 'alt', value: '1' }]);
|
|
expect(decode('\x1bk')).toEqual([{ type: 'alt', value: 'k' }]);
|
|
});
|
|
|
|
it('never steals the sequence introducers, or every arrow key would break', () => {
|
|
// ESC [ is CSI and ESC O is SS3: both are Up, not Alt+[ / Alt+O.
|
|
expect(decode('\x1b[A')).toEqual([{ type: 'key', name: 'up' }]);
|
|
expect(decode('\x1bOA')).toEqual([{ type: 'key', name: 'up' }]);
|
|
});
|
|
|
|
it('leaves ESC ] alone, so a terminal colour reply is never read as a chord', () => {
|
|
// OSC introducer: decoded as Escape then `]`, exactly as before.
|
|
expect(decode('\x1b]')).toEqual([{ type: 'escape' }, { type: 'char', value: ']' }]);
|
|
});
|
|
|
|
it('keeps a lone ESC held, which is what separates it from a chord', () => {
|
|
const parser = createKeyParser();
|
|
expect(parser.feed('\x1b')).toEqual([]);
|
|
expect(parser.flush()).toEqual([{ type: 'escape' }]);
|
|
});
|
|
|
|
it('decodes a chord torn across two reads as Escape then the character', () => {
|
|
// The unavoidable ambiguity, resolved the standard way: same read = chord.
|
|
const parser = createKeyParser();
|
|
expect(parser.feed('\x1b')).toEqual([]);
|
|
expect(parser.flush()).toEqual([{ type: 'escape' }]);
|
|
expect(parser.feed('1')).toEqual([{ type: 'char', value: '1' }]);
|
|
});
|
|
});
|