Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"registry:build": "bun scripts/build-registry.ts"
},
"devDependencies": {
"@termuijs/adapters": "workspace:*",
"@types/bun": "latest",
"@types/node": "^25.2.3",
"@vitest/coverage-v8": "^4.1.8",
Expand All @@ -42,7 +43,7 @@
"license": "MIT",
"dependencies": {
"commander": "^15.0.0",
"dotenv": "^16.0.0"
"dotenv": "^17.4.2"
Comment thread
Srushti-Thombre marked this conversation as resolved.
}
}

125 changes: 40 additions & 85 deletions packages/core/src/input/InputParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,32 +125,30 @@ export class InputParser {
* Process a chunk of raw input bytes.
*/
private _processInput(data: Buffer): void {
const str = data.toString('utf8');
const PASTE_START = '\x1b[200~';
const PASTE_END = '\x1b[201~';

if (this._isPasting) {
const str = this._decoder.write(data);
const endIdx = str.indexOf(PASTE_END);
if (endIdx !== -1) {
this._pasteBuffer += str.substring(0, endIdx);
const pastedText = this._pasteBuffer;
this._isPasting = false;
this._pasteBuffer = '';
this._clearPasteTimeout();
this._events.emit('paste', pastedText);

const remaining = str.substring(endIdx + PASTE_END.length);
if (remaining.length > 0) {
this._processInput(Buffer.from(remaining, 'utf8'));
}
} else {
this._pasteBuffer += str;
this._startPasteTimeout();
this._pasteBuffer += str;
this._finishPasteIfComplete(PASTE_END);
return;
}

const pasteStartIndex = str.indexOf(PASTE_START);
if (pasteStartIndex !== -1) {
const beforePaste = str.slice(0, pasteStartIndex);
if (beforePaste.length > 0) {
this._processInput(Buffer.from(beforePaste, 'utf8'));
}

this._isPasting = true;
this._pasteBuffer = str.slice(pasteStartIndex + PASTE_START.length);
this._finishPasteIfComplete(PASTE_END);
return;
}

// If we are currently collecting an escape sequence, continue collecting it
// If we're collecting an escape sequence
if (this._escapeBuffer.length > 0) {
this._escapeBuffer = Buffer.concat([this._escapeBuffer, data]);
if (this._escapeTimeout) {
Expand All @@ -161,38 +159,10 @@ export class InputParser {
return;
}

const str = data.toString('utf8');

const startIdx = str.indexOf(PASTE_START);
if (startIdx !== -1) {
if (startIdx > 0) {
const before = str.substring(0, startIdx);
this._processInput(Buffer.from(before, 'utf8'));
}

const afterStart = str.substring(startIdx + PASTE_START.length);
const endIdx = afterStart.indexOf(PASTE_END);

if (endIdx !== -1) {
this._events.emit('paste', afterStart.substring(0, endIdx));
const remaining = afterStart.substring(endIdx + PASTE_END.length);
if (remaining.length > 0) {
this._processInput(Buffer.from(remaining, 'utf8'));
}
} else {
this._isPasting = true;
this._pasteBuffer = afterStart;
this._startPasteTimeout();
}
return;
}

// Check if this starts an escape sequence
if (str.startsWith('\x1b') && str.length === 1) {
// Lone ESC — wait for more bytes (FSM via _escapeBuffer handles continuation)
this._escapeBuffer = data;
this._escapeTimeout = setTimeout(() => {
// Timeout — it was a standalone Escape key
const remained = this._escapeBuffer;
this._escapeBuffer = Buffer.alloc(0);
this._escapeTimeout = null;
Expand All @@ -203,7 +173,7 @@ export class InputParser {
alt: false,
shift: false,
}));
}, 200); // 200ms debounce (increased from 50ms to avoid race with render)
}, 200);
return;
}

Expand All @@ -222,7 +192,6 @@ export class InputParser {
this._graphemeTimeout = null;
}

// Process after a short 10ms delay to merge split chunks (like modifiers or ZWJ sequences)
this._graphemeTimeout = setTimeout(() => {
this._processGraphemeBuffer();
this._graphemeTimeout = null;
Expand All @@ -236,7 +205,6 @@ export class InputParser {
if (graphemes.length === 0) return;

let processCount = graphemes.length;
// Check if the last grapheme is potentially incomplete
const lastGrapheme = graphemes[graphemes.length - 1];
if (this._isPossiblyIncompleteGrapheme(lastGrapheme)) {
processCount = graphemes.length - 1;
Expand All @@ -247,7 +215,6 @@ export class InputParser {
const code = ch.codePointAt(0)!;
const raw = Buffer.from(ch, 'utf8');

// Ctrl+key (0x01-0x1A, excluding tab/enter/backspace)
if (code >= 0x01 && code <= 0x1A) {
const keyName = CTRL_KEYS[code];
const isCtrl = code !== 0x09 && code !== 0x0D && code !== 0x0A;
Expand All @@ -261,7 +228,6 @@ export class InputParser {
continue;
}

// Special keys
if (code in SPECIAL_KEYS) {
this._events.emit('key', createKeyEvent({
key: SPECIAL_KEYS[code],
Expand All @@ -273,7 +239,6 @@ export class InputParser {
continue;
}

// Regular printable character
if (code >= 0x20) {
this._events.emit('key', createKeyEvent({
key: ch,
Expand All @@ -285,20 +250,15 @@ export class InputParser {
}
}

// Update the remaining buffer
this._graphemeBuffer = graphemes.slice(processCount).join('');
}

private _isPossiblyIncompleteGrapheme(ch: string): boolean {
if (!ch) return false;
// ZWJ sequence continuation check
if (ch.endsWith('\u200D')) return true;
// Surrogate pair incomplete check (last char is high surrogate)
const lastCharCode = ch.charCodeAt(ch.length - 1);
if (lastCharCode >= 0xD800 && lastCharCode <= 0xDBFF) return true;

// Regional Indicator Symbols (flags: U+1F1E6 to U+1F1FF)
// Check if there's an odd number of regional indicator symbols in this grapheme
const codePoints = Array.from(ch);
const lastCpVal = codePoints[codePoints.length - 1].codePointAt(0)!;
if (lastCpVal >= 0x1F1E6 && lastCpVal <= 0x1F1FF) {
Expand All @@ -311,18 +271,11 @@ export class InputParser {
break;
}
}
if (riCount % 2 !== 0) {
return true;
}
if (riCount % 2 !== 0) return true;
}
return false;
}

/**
* Start or restart the paste inactivity timeout.
* If no additional paste data arrives within the timeout,
* the paste state is aborted to prevent stale state.
*/
private _startPasteTimeout(): void {
this._clearPasteTimeout();
this._pasteTimeout = setTimeout(() => {
Expand All @@ -339,6 +292,25 @@ export class InputParser {
}
}

private _finishPasteIfComplete(pasteEnd: string): void {
const pasteEndIndex = this._pasteBuffer.indexOf(pasteEnd);
if (pasteEndIndex === -1) {
return;
}

const pastedText = this._pasteBuffer.slice(0, pasteEndIndex);
const remainder = this._pasteBuffer.slice(pasteEndIndex + pasteEnd.length);

this._isPasting = false;
this._pasteBuffer = '';

this._events.emit('paste', pastedText);

if (remainder.length > 0) {
this._processInput(Buffer.from(remainder, 'utf8'));
}
}

/**
* Try to parse buffered escape sequence.
*/
Expand All @@ -358,21 +330,16 @@ export class InputParser {
return;
}

// Check for mouse event first
if (isMouseSequence(seq)) {
const mouseEvt = parseMouseEvent(seq);
if (mouseEvt) {
this._events.emit('mouse', mouseEvt);
this._escapeBuffer = Buffer.alloc(0);
return;
}
// Might be incomplete mouse sequence — wait for more data (no timeout, FSM handles via _escapeBuffer)
if (seq.length < 20) { // safety cap
return;
}
if (seq.length < 20) return;
}

// Cursor position report
const cursorMatch = seq.match(/^\x1b\[(\d+);(\d+)R$/);
if (cursorMatch) {
const row = parseInt(cursorMatch[1], 10);
Expand All @@ -388,7 +355,6 @@ export class InputParser {
return;
}

// Focus tracking sequences
if (seq === '\x1b[I') {
this._events.emit('focuschange', true);
this._escapeBuffer = Buffer.alloc(0);
Expand All @@ -401,7 +367,6 @@ export class InputParser {
return;
}

// Check known escape sequences
if (seq in ESCAPE_SEQUENCES) {
const keyName = ESCAPE_SEQUENCES[seq];
const isShift = keyName.startsWith('shift+');
Expand All @@ -420,13 +385,7 @@ export class InputParser {
return;
}

// Alt+key: ESC followed by a regular character
if (
seq.length === 2 &&
seq[0] === '\x1b' &&
seq[1] !== '[' &&
seq[1] !== 'O'
) {
if (seq.length === 2 && seq[0] === '\x1b' && seq[1] !== '[' && seq[1] !== 'O') {
const ch = seq[1];
this._events.emit('key', createKeyEvent({
key: ch,
Expand All @@ -439,13 +398,9 @@ export class InputParser {
return;
}

// If the sequence is getting too long, give up
if (seq.length > 20) {
this._escapeBuffer = Buffer.alloc(0);
return;
}

// Wait for more bytes (might be an incomplete sequence; FSM handles via _escapeBuffer)
return;
}
}
}
Loading