Files
Codeman/test/tui/tui-keys.test.ts
Codeman maintainer 95a1f540b5 feat(tui): switch sessions with the web UI's shortcuts
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.
2026-08-22 14:13:58 +02:00

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' }]);
});
});