Files
oh-my-pi/packages/tui/src/stdin-buffer.ts
T
can1357 2d00ac3e45 fix(tui): restored terminal state on crash and unwedged paste-mode input
emergency restore leaves the alt screen and disables mouse tracking; bracketed paste gets an inactivity watchdog and byte cap so a lost end marker cannot eat input forever; split-escape flush window raised to 50ms; kitty printable dedup expires; resetDisplay repaints on the alt screen; input scanning is index-based instead of O(n^2) slicing; appearance poll no longer clears selection every 2s.
2026-06-10 01:28:23 +02:00

510 lines
14 KiB
TypeScript

/**
* 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~";
// Paste-mode recovery bounds: a lost/corrupted end marker (ssh/tmux
// truncation) must not hang input forever or grow memory unboundedly.
const PASTE_INACTIVITY_TIMEOUT_MS = 1000;
const PASTE_MAX_BYTES = 64 * 1024 * 1024;
// A buggy double-report (CSI-u event plus the bare printable for the same
// keypress) arrives in the same terminal write; a bare char that shows up
// later than this window is a real keystroke and must not be swallowed.
const KITTY_PRINTABLE_DEDUP_WINDOW_MS = 25;
/**
* 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";
}
// ESC-prefixed sequences (terminals with metaSendsEscape):
// Only when the inner ESC starts a CSI ('[') or SS3 ('O') sequence.
// Bare double-ESC (e.g. \x1b\x1bX) remains complete to avoid 10ms timeout lag.
if (afterEsc.startsWith(ESC)) {
const inner = data.slice(1);
const third = inner.charCodeAt(1);
if (third === 0x5b || third === 0x4f) {
return isCompleteSequence(inner);
}
return "complete";
}
// 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 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;
}
function extractCompleteSequences(buffer: string): { sequences: string[]; remainder: string } {
const sequences: string[] = [];
const length = buffer.length;
let pos = 0;
// Index-based scanning: this is the input hot path. Slicing the remaining
// buffer (or Array.from-ing it) per iteration would make plain-text bursts
// O(n²) — a 100KB non-bracketed paste must stay O(n).
while (pos < length) {
if (buffer.charCodeAt(pos) === 0x1b) {
// Find the end of this escape sequence by growing the candidate.
let end = pos + 1;
let consumed = false;
while (end <= length) {
const candidate = buffer.slice(pos, end);
const status = isCompleteSequence(candidate);
if (status === "incomplete") {
end++;
continue;
}
// "complete" — or "not-escape", which should not happen when
// starting with ESC; both consume the candidate.
sequences.push(candidate);
pos = end;
consumed = true;
break;
}
if (!consumed) {
return { sequences, remainder: buffer.slice(pos) };
}
} else {
// Not an escape sequence - take one Unicode scalar, not a UTF-16 code unit.
const codePoint = buffer.codePointAt(pos)!;
const charLength = codePoint > 0xffff ? 2 : 1;
sequences.push(buffer.slice(pos, pos + charLength));
pos += charLength;
}
}
return { sequences, remainder: "" };
}
export type StdinBufferOptions = {
/**
* Maximum time to wait for sequence completion (default: 75ms).
* After this time, a genuinely incomplete escape is flushed.
*/
timeout?: number;
/**
* Paste-mode inactivity watchdog (default: 1000ms). If no input arrives for
* this long while waiting for the bracketed-paste end marker, the paste is
* assumed truncated: accumulated bytes are delivered and input recovers.
*/
pasteTimeout?: number;
/**
* Paste-mode byte cap (default: 64 MiB). Exceeding it aborts paste mode the
* same way, bounding memory when the end marker never arrives.
*/
pasteByteLimit?: 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> {
#buffer: string = "";
#timeout?: NodeJS.Timeout;
readonly #timeoutMs: number;
readonly #pasteTimeoutMs: number;
readonly #pasteByteLimit: number;
#pasteMode: boolean = false;
#pasteChunks: string[] = [];
#pasteOverlap: string = "";
#pasteBytes = 0;
#pasteWatchdog?: NodeJS.Timeout;
#pendingKittyPrintableCodepoint: number | undefined;
#pendingKittyPrintableAtMs = 0;
constructor(options: StdinBufferOptions = {}) {
super();
this.#timeoutMs = options.timeout ?? 75;
this.#pasteTimeoutMs = options.pasteTimeout ?? PASTE_INACTIVITY_TIMEOUT_MS;
this.#pasteByteLimit = options.pasteByteLimit ?? PASTE_MAX_BYTES;
}
process(data: string | Buffer): void {
// Clear any pending timeout
if (this.#timeout) {
clearTimeout(this.#timeout);
this.#timeout = undefined;
}
// 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.#emitDataSequence("");
return;
}
this.#buffer += str;
if (this.#pasteMode) {
const chunk = this.#buffer;
this.#buffer = "";
this.#consumePasteChunk(chunk);
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.#emitDataSequence(sequence);
}
}
this.#pendingKittyPrintableCodepoint = undefined;
this.#buffer = this.#buffer.slice(startIndex + BRACKETED_PASTE_START.length);
const firstChunk = this.#buffer;
this.#buffer = "";
this.#pasteMode = true;
this.#pasteChunks = [];
this.#pasteOverlap = "";
this.#pasteBytes = 0;
this.#consumePasteChunk(firstChunk);
return;
}
const result = extractCompleteSequences(this.#buffer);
this.#buffer = result.remainder;
for (const sequence of result.sequences) {
this.#emitDataSequence(sequence);
}
if (this.#buffer.length > 0) {
this.#timeout = setTimeout(() => {
const flushed = this.flush();
for (const sequence of flushed) {
this.#emitDataSequence(sequence);
}
}, this.#timeoutMs);
}
}
/**
* Consume one chunk of paste-mode input. Chunks are accumulated in an array
* and only joined once the end marker arrives, so a large paste delivered in
* many small terminal reads stays O(total) instead of the O(total^2) cost of
* re-concatenating and rescanning the whole buffer on every chunk. A short
* overlap tail (end-marker length - 1) is carried across chunk boundaries so
* a marker split between two reads is still detected without rescanning.
*/
#consumePasteChunk(chunk: string): void {
const probe = this.#pasteOverlap + chunk;
if (probe.indexOf(BRACKETED_PASTE_END) === -1) {
this.#pasteChunks.push(chunk);
this.#pasteBytes += chunk.length;
const keep = BRACKETED_PASTE_END.length - 1;
this.#pasteOverlap = probe.length > keep ? probe.slice(probe.length - keep) : probe;
if (this.#pasteBytes > this.#pasteByteLimit) {
this.#abortPaste();
return;
}
this.#armPasteWatchdog();
return;
}
// End marker arrived: join once and split at its first occurrence,
// matching the prior indexOf-from-start semantics exactly.
const flat = this.#pasteChunks.length > 0 ? `${this.#pasteChunks.join("")}${chunk}` : chunk;
const endIndex = flat.indexOf(BRACKETED_PASTE_END);
const pastedContent = flat.slice(0, endIndex);
const remaining = flat.slice(endIndex + BRACKETED_PASTE_END.length);
this.#clearPasteWatchdog();
this.#pasteMode = false;
this.#pasteChunks = [];
this.#pasteOverlap = "";
this.#pasteBytes = 0;
this.#pendingKittyPrintableCodepoint = undefined;
this.emit("paste", pastedContent);
if (remaining.length > 0) {
this.process(remaining);
}
}
/** Re-arm the paste-mode inactivity watchdog after each chunk. */
#armPasteWatchdog(): void {
if (this.#pasteWatchdog) clearTimeout(this.#pasteWatchdog);
this.#pasteWatchdog = setTimeout(() => {
this.#pasteWatchdog = undefined;
this.#abortPaste();
}, this.#pasteTimeoutMs);
}
#clearPasteWatchdog(): void {
if (this.#pasteWatchdog) {
clearTimeout(this.#pasteWatchdog);
this.#pasteWatchdog = undefined;
}
}
/**
* Recover from a paste whose end marker never arrived (dropped or corrupted
* in transit, or past the byte cap): exit paste mode and deliver the
* accumulated bytes as a paste, so they are neither lost, replayed as
* keystrokes, nor accumulated forever while input appears dead.
*/
#abortPaste(): void {
this.#clearPasteWatchdog();
const content = this.#pasteChunks.join("");
this.#pasteMode = false;
this.#pasteChunks = [];
this.#pasteOverlap = "";
this.#pasteBytes = 0;
this.emit("paste", content);
}
#emitDataSequence(sequence: string): void {
const rawCodepoint = sequence.length === 1 ? sequence.codePointAt(0) : undefined;
if (
rawCodepoint !== undefined &&
rawCodepoint === this.#pendingKittyPrintableCodepoint &&
Date.now() - this.#pendingKittyPrintableAtMs <= KITTY_PRINTABLE_DEDUP_WINDOW_MS
) {
this.#pendingKittyPrintableCodepoint = undefined;
return;
}
this.#pendingKittyPrintableCodepoint = parseUnmodifiedKittyPrintableCodepoint(sequence);
if (this.#pendingKittyPrintableCodepoint !== undefined) {
this.#pendingKittyPrintableAtMs = Date.now();
}
this.emit("data", sequence);
}
flush(): string[] {
if (this.#timeout) {
clearTimeout(this.#timeout);
this.#timeout = undefined;
}
if (this.#buffer.length === 0) {
return [];
}
const sequences = [this.#buffer];
this.#buffer = "";
this.#pendingKittyPrintableCodepoint = undefined;
return sequences;
}
clear(): void {
if (this.#timeout) {
clearTimeout(this.#timeout);
this.#timeout = undefined;
}
this.#clearPasteWatchdog();
this.#buffer = "";
this.#pasteMode = false;
this.#pasteChunks = [];
this.#pasteOverlap = "";
this.#pasteBytes = 0;
this.#pendingKittyPrintableCodepoint = undefined;
}
getBuffer(): string {
return this.#buffer;
}
destroy(): void {
this.clear();
}
}