[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
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481import { 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 sync-output=true from a value-carrying XTGETTCAP Sync reply", async () => { let { input } = await withTerminfo(); let result = input.scan(str("\x1bP1+r53796e63=1b5b3f3230323668\x1b\\")); expect(result.events).toEqual([ { type: "capability", key: "sync-output", value: true }, ]); });
it("surfaces sync-output=false from an invalid XTGETTCAP Sync reply", async () => { let { input } = await withTerminfo(); let result = input.scan(str("\x1bP0+r53796e63\x1b\\")); expect(result.events).toEqual([ { type: "capability", key: "sync-output", value: false }, ]); });
it("surfaces sync-output=false from a Sync reply without a value", async () => { let { input } = await withTerminfo(); let result = input.scan(str("\x1bP1+r53796e63\x1b\\")); expect(result.events).toEqual([ { type: "capability", key: "sync-output", value: false }, ]); });
it("accepts a BEL-terminated XTGETTCAP Sync reply", async () => { let { input } = await withTerminfo(); let result = input.scan(str("\x1bP1+r53796e63=31\x07")); expect(result.events).toEqual([ { type: "capability", key: "sync-output", value: true }, ]); });
it("does not let a Sync denial disturb the colordepth tally", async () => { let { input } = await withTerminfo(); let result = input.scan( str("\x1bP0+r524742\x1b\\\x1bP0+r53796e63\x1b\\"), ); expect(result.events).toEqual([ { type: "capability", key: "sync-output", value: false }, ]); });
it("keeps the colordepth denial working after a Sync reply", async () => { let { input } = await withTerminfo(); let result = input.scan( str("\x1bP1+r53796e63=31\x1b\\\x1bP0+r\x1b\\"), ); expect(result.events).toEqual([ { type: "capability", key: "sync-output", value: true }, { 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")]], [ "\x1bP1+r53796e63=1b5b3f3230323668\x1b\\", [cap("sync-output", true)], ], ["\x1bP0+r53796e63\x1b\\", [cap("sync-output", false)]], ["\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" }); }); });});