diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 843ce36a..68da443b 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -5,6 +5,11 @@ ### Added - `EditorComponent` interface for custom editor implementations +- `StdinBuffer` class to split batched stdin into individual sequences (adapted from [OpenTUI](https://github.com/anomalyco/opentui), MIT license) + +### Fixed + +- Key presses no longer dropped when batched with other events over SSH ([#538](https://github.com/badlogic/pi-mono/pull/538)) ## [0.37.8] - 2026-01-07 diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 3a7944ca..7e87d71a 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -43,6 +43,8 @@ export { parseKey, setKittyProtocolActive, } from "./keys.js"; +// Input buffering for batch splitting +export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer.js"; // Terminal interface and implementations export { ProcessTerminal, type Terminal } from "./terminal.js"; // Terminal image support diff --git a/packages/tui/src/stdin-buffer.ts b/packages/tui/src/stdin-buffer.ts new file mode 100644 index 00000000..5b2f977b --- /dev/null +++ b/packages/tui/src/stdin-buffer.ts @@ -0,0 +1,386 @@ +/** + * StdinBuffer buffers input and emits complete sequences. + * + * This is necessary because stdin data events can arrive in partial chunks, + * especially for escape sequences like mouse events. Without buffering, + * partial sequences can be misinterpreted as regular keypresses. + * + * For example, the mouse SGR sequence `\x1b[<35;20;5m` might arrive as: + * - Event 1: `\x1b` + * - Event 2: `[<35` + * - Event 3: `;20;5m` + * + * The buffer accumulates these until a complete sequence is detected. + * Call the `process()` method to feed input data. + * + * Based on code from OpenTUI (https://github.com/anomalyco/opentui) + * MIT License - Copyright (c) 2025 opentui + */ + +import { EventEmitter } from "events"; + +const ESC = "\x1b"; +const BRACKETED_PASTE_START = "\x1b[200~"; +const BRACKETED_PASTE_END = "\x1b[201~"; + +/** + * Check if a string is a complete escape sequence or needs more data + */ +function isCompleteSequence(data: string): "complete" | "incomplete" | "not-escape" { + if (!data.startsWith(ESC)) { + return "not-escape"; + } + + if (data.length === 1) { + return "incomplete"; + } + + const afterEsc = data.slice(1); + + // CSI sequences: ESC [ + if (afterEsc.startsWith("[")) { + // Check for old-style mouse sequence: ESC[M + 3 bytes + if (afterEsc.startsWith("[M")) { + // Old-style mouse needs ESC[M + 3 bytes = 6 total + return data.length >= 6 ? "complete" : "incomplete"; + } + return isCompleteCsiSequence(data); + } + + // OSC sequences: ESC ] + if (afterEsc.startsWith("]")) { + return isCompleteOscSequence(data); + } + + // DCS sequences: ESC P ... ESC \ (includes XTVersion responses) + if (afterEsc.startsWith("P")) { + return isCompleteDcsSequence(data); + } + + // APC sequences: ESC _ ... ESC \ (includes Kitty graphics responses) + if (afterEsc.startsWith("_")) { + return isCompleteApcSequence(data); + } + + // SS3 sequences: ESC O + if (afterEsc.startsWith("O")) { + // ESC O followed by a single character + return afterEsc.length >= 2 ? "complete" : "incomplete"; + } + + // Meta key sequences: ESC followed by a single character + if (afterEsc.length === 1) { + return "complete"; + } + + // Unknown escape sequence - treat as complete + return "complete"; +} + +/** + * Check if CSI sequence is complete + * CSI sequences: ESC [ ... followed by a final byte (0x40-0x7E) + */ +function isCompleteCsiSequence(data: string): "complete" | "incomplete" { + if (!data.startsWith(`${ESC}[`)) { + return "complete"; + } + + // Need at least ESC [ and one more character + if (data.length < 3) { + return "incomplete"; + } + + const payload = data.slice(2); + + // CSI sequences end with a byte in the range 0x40-0x7E (@-~) + // This includes all letters and several special characters + const lastChar = payload[payload.length - 1]; + const lastCharCode = lastChar.charCodeAt(0); + + if (lastCharCode >= 0x40 && lastCharCode <= 0x7e) { + // Special handling for SGR mouse sequences + // Format: ESC[ /^\d+$/.test(p))) { + return "complete"; + } + } + + return "incomplete"; + } + + return "complete"; + } + + return "incomplete"; +} + +/** + * Check if OSC sequence is complete + * OSC sequences: ESC ] ... ST (where ST is ESC \ or BEL) + */ +function isCompleteOscSequence(data: string): "complete" | "incomplete" { + if (!data.startsWith(`${ESC}]`)) { + return "complete"; + } + + // OSC sequences end with ST (ESC \) or BEL (\x07) + if (data.endsWith(`${ESC}\\`) || data.endsWith("\x07")) { + return "complete"; + } + + return "incomplete"; +} + +/** + * Check if DCS (Device Control String) sequence is complete + * DCS sequences: ESC P ... ST (where ST is ESC \) + * Used for XTVersion responses like ESC P >| ... ESC \ + */ +function isCompleteDcsSequence(data: string): "complete" | "incomplete" { + if (!data.startsWith(`${ESC}P`)) { + return "complete"; + } + + // DCS sequences end with ST (ESC \) + if (data.endsWith(`${ESC}\\`)) { + return "complete"; + } + + return "incomplete"; +} + +/** + * Check if APC (Application Program Command) sequence is complete + * APC sequences: ESC _ ... ST (where ST is ESC \) + * Used for Kitty graphics responses like ESC _ G ... ESC \ + */ +function isCompleteApcSequence(data: string): "complete" | "incomplete" { + if (!data.startsWith(`${ESC}_`)) { + return "complete"; + } + + // APC sequences end with ST (ESC \) + if (data.endsWith(`${ESC}\\`)) { + return "complete"; + } + + return "incomplete"; +} + +/** + * Split accumulated buffer into complete sequences + */ +function extractCompleteSequences(buffer: string): { sequences: string[]; remainder: string } { + const sequences: string[] = []; + let pos = 0; + + while (pos < buffer.length) { + const remaining = buffer.slice(pos); + + // Try to extract a sequence starting at this position + if (remaining.startsWith(ESC)) { + // Find the end of this escape sequence + let seqEnd = 1; + while (seqEnd <= remaining.length) { + const candidate = remaining.slice(0, seqEnd); + const status = isCompleteSequence(candidate); + + if (status === "complete") { + sequences.push(candidate); + pos += seqEnd; + break; + } else if (status === "incomplete") { + seqEnd++; + } else { + // Should not happen when starting with ESC + sequences.push(candidate); + pos += seqEnd; + break; + } + } + + if (seqEnd > remaining.length) { + return { sequences, remainder: remaining }; + } + } else { + // Not an escape sequence - take a single character + sequences.push(remaining[0]!); + pos++; + } + } + + return { sequences, remainder: "" }; +} + +export type StdinBufferOptions = { + /** + * Maximum time to wait for sequence completion (default: 10ms) + * After this time, the buffer is flushed even if incomplete + */ + timeout?: number; +}; + +export type StdinBufferEventMap = { + data: [string]; + paste: [string]; +}; + +/** + * Buffers stdin input and emits complete sequences via the 'data' event. + * Handles partial escape sequences that arrive across multiple chunks. + */ +export class StdinBuffer extends EventEmitter { + private buffer: string = ""; + private timeout: ReturnType | null = null; + private readonly timeoutMs: number; + private pasteMode: boolean = false; + private pasteBuffer: string = ""; + + constructor(options: StdinBufferOptions = {}) { + super(); + this.timeoutMs = options.timeout ?? 10; + } + + public process(data: string | Buffer): void { + // Clear any pending timeout + if (this.timeout) { + clearTimeout(this.timeout); + this.timeout = null; + } + + // Handle high-byte conversion (for compatibility with parseKeypress) + // If buffer has single byte > 127, convert to ESC + (byte - 128) + let str: string; + if (Buffer.isBuffer(data)) { + if (data.length === 1 && data[0]! > 127) { + const byte = data[0]! - 128; + str = `\x1b${String.fromCharCode(byte)}`; + } else { + str = data.toString(); + } + } else { + str = data; + } + + if (str.length === 0 && this.buffer.length === 0) { + this.emit("data", ""); + return; + } + + this.buffer += str; + + if (this.pasteMode) { + this.pasteBuffer += this.buffer; + this.buffer = ""; + + const endIndex = this.pasteBuffer.indexOf(BRACKETED_PASTE_END); + if (endIndex !== -1) { + const pastedContent = this.pasteBuffer.slice(0, endIndex); + const remaining = this.pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length); + + this.pasteMode = false; + this.pasteBuffer = ""; + + this.emit("paste", pastedContent); + + if (remaining.length > 0) { + this.process(remaining); + } + } + return; + } + + const startIndex = this.buffer.indexOf(BRACKETED_PASTE_START); + if (startIndex !== -1) { + if (startIndex > 0) { + const beforePaste = this.buffer.slice(0, startIndex); + const result = extractCompleteSequences(beforePaste); + for (const sequence of result.sequences) { + this.emit("data", sequence); + } + } + + this.buffer = this.buffer.slice(startIndex + BRACKETED_PASTE_START.length); + this.pasteMode = true; + this.pasteBuffer = this.buffer; + this.buffer = ""; + + const endIndex = this.pasteBuffer.indexOf(BRACKETED_PASTE_END); + if (endIndex !== -1) { + const pastedContent = this.pasteBuffer.slice(0, endIndex); + const remaining = this.pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length); + + this.pasteMode = false; + this.pasteBuffer = ""; + + this.emit("paste", pastedContent); + + if (remaining.length > 0) { + this.process(remaining); + } + } + return; + } + + const result = extractCompleteSequences(this.buffer); + this.buffer = result.remainder; + + for (const sequence of result.sequences) { + this.emit("data", sequence); + } + + if (this.buffer.length > 0) { + this.timeout = setTimeout(() => { + const flushed = this.flush(); + + for (const sequence of flushed) { + this.emit("data", sequence); + } + }, this.timeoutMs); + } + } + + flush(): string[] { + if (this.timeout) { + clearTimeout(this.timeout); + this.timeout = null; + } + + if (this.buffer.length === 0) { + return []; + } + + const sequences = [this.buffer]; + this.buffer = ""; + return sequences; + } + + clear(): void { + if (this.timeout) { + clearTimeout(this.timeout); + this.timeout = null; + } + this.buffer = ""; + this.pasteMode = false; + this.pasteBuffer = ""; + } + + getBuffer(): string { + return this.buffer; + } + + destroy(): void { + this.clear(); + } +} diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 65411880..557d54b6 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -1,4 +1,5 @@ import { setKittyProtocolActive } from "./keys.js"; +import { StdinBuffer } from "./stdin-buffer.js"; /** * Minimal terminal interface for TUI @@ -44,6 +45,8 @@ export class ProcessTerminal implements Terminal { private inputHandler?: (data: string) => void; private resizeHandler?: () => void; private _kittyProtocolActive = false; + private stdinBuffer?: StdinBuffer; + private stdinDataHandler?: (data: string) => void; get kittyProtocolActive(): boolean { return this._kittyProtocolActive; @@ -73,6 +76,35 @@ export class ProcessTerminal implements Terminal { this.queryAndEnableKittyProtocol(); } + /** + * Set up StdinBuffer to split batched input into individual sequences. + * This ensures components receive single events, making matchesKey/isKeyRelease work correctly. + * Note: Does NOT register the stdin handler - that's done after the Kitty protocol query. + */ + private setupStdinBuffer(): void { + this.stdinBuffer = new StdinBuffer({ timeout: 10 }); + + // Forward individual sequences to the input handler + this.stdinBuffer.on("data", (sequence) => { + if (this.inputHandler) { + this.inputHandler(sequence); + } + }); + + // Re-wrap paste content with bracketed paste markers for existing editor handling + this.stdinBuffer.on("paste", (content) => { + if (this.inputHandler) { + this.inputHandler(`\x1b[200~${content}\x1b[201~`); + } + }); + + // Handler that pipes stdin data through the buffer + // Registration happens after Kitty protocol query completes + this.stdinDataHandler = (data: string) => { + this.stdinBuffer!.process(data); + }; + } + /** * Query terminal for Kitty keyboard protocol support and enable if available. * @@ -91,9 +123,9 @@ export class ProcessTerminal implements Terminal { const queryHandler = (data: string) => { if (resolved) { - // Query phase done, forward to user handler - if (this.inputHandler) { - this.inputHandler(data); + // Query phase done, forward to StdinBuffer + if (this.stdinBuffer) { + this.stdinBuffer.process(data); } return; } @@ -112,21 +144,24 @@ export class ProcessTerminal implements Terminal { // Flag 2 = report event types (press/repeat/release) process.stdout.write("\x1b[>3u"); - // Remove the response from buffer, forward any remaining input + // Remove the response from buffer, forward any remaining input through StdinBuffer const remaining = buffer.replace(kittyResponsePattern, ""); - if (remaining && this.inputHandler) { - this.inputHandler(remaining); + if (remaining && this.stdinBuffer) { + this.stdinBuffer.process(remaining); } - // Replace with user handler + // Replace query handler with StdinBuffer handler process.stdin.removeListener("data", queryHandler); - if (this.inputHandler) { - process.stdin.on("data", this.inputHandler); + if (this.stdinDataHandler) { + process.stdin.on("data", this.stdinDataHandler); } } }; - // Temporarily intercept input for the query + // Set up StdinBuffer before query (it will receive input after query completes) + this.setupStdinBuffer(); + + // Temporarily intercept input for the query (before StdinBuffer) process.stdin.on("data", queryHandler); // Send query @@ -139,15 +174,15 @@ export class ProcessTerminal implements Terminal { this._kittyProtocolActive = false; setKittyProtocolActive(false); - // Forward any buffered input that wasn't a Kitty response - if (buffer && this.inputHandler) { - this.inputHandler(buffer); + // Forward any buffered input that wasn't a Kitty response through StdinBuffer + if (buffer && this.stdinBuffer) { + this.stdinBuffer.process(buffer); } - // Replace with user handler + // Replace query handler with StdinBuffer handler process.stdin.removeListener("data", queryHandler); - if (this.inputHandler) { - process.stdin.on("data", this.inputHandler); + if (this.stdinDataHandler) { + process.stdin.on("data", this.stdinDataHandler); } } }, QUERY_TIMEOUT_MS); @@ -164,11 +199,18 @@ export class ProcessTerminal implements Terminal { setKittyProtocolActive(false); } - // Remove event handlers - if (this.inputHandler) { - process.stdin.removeListener("data", this.inputHandler); - this.inputHandler = undefined; + // Clean up StdinBuffer + if (this.stdinBuffer) { + this.stdinBuffer.destroy(); + this.stdinBuffer = undefined; } + + // Remove event handlers + if (this.stdinDataHandler) { + process.stdin.removeListener("data", this.stdinDataHandler); + this.stdinDataHandler = undefined; + } + this.inputHandler = undefined; if (this.resizeHandler) { process.stdout.removeListener("resize", this.resizeHandler); this.resizeHandler = undefined; diff --git a/packages/tui/test/stdin-buffer.test.ts b/packages/tui/test/stdin-buffer.test.ts new file mode 100644 index 00000000..5fb0d6ff --- /dev/null +++ b/packages/tui/test/stdin-buffer.test.ts @@ -0,0 +1,422 @@ +/** + * Tests for StdinBuffer + * + * Based on code from OpenTUI (https://github.com/anomalyco/opentui) + * MIT License - Copyright (c) 2025 opentui + */ + +import assert from "node:assert"; +import { beforeEach, describe, it } from "node:test"; +import { StdinBuffer } from "../src/stdin-buffer.js"; + +describe("StdinBuffer", () => { + let buffer: StdinBuffer; + let emittedSequences: string[]; + + beforeEach(() => { + buffer = new StdinBuffer({ timeout: 10 }); + + // Collect emitted sequences + emittedSequences = []; + buffer.on("data", (sequence) => { + emittedSequences.push(sequence); + }); + }); + + // Helper to process data through the buffer + function processInput(data: string | Buffer): void { + buffer.process(data); + } + + // Helper to wait for async operations + async function wait(ms: number): Promise { + 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, []); + + // Wait for timeout + 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", () => { + // Press 'a' in Kitty protocol + processInput("\x1b[97u"); + assert.deepStrictEqual(emittedSequences, ["\x1b[97u"]); + }); + + it("should handle Kitty CSI u release events", () => { + // Release 'a' in Kitty protocol + processInput("\x1b[97;1:3u"); + assert.deepStrictEqual(emittedSequences, ["\x1b[97;1:3u"]); + }); + + it("should handle batched Kitty press and release", () => { + // Press 'a', release 'a' batched together (common over SSH) + processInput("\x1b[97u\x1b[97;1:3u"); + assert.deepStrictEqual(emittedSequences, ["\x1b[97u", "\x1b[97;1:3u"]); + }); + + it("should handle multiple batched Kitty events", () => { + // Press 'a', release 'a', press 'b', release 'b' + 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", () => { + // Up arrow press with event type + processInput("\x1b[1;1:1A"); + assert.deepStrictEqual(emittedSequences, ["\x1b[1;1:1A"]); + }); + + it("should handle Kitty functional keys with event type", () => { + // Delete key release + processInput("\x1b[3;1:3~"); + assert.deepStrictEqual(emittedSequences, ["\x1b[3;1:3~"]); + }); + + it("should handle plain characters mixed with Kitty sequences", () => { + // Plain 'a' followed by Kitty release + processInput("a\x1b[97;1:3u"); + assert.deepStrictEqual(emittedSequences, ["a", "\x1b[97;1:3u"]); + }); + + it("should handle Kitty sequence followed by plain characters", () => { + processInput("\x1b[97ua"); + assert.deepStrictEqual(emittedSequences, ["\x1b[97u", "a"]); + }); + + it("should handle rapid typing simulation with Kitty protocol", () => { + // Simulates typing "hi" quickly with releases interleaved + 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(""); + // Empty string emits an empty data event + assert.deepStrictEqual(emittedSequences, [""]); + }); + + it("should handle lone escape character with timeout", async () => { + processInput("\x1b"); + assert.deepStrictEqual(emittedSequences, []); + + // After timeout, should emit + 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, []); + + // Wait for timeout to flush + 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 }); + + // Collect emitted sequences + emittedSequences = []; + buffer.on("data", (sequence) => { + emittedSequences.push(sequence); + }); + + // Collect paste events + 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("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(); + + // Wait longer than timeout + await wait(15); + + // Should not have emitted anything + assert.deepStrictEqual(emittedSequences, []); + }); + }); +});