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:
Yunfei He
2026-05-26 13:52:01 +08:00
parent 1106be867b
commit ab2444fcb0
2 changed files with 360 additions and 0 deletions
@@ -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"]);
});
});
+284
View File
@@ -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 = "";
},
};
};