Something went wrong. Try again.
fork of prime agent with changes i want but are not upstreamable probqbly (and some fixes)
Something went wrong. Try again.
20 kB · 646 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647import assert from "node:assert";import { beforeEach, describe, it } from "node:test";import { isMouseSequence, isWheelDown, isWheelUp, parseSgrMouseEvent } from "../src/mouse.js";import { StdinBuffer } from "../src/stdin-buffer.js";import { getCellDimensions, resetCapabilitiesCache, setCellDimensions } from "../src/terminal-image.js";import { type Component, TUI } from "../src/tui.js";import { VirtualTerminal } from "./virtual-terminal.js";
describe("StdinBuffer", () => { let buffer: StdinBuffer; let emittedSequences: string[];
beforeEach(() => { buffer = new StdinBuffer({ timeout: 10 });
emittedSequences = []; buffer.on("data", (sequence) => { emittedSequences.push(sequence); }); });
function processInput(data: string | Buffer): void { buffer.process(data); }
async function wait(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)); }
describe("Regular Characters", () => { it("should pass through regular characters immediately", () => { processInput("a"); assert.deepStrictEqual(emittedSequences, ["a"]); });
it("should pass through multiple regular characters", () => { processInput("abc"); assert.deepStrictEqual(emittedSequences, ["a", "b", "c"]); });
it("should handle unicode characters", () => { processInput("hello 世界"); assert.deepStrictEqual(emittedSequences, ["h", "e", "l", "l", "o", " ", "世", "界"]); }); });
describe("Complete Escape Sequences", () => { it("should pass through complete mouse SGR sequences", () => { const mouseSeq = "\x1b[<35;20;5m"; processInput(mouseSeq); assert.deepStrictEqual(emittedSequences, [mouseSeq]); });
it("should pass through complete arrow key sequences", () => { const upArrow = "\x1b[A"; processInput(upArrow); assert.deepStrictEqual(emittedSequences, [upArrow]); });
it("should pass through complete function key sequences", () => { const f1 = "\x1b[11~"; processInput(f1); assert.deepStrictEqual(emittedSequences, [f1]); });
it("should pass through meta key sequences", () => { const metaA = "\x1ba"; processInput(metaA); assert.deepStrictEqual(emittedSequences, [metaA]); });
it("should pass through SS3 sequences", () => { const ss3 = "\x1bOA"; processInput(ss3); assert.deepStrictEqual(emittedSequences, [ss3]); }); });
describe("Partial Escape Sequences", () => { it("should buffer incomplete mouse SGR sequence", async () => { processInput("\x1b"); assert.deepStrictEqual(emittedSequences, []); assert.strictEqual(buffer.getBuffer(), "\x1b");
processInput("[<35"); assert.deepStrictEqual(emittedSequences, []); assert.strictEqual(buffer.getBuffer(), "\x1b[<35");
processInput(";20;5m"); assert.deepStrictEqual(emittedSequences, ["\x1b[<35;20;5m"]); assert.strictEqual(buffer.getBuffer(), ""); });
it("should buffer incomplete CSI sequence", () => { processInput("\x1b["); assert.deepStrictEqual(emittedSequences, []);
processInput("1;"); assert.deepStrictEqual(emittedSequences, []);
processInput("5H"); assert.deepStrictEqual(emittedSequences, ["\x1b[1;5H"]); });
it("should buffer split across many chunks", () => { processInput("\x1b"); processInput("["); processInput("<"); processInput("3"); processInput("5"); processInput(";"); processInput("2"); processInput("0"); processInput(";"); processInput("5"); processInput("m");
assert.deepStrictEqual(emittedSequences, ["\x1b[<35;20;5m"]); });
it("should flush incomplete sequence after timeout", async () => { processInput("\x1b[<35"); assert.deepStrictEqual(emittedSequences, []);
await wait(15);
assert.deepStrictEqual(emittedSequences, ["\x1b[<35"]); }); });
describe("Mixed Content", () => { it("should handle characters followed by escape sequence", () => { processInput("abc\x1b[A"); assert.deepStrictEqual(emittedSequences, ["a", "b", "c", "\x1b[A"]); });
it("should handle escape sequence followed by characters", () => { processInput("\x1b[Aabc"); assert.deepStrictEqual(emittedSequences, ["\x1b[A", "a", "b", "c"]); });
it("should handle multiple complete sequences", () => { processInput("\x1b[A\x1b[B\x1b[C"); assert.deepStrictEqual(emittedSequences, ["\x1b[A", "\x1b[B", "\x1b[C"]); });
it("should handle partial sequence with preceding characters", () => { processInput("abc\x1b[<35"); assert.deepStrictEqual(emittedSequences, ["a", "b", "c"]); assert.strictEqual(buffer.getBuffer(), "\x1b[<35");
processInput(";20;5m"); assert.deepStrictEqual(emittedSequences, ["a", "b", "c", "\x1b[<35;20;5m"]); }); });
describe("Kitty Keyboard Protocol", () => { it("should handle Kitty CSI u press events", () => { processInput("\x1b[97u"); assert.deepStrictEqual(emittedSequences, ["\x1b[97u"]); });
it("should handle Kitty CSI u release events", () => { processInput("\x1b[97;1:3u"); assert.deepStrictEqual(emittedSequences, ["\x1b[97;1:3u"]); });
it("should handle batched Kitty press and release", () => { processInput("\x1b[97u\x1b[97;1:3u"); assert.deepStrictEqual(emittedSequences, ["\x1b[97u", "\x1b[97;1:3u"]); });
it("should handle multiple batched Kitty events", () => { processInput("\x1b[97u\x1b[97;1:3u\x1b[98u\x1b[98;1:3u"); assert.deepStrictEqual(emittedSequences, ["\x1b[97u", "\x1b[97;1:3u", "\x1b[98u", "\x1b[98;1:3u"]); });
it("should handle Kitty arrow keys with event type", () => { processInput("\x1b[1;1:1A"); assert.deepStrictEqual(emittedSequences, ["\x1b[1;1:1A"]); });
it("should handle Kitty functional keys with event type", () => { processInput("\x1b[3;1:3~"); assert.deepStrictEqual(emittedSequences, ["\x1b[3;1:3~"]); });
it("should handle plain characters mixed with Kitty sequences", () => { processInput("a\x1b[97;1:3u"); assert.deepStrictEqual(emittedSequences, ["a", "\x1b[97;1:3u"]); });
it("should drop raw duplicate character after matching Kitty printable sequence", () => { processInput("\x1b[224uà"); assert.deepStrictEqual(emittedSequences, ["\x1b[224u"]); });
it("should drop raw duplicate character after matching Kitty printable sequence across chunks", () => { processInput("\x1b[64u"); processInput("@"); assert.deepStrictEqual(emittedSequences, ["\x1b[64u"]); });
it("should keep non-matching plain character after Kitty printable sequence", () => { processInput("\x1b[97ub"); assert.deepStrictEqual(emittedSequences, ["\x1b[97u", "b"]); });
it("should keep raw character after modified Kitty printable sequence", () => { processInput("\x1b[64;3u@"); assert.deepStrictEqual(emittedSequences, ["\x1b[64;3u", "@"]); });
it("should handle rapid typing simulation with Kitty protocol", () => { processInput("\x1b[104u\x1b[104;1:3u\x1b[105u\x1b[105;1:3u"); assert.deepStrictEqual(emittedSequences, ["\x1b[104u", "\x1b[104;1:3u", "\x1b[105u", "\x1b[105;1:3u"]); }); });
describe("Mouse Events", () => { it("should handle mouse press event", () => { processInput("\x1b[<0;10;5M"); assert.deepStrictEqual(emittedSequences, ["\x1b[<0;10;5M"]); });
it("should handle mouse release event", () => { processInput("\x1b[<0;10;5m"); assert.deepStrictEqual(emittedSequences, ["\x1b[<0;10;5m"]); });
it("should handle mouse move event", () => { processInput("\x1b[<35;20;5m"); assert.deepStrictEqual(emittedSequences, ["\x1b[<35;20;5m"]); });
it("should handle split mouse events", () => { processInput("\x1b[<3"); processInput("5;1"); processInput("5;"); processInput("10m"); assert.deepStrictEqual(emittedSequences, ["\x1b[<35;15;10m"]); });
it("should handle multiple mouse events", () => { processInput("\x1b[<35;1;1m\x1b[<35;2;2m\x1b[<35;3;3m"); assert.deepStrictEqual(emittedSequences, ["\x1b[<35;1;1m", "\x1b[<35;2;2m", "\x1b[<35;3;3m"]); });
it("should handle old-style mouse sequence (ESC[M + 3 bytes)", () => { processInput("\x1b[M abc"); assert.deepStrictEqual(emittedSequences, ["\x1b[M ab", "c"]); });
it("should buffer incomplete old-style mouse sequence", () => { processInput("\x1b[M"); assert.strictEqual(buffer.getBuffer(), "\x1b[M");
processInput(" a"); assert.strictEqual(buffer.getBuffer(), "\x1b[M a");
processInput("b"); assert.deepStrictEqual(emittedSequences, ["\x1b[M ab"]); }); });
describe("Edge Cases", () => { it("should handle empty input", () => { processInput(""); assert.deepStrictEqual(emittedSequences, [""]); });
it("should handle lone escape character with timeout", async () => { processInput("\x1b"); assert.deepStrictEqual(emittedSequences, []);
await wait(15); assert.deepStrictEqual(emittedSequences, ["\x1b"]); });
it("should handle lone escape character with explicit flush", () => { processInput("\x1b"); assert.deepStrictEqual(emittedSequences, []);
const flushed = buffer.flush(); assert.deepStrictEqual(flushed, ["\x1b"]); });
it("should handle buffer input", () => { processInput(Buffer.from("\x1b[A")); assert.deepStrictEqual(emittedSequences, ["\x1b[A"]); });
it("should handle very long sequences", () => { const longSeq = `\x1b[${"1;".repeat(50)}H`; processInput(longSeq); assert.deepStrictEqual(emittedSequences, [longSeq]); }); });
describe("Flush", () => { it("should flush incomplete sequences", () => { processInput("\x1b[<35"); const flushed = buffer.flush(); assert.deepStrictEqual(flushed, ["\x1b[<35"]); assert.strictEqual(buffer.getBuffer(), ""); });
it("should return empty array if nothing to flush", () => { const flushed = buffer.flush(); assert.deepStrictEqual(flushed, []); });
it("should emit flushed data via timeout", async () => { processInput("\x1b[<35"); assert.deepStrictEqual(emittedSequences, []);
await wait(15);
assert.deepStrictEqual(emittedSequences, ["\x1b[<35"]); }); });
describe("Clear", () => { it("should clear buffered content without emitting", () => { processInput("\x1b[<35"); assert.strictEqual(buffer.getBuffer(), "\x1b[<35");
buffer.clear(); assert.strictEqual(buffer.getBuffer(), ""); assert.deepStrictEqual(emittedSequences, []); }); });
describe("Bracketed Paste", () => { let emittedPaste: string[] = [];
beforeEach(() => { buffer = new StdinBuffer({ timeout: 10 });
emittedSequences = []; buffer.on("data", (sequence) => { emittedSequences.push(sequence); });
emittedPaste = []; buffer.on("paste", (data) => { emittedPaste.push(data); }); });
it("should emit paste event for complete bracketed paste", () => { const pasteStart = "\x1b[200~"; const pasteEnd = "\x1b[201~"; const content = "hello world";
processInput(pasteStart + content + pasteEnd);
assert.deepStrictEqual(emittedPaste, ["hello world"]); assert.deepStrictEqual(emittedSequences, []); // No data events during paste });
it("should handle paste arriving in chunks", () => { processInput("\x1b[200~"); assert.deepStrictEqual(emittedPaste, []);
processInput("hello "); assert.deepStrictEqual(emittedPaste, []);
processInput("world\x1b[201~"); assert.deepStrictEqual(emittedPaste, ["hello world"]); assert.deepStrictEqual(emittedSequences, []); });
it("should handle paste with input before and after", () => { processInput("a"); processInput("\x1b[200~pasted\x1b[201~"); processInput("b");
assert.deepStrictEqual(emittedSequences, ["a", "b"]); assert.deepStrictEqual(emittedPaste, ["pasted"]); });
it("should handle paste with newlines", () => { processInput("\x1b[200~line1\nline2\nline3\x1b[201~");
assert.deepStrictEqual(emittedPaste, ["line1\nline2\nline3"]); assert.deepStrictEqual(emittedSequences, []); });
it("should handle paste with unicode", () => { processInput("\x1b[200~Hello 世界 🎉\x1b[201~");
assert.deepStrictEqual(emittedPaste, ["Hello 世界 🎉"]); assert.deepStrictEqual(emittedSequences, []); }); });
describe("Raw Multiline Paste", () => { let emittedPaste: string[];
beforeEach(() => { buffer = new StdinBuffer({ timeout: 10 }); emittedSequences = []; emittedPaste = []; buffer.on("data", (sequence) => emittedSequences.push(sequence)); buffer.on("paste", (data) => emittedPaste.push(data)); });
for (const [name, input] of [ ["CRLF", "line1\r\nline2"], ["LF", "line1\nline2"], ["CR", "line1\rline2"], ["blank lines", "line1\r\n\r\nline2"], ["mixed line endings", "a\rb\nc"], ["Unicode", "Hello 世界\n🎉"], ] as const) { it(`emits ${name} text in one raw chunk as paste`, () => { processInput(input); assert.deepStrictEqual(emittedPaste, [input]); assert.deepStrictEqual(emittedSequences, []); }); }
for (const input of ["hello\r", "hello\n", "hello\r\n", "\rhello"] as const) { it(`preserves text and Enter regardless of chunk boundary: ${JSON.stringify(input)}`, () => { for (let split = 0; split <= input.length; split++) { buffer.clear(); emittedSequences.length = 0; emittedPaste.length = 0; if (split > 0) processInput(input.slice(0, split)); if (split < input.length) processInput(input.slice(split)); assert.deepStrictEqual(emittedPaste, []); assert.deepStrictEqual(emittedSequences, [...input]); } }); }
it("clears pending Kitty duplicate suppression after raw paste", () => { processInput("\x1b[97u"); processInput("a\nb"); processInput("a"); assert.deepStrictEqual(emittedPaste, ["a\nb"]); assert.deepStrictEqual(emittedSequences, ["\x1b[97u", "a"]); assert.strictEqual(buffer.getBuffer(), ""); });
it("emits multiline Buffer input as paste", () => { processInput(Buffer.from("line1\r\nline2")); assert.deepStrictEqual(emittedPaste, ["line1\r\nline2"]); assert.deepStrictEqual(emittedSequences, []); });
for (const input of ["\r", "\n", "\r\n", "\r\r\r"] as const) { it(`keeps linebreak-only chunk ${JSON.stringify(input)} as key data`, () => { processInput(input); assert.deepStrictEqual(emittedPaste, []); assert.deepStrictEqual(emittedSequences, [...input]); }); }
it("does not disturb bracketed paste", () => { processInput("\x1b[200~pasted\r\ntext\x1b[201~"); assert.deepStrictEqual(emittedPaste, ["pasted\r\ntext"]); assert.deepStrictEqual(emittedSequences, []); });
it("keeps escape-containing chunks on the escape parser path", () => { processInput("a\x1b[Aline1\r\nline2"); assert.deepStrictEqual(emittedPaste, []); assert.deepStrictEqual(emittedSequences, [ "a", "\x1b[A", "l", "i", "n", "e", "1", "\r", "\n", "l", "i", "n", "e", "2", ]); }); });
describe("Destroy", () => { it("should clear buffer on destroy", () => { processInput("\x1b[<35"); assert.strictEqual(buffer.getBuffer(), "\x1b[<35");
buffer.destroy(); assert.strictEqual(buffer.getBuffer(), ""); });
it("should clear pending timeouts on destroy", async () => { processInput("\x1b[<35"); buffer.destroy();
await wait(15);
assert.deepStrictEqual(emittedSequences, []); }); });});
describe("SGR mouse reports", () => { const cases: Array<{ sequence: string; expected: ReturnType<typeof parseSgrMouseEvent>; wheel?: "up" | "down"; }> = [ { sequence: "\x1b[<64;10;5M", expected: { button: 64, x: 10, y: 5, press: true, motion: false, shift: false, alt: false, ctrl: false }, wheel: "up", }, { sequence: "\x1b[<65;1;1M", expected: { button: 65, x: 1, y: 1, press: true, motion: false, shift: false, alt: false, ctrl: false }, wheel: "down", }, { // button 84 = wheel up (64) + shift (4) + ctrl (16); modifier bits are stripped from the button sequence: "\x1b[<84;3;7M", expected: { button: 64, x: 3, y: 7, press: true, motion: false, shift: true, alt: false, ctrl: true }, wheel: "up", }, { sequence: "\x1b[<0;5;5M", expected: { button: 0, x: 5, y: 5, press: true, motion: false, shift: false, alt: false, ctrl: false }, }, { sequence: "\x1b[<0;5;5m", expected: { button: 0, x: 5, y: 5, press: false, motion: false, shift: false, alt: false, ctrl: false }, }, { // motion bit (32) is reported separately from the button sequence: "\x1b[<32;5;5M", expected: { button: 0, x: 5, y: 5, press: true, motion: true, shift: false, alt: false, ctrl: false }, }, { sequence: "\x1b[A", expected: null }, { sequence: "a", expected: null }, { sequence: "\x1b[<64;10M", expected: null }, ];
for (const testCase of cases) { it(`parses ${JSON.stringify(testCase.sequence)}`, () => { const event = parseSgrMouseEvent(testCase.sequence); assert.deepStrictEqual(event, testCase.expected); if (event) { assert.strictEqual(isWheelUp(event), testCase.wheel === "up"); assert.strictEqual(isWheelDown(event), testCase.wheel === "down"); } }); }
it("matches SGR and legacy mouse reports", () => { assert.strictEqual(isMouseSequence("\x1b[<64;10;5M"), true); assert.strictEqual(isMouseSequence("\x1b[M "), true); assert.strictEqual(isMouseSequence("\x1b[A"), false); });
for (const [name, chunks, expected] of [ ["an SGR report split across chunks", ["\x1b", "[<64", ";20;5M"], "\x1b[<64;20;5M"], ["a DECRPM mouse-probe response split across chunks", ["\x1b[?1006;", "2$y"], "\x1b[?1006;2$y"], ] as const) { it(`assembles ${name}`, () => { const buffer = new StdinBuffer({ timeout: 10 }); const received: string[] = []; buffer.on("data", (sequence) => received.push(sequence)); try { for (const chunk of chunks) buffer.process(chunk); assert.deepStrictEqual(received, [expected]); } finally { buffer.destroy(); } }); }});
describe("TUI cell size replies", () => { class InputRecorder implements Component { readonly inputs: string[] = [];
render(): string[] { return [""]; }
handleInput(data: string): void { this.inputs.push(data); }
invalidate(): void {} }
function withImageTerminal(fn: (terminal: VirtualTerminal, recorder: InputRecorder) => void): void { const saved = { TERM_PROGRAM: process.env.TERM_PROGRAM, TERM: process.env.TERM, GHOSTTY_RESOURCES_DIR: process.env.GHOSTTY_RESOURCES_DIR, }; process.env.TERM_PROGRAM = "ghostty"; delete process.env.TERM; delete process.env.GHOSTTY_RESOURCES_DIR; resetCapabilitiesCache(); setCellDimensions({ widthPx: 9, heightPx: 18 });
const terminal = new VirtualTerminal(80, 24); const tui = new TUI(terminal); const recorder = new InputRecorder(); tui.setFocus(recorder); tui.start(); try { fn(terminal, recorder); } finally { tui.stop(); for (const [key, value] of Object.entries(saved)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } resetCapabilitiesCache(); setCellDimensions({ widthPx: 9, heightPx: 18 }); } }
it("forwards a bare escape even though a cell size query was sent at startup", () => { withImageTerminal((terminal, recorder) => { terminal.sendInput("\x1b"); assert.deepStrictEqual(recorder.inputs, ["\x1b"]); }); });
it("consumes a cell size reply without swallowing later user input", () => { withImageTerminal((terminal, recorder) => { terminal.sendInput("\x1b[6;20;10t"); assert.deepStrictEqual(recorder.inputs, []); assert.deepStrictEqual(getCellDimensions(), { widthPx: 10, heightPx: 20 });
terminal.sendInput("q"); assert.deepStrictEqual(recorder.inputs, ["q"]); }); });});