takt/src/features/interactive/lineEditor.ts
nrs c542dc0896
github-issue-155-taktno-moodo (#158)
* caffeinate に -d フラグを追加し、ディスプレイスリープ中の App Nap によるプロセス凍結を防止

* takt 対話モードの save_task を takt add と同じ worktree 設定フローに統一

takt 対話モードで Save Task を選択した際に worktree/branch/auto_pr の
設定プロンプトがスキップされ、takt run で clone なしに実行されて成果物が
消失するバグを修正。promptWorktreeSettings() を共通関数として抽出し、
saveTaskFromInteractive() と addTask() の両方から使用するようにした。

* Release v0.9.0

* takt: github-issue-155-taktno-moodo
2026-02-09 00:18:07 +09:00

690 lines
22 KiB
TypeScript

/**
* 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[<u';
/** Known escape sequence prefixes for matching */
const ESC_PASTE_START = '[200~';
const ESC_PASTE_END = '[201~';
const ESC_SHIFT_ENTER = '[13;2u';
type InputState = 'normal' | 'paste';
/**
* Decode Kitty CSI-u key sequence into a control character.
* Example: "[99;5u" (Ctrl+C) -> "\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<string | null> {
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);
});
}