/** * Line editor with cursor management for raw-mode terminal input. * * Handles: * - Escape sequence parsing (Kitty keyboard protocol, paste bracket mode) * - Cursor-aware buffer editing (insert, delete, move) * - Terminal rendering via ANSI escape sequences */ import * as readline from 'node:readline'; import { StringDecoder } from 'node:string_decoder'; import { stripAnsi, getDisplayWidth } from '../../shared/utils/text.js'; /** Escape sequences for terminal protocol control */ const PASTE_BRACKET_ENABLE = '\x1B[?2004h'; const PASTE_BRACKET_DISABLE = '\x1B[?2004l'; // flag 1: Disambiguate escape codes — modified keys (e.g. Shift+Enter) are reported // as CSI sequences while unmodified keys (e.g. Enter) remain as legacy codes (\r) const KITTY_KB_ENABLE = '\x1B[>1u'; const KITTY_KB_DISABLE = '\x1B[ "\x03" */ function decodeCtrlKey(rest: string): { ch: string; consumed: number } | null { // Kitty CSI-u: [codepoint;modifiersu const kittyMatch = rest.match(/^\[(\d+);(\d+)u/); if (kittyMatch) { const codepoint = Number.parseInt(kittyMatch[1]!, 10); const modifiers = Number.parseInt(kittyMatch[2]!, 10); // Kitty modifiers are 1-based; Ctrl bit is 4 in 0-based flags. const ctrlPressed = ((modifiers - 1) & 4) !== 0; if (!ctrlPressed) return null; const key = String.fromCodePoint(codepoint); if (!/^[A-Za-z]$/.test(key)) return null; const upper = key.toUpperCase(); const controlCode = upper.charCodeAt(0) & 0x1f; return { ch: String.fromCharCode(controlCode), consumed: kittyMatch[0].length }; } // xterm modifyOtherKeys: [27;modifiers;codepoint~ const xtermMatch = rest.match(/^\[27;(\d+);(\d+)~/); if (!xtermMatch) return null; const modifiers = Number.parseInt(xtermMatch[1]!, 10); const codepoint = Number.parseInt(xtermMatch[2]!, 10); const ctrlPressed = ((modifiers - 1) & 4) !== 0; if (!ctrlPressed) return null; const key = String.fromCodePoint(codepoint); if (!/^[A-Za-z]$/.test(key)) return null; const upper = key.toUpperCase(); const controlCode = upper.charCodeAt(0) & 0x1f; return { ch: String.fromCharCode(controlCode), consumed: xtermMatch[0].length }; } /** Callbacks for parsed input events */ export interface InputCallbacks { onPasteStart: () => void; onPasteEnd: () => void; onShiftEnter: () => void; onArrowLeft: () => void; onArrowRight: () => void; onArrowUp: () => void; onArrowDown: () => void; onWordLeft: () => void; onWordRight: () => void; onHome: () => void; onEnd: () => void; onChar: (ch: string) => void; } /** * Parse raw stdin data into semantic input events. * * Handles paste bracket mode, Kitty keyboard protocol, arrow keys, * Home/End, and Ctrl key combinations. Unknown CSI sequences are skipped. */ export function parseInputData(data: string, callbacks: InputCallbacks): void { let i = 0; while (i < data.length) { const ch = data[i]!; if (ch === '\x1B') { const rest = data.slice(i + 1); if (rest.startsWith(ESC_PASTE_START)) { callbacks.onPasteStart(); i += 1 + ESC_PASTE_START.length; continue; } if (rest.startsWith(ESC_PASTE_END)) { callbacks.onPasteEnd(); i += 1 + ESC_PASTE_END.length; continue; } if (rest.startsWith(ESC_SHIFT_ENTER)) { callbacks.onShiftEnter(); i += 1 + ESC_SHIFT_ENTER.length; continue; } const ctrlKey = decodeCtrlKey(rest); if (ctrlKey) { callbacks.onChar(ctrlKey.ch); i += 1 + ctrlKey.consumed; continue; } // Arrow keys if (rest.startsWith('[D')) { callbacks.onArrowLeft(); i += 3; continue; } if (rest.startsWith('[C')) { callbacks.onArrowRight(); i += 3; continue; } if (rest.startsWith('[A')) { callbacks.onArrowUp(); i += 3; continue; } if (rest.startsWith('[B')) { callbacks.onArrowDown(); i += 3; continue; } // Option+Arrow (CSI modified): \x1B[1;3D (left), \x1B[1;3C (right) if (rest.startsWith('[1;3D')) { callbacks.onWordLeft(); i += 6; continue; } if (rest.startsWith('[1;3C')) { callbacks.onWordRight(); i += 6; continue; } // Option+Arrow (SS3/alt): \x1Bb (left), \x1Bf (right) if (rest.startsWith('b')) { callbacks.onWordLeft(); i += 2; continue; } if (rest.startsWith('f')) { callbacks.onWordRight(); i += 2; continue; } // Home: \x1B[H (CSI) or \x1BOH (SS3/application mode) if (rest.startsWith('[H') || rest.startsWith('OH')) { callbacks.onHome(); i += 3; continue; } // End: \x1B[F (CSI) or \x1BOF (SS3/application mode) if (rest.startsWith('[F') || rest.startsWith('OF')) { callbacks.onEnd(); i += 3; continue; } // Unknown CSI sequences: skip if (rest.startsWith('[')) { const csiMatch = rest.match(/^\[[0-9;]*[A-Za-z~]/); if (csiMatch) { i += 1 + csiMatch[0].length; continue; } } // Unrecognized escape: skip the \x1B i++; continue; } callbacks.onChar(ch); i++; } } /** * Read multiline input from the user using raw mode with cursor management. * * Supports: * - Enter to submit, Shift+Enter to insert newline * - Paste bracket mode for pasted text with newlines * - Left/Right arrows, Home/End for cursor movement * - Ctrl+A/E (line start/end), Ctrl+K/U (kill line), Ctrl+W (delete word) * - Backspace / Ctrl+H, Ctrl+C / Ctrl+D (cancel) * * Falls back to readline.question() in non-TTY environments. */ export function readMultilineInput(prompt: string): Promise { if (!process.stdin.isTTY) { return new Promise((resolve) => { if (process.stdin.readable && !process.stdin.destroyed) { process.stdin.resume(); } const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); let answered = false; rl.question(prompt, (answer) => { answered = true; rl.close(); resolve(answer); }); rl.on('close', () => { if (!answered) { resolve(null); } }); }); } return new Promise((resolve) => { let buffer = ''; let cursorPos = 0; let state: InputState = 'normal'; const wasRaw = process.stdin.isRaw; process.stdin.setRawMode(true); process.stdin.resume(); process.stdout.write(PASTE_BRACKET_ENABLE); process.stdout.write(KITTY_KB_ENABLE); process.stdout.write(prompt); // --- Buffer position helpers --- function getLineStartAt(pos: number): number { const lastNl = buffer.lastIndexOf('\n', pos - 1); return lastNl + 1; } function getLineStart(): number { return getLineStartAt(cursorPos); } function getLineEndAt(pos: number): number { const nextNl = buffer.indexOf('\n', pos); return nextNl >= 0 ? nextNl : buffer.length; } function getLineEnd(): number { return getLineEndAt(cursorPos); } const promptWidth = getDisplayWidth(stripAnsi(prompt)); // --- Display row helpers (soft-wrap awareness) --- function getTermWidth(): number { return process.stdout.columns || 80; } /** Buffer position of the display row start that contains `pos` */ function getDisplayRowStart(pos: number): number { const logicalStart = getLineStartAt(pos); const termWidth = getTermWidth(); const isFirstLogicalLine = logicalStart === 0; let firstRowWidth = isFirstLogicalLine ? termWidth - promptWidth : termWidth; if (firstRowWidth <= 0) firstRowWidth = 1; let rowStart = logicalStart; let accumulated = 0; let available = firstRowWidth; let i = logicalStart; for (const ch of buffer.slice(logicalStart, pos)) { const w = getDisplayWidth(ch); if (accumulated + w > available) { rowStart = i; accumulated = w; available = termWidth; } else { accumulated += w; // Row exactly filled — next position starts a new display row if (accumulated === available) { rowStart = i + ch.length; accumulated = 0; available = termWidth; } } i += ch.length; } return rowStart; } /** Buffer position of the display row end that contains `pos` */ function getDisplayRowEnd(pos: number): number { const logicalEnd = getLineEndAt(pos); const rowStart = getDisplayRowStart(pos); const termWidth = getTermWidth(); // The first display row of the first logical line has reduced width const isFirstDisplayRow = rowStart === 0; const available = isFirstDisplayRow ? termWidth - promptWidth : termWidth; let accumulated = 0; let i = rowStart; for (const ch of buffer.slice(rowStart, logicalEnd)) { const w = getDisplayWidth(ch); if (accumulated + w > available) return i; accumulated += w; i += ch.length; } return logicalEnd; } /** Display column (0-based) within the display row that contains `pos` */ function getDisplayRowColumn(pos: number): number { return getDisplayWidth(buffer.slice(getDisplayRowStart(pos), pos)); } /** Terminal column (1-based) for a given buffer position */ function getTerminalColumn(pos: number): number { const displayRowStart = getDisplayRowStart(pos); const col = getDisplayWidth(buffer.slice(displayRowStart, pos)); // Only the first display row of the first logical line has the prompt offset const isFirstDisplayRow = displayRowStart === 0; return isFirstDisplayRow ? promptWidth + col + 1 : col + 1; } /** Find the buffer position in a range that matches a target display column */ function findPositionByDisplayColumn(rangeStart: number, rangeEnd: number, targetDisplayCol: number): number { let displayCol = 0; let pos = rangeStart; for (const ch of buffer.slice(rangeStart, rangeEnd)) { const w = getDisplayWidth(ch); if (displayCol + w > targetDisplayCol) break; displayCol += w; pos += ch.length; } return pos; } // --- Terminal output helpers --- function rerenderFromCursor(): void { const afterCursor = buffer.slice(cursorPos, getLineEnd()); if (afterCursor.length > 0) { process.stdout.write(afterCursor); } process.stdout.write('\x1B[K'); const afterWidth = getDisplayWidth(afterCursor); if (afterWidth > 0) { process.stdout.write(`\x1B[${afterWidth}D`); } } function cleanup(): void { process.stdin.removeListener('data', onData); process.stdout.write(PASTE_BRACKET_DISABLE); process.stdout.write(KITTY_KB_DISABLE); process.stdin.setRawMode(wasRaw ?? false); process.stdin.pause(); } // --- Cursor movement --- function moveCursorToDisplayRowStart(): void { const displayRowStart = getDisplayRowStart(cursorPos); const displayOffset = getDisplayRowColumn(cursorPos); if (displayOffset > 0) { cursorPos = displayRowStart; process.stdout.write(`\x1B[${displayOffset}D`); } } function moveCursorToDisplayRowEnd(): void { const displayRowEnd = getDisplayRowEnd(cursorPos); const displayOffset = getDisplayWidth(buffer.slice(cursorPos, displayRowEnd)); if (displayOffset > 0) { cursorPos = displayRowEnd; process.stdout.write(`\x1B[${displayOffset}C`); } } /** Move cursor to a target display row, positioning at the given display column */ function moveCursorToDisplayRow( targetRowStart: number, targetRowEnd: number, displayCol: number, direction: 'A' | 'B', ): void { cursorPos = findPositionByDisplayColumn(targetRowStart, targetRowEnd, displayCol); const termCol = getTerminalColumn(cursorPos); process.stdout.write(`\x1B[${direction}`); process.stdout.write(`\x1B[${termCol}G`); } /** Count how many display rows lie between two buffer positions in the same logical line */ function countDisplayRowsBetween(from: number, to: number): number { if (from === to) return 0; const start = Math.min(from, to); const end = Math.max(from, to); let count = 0; let pos = start; while (pos < end) { const nextRowStart = getDisplayRowEnd(pos); if (nextRowStart >= end) break; pos = nextRowStart; count++; } return count; } function moveCursorToLogicalLineStart(): void { const lineStart = getLineStart(); if (cursorPos === lineStart) return; const rowDiff = countDisplayRowsBetween(lineStart, cursorPos); cursorPos = lineStart; if (rowDiff > 0) { process.stdout.write(`\x1B[${rowDiff}A`); } const termCol = getTerminalColumn(cursorPos); process.stdout.write(`\x1B[${termCol}G`); } function moveCursorToLogicalLineEnd(): void { const lineEnd = getLineEnd(); if (cursorPos === lineEnd) return; const rowDiff = countDisplayRowsBetween(cursorPos, lineEnd); cursorPos = lineEnd; if (rowDiff > 0) { process.stdout.write(`\x1B[${rowDiff}B`); } const termCol = getTerminalColumn(cursorPos); process.stdout.write(`\x1B[${termCol}G`); } // --- Buffer editing --- function insertAt(pos: number, text: string): void { buffer = buffer.slice(0, pos) + text + buffer.slice(pos); } function deleteRange(start: number, end: number): void { buffer = buffer.slice(0, start) + buffer.slice(end); } function insertChar(ch: string): void { insertAt(cursorPos, ch); cursorPos += ch.length; process.stdout.write(ch); if (cursorPos < getLineEnd()) { const afterCursor = buffer.slice(cursorPos, getLineEnd()); process.stdout.write(afterCursor); process.stdout.write('\x1B[K'); const afterWidth = getDisplayWidth(afterCursor); process.stdout.write(`\x1B[${afterWidth}D`); } } function deleteCharBefore(): void { if (cursorPos <= getLineStart()) return; const charWidth = getDisplayWidth(buffer[cursorPos - 1]!); deleteRange(cursorPos - 1, cursorPos); cursorPos--; process.stdout.write(`\x1B[${charWidth}D`); rerenderFromCursor(); } function deleteToLineEnd(): void { const lineEnd = getLineEnd(); if (cursorPos < lineEnd) { deleteRange(cursorPos, lineEnd); process.stdout.write('\x1B[K'); } } function deleteToLineStart(): void { const lineStart = getLineStart(); if (cursorPos > lineStart) { const deletedWidth = getDisplayWidth(buffer.slice(lineStart, cursorPos)); deleteRange(lineStart, cursorPos); cursorPos = lineStart; process.stdout.write(`\x1B[${deletedWidth}D`); rerenderFromCursor(); } } function deleteWord(): void { const lineStart = getLineStart(); let end = cursorPos; while (end > lineStart && buffer[end - 1] === ' ') end--; while (end > lineStart && buffer[end - 1] !== ' ') end--; if (end < cursorPos) { const deletedWidth = getDisplayWidth(buffer.slice(end, cursorPos)); deleteRange(end, cursorPos); cursorPos = end; process.stdout.write(`\x1B[${deletedWidth}D`); rerenderFromCursor(); } } function insertNewline(): void { const afterCursorOnLine = buffer.slice(cursorPos, getLineEnd()); insertAt(cursorPos, '\n'); cursorPos++; process.stdout.write('\x1B[K'); process.stdout.write('\n'); if (afterCursorOnLine.length > 0) { process.stdout.write(afterCursorOnLine); const afterWidth = getDisplayWidth(afterCursorOnLine); process.stdout.write(`\x1B[${afterWidth}D`); } } // --- Input dispatch --- const utf8Decoder = new StringDecoder('utf8'); function onData(data: Buffer): void { try { const str = utf8Decoder.write(data); if (!str) return; parseInputData(str, { onPasteStart() { state = 'paste'; }, onPasteEnd() { state = 'normal'; rerenderFromCursor(); }, onShiftEnter() { insertNewline(); }, onArrowLeft() { if (state !== 'normal') return; if (cursorPos > getLineStart()) { const charWidth = getDisplayWidth(buffer[cursorPos - 1]!); cursorPos--; process.stdout.write(`\x1B[${charWidth}D`); } else if (getLineStart() > 0) { cursorPos = getLineStart() - 1; const col = getTerminalColumn(cursorPos); process.stdout.write('\x1B[A'); process.stdout.write(`\x1B[${col}G`); } }, onArrowRight() { if (state !== 'normal') return; if (cursorPos < getLineEnd()) { const charWidth = getDisplayWidth(buffer[cursorPos]!); cursorPos++; process.stdout.write(`\x1B[${charWidth}C`); } else if (cursorPos < buffer.length && buffer[cursorPos] === '\n') { cursorPos++; const col = getTerminalColumn(cursorPos); process.stdout.write('\x1B[B'); process.stdout.write(`\x1B[${col}G`); } }, onArrowUp() { if (state !== 'normal') return; const logicalLineStart = getLineStart(); const displayRowStart = getDisplayRowStart(cursorPos); const displayCol = getDisplayRowColumn(cursorPos); if (displayRowStart > logicalLineStart) { // Move to previous display row within the same logical line const prevRowStart = getDisplayRowStart(displayRowStart - 1); const prevRowEnd = getDisplayRowEnd(displayRowStart - 1); moveCursorToDisplayRow(prevRowStart, prevRowEnd, displayCol, 'A'); } else if (logicalLineStart > 0) { // Move to the last display row of the previous logical line const prevLogicalLineEnd = logicalLineStart - 1; const prevRowStart = getDisplayRowStart(prevLogicalLineEnd); const prevRowEnd = getDisplayRowEnd(prevLogicalLineEnd); moveCursorToDisplayRow(prevRowStart, prevRowEnd, displayCol, 'A'); } }, onArrowDown() { if (state !== 'normal') return; const logicalLineEnd = getLineEnd(); const displayRowEnd = getDisplayRowEnd(cursorPos); const displayCol = getDisplayRowColumn(cursorPos); if (displayRowEnd < logicalLineEnd) { // Move to next display row within the same logical line const nextRowStart = displayRowEnd; const nextRowEnd = getDisplayRowEnd(displayRowEnd); moveCursorToDisplayRow(nextRowStart, nextRowEnd, displayCol, 'B'); } else if (logicalLineEnd < buffer.length) { // Move to the first display row of the next logical line const nextLineStart = logicalLineEnd + 1; const nextRowEnd = getDisplayRowEnd(nextLineStart); moveCursorToDisplayRow(nextLineStart, nextRowEnd, displayCol, 'B'); } }, onWordLeft() { if (state !== 'normal') return; const lineStart = getLineStart(); if (cursorPos <= lineStart) return; let pos = cursorPos; while (pos > lineStart && buffer[pos - 1] === ' ') pos--; while (pos > lineStart && buffer[pos - 1] !== ' ') pos--; const moveWidth = getDisplayWidth(buffer.slice(pos, cursorPos)); cursorPos = pos; process.stdout.write(`\x1B[${moveWidth}D`); }, onWordRight() { if (state !== 'normal') return; const lineEnd = getLineEnd(); if (cursorPos >= lineEnd) return; let pos = cursorPos; while (pos < lineEnd && buffer[pos] !== ' ') pos++; while (pos < lineEnd && buffer[pos] === ' ') pos++; const moveWidth = getDisplayWidth(buffer.slice(cursorPos, pos)); cursorPos = pos; process.stdout.write(`\x1B[${moveWidth}C`); }, onHome() { if (state !== 'normal') return; moveCursorToLogicalLineStart(); }, onEnd() { if (state !== 'normal') return; moveCursorToLogicalLineEnd(); }, onChar(ch: string) { if (state === 'paste') { if (ch === '\r' || ch === '\n') { insertAt(cursorPos, '\n'); cursorPos++; process.stdout.write('\n'); } else { insertAt(cursorPos, ch); cursorPos++; process.stdout.write(ch); } return; } // Submit if (ch === '\r') { process.stdout.write('\n'); cleanup(); resolve(buffer); return; } // Cancel if (ch === '\x03' || ch === '\x04') { process.stdout.write('\n'); cleanup(); resolve(null); return; } // Editing if (ch === '\x7F' || ch === '\x08') { deleteCharBefore(); return; } if (ch === '\x01') { moveCursorToDisplayRowStart(); return; } if (ch === '\x05') { moveCursorToDisplayRowEnd(); return; } if (ch === '\x0B') { deleteToLineEnd(); return; } if (ch === '\x15') { deleteToLineStart(); return; } if (ch === '\x17') { deleteWord(); return; } // Ignore unknown control characters if (ch.charCodeAt(0) < 0x20) return; // Regular character insertChar(ch); }, }); } catch { cleanup(); resolve(null); } } process.stdin.on('data', onData); }); }