[READ-ONLY] Mirror of https://github.com/bombshell-dev/tty. Platform independent 2D layout engine for terminal applications based on Clay bomb.sh/docs/tty
Something went wrong. Try again.
TypeScript
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423import { 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<number, string>): 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<number, string> = {}; 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" }); }); });});