/** * 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 { StringDecoder } from "node:string_decoder"; import { EventEmitter } from "events"; const ESC = "\x1b"; const BRACKETED_PASTE_START = "\x1b[200~"; const BRACKETED_PASTE_END = "\x1b[201~"; const SGR_QUARANTINE_MAX_BYTES = 256; const SGR_QUARANTINE_TIMEOUT_MS = 100; // Bounds for holding an incomplete terminal capability-probe reply instead of // flushing its fragments into the input stream. const PROBE_FRAGMENT_HOLD_MAX_MS = 500; const PROBE_FRAGMENT_MAX_BYTES = 256; const PROBE_ESCAPE_HOLD_MAX_MS = 150; const PROBE_REPLY_WINDOW_MS = 2500; /** * True when the buffer is an escape sequence that only a terminal reply can * complete: an OSC without its BEL/ST terminator (OSC 11 background color) or a * private CSI without its final byte (DA1, kitty flags, Mode 2031 DSR). * SGR mouse prefixes are excluded; they have their own quarantine. */ function isIncompleteProbeReplyPrefix(buffer: string): boolean { if (buffer.length > PROBE_FRAGMENT_MAX_BYTES) return false; if (/^\x1b\][^\x07\x1b]*$/.test(buffer)) return true; return /^\x1b\[\??[\d;]*$/.test(buffer); } /** True for complete SGR mouse CSI reports. These remain control input, never text. */ export function isSgrMouseSequence(sequence: string): boolean { return /^\x1b\[<\d+;\d+;\d+[Mm]$/.test(sequence); } /** True when a buffered sequence begins an SGR mouse report, valid or not. */ function isSgrMousePrefix(sequence: string): boolean { return sequence.startsWith(`${ESC}[<`); } function isHighSurrogate(codeUnit: number): boolean { return codeUnit >= 0xd800 && codeUnit <= 0xdbff; } function isLowSurrogate(codeUnit: number): boolean { return codeUnit >= 0xdc00 && codeUnit <= 0xdfff; } function singleCodePoint(sequence: string): number | undefined { const codepoint = sequence.codePointAt(0); if (codepoint === undefined) return undefined; return sequence.length === (codepoint > 0xffff ? 2 : 1) ? codepoint : undefined; } function isUtf8LeadByte(byte: number): boolean { return byte >= 0xc2 && byte <= 0xf4; } function endsWithIncompleteUtf8Sequence(data: Buffer): boolean { if (data.length === 0) return false; let index = data.length - 1; let continuationCount = 0; while (index >= 0) { const byte = data[index]!; if (byte < 0x80 || byte > 0xbf) break; continuationCount++; index--; } if (index < 0) { return continuationCount > 0; } const lead = data[index]!; let expectedLength = 0; if (lead >= 0xc2 && lead <= 0xdf) expectedLength = 2; else if (lead >= 0xe0 && lead <= 0xef) expectedLength = 3; else if (lead >= 0xf0 && lead <= 0xf4) expectedLength = 4; return expectedLength > 0 && continuationCount + 1 < expectedLength; } function legacyMetaSequence(byte: number): string { return `\x1b${String.fromCharCode(byte - 128)}`; } /** * 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); // macOS Terminal.app with "Use Option as Meta key" applies to both // physical Option keys and wraps an escape sequence with a second ESC // (for example Option+Up: ESC ESC [ A). // Delegate completeness to the inner sequence so the pair is emitted // atomically instead of being split into ESC ESC and the CSI suffix. if (afterEsc.startsWith(ESC)) { return isCompleteSequence(afterEsc); } // 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 Unicode code point if (afterEsc.length === 1) { return isHighSurrogate(afterEsc.charCodeAt(0)) ? "incomplete" : "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[| ... 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 parseUnmodifiedKittyPrintableCodepoint(sequence: string): number | undefined { const match = sequence.match(/^\x1b\[(\d+)(?::\d*)?(?::\d+)?u$/); if (!match) return undefined; const codepoint = parseInt(match[1]!, 10); return codepoint >= 32 ? codepoint : undefined; } /** * True when the ESC at `index` can still continue the escape sequence that * started at offset 0, i.e. it is (or may become) the ST terminator `ESC \` of * an OSC/DCS/APC string. Anywhere else an ESC cancels the sequence in progress. */ function continuesAsStringTerminator(remaining: string, index: number): boolean { const introducer = remaining[1]; if (introducer !== "]" && introducer !== "P" && introducer !== "_") return false; const afterEsc = remaining[index + 1]; // Terminator not fully delivered yet: keep buffering rather than guessing. return afterEsc === undefined || afterEsc === "\\"; } /** * A buffered run of nothing but ESC bytes is N real Escape key presses, not an * Option-as-Meta prefix. Emitting the run as one sequence parses as the unbound * `alt+escape` and silently swallows every press, so any path that gives up on a * continuation must split the run first. */ function splitResolvedEscapeRun(buffer: string): string[] { return /^\x1b{2,}$/.test(buffer) ? buffer.split("") : [buffer]; } /** * `knownEscapeRunLength` is the number of leading ESC bytes a previous call * already measured and returned as an all-Escape remainder. Resuming the scan * there keeps a run delivered across many small reads linear overall instead of * re-walking the whole accumulated prefix on every chunk. */ function extractCompleteSequences( buffer: string, knownEscapeRunLength = 0, ): { sequences: string[]; remainder: string; escapeRunRemainder: number } { const sequences: string[] = []; let pos = 0; while (pos < buffer.length) { // Slicing at 0 would copy the whole buffer on every call, which is the // dominant cost when a long Escape run arrives as many single-byte reads. const remaining = pos === 0 ? buffer : buffer.slice(pos); // Try to extract a sequence starting at this position if (remaining.startsWith(ESC)) { // A split Meta + supplementary code point stays buffered after its high // surrogate. If the next code unit cannot complete that pair, flush the // malformed Meta sequence without consuming the following input. if ( remaining.length >= 3 && isHighSurrogate(remaining.charCodeAt(1)) && !isLowSurrogate(remaining.charCodeAt(2)) ) { sequences.push(remaining.slice(0, 2)); pos += 2; continue; } // Measure the ESC run once. Testing the whole suffix on every iteration // while the cut below advances only two bytes made a long run quadratic. let runLength = pos === 0 ? Math.max(knownEscapeRunLength, 1) : 1; while (runLength < remaining.length && remaining[runLength] === ESC) runLength++; // A trailing run of nothing but ESC bytes is ambiguous: the next chunk // may still deliver the continuation that turns its last ESC into a Meta // prefix (ESC ESC ESC + "[A" is bare Escape then Option+Up). Splitting it // now would emit an extra Escape and downgrade the wrapped key to a plain // one, firing the destructive double-Escape gesture. Keep the whole run // buffered; the flush timeout emits it as individual Escape presses. if (runLength === remaining.length) { // Only the last two bytes of the run are still ambiguous: a Meta prefix // is at most ESC ESC, so any earlier ESC is already a settled press. // Emitting them now keeps the retained buffer bounded; holding the whole // run made every later read rescan it, which is quadratic for a long run // delivered as many small chunks. Order of emitted presses is unchanged. const settled = runLength - 2; if (settled > 0) { for (let index = 0; index < settled; index++) sequences.push(ESC); return { sequences, remainder: remaining.slice(settled), escapeRunRemainder: 2 }; } return { sequences, remainder: remaining, escapeRunRemainder: runLength }; } // Only the final two ESC bytes can still form a Meta prefix for the // continuation that follows the run; everything before them is a settled // Escape press. Emitting them in one step keeps the walk linear. if (runLength > 2) { for (let index = 0; index < runLength - 2; index++) sequences.push(ESC); pos += runLength - 2; continue; } // 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") { // An ESC cancels an escape sequence already in progress; it can only // continue one as the ST terminator of an OSC/DCS/APC string. Cutting // here keeps an unterminated sequence from swallowing the next key. // seqEnd === 1 is excluded so Meta sequences (ESC ESC) still parse. if (remaining[seqEnd] === ESC && seqEnd >= 2 && !continuesAsStringTerminator(remaining, seqEnd)) { // A bare Escape may be followed in the same read by a Meta-wrapped // sequence (ESC ESC ESC [ A). Keep the final Meta prefix intact; // splitting all three ESC bytes would turn the wrapped arrow into a // plain arrow after a destructive double-Escape gesture. const trailing = remaining.slice(seqEnd); if (/^\x1b+$/.test(candidate) && /^\x1b[^\x1b]/.test(trailing)) { sequences.push(...candidate.slice(0, -1).split("")); pos += seqEnd - 1; break; } // A cut candidate of nothing but ESC bytes is real Escape key // presses, not an Option-as-Meta prefix: a following ESC proves // no continuation (like "[A") belongs to it. Emitting the pair // as one sequence would parse as the unbound "alt+escape" and // silently swallow both presses. if (/^\x1b+$/.test(candidate)) sequences.push(...candidate.split("")); else sequences.push(candidate); pos += seqEnd; break; } seqEnd++; } else { // Should not happen when starting with ESC sequences.push(candidate); pos += seqEnd; break; } } if (seqEnd > remaining.length) { return { sequences, remainder: remaining, escapeRunRemainder: 0 }; } } else { // Not an escape sequence - take a single Unicode code point. Keep a // trailing high surrogate buffered so a following string chunk can // complete it. const firstCodeUnit = remaining.charCodeAt(0); if (isHighSurrogate(firstCodeUnit)) { if (remaining.length === 1) return { sequences, remainder: remaining, escapeRunRemainder: 0 }; const secondCodeUnit = remaining.charCodeAt(1); if (isLowSurrogate(secondCodeUnit)) { sequences.push(remaining.slice(0, 2)); pos += 2; continue; } } sequences.push(remaining[0]!); pos++; } } return { sequences, remainder: "", escapeRunRemainder: 0 }; } 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. * * StdinBuffer is the single raw-stdin decoding boundary: raw terminal bytes * enter via `process()` and decoded string events leave via the 'data' and * 'paste' events. UTF-8 is decoded exactly once here (using a persistent * StringDecoder) so multi-byte characters split across chunk boundaries are * reassembled rather than corrupted into U+FFFD. All downstream parsing * (escape sequences, bracketed paste, Kitty/CSI, OSC/DA1) operates on strings. */ export class StdinBuffer extends EventEmitter { #buffer: string = ""; // Length of the leading all-Escape run already measured in #buffer, so a run // arriving as many small reads is scanned once overall instead of per chunk. #bufferedEscapeRunLength = 0; #timeout?: NodeJS.Timeout; readonly #timeoutMs: number; #pasteMode: boolean = false; #pasteBuffer: string = ""; #pendingKittyPrintableCodepoint: number | undefined; // Persistent UTF-8 decoder. Holds an incomplete trailing multi-byte // sequence between chunks so split reads (e.g. a 3-byte Korean syllable // split across two stdin events) reassemble correctly instead of emitting // U+FFFD. Reset on clear()/destroy(); never finalized on normal flush. #decoder = new StringDecoder("utf8"); #decoderHasPendingUtf8 = false; #pendingSingleUtf8LeadByte: number | undefined; #sgrQuarantine = false; #sgrQuarantineBytes = 0; #sgrQuarantineSemicolons = 0; #sgrQuarantineHasDigit = false; // Probe-reply fragment hold. #probeHoldStartedAt: number | undefined; #probeHoldBuffer = ""; #probeReplyWindowUntil = 0; constructor(options: StdinBufferOptions = {}) { super(); this.#timeoutMs = options.timeout ?? 10; } process(data: string | Buffer): void { // Do not cancel a bounded SGR quarantine while waiting for its final byte. if (this.#timeout && !this.#sgrQuarantine) { clearTimeout(this.#timeout); this.#timeout = undefined; } // Decode raw bytes into a string. Buffers come from raw stdin; strings // come from tests or non-terminal callers and are already decoded. let str: string; let decodedFromBuffer = false; if (Buffer.isBuffer(data)) { let bytes = data; const hadPendingUtf8 = this.#decoderHasPendingUtf8 || this.#pendingSingleUtf8LeadByte !== undefined; if (this.#pendingSingleUtf8LeadByte !== undefined) { const nextByte = data[0]; if (nextByte !== undefined && nextByte >= 0x80 && nextByte <= 0xbf) { bytes = Buffer.concat([Buffer.from([this.#pendingSingleUtf8LeadByte]), data]); this.#pendingSingleUtf8LeadByte = undefined; } else { const pendingMeta = this.#consumePendingSingleUtf8LeadAsMeta(); if (pendingMeta !== undefined) { this.#emitDataSequence(pendingMeta); } } } if (bytes.length === 1 && bytes[0]! > 127 && !this.#decoderHasPendingUtf8) { const byte = bytes[0]!; if (isUtf8LeadByte(byte)) { this.#pendingSingleUtf8LeadByte = byte; this.#decoderHasPendingUtf8 = true; this.#timeout = setTimeout(() => { const sequence = this.#consumePendingSingleUtf8LeadAsMeta(); if (sequence !== undefined) { this.#emitDataSequence(sequence); } }, this.#timeoutMs); return; } str = legacyMetaSequence(byte); } else { // Decode through the persistent StringDecoder so a multi-byte // sequence split across chunks (e.g. a 3-byte Korean syllable) // is reassembled instead of emitting U+FFFD. str = this.#decoder.write(bytes); decodedFromBuffer = true; const allContinuationBytes = bytes.every(byte => byte >= 0x80 && byte <= 0xbf); this.#decoderHasPendingUtf8 = endsWithIncompleteUtf8Sequence(bytes) && !(hadPendingUtf8 && str.length > 0 && allContinuationBytes); } } else { const pendingMeta = this.#consumePendingSingleUtf8LeadAsMeta(); if (pendingMeta !== undefined) { this.#emitDataSequence(pendingMeta); } str = data; } if (str.length === 0 && this.#buffer.length === 0) { // A Buffer that decoded to nothing means the decoder is holding an // incomplete UTF-8 prefix; emit nothing and wait for the completing // bytes. Preserve the historical empty 'data' event for explicit // empty-string input only. if (!decodedFromBuffer) { this.#emitDataSequence(""); } return; } if (this.#sgrQuarantine) { str = this.#consumeSgrQuarantine(str); if (str.length === 0) return; } this.#buffer += str; if (this.#pasteMode) { this.#pasteBuffer += this.#buffer; this.#buffer = ""; this.#bufferedEscapeRunLength = 0; 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.#pendingKittyPrintableCodepoint = undefined; this.emit("paste", pastedContent); if (remaining.length > 0) { this.process(remaining); } } return; } // A known all-Escape prefix cannot contain the paste introducer, so start // the scan just far enough back to catch a marker straddling the boundary. // Rescanning the whole retained run on every read made a long run quadratic. const pasteScanFrom = Math.max(0, this.#bufferedEscapeRunLength - BRACKETED_PASTE_START.length); const startIndex = this.#buffer.indexOf(BRACKETED_PASTE_START, pasteScanFrom); if (startIndex !== -1) { if (startIndex > 0) { const beforePaste = this.#buffer.slice(0, startIndex); const result = extractCompleteSequences(beforePaste); for (const sequence of result.sequences) { this.#emitDataSequence(sequence); } // A bracketed paste start proves no Meta continuation is coming for a // buffered Escape run, so resolve it into individual presses here too. if (result.remainder.length > 0) { for (const sequence of splitResolvedEscapeRun(result.remainder)) { this.#emitDataSequence(sequence); } } } this.#pendingKittyPrintableCodepoint = undefined; this.#buffer = this.#buffer.slice(startIndex + BRACKETED_PASTE_START.length); this.#pasteMode = true; this.#pasteBuffer = this.#buffer; this.#buffer = ""; this.#bufferedEscapeRunLength = 0; 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.#pendingKittyPrintableCodepoint = undefined; this.emit("paste", pastedContent); if (remaining.length > 0) { this.process(remaining); } } return; } const result = extractCompleteSequences(this.#buffer, this.#bufferedEscapeRunLength); this.#buffer = result.remainder; // Remember an all-Escape remainder so the next chunk resumes the run scan // at its end rather than re-walking every byte received so far. this.#bufferedEscapeRunLength = result.escapeRunRemainder; for (const sequence of result.sequences) { if (isSgrMousePrefix(sequence) && !isSgrMouseSequence(sequence)) continue; this.#emitDataSequence(sequence); } if (this.#buffer.length === 0) { this.#probeHoldStartedAt = undefined; } else { this.#timeout = setTimeout(() => this.#onFlushTimeout(), this.#timeoutMs); } } #beginSgrQuarantine(): void { const suffix = this.#buffer.slice(3); let semicolons = 0; let hasDigit = false; for (let index = 0; index < suffix.length; index += 1) { const char = suffix[index]!; if (/\d/u.test(char)) { hasDigit = true; continue; } if (char === ";" && hasDigit && semicolons < 2) { semicolons += 1; hasDigit = false; continue; } const remainder = suffix.slice(index); this.#buffer = ""; this.#bufferedEscapeRunLength = 0; this.#pendingKittyPrintableCodepoint = undefined; if (remainder) this.process(remainder); return; } this.#buffer = ""; this.#bufferedEscapeRunLength = 0; this.#pendingKittyPrintableCodepoint = undefined; this.#sgrQuarantine = true; this.#sgrQuarantineBytes = suffix.length; this.#sgrQuarantineSemicolons = semicolons; this.#sgrQuarantineHasDigit = hasDigit; this.#timeout = setTimeout(() => this.#endSgrQuarantine(), SGR_QUARANTINE_TIMEOUT_MS); } #endSgrQuarantine(): void { if (this.#timeout) clearTimeout(this.#timeout); this.#timeout = undefined; this.#sgrQuarantine = false; this.#sgrQuarantineBytes = 0; this.#sgrQuarantineSemicolons = 0; this.#sgrQuarantineHasDigit = false; } #consumeSgrQuarantine(data: string): string { for (let index = 0; index < data.length; index += 1) { const char = data[index]!; if (this.#sgrQuarantineBytes >= SGR_QUARANTINE_MAX_BYTES) { let resume = index; while (resume < data.length && /[\d;]/u.test(data[resume]!)) resume += 1; if (resume < data.length && /[Mm]/u.test(data[resume]!)) resume += 1; this.#endSgrQuarantine(); return data.slice(resume); } if (/\d/u.test(char)) { this.#sgrQuarantineHasDigit = true; this.#sgrQuarantineBytes += 1; continue; } if (char === ";" && this.#sgrQuarantineHasDigit && this.#sgrQuarantineSemicolons < 2) { this.#sgrQuarantineSemicolons += 1; this.#sgrQuarantineHasDigit = false; this.#sgrQuarantineBytes += 1; continue; } if ((char === "M" || char === "m") && this.#sgrQuarantineSemicolons === 2 && this.#sgrQuarantineHasDigit) { this.#endSgrQuarantine(); return data.slice(index + 1); } this.#endSgrQuarantine(); return data.slice(index); } return ""; } #consumePendingSingleUtf8LeadAsMeta(): string | undefined { const byte = this.#pendingSingleUtf8LeadByte; if (byte === undefined) return undefined; this.#pendingSingleUtf8LeadByte = undefined; this.#decoderHasPendingUtf8 = false; return legacyMetaSequence(byte); } #emitDataSequence(sequence: string): void { const rawCodepoint = singleCodePoint(sequence); if (rawCodepoint !== undefined && rawCodepoint === this.#pendingKittyPrintableCodepoint) { this.#pendingKittyPrintableCodepoint = undefined; return; } this.#pendingKittyPrintableCodepoint = parseUnmodifiedKittyPrintableCodepoint(sequence); this.emit("data", sequence); } /** * ProcessTerminal calls this right after writing a capability probe so a lone * ESC arriving inside the reply window is treated as a possible reply fragment * rather than a keypress. */ noteProbeIssued(windowMs: number = PROBE_REPLY_WINDOW_MS): void { this.#probeReplyWindowUntil = Date.now() + windowMs; } #onFlushTimeout(): void { if (isSgrMousePrefix(this.#buffer)) { this.#beginSgrQuarantine(); return; } if (this.#shouldHoldProbeFragment()) { this.#timeout = setTimeout(() => this.#onFlushTimeout(), this.#timeoutMs); return; } this.#probeHoldStartedAt = undefined; const flushed = this.flush(); for (const sequence of flushed) { this.#emitDataSequence(sequence); } } #shouldHoldProbeFragment(): boolean { const buffer = this.#buffer; if (buffer.length === 0) return false; const now = Date.now(); const bareEscape = buffer === "\x1b"; if (bareEscape) { // A lone ESC is a real key press: only hold it while a probe reply is // still expected, and only briefly. if (now >= this.#probeReplyWindowUntil) return false; } else if (!isIncompleteProbeReplyPrefix(buffer)) { return false; } if (this.#probeHoldStartedAt === undefined || this.#probeHoldBuffer !== buffer) { // Restart the clock whenever the fragment makes progress. A start stamp left // over from an earlier fragment expired every later hold instantly, so a // reply split across many reads still leaked character by character. this.#probeHoldStartedAt = now; this.#probeHoldBuffer = buffer; } const limit = bareEscape ? PROBE_ESCAPE_HOLD_MAX_MS : PROBE_FRAGMENT_HOLD_MAX_MS; return now - this.#probeHoldStartedAt < limit; } flush(): string[] { if (this.#timeout) { clearTimeout(this.#timeout); this.#timeout = undefined; } if (this.#sgrQuarantine) this.#endSgrQuarantine(); this.#probeHoldStartedAt = undefined; const pendingMeta = this.#consumePendingSingleUtf8LeadAsMeta(); if (this.#buffer.length === 0) { return pendingMeta === undefined ? [] : [pendingMeta]; } if (isSgrMousePrefix(this.#buffer)) { this.#buffer = ""; this.#bufferedEscapeRunLength = 0; this.#pendingKittyPrintableCodepoint = undefined; return pendingMeta === undefined ? [] : [pendingMeta]; } // A buffer of nothing but ESC bytes at flush time is N real Escape key // presses that arrived faster than the flush window (tmux forwards a // quick double-Esc as one "\x1b\x1b" chunk within escape-time). Keeping // the pair atomic is only correct while a continuation can still turn it // into an Option-as-Meta sequence (ESC ESC [ A); once the flush timeout // fires, no continuation is coming, and emitting the pair as one // sequence parses as the unbound "alt+escape" — silently swallowing // both presses and breaking the double-Esc draft-clear gesture. const flushedBuffer = splitResolvedEscapeRun(this.#buffer); const sequences = pendingMeta === undefined ? flushedBuffer : [pendingMeta, ...flushedBuffer]; this.#buffer = ""; this.#bufferedEscapeRunLength = 0; this.#pendingKittyPrintableCodepoint = undefined; return sequences; } clear(): void { if (this.#timeout) { clearTimeout(this.#timeout); this.#timeout = undefined; } this.#buffer = ""; this.#bufferedEscapeRunLength = 0; this.#pasteMode = false; this.#pasteBuffer = ""; this.#pendingKittyPrintableCodepoint = undefined; this.#sgrQuarantine = false; this.#sgrQuarantineBytes = 0; this.#sgrQuarantineSemicolons = 0; this.#sgrQuarantineHasDigit = false; this.#probeHoldStartedAt = undefined; // Drop any incomplete multi-byte sequence the decoder is holding so a // stale partial prefix cannot combine with future input. destroy() // resets the decoder by calling clear(). this.#decoder = new StringDecoder("utf8"); this.#decoderHasPendingUtf8 = false; this.#pendingSingleUtf8LeadByte = undefined; } getBuffer(): string { return this.#buffer; } destroy(): void { this.clear(); } }