fix(tui): handle batched input over SSH with StdinBuffer

Adds StdinBuffer class (adapted from OpenTUI, MIT license) to split
batched stdin into individual sequences before they reach components.

This fixes key presses being dropped when batched with release events,
which commonly occurs over SSH due to network buffering.

- Each handleInput() call now receives a single event
- matchesKey() and isKeyRelease() work correctly without batching awareness
- Properly buffers incomplete escape sequences across chunks
- Handles bracketed paste mode

Addresses #538
This commit is contained in:
Mario Zechner 2026-01-07 17:50:06 +01:00
parent 615ed0ae2e
commit f3b7b0b179
5 changed files with 877 additions and 20 deletions

View file

@ -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

View file

@ -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

View file

@ -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[<B;X;Ym or ESC[<B;X;YM
if (payload.startsWith("<")) {
// Must have format: <digits;digits;digits[Mm]
const mouseMatch = /^<\d+;\d+;\d+[Mm]$/.test(payload);
if (mouseMatch) {
return "complete";
}
// If it ends with M or m but doesn't match the pattern, still incomplete
if (lastChar === "M" || lastChar === "m") {
// Check if we have the right structure
const parts = payload.slice(1, -1).split(";");
if (parts.length === 3 && parts.every((p) => /^\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<StdinBufferEventMap> {
private buffer: string = "";
private timeout: ReturnType<typeof setTimeout> | 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();
}
}

View file

@ -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;

View file

@ -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<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, []);
// 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, []);
});
});
});