feat: port input-parser (chunk splitter) from Ink
Port Ink's stateful input parser that splits raw stdin chunks into discrete input events. Supports CSI/SS3 sequence parsing, double-ESC prefix, bracketed paste, backspace byte splitting, and pending state for incomplete escape sequences. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import { describe, test, expect } from "vite-plus/test";
|
||||
import { createInputParser } from "./input-parser.ts";
|
||||
|
||||
describe("input-parser", () => {
|
||||
test("splits plain text into single event", () => {
|
||||
const parser = createInputParser();
|
||||
expect(parser.push("hello")).toEqual(["hello"]);
|
||||
});
|
||||
|
||||
test("parses CSI sequence (arrow up)", () => {
|
||||
const parser = createInputParser();
|
||||
expect(parser.push("\x1b[A")).toEqual(["\x1b[A"]);
|
||||
});
|
||||
|
||||
test("handles incomplete CSI as pending", () => {
|
||||
const parser = createInputParser();
|
||||
expect(parser.push("\x1b[")).toEqual([]);
|
||||
expect(parser.hasPendingEscape()).toBe(true);
|
||||
expect(parser.push("A")).toEqual(["\x1b[A"]);
|
||||
});
|
||||
|
||||
test("parses bracketed paste", () => {
|
||||
const parser = createInputParser();
|
||||
const events = parser.push("\x1b[200~pasted text\x1b[201~");
|
||||
expect(events).toEqual([{ paste: "pasted text" }]);
|
||||
});
|
||||
|
||||
test("splits backspace bytes", () => {
|
||||
const parser = createInputParser();
|
||||
const events = parser.push("ab\x7F\x7Fc");
|
||||
expect(events).toEqual(["ab", "\x7F", "\x7F", "c"]);
|
||||
});
|
||||
|
||||
test("hasPendingEscape is false during paste assembly", () => {
|
||||
const parser = createInputParser();
|
||||
parser.push("\x1b[200");
|
||||
expect(parser.hasPendingEscape()).toBe(false);
|
||||
});
|
||||
|
||||
test("flushPendingEscape returns pending sequence", () => {
|
||||
const parser = createInputParser();
|
||||
parser.push("\x1b");
|
||||
expect(parser.hasPendingEscape()).toBe(true);
|
||||
expect(parser.flushPendingEscape()).toBe("\x1b");
|
||||
});
|
||||
|
||||
test("parses SS3 sequence", () => {
|
||||
const parser = createInputParser();
|
||||
expect(parser.push("\x1bOP")).toEqual(["\x1bOP"]);
|
||||
});
|
||||
|
||||
test("parses double-ESC prefix (meta+arrow)", () => {
|
||||
const parser = createInputParser();
|
||||
expect(parser.push("\x1b\x1b[A")).toEqual(["\x1b\x1b[A"]);
|
||||
});
|
||||
|
||||
test("reset clears pending state", () => {
|
||||
const parser = createInputParser();
|
||||
parser.push("\x1b[");
|
||||
expect(parser.hasPendingEscape()).toBe(true);
|
||||
parser.reset();
|
||||
expect(parser.hasPendingEscape()).toBe(false);
|
||||
});
|
||||
|
||||
test("incomplete paste start does not trigger flush", () => {
|
||||
const parser = createInputParser();
|
||||
parser.push("\x1b[200");
|
||||
expect(parser.hasPendingEscape()).toBe(false);
|
||||
});
|
||||
|
||||
test("text mixed with escape sequences", () => {
|
||||
const parser = createInputParser();
|
||||
const events = parser.push("a\x1b[Ab\x1b[B");
|
||||
expect(events).toEqual(["a", "\x1b[A", "b", "\x1b[B"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
const escape = "";
|
||||
const pasteStart = "[200~";
|
||||
const pasteEnd = "[201~";
|
||||
|
||||
export type InputEvent = string | { readonly paste: string };
|
||||
|
||||
type ParsedInput = {
|
||||
readonly events: InputEvent[];
|
||||
readonly pending: string;
|
||||
};
|
||||
|
||||
type ParsedSequence =
|
||||
| {
|
||||
readonly sequence: string;
|
||||
readonly nextIndex: number;
|
||||
}
|
||||
| "pending"
|
||||
| undefined;
|
||||
|
||||
const isCsiParameterByte = (byte: number): boolean => {
|
||||
return byte >= 0x30 && byte <= 0x3f;
|
||||
};
|
||||
|
||||
const isCsiIntermediateByte = (byte: number): boolean => {
|
||||
return byte >= 0x20 && byte <= 0x2f;
|
||||
};
|
||||
|
||||
const isCsiFinalByte = (byte: number): boolean => {
|
||||
return byte >= 0x40 && byte <= 0x7e;
|
||||
};
|
||||
|
||||
const parseCsiSequence = (
|
||||
input: string,
|
||||
startIndex: number,
|
||||
prefixLength: number,
|
||||
): ParsedSequence => {
|
||||
const csiPayloadStart = startIndex + prefixLength + 1;
|
||||
let index = csiPayloadStart;
|
||||
for (; index < input.length; index++) {
|
||||
const byte = input.codePointAt(index);
|
||||
if (byte === undefined) {
|
||||
return "pending";
|
||||
}
|
||||
|
||||
if (isCsiParameterByte(byte) || isCsiIntermediateByte(byte)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Preserve legacy terminal function-key sequences like ESC[[A and ESC[[5~.
|
||||
if (byte === 0x5b && index === csiPayloadStart) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isCsiFinalByte(byte)) {
|
||||
return {
|
||||
sequence: input.slice(startIndex, index + 1),
|
||||
nextIndex: index + 1,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return "pending";
|
||||
};
|
||||
|
||||
const parseSs3Sequence = (
|
||||
input: string,
|
||||
startIndex: number,
|
||||
prefixLength: number,
|
||||
): ParsedSequence => {
|
||||
const nextIndex = startIndex + prefixLength + 2;
|
||||
if (nextIndex > input.length) {
|
||||
return "pending";
|
||||
}
|
||||
|
||||
const finalByte = input.codePointAt(nextIndex - 1);
|
||||
if (finalByte === undefined || !isCsiFinalByte(finalByte)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
sequence: input.slice(startIndex, nextIndex),
|
||||
nextIndex,
|
||||
};
|
||||
};
|
||||
|
||||
const parseControlSequence = (
|
||||
input: string,
|
||||
startIndex: number,
|
||||
prefixLength: number,
|
||||
): ParsedSequence => {
|
||||
const sequenceType = input[startIndex + prefixLength];
|
||||
if (sequenceType === undefined) {
|
||||
return "pending";
|
||||
}
|
||||
|
||||
if (sequenceType === "[") {
|
||||
return parseCsiSequence(input, startIndex, prefixLength);
|
||||
}
|
||||
|
||||
if (sequenceType === "O") {
|
||||
return parseSs3Sequence(input, startIndex, prefixLength);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const parseEscapedCodePoint = (
|
||||
input: string,
|
||||
escapeIndex: number,
|
||||
): {
|
||||
readonly sequence: string;
|
||||
readonly nextIndex: number;
|
||||
} => {
|
||||
const nextCodePoint = input.codePointAt(escapeIndex + 1);
|
||||
const nextCodePointLength = nextCodePoint !== undefined && nextCodePoint > 0xff_ff ? 2 : 1;
|
||||
const nextIndex = escapeIndex + 1 + nextCodePointLength;
|
||||
|
||||
return {
|
||||
sequence: input.slice(escapeIndex, nextIndex),
|
||||
nextIndex,
|
||||
};
|
||||
};
|
||||
|
||||
type ParsedEscapeSequence =
|
||||
| {
|
||||
readonly sequence: string;
|
||||
readonly nextIndex: number;
|
||||
}
|
||||
| "pending";
|
||||
|
||||
const parseEscapeSequence = (input: string, escapeIndex: number): ParsedEscapeSequence => {
|
||||
if (escapeIndex === input.length - 1) {
|
||||
return "pending";
|
||||
}
|
||||
|
||||
const next = input[escapeIndex + 1]!;
|
||||
if (next === escape) {
|
||||
if (escapeIndex + 2 >= input.length) {
|
||||
return "pending";
|
||||
}
|
||||
|
||||
const doubleEscapeSequence = parseControlSequence(input, escapeIndex, 2);
|
||||
if (doubleEscapeSequence === "pending") {
|
||||
return "pending";
|
||||
}
|
||||
|
||||
if (doubleEscapeSequence) {
|
||||
return doubleEscapeSequence;
|
||||
}
|
||||
|
||||
return {
|
||||
sequence: input.slice(escapeIndex, escapeIndex + 2),
|
||||
nextIndex: escapeIndex + 2,
|
||||
};
|
||||
}
|
||||
|
||||
const controlSequence = parseControlSequence(input, escapeIndex, 1);
|
||||
if (controlSequence === "pending") {
|
||||
return "pending";
|
||||
}
|
||||
|
||||
if (controlSequence) {
|
||||
return controlSequence;
|
||||
}
|
||||
|
||||
return parseEscapedCodePoint(input, escapeIndex);
|
||||
};
|
||||
|
||||
/**
|
||||
* Split a chunk of non-escape text so that backspace bytes (`0x7F` and `0x08`)
|
||||
* become individual events. When a user holds the backspace key, the terminal
|
||||
* sends repeated bytes in a single stdin chunk. Without splitting,
|
||||
* `parseKeypress` receives the multi-byte string and fails to recognize it as a
|
||||
* key event, corrupting the input state.
|
||||
*
|
||||
* Other control characters like `\r` and `\t` are NOT split because they can
|
||||
* legitimately appear inside pasted text.
|
||||
*/
|
||||
const splitBackspaceBytes = (text: string, events: InputEvent[]): void => {
|
||||
let textSegmentStart = 0;
|
||||
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
const character = text[index]!;
|
||||
if (character === "" || character === "") {
|
||||
if (index > textSegmentStart) {
|
||||
events.push(text.slice(textSegmentStart, index));
|
||||
}
|
||||
|
||||
events.push(character);
|
||||
textSegmentStart = index + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (textSegmentStart < text.length) {
|
||||
events.push(text.slice(textSegmentStart));
|
||||
}
|
||||
};
|
||||
|
||||
const parseKeypresses = (input: string): ParsedInput => {
|
||||
const events: InputEvent[] = [];
|
||||
let index = 0;
|
||||
const pendingFrom = (pendingStartIndex: number): ParsedInput => ({
|
||||
events,
|
||||
pending: input.slice(pendingStartIndex),
|
||||
});
|
||||
|
||||
while (index < input.length) {
|
||||
const escapeIndex = input.indexOf(escape, index);
|
||||
if (escapeIndex === -1) {
|
||||
splitBackspaceBytes(input.slice(index), events);
|
||||
return {
|
||||
events,
|
||||
pending: "",
|
||||
};
|
||||
}
|
||||
|
||||
if (escapeIndex > index) {
|
||||
splitBackspaceBytes(input.slice(index, escapeIndex), events);
|
||||
}
|
||||
|
||||
const parsedEscapeSequence = parseEscapeSequence(input, escapeIndex);
|
||||
if (parsedEscapeSequence === "pending") {
|
||||
return pendingFrom(escapeIndex);
|
||||
}
|
||||
|
||||
if (parsedEscapeSequence.sequence === pasteStart) {
|
||||
const afterStart = parsedEscapeSequence.nextIndex;
|
||||
const endIndex = input.indexOf(pasteEnd, afterStart);
|
||||
if (endIndex === -1) {
|
||||
return pendingFrom(escapeIndex);
|
||||
}
|
||||
|
||||
events.push({ paste: input.slice(afterStart, endIndex) });
|
||||
index = endIndex + pasteEnd.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
events.push(parsedEscapeSequence.sequence);
|
||||
index = parsedEscapeSequence.nextIndex;
|
||||
}
|
||||
|
||||
return {
|
||||
events,
|
||||
pending: "",
|
||||
};
|
||||
};
|
||||
|
||||
export type InputParser = {
|
||||
push: (chunk: string) => InputEvent[];
|
||||
hasPendingEscape: () => boolean;
|
||||
flushPendingEscape: () => string | undefined;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
export const createInputParser = (): InputParser => {
|
||||
let pending = "";
|
||||
|
||||
return {
|
||||
push(chunk) {
|
||||
const parsedInput = parseKeypresses(pending + chunk);
|
||||
pending = parsedInput.pending;
|
||||
return parsedInput.events;
|
||||
},
|
||||
hasPendingEscape() {
|
||||
// Don't trigger the escape flush timer while assembling a paste start
|
||||
// marker (`[200` and then `~`) or while waiting for paste end.
|
||||
return pending.startsWith(escape) && !pending.startsWith(pasteStart) && pending !== "[200";
|
||||
},
|
||||
flushPendingEscape() {
|
||||
if (!pending.startsWith(escape)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const pendingEscape = pending;
|
||||
pending = "";
|
||||
return pendingEscape;
|
||||
},
|
||||
reset() {
|
||||
pending = "";
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user