import { beforeEach, describe, expect, it } from "./suite.ts"; import { createInput, type Input } from "../input.ts"; import { detectTerminal } from "../terminfo.ts"; import { CLAYTERM_16, CLAYTERM_TC } from "./fixtures.ts"; function bytes(...values: number[]): Uint8Array { return new Uint8Array(values); } function str(s: string): Uint8Array { return new TextEncoder().encode(s); } describe("input", () => { let input: Input; beforeEach(async () => { input = await createInput({ escLatency: 25 }); }); describe("printable ASCII", () => { it("parses a single character", () => { let result = input.scan(bytes(0x61)); // 'a' expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "a" }); }); it("parses multiple characters", () => { let result = input.scan(str("hello")); expect(result.events.length).toBe(5); expect(result.events[0]).toMatchObject({ type: "keydown", key: "h" }); expect(result.events[4]).toMatchObject({ type: "keydown", key: "o" }); }); }); describe("control characters", () => { it("parses Ctrl+C", () => { let result = input.scan(bytes(0x03)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "c", ctrl: true, }); }); it("parses Tab without ctrl modifier", () => { let result = input.scan(bytes(0x09)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "Tab" }); }); it("parses Enter without ctrl modifier", () => { let result = input.scan(bytes(0x0d)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "Enter" }); }); }); describe("backspace", () => { it("parses 0x7f as backspace", () => { let result = input.scan(bytes(0x7f)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "Backspace", }); }); }); describe("arrow keys", () => { it("parses arrow up (CSI)", () => { let result = input.scan(bytes(0x1b, 0x5b, 0x41)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "ArrowUp", }); }); it("parses arrow down (SS3)", () => { let result = input.scan(bytes(0x1b, 0x4f, 0x42)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "ArrowDown", }); }); }); describe("modifier combos", () => { it("parses Ctrl+Arrow Up", () => { let result = input.scan(bytes(0x1b, 0x5b, 0x31, 0x3b, 0x35, 0x41)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "ArrowUp", ctrl: true, }); }); it("parses Shift+Arrow Up", () => { let result = input.scan(bytes(0x1b, 0x5b, 0x31, 0x3b, 0x32, 0x41)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "ArrowUp", shift: true, }); }); it("parses Ctrl+Alt+Shift combo", () => { let result = input.scan(bytes(0x1b, 0x5b, 0x31, 0x3b, 0x38, 0x41)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "ArrowUp", ctrl: true, alt: true, shift: true, }); }); }); describe("function keys", () => { it("parses F1 (SS3)", () => { let result = input.scan(bytes(0x1b, 0x4f, 0x50)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "F1" }); }); it("parses F5 (CSI ~)", () => { let result = input.scan(str("\x1b[15~")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "F5" }); }); }); describe("navigation keys", () => { it("parses Home", () => { let result = input.scan(bytes(0x1b, 0x4f, 0x48)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "Home" }); }); it("parses End", () => { let result = input.scan(bytes(0x1b, 0x4f, 0x46)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "End" }); }); it("parses Delete", () => { let result = input.scan(str("\x1b[3~")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "Delete", }); }); it("parses PgDn with Ctrl", () => { let result = input.scan(str("\x1b[6;5~")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "PageDown", ctrl: true, }); }); }); describe("mouse", () => { it("parses SGR mouse press", () => { let result = input.scan(str("\x1b[<0;35;12M")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "mousedown", button: "left", x: 34, y: 11, }); }); it("parses SGR left release", () => { let result = input.scan(str("\x1b[<0;10;5m")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "mouseup", button: "left", x: 9, y: 4, }); }); it("parses SGR right release", () => { let result = input.scan(str("\x1b[<2;10;5m")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "mouseup", button: "right", x: 9, y: 4, }); }); it("parses SGR middle release", () => { let result = input.scan(str("\x1b[<1;10;5m")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "mouseup", button: "middle", x: 9, y: 4, }); }); it("parses VT200 release", () => { // button 3 = release (0x23 = 3 + 0x20), x=10 (0x2b), y=5 (0x26) let result = input.scan(bytes(0x1b, 0x5b, 0x4d, 0x23, 0x2b, 0x26)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "mouseup", button: "release", x: 10, y: 5, }); }); it("parses SGR wheel up", () => { let result = input.scan(str("\x1b[<64;15;20M")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "wheel", direction: "up", x: 14, y: 19, }); }); it("parses SGR wheel down", () => { let result = input.scan(str("\x1b[<65;15;20M")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "wheel", direction: "down", x: 14, y: 19, }); }); it("parses VT200 mouse", () => { // \x1b[M + button(0+0x20=0x20) + x(10+0x21=0x2b) + y(5+0x21=0x26) let result = input.scan(bytes(0x1b, 0x5b, 0x4d, 0x20, 0x2b, 0x26)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "mousedown", button: "left", x: 10, y: 5, }); }); it("parses SGR mouse press with shift", () => { // btn=4 (shift bit) => left + shift let result = input.scan(str("\x1b[<4;10;5M")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "mousedown", button: "left", x: 9, y: 4, shift: true, }); }); it("parses SGR mouse press with ctrl", () => { // btn=16 (ctrl bit) => left + ctrl let result = input.scan(str("\x1b[<16;10;5M")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "mousedown", button: "left", x: 9, y: 4, ctrl: true, }); }); it("parses SGR mouse press with alt", () => { // btn=8 (alt bit) => left + alt let result = input.scan(str("\x1b[<8;10;5M")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "mousedown", button: "left", x: 9, y: 4, alt: true, }); }); it("parses SGR mouse press with ctrl+shift", () => { // btn=20 (ctrl=16 + shift=4) => left + ctrl + shift let result = input.scan(str("\x1b[<20;10;5M")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "mousedown", button: "left", x: 9, y: 4, ctrl: true, shift: true, }); }); }); describe("ESC timeout", () => { it("returns pending for lone ESC", () => { let result = input.scan(bytes(0x1b)); expect(result.events.length).toBe(0); expect(result.pending).toBeDefined(); expect(result.pending!.delay).toBe(25); }); it("resolves ESC after timeout", async () => { input.scan(bytes(0x1b)); await new Promise((r) => setTimeout(r, 30)); let result = input.scan(new Uint8Array(0)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "Escape", }); expect(result.pending).toBeUndefined(); }); it("resolves as escape sequence when more bytes arrive", () => { let r1 = input.scan(bytes(0x1b)); expect(r1.events.length).toBe(0); let r2 = input.scan(bytes(0x5b, 0x41)); expect(r2.events.length).toBe(1); expect(r2.events[0]).toMatchObject({ type: "keydown", key: "ArrowUp" }); expect(r2.pending).toBeUndefined(); }); it("resolves pending ESC as Alt modifier when plain byte arrives", () => { let r1 = input.scan(bytes(0x1b)); expect(r1.events.length).toBe(0); expect(r1.pending).toBeDefined(); // "x" arrives — ESC + x = Alt+x let r2 = input.scan(bytes(0x78)); // 'x' expect(r2.events.length).toBe(1); expect(r2.events[0]).toMatchObject({ type: "keydown", key: "x", alt: true, }); expect(r2.pending).toBeUndefined(); }); it("completes pending ESC sequence and parses trailing bytes", () => { let r1 = input.scan(bytes(0x1b)); expect(r1.events.length).toBe(0); expect(r1.pending).toBeDefined(); // rest of ArrowUp + "hi" let r2 = input.scan(bytes(0x5b, 0x41, 0x68, 0x69)); expect(r2.events.length).toBe(3); expect(r2.events[0]).toMatchObject({ type: "keydown", key: "ArrowUp" }); expect(r2.events[1]).toMatchObject({ type: "keydown", key: "h" }); expect(r2.events[2]).toMatchObject({ type: "keydown", key: "i" }); expect(r2.pending).toBeUndefined(); }); }); describe("ESC + introducer timeout", () => { for (let introducer of ["[", "O", "]", "P", "_"]) { it(`reports ESC ${introducer} as pending`, () => { let result = input.scan(str(`\x1b${introducer}`)); expect(result.events).toEqual([]); expect(result.pending?.delay).toBe(25); }); it(`resolves ESC ${introducer} to Alt+${introducer} after the timeout`, async () => { input.scan(str(`\x1b${introducer}`)); await new Promise((r) => setTimeout(r, 30)); let result = input.scan(); expect(result.events).toEqual([ expect.objectContaining({ type: "keydown", key: introducer, alt: true, }), ]); expect(result.pending).toBeUndefined(); }); } it("stays pending when the introducer arrives after a lone ESC", () => { input.scan(bytes(0x1b)); let result = input.scan(str("P")); expect(result.events).toEqual([]); expect(result.pending?.delay).toBe(25); }); it("parses the sequence when more bytes arrive before the timeout", () => { input.scan(str("\x1b]")); let result = input.scan(str("11;rgb:00/00/00\x07")); expect(result.events).toEqual([ { type: "capability", key: "background-color", value: { r: 0, g: 0, b: 0 }, }, ]); expect(result.pending).toBeUndefined(); }); }); describe("Alt combinations", () => { it("parses Alt+a as unrecognized ESC sequence", () => { let result = input.scan(bytes(0x1b, 0x61)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "a", alt: true, }); }); }); describe("multi-event burst", () => { it("parses multiple escape sequences in one call", () => { // Arrow Up + Arrow Down + "hi" let result = input.scan( bytes(0x1b, 0x5b, 0x41, 0x1b, 0x5b, 0x42, 0x68, 0x69), ); expect(result.events.length).toBe(4); expect(result.events[0]).toMatchObject({ type: "keydown", key: "ArrowUp", }); expect(result.events[1]).toMatchObject({ type: "keydown", key: "ArrowDown", }); expect(result.events[2]).toMatchObject({ type: "keydown", key: "h" }); expect(result.events[3]).toMatchObject({ type: "keydown", key: "i" }); }); }); describe("partial sequences", () => { it("buffers partial escape sequence", () => { let r1 = input.scan(bytes(0x1b, 0x5b)); expect(r1.events.length).toBe(0); let r2 = input.scan(bytes(0x41)); expect(r2.events.length).toBe(1); expect(r2.events[0]).toMatchObject({ type: "keydown", key: "ArrowUp" }); }); }); describe("Kitty keyboard protocol (CSI u)", () => { it("parses character with alt modifier", () => { let result = input.scan(str("\x1b[97;3u")); // a + Alt expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "a", alt: true, }); }); it("parses Tab with alt modifier", () => { let result = input.scan(str("\x1b[9;3u")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "Tab", alt: true, }); }); it("parses Enter with ctrl modifier", () => { let result = input.scan(str("\x1b[13;5u")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "Enter", ctrl: true, }); }); it("parses Escape with shift modifier", () => { let result = input.scan(str("\x1b[27;2u")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "Escape", shift: true, }); }); it("parses Backspace without modifiers", () => { let result = input.scan(str("\x1b[127u")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "Backspace", }); }); it("parses combined modifiers", () => { let result = input.scan(str("\x1b[105;7u")); // i + Ctrl+Alt expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "i", ctrl: true, alt: true, }); }); it("parses F1 functional key codepoint", () => { let result = input.scan(str("\x1b[57376;2u")); // F1 + Shift expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "F1", shift: true, }); }); it("parses arrow key codepoint", () => { let result = input.scan(str("\x1b[57352;5u")); // ArrowUp + Ctrl expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "ArrowUp", ctrl: true, }); }); }); describe("Kitty action types", () => { it("parses press as keydown", () => { let result = input.scan(str("\x1b[97;1:1u")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "a" }); }); it("parses repeat as keyrepeat", () => { let result = input.scan(str("\x1b[97;1:2u")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keyrepeat", key: "a" }); }); it("parses release as keyup", () => { let result = input.scan(str("\x1b[97;1:3u")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keyup", key: "a" }); }); it("parses F1 press with shift", () => { let result = input.scan(str("\x1b[57376;2:1u")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "F1", shift: true, }); }); }); describe("Kitty alternate keys", () => { it("parses shifted key", () => { let result = input.scan(str("\x1b[97:65;6u")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "a", ctrl: true, shift: true, shifted: "A", }); }); it("parses shifted and base keys", () => { let result = input.scan(str("\x1b[97:65:97;3:1u")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "a", code: "a", alt: true, shifted: "A", }); }); it("parses empty shifted with base key as code", () => { let result = input.scan(str("\x1b[1057::99;5u")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "\u0421", code: "c", ctrl: true, }); }); }); describe("Kitty associated text", () => { it("parses single codepoint text", () => { let result = input.scan(str("\x1b[97;;97u")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "a", text: "a", }); }); it("parses text with shift modifier", () => { let result = input.scan(str("\x1b[97;2;65u")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "a", shift: true, text: "A", }); }); it("parses IME text event", () => { let result = input.scan(str("\x1b[0;;228u")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "\0", text: "\u00e4", }); }); it("parses multi-codepoint text", () => { let result = input.scan(str("\x1b[0;;4354:4449:4523u")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "\0", text: "\u1102\u1161\u11AB", }); }); it("keyup has no text", () => { let result = input.scan(str("\x1b[97;1:3u")); expect(result.events.length).toBe(1); let ev = result.events[0]; expect(ev).toMatchObject({ type: "keyup", key: "a" }); expect("text" in ev).toBe(false); expect("shifted" in ev).toBe(false); expect("base" in ev).toBe(false); }); }); describe("Kitty legacy CSI sequences", () => { it("parses arrow up with action", () => { let result = input.scan(str("\x1b[1;1:1A")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "ArrowUp", code: "ArrowUp", }); }); it("parses arrow up release", () => { let result = input.scan(str("\x1b[1;1:3A")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keyup", key: "ArrowUp", code: "ArrowUp", }); }); it("parses arrow with shift modifier", () => { let result = input.scan(str("\x1b[1;2:1D")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "ArrowLeft", code: "ArrowLeft", shift: true, }); }); it("parses plain arrow without action", () => { let result = input.scan(str("\x1b[1;1A")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "ArrowUp", code: "ArrowUp", }); }); it("parses delete with action", () => { let result = input.scan(str("\x1b[3;1:1~")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "Delete", code: "Delete", }); }); it("parses F1 with action", () => { let result = input.scan(str("\x1b[1;1:1P")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "F1", code: "F1", }); }); }); describe("cursor position (DSR response)", () => { it("parses cursor position report", () => { let result = input.scan(str("\x1b[24;80R")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "cursor", row: 24, column: 80, }); }); it("parses cursor at origin", () => { let result = input.scan(str("\x1b[1;1R")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "cursor", row: 1, column: 1, }); }); it("parses cursor interleaved with other input", () => { let result = input.scan(str("a\x1b[10;5Rb")); expect(result.events.length).toBe(3); expect(result.events[0]).toMatchObject({ type: "keydown", key: "a" }); expect(result.events[1]).toMatchObject({ type: "cursor", row: 10, column: 5, }); expect(result.events[2]).toMatchObject({ type: "keydown", key: "b" }); }); }); describe("UTF-8", () => { it("parses 2-byte UTF-8 (é)", () => { let result = input.scan(bytes(0xc3, 0xa9)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "\u00e9", }); }); it("parses 3-byte UTF-8 (中)", () => { let result = input.scan(bytes(0xe4, 0xb8, 0xad)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "\u4e2d", }); }); it("parses 4-byte UTF-8 (emoji 🎉)", () => { let result = input.scan(bytes(0xf0, 0x9f, 0x8e, 0x89)); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "\u{1f389}", }); }); }); }); function terminfoEntry(strings: Record): Uint8Array { let names = str("synthetic|generated key-length test entry\0"); let count = Math.max(...Object.keys(strings).map(Number)) + 1; let offsets = new Int16Array(count).fill(-1); let table: number[] = []; for (let [index, value] of Object.entries(strings)) { offsets[Number(index)] = table.length; table.push(...str(value), 0); } let header = new Int16Array([0o432, names.length, 0, 0, count, table.length]); let pad = names.length % 2; let out = new Uint8Array(12 + names.length + pad + count * 2 + table.length); let view = new DataView(out.buffer); header.forEach((v, i) => view.setInt16(i * 2, v, true)); out.set(names, 12); let at = 12 + names.length + pad; offsets.forEach((v, i) => view.setInt16(at + i * 2, v, true)); out.set(table, at + count * 2); return out; } describe("terminfo integration", () => { async function withTerminfo() { let terminfo = await detectTerminal({ env: {}, entry: CLAYTERM_TC }); let input = await createInput({ terminfo }); return { terminfo, input }; } describe("key sequences from terminfo", () => { it("decodes a terminfo-specific arrow sequence", async () => { // clayterm-tc defines kcuu1=\EOZ let { input } = await withTerminfo(); let result = input.scan(str("\x1bOZ")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "ArrowUp", }); }); it("decodes a terminfo-specific function key", async () => { // clayterm-tc defines kf5=\E[99~ let { input } = await withTerminfo(); let result = input.scan(str("\x1b[99~")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "F5" }); }); it("accepts a terminfo key sequence of exactly 16 bytes", async () => { let seq = "\x1bO" + "z".repeat(13) + "Z"; let terminfo = await detectTerminal({ env: {}, entry: terminfoEntry({ 87: seq }), }); let input = await createInput({ terminfo }); expect(input.scan(str(seq)).events).toEqual([ expect.objectContaining({ type: "keydown", key: "ArrowUp" }), ]); }); it("ignores terminfo key sequences longer than 16 bytes", async () => { let seq = "\x1bO" + "z".repeat(14) + "Z"; let terminfo = await detectTerminal({ env: {}, entry: terminfoEntry({ 87: seq }), }); let input = await createInput({ terminfo }); let events = input.scan(str(seq)).events; expect(events.some((e) => "key" in e && e.key === "ArrowUp")).toBe(false); }); it("keeps the default keys when every terminfo key is oversized", async () => { let caps = [ 59, 61, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 79, 81, 82, 83, 87, 148, 164, 216, 217, ]; let strings: Record = {}; caps.forEach((cap, i) => { strings[cap] = "\x1bX" + String.fromCharCode(0x61 + i) + "y".repeat(997); }); let terminfo = await detectTerminal({ env: {}, entry: terminfoEntry(strings), }); expect(terminfo.keys.byteLength).toBeGreaterThan(0); let input = await createInput({ terminfo }); for ( let [seq, key] of [ ["\x1bOA", "ArrowUp"], ["\x1bOP", "F1"], ["\x1bOH", "Home"], ["\x1bOF", "End"], ["\x1b[3~", "Delete"], ] ) { expect(input.scan(str(seq)).events).toEqual([ expect.objectContaining({ type: "keydown", key }), ]); } }); it("fits a key table larger than the initial wasm memory", async () => { let terminfo = await detectTerminal({ env: {}, entry: CLAYTERM_TC }); let keys = new Uint8Array(300_000); keys.set(terminfo.keys); let input = await createInput({ terminfo: { ...terminfo, keys } }); let result = input.scan(str("\x1bOZ")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "ArrowUp", }); }); it("keeps the xterm defaults registered", async () => { let { input } = await withTerminfo(); let result = input.scan(str("\x1bOA")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "keydown", key: "ArrowUp", }); }); }); describe("query response recognition", () => { it("surfaces an OSC 10 foreground report as CapabilityEvent", async () => { let { input } = await withTerminfo(); let result = input.scan(str("\x1b]10;rgb:ffff/ffff/ffff\x07")); expect(result.events.length).toBe(1); expect(result.events[0]).toEqual({ type: "capability", key: "foreground-color", value: { r: 255, g: 255, b: 255 }, }); }); it("surfaces an OSC 11 background report (ST-terminated)", async () => { let { input } = await withTerminfo(); let result = input.scan(str("\x1b]11;rgb:1e1e/2a2a/3b3b\x1b\\")); expect(result.events.length).toBe(1); expect(result.events[0]).toEqual({ type: "capability", key: "background-color", value: { r: 0x1e, g: 0x2a, b: 0x3b }, }); }); it("surfaces an OSC 12 cursor color report", async () => { let { input } = await withTerminfo(); let result = input.scan(str("\x1b]12;#ff8800\x1b\\")); expect(result.events.length).toBe(1); expect(result.events[0]).toEqual({ type: "capability", key: "cursor-color", value: { r: 0xff, g: 0x88, b: 0 }, }); }); it("surfaces per-field events from an OSC 21 kitty color report", async () => { let { input } = await withTerminfo(); let result = input.scan( str("\x1b]21;foreground=rgb:ff/00/00;background=\x1b\\"), ); expect(result.events.length).toBe(1); expect(result.events[0]).toEqual({ type: "capability", key: "foreground-color", value: { r: 255, g: 0, b: 0 }, }); }); it("surfaces an OSC 22 pointer shape report", async () => { let { input } = await withTerminfo(); let result = input.scan(str("\x1b]22;default\x1b\\")); expect(result.events.length).toBe(1); expect(result.events[0]).toEqual({ type: "capability", key: "pointer-shape", value: true, }); }); it("surfaces a BEL-terminated OSC 22 pointer shape report", async () => { let { input } = await withTerminfo(); let result = input.scan(str("\x1b]22;pointer\x07")); expect(result.events).toEqual([ { type: "capability", key: "pointer-shape", value: true }, ]); }); it("surfaces an OSC 22 empty-stack report", async () => { let { input } = await withTerminfo(); let result = input.scan(str("\x1b]22;0\x1b\\")); expect(result.events).toEqual([ { type: "capability", key: "pointer-shape", value: true }, ]); }); it("buffers an OSC 22 report fed one byte at a time", async () => { let { input } = await withTerminfo(); let reply = str("\x1b]22;default\x1b\\"); for (let i = 0; i < reply.length - 1; i++) { expect(input.scan(reply.subarray(i, i + 1)).events).toEqual([]); } expect(input.scan(reply.subarray(reply.length - 1)).events).toEqual([ { type: "capability", key: "pointer-shape", value: true }, ]); }); it("rejects an OSC 22 report whose ST is split from its ESC by a non-backslash", async () => { let { input } = await withTerminfo(); let result = input.scan(str("\x1b]22;default\x1bx")); expect(result.events.some((e) => e.type === "capability")).toBe(false); }); it("bails on an OSC number longer than any it accepts without overflowing", async () => { let { input } = await withTerminfo(); let digits = "9".repeat(64); let result = input.scan(str(`\x1b]${digits};pointer\x07`)); expect(result.events.some((e) => e.type === "capability")).toBe(false); expect(input.scan(str("\x1b]22;default\x1b\\")).events).toEqual([ { type: "capability", key: "pointer-shape", value: true }, ]); }); it("does not read OSC 220 as OSC 22", async () => { let { input } = await withTerminfo(); let result = input.scan(str("\x1b]220;pointer\x07")); expect(result.events.some((e) => e.type === "capability")).toBe(false); }); it("drops an OSC 22 report whose payload exceeds the response bound", async () => { let { input } = await withTerminfo(); let events = input.scan(str(`\x1b]22;${"x".repeat(2048)}\x07`)).events; expect(events.some((e) => e.type === "capability")).toBe(false); events = input.scan(str("\x1b]22;default\x07")).events; for (let i = 0; i < 32 && events.length > 0; i++) { let last = events[events.length - 1]; if (last.type === "capability") break; events = input.scan().events; } expect(events[events.length - 1]).toEqual({ type: "capability", key: "pointer-shape", value: true, }); }); it("surfaces truecolor colordepth from a valid XTGETTCAP reply", async () => { let terminfo = await detectTerminal({ env: {}, entry: CLAYTERM_16 }); let input = await createInput({ terminfo }); let result = input.scan(str("\x1bP1+r524742\x1b\\")); expect(result.events.length).toBe(1); expect(result.events[0]).toEqual({ type: "capability", key: "colordepth", value: "truecolor", }); }); it("surfaces 256-color colordepth denial from an invalid XTGETTCAP reply", async () => { let { input } = await withTerminfo(); let result = input.scan(str("\x1bP0+r\x1b\\")); expect(result.events.length).toBe(1); expect(result.events[0]).toEqual({ type: "capability", key: "colordepth", value: "256", }); }); it("surfaces 16-color colordepth denial when colors <= 16", async () => { let terminfo = await detectTerminal({ env: {}, entry: CLAYTERM_16 }); let input = await createInput({ terminfo }); let result = input.scan(str("\x1bP0+r\x1b\\")); expect(result.events.length).toBe(1); expect(result.events[0]).toEqual({ type: "capability", key: "colordepth", value: "16", }); }); it("preserves truecolor when RGB succeeds and Tc fails, in either order", async () => { let truecolor = { type: "capability", key: "colordepth", value: "truecolor", }; let rgbOk = "\x1bP1+r524742\x1b\\"; let tcDenied = "\x1bP0+r5463\x1b\\"; let { input: together } = await withTerminfo(); expect(together.scan(str(rgbOk + tcDenied)).events).toEqual([truecolor]); let { input: reversed } = await withTerminfo(); expect(reversed.scan(str(tcDenied + rgbOk)).events).toEqual([truecolor]); let { input: split } = await withTerminfo(); expect(split.scan(str(rgbOk)).events).toEqual([truecolor]); expect(split.scan(str(tcDenied)).events).toEqual([]); let { input: splitReversed } = await withTerminfo(); expect(splitReversed.scan(str(tcDenied)).events).toEqual([]); expect(splitReversed.scan(str(rgbOk)).events).toEqual([truecolor]); }); it("emits the static color tier only when both RGB and Tc are denied", async () => { for ( let [entry, tier] of [[CLAYTERM_TC, "256"], [ CLAYTERM_16, "16", ]] as const ) { let terminfo = await detectTerminal({ env: {}, entry }); let input = await createInput({ terminfo }); expect(input.scan(str("\x1bP0+r524742\x1b\\")).events).toEqual([]); expect(input.scan(str("\x1bP0+r5463\x1b\\")).events).toEqual([ { type: "capability", key: "colordepth", value: tier }, ]); } }); it("starts a fresh colordepth tally after the DA1 fence", async () => { let { input } = await withTerminfo(); let result = input.scan( str( "\x1bP1+r524742\x1b\\\x1b[?65;1c" + "\x1bP0+r524742\x1b\\\x1bP0+r5463\x1b\\", ), ); expect(result.events).toEqual([ { type: "capability", key: "colordepth", value: "truecolor" }, { type: "capability", key: "colordepth", value: "256" }, ]); }); it("surfaces synchronized output from a DECRPM confirm report", async () => { let { input } = await withTerminfo(); let result = input.scan(str("\x1b[?2026;2$y")); expect(result.events.length).toBe(1); expect(result.events[0]).toEqual({ type: "capability", key: "sync-output", value: true, }); }); it("surfaces sync-output=false from a not-recognized DECRPM report", async () => { let { input } = await withTerminfo(); let result = input.scan(str("\x1b[?2026;0$y")); expect(result.events.length).toBe(1); expect(result.events[0]).toEqual({ type: "capability", key: "sync-output", value: false, }); }); it("surfaces kitty-keyboard from a flags report", async () => { let { input } = await withTerminfo(); let result = input.scan(str("\x1b[?1u")); expect(result.events.length).toBe(1); expect(result.events[0]).toEqual({ type: "capability", key: "kitty-keyboard", value: true, }); }); it("surfaces kitty-graphics=true from an OK APC reply", async () => { let { input } = await withTerminfo(); let result = input.scan(str("\x1b_Gi=31;OK\x1b\\")); expect(result.events.length).toBe(1); expect(result.events[0]).toEqual({ type: "capability", key: "kitty-graphics", value: true, }); }); it("surfaces kitty-graphics=false from an error APC reply", async () => { let { input } = await withTerminfo(); let result = input.scan(str("\x1b_Gi=31;ENOTSUPPORTED:x\x1b\\")); expect(result.events.length).toBe(1); expect(result.events[0]).toEqual({ type: "capability", key: "kitty-graphics", value: false, }); }); it("consumes a DA1 report without emitting any event", async () => { let { input } = await withTerminfo(); let result = input.scan(str("\x1b[?65;1;9c")); expect(result.events).toEqual([]); }); it("interleaves CapabilityEvents with key events correctly", async () => { let { input } = await withTerminfo(); let result = input.scan( str("a\x1b]11;rgb:0000/0000/0000\x1b\\b"), ); expect(result.events.length).toBe(3); expect(result.events[0]).toMatchObject({ type: "keydown", key: "a" }); expect(result.events[1]).toEqual({ type: "capability", key: "background-color", value: { r: 0, g: 0, b: 0 }, }); expect(result.events[2]).toMatchObject({ type: "keydown", key: "b" }); }); it("buffers a response split across scans", async () => { let { input } = await withTerminfo(); let first = input.scan(str("\x1b]11;rgb:12")); expect(first.events).toEqual([]); let second = input.scan(str("34/5678/9abc\x1b\\")); expect(second.events.length).toBe(1); expect(second.events[0]).toEqual({ type: "capability", key: "background-color", value: { r: 0x12, g: 0x56, b: 0x9a }, }); }); it("recognizes every supported response split at every byte boundary", async () => { let cap = (key: string, value: unknown) => ({ type: "capability", key, value, }); let white = { r: 255, g: 255, b: 255 }; let cases: [string, unknown[]][] = [ ["\x1b]10;rgb:ffff/ffff/ffff\x07", [cap("foreground-color", white)]], ["\x1b]11;rgb:ff/ff/ff\x1b\\", [cap("background-color", white)]], ["\x1b]12;#ffffff\x1b\\", [cap("cursor-color", white)]], [ "\x1b]21;foreground=#ffffff;cursor=rgb:f/f/f\x1b\\", [cap("foreground-color", white), cap("cursor-color", white)], ], ["\x1b]22;default\x1b\\", [cap("pointer-shape", true)]], ["\x1bP1+r524742=382F382F38\x1b\\", [cap("colordepth", "truecolor")]], ["\x1bP0+r\x1b\\", [cap("colordepth", "256")]], ["\x1b[?2026;2$y", [cap("sync-output", true)]], ["\x1b[?1u", [cap("kitty-keyboard", true)]], ["\x1b_Gi=31;OK\x1b\\", [cap("kitty-graphics", true)]], ["\x1b[?65;1;9c", []], ]; let x = expect.objectContaining({ type: "keydown", key: "x" }); let y = expect.objectContaining({ type: "keydown", key: "y" }); for (let [response, expected] of cases) { let data = str("x" + response + "y"); for (let at = 1; at < data.length; at++) { let input = await createInput({ escLatency: 60_000 }); let events = [ ...input.scan(data.subarray(0, at)).events, ...input.scan(data.subarray(at)).events, ]; expect({ response, at, events }).toEqual({ response, at, events: [x, ...expected, y], }); } let input = await createInput({ escLatency: 60_000 }); let events = [...data].flatMap((b) => input.scan(bytes(b)).events); expect({ response, events }).toEqual({ response, events: [x, ...expected, y], }); } }); it("surfaces CapabilityEvents on a standalone parser without terminfo", async () => { let input = await createInput(); let result = input.scan(str("\x1b]11;rgb:0000/0000/0000\x1b\\")); expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "capability", key: "background-color", }); }); }); describe("response recovery", () => { function drain(input: Input, data: Uint8Array) { let events = input.scan(data).events; for ( let more = input.scan().events; more.length; more = input.scan().events ) { events.push(...more); } return events; } it("rejects malformed color payloads and preserves the following key", async () => { for ( let payload of [ "\x1b]10;#fffjunk\x07", "\x1b]11;rgb:ff/ff/ffzz\x1b\\", "\x1b]12;rgb:ff/ff\x07", "\x1b]11;rgba:ff/ff/ff/\x07", "\x1b]21;foreground=#fffjunk\x1b\\", ] ) { let input = await createInput(); let events = input.scan(str(payload + "a")).events; expect(events).toEqual([ expect.objectContaining({ type: "keydown", key: "a" }), ]); } }); it("accepts an rgba payload and ignores its alpha channel", async () => { let input = await createInput(); expect(input.scan(str("\x1b]11;rgba:10/20/30/ff\x07")).events).toEqual([ { type: "capability", key: "background-color", value: { r: 0x10, g: 0x20, b: 0x30 }, }, ]); }); it("ends a cut-off response at a control byte and keeps the typed keys", async () => { let truncated = [ "\x1b]11;rgb:ff", "\x1b]21;foreground=#ff", "\x1b]22;def", "\x1bP1+r5247", "\x1b_Gi=31", ]; let tails = ["\rab", "\nab", "\x7fab", "\x03", "\x1b[Aab", "\x1bxab"]; for (let response of truncated) { for (let tail of tails) { let reference = await createInput(); let expected = reference.scan(str(tail)).events; let together = await createInput(); expect({ response, tail, events: together.scan(str(response + tail)).events, }) .toEqual({ response, tail, events: expected }); let split = await createInput(); expect(split.scan(str(response)).events).toEqual([]); expect({ response, tail, events: split.scan(str(tail)).events }) .toEqual({ response, tail, events: expected }); } } }); it("parses a complete response that follows a cut-off one", async () => { let input = await createInput(); let result = input.scan( str("\x1b]11;rgb:ff\x1b]10;rgb:00/00/00\x07"), ); expect(result.events).toEqual([ { type: "capability", key: "foreground-color", value: { r: 0, g: 0, b: 0 }, }, ]); }); it("recovers from oversized OSC, DCS, and APC responses", async () => { let filler = "x".repeat(2000); for ( let response of [ `\x1b]11;${filler}\x07`, `\x1bP1+r${filler}\x1b\\`, `\x1b_G${filler}\x1b\\`, ] ) { let input = await createInput(); let events = drain(input, str(response)); events.push(...drain(input, str("a"))); expect(events.filter((e) => e.type === "capability")).toEqual([]); expect(events.at(-1)).toMatchObject({ type: "keydown", key: "a" }); } }); it("recovers when an incomplete private CSI response fills the input buffer", async () => { let input = await createInput(); let events = drain(input, str("\x1b[?" + ";".repeat(4093))); events.push(...drain(input, str("a"))); expect(events.at(-1)).toMatchObject({ type: "keydown", key: "a" }); }); it("does not wrap an overflowing private CSI parameter into a known mode", async () => { let input = await createInput(); // 4294969322 = 2026 + 2^32 let result = input.scan(str("\x1b[?4294969322;1$ya")); expect(result.events.filter((e) => e.type === "capability")).toEqual([]); expect(result.events.at(-1)).toMatchObject({ type: "keydown", key: "a" }); }); }); });