diff --git a/src/tty-input.ts b/src/tty-input.ts new file mode 100644 index 0000000..349380b --- /dev/null +++ b/src/tty-input.ts @@ -0,0 +1,163 @@ +// Shared TTY input utilities used by command handlers. +// Provides a simple line-input "ask" helper and a reusable inline +// raw-mode text editor (used by both commit message editing and +// interactive config editing). + +import * as readline from "node:readline"; +import { isStdinTTY } from "./tty"; + +// ── Simple line input (cooked mode) ────────────────────────────────── + +export function ask(question: string): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +// ── Inline raw-mode editor ──────────────────────────────────────────── +// +// Provides a simple line editor that runs in raw mode and supports: +// - Left/Right arrows, Home/End for cursor movement +// - Backspace / Delete for character removal +// - Ctrl+A (beginning), Ctrl+E (end), Ctrl+K (kill to end), Ctrl+U (kill to start) +// - Enter to confirm, Ctrl+C / Esc to cancel +// +// Returns the edited string, or null if the user cancelled. + +export interface EditResult { + value: string | null; +} + +export async function editLine(initial: string): Promise { + if (!isStdinTTY()) return null; + + const savedRaw = process.stdin.isRaw; + process.stdin.setRawMode(true); + process.stdin.resume(); + + let buffer = initial; + let cursor = initial.length; + + function render() { + // Clear line, move to start, show prompt + buffer, then reposition cursor + process.stdout.write("\r\x1b[2K > " + buffer); + if (cursor < buffer.length) { + process.stdout.write(`\x1b[${buffer.length - cursor}D`); + } + } + + process.stdout.write(" > " + buffer); + + return new Promise((resolve) => { + let escapeBuf = ""; + + function finish(value: string | null) { + process.stdin.setRawMode(savedRaw === true); + process.stdin.pause(); + process.stdin.removeAllListeners("data"); + process.stdout.write("\n"); + resolve(value); + } + + function handleEscapeSeq(seq: string) { + switch (seq) { + case "\x1b[D": case "\x1bOD": // Left + if (cursor > 0) { cursor--; process.stdout.write("\x1b[D"); } + break; + case "\x1b[C": case "\x1bOC": // Right + if (cursor < buffer.length) { cursor++; process.stdout.write("\x1b[C"); } + break; + case "\x1b[H": case "\x1b[1~": case "\x1bOH": // Home + if (cursor > 0) { process.stdout.write(`\x1b[${cursor}D`); cursor = 0; } + break; + case "\x1b[F": case "\x1b[4~": case "\x1bOF": // End + if (cursor < buffer.length) { process.stdout.write(`\x1b[${buffer.length - cursor}C`); cursor = buffer.length; } + break; + case "\x1b[3~": // Delete + if (cursor < buffer.length) { buffer = buffer.slice(0, cursor) + buffer.slice(cursor + 1); render(); } + break; + } + } + + process.stdin.on("data", (data: Buffer) => { + const key = data.toString(); + + // Ctrl+C + if (key === "\x03") { finish(null); return; } + // Esc + if (key === "\x1b") { + if (escapeBuf) { + // Already buffering — check if this completes a sequence + const next = escapeBuf + key; + if (/^(\x1b\[[0-9;]*[A-Za-z~]|\x1bO[A-Z])$/.test(next)) { + handleEscapeSeq(next); + escapeBuf = ""; + } else { + // Treat lone Esc as cancel + finish(null); + } + return; + } + escapeBuf = "\x1b"; + return; + } + + // Buffering an escape sequence + if (escapeBuf) { + escapeBuf += key; + // Check if this completes a valid sequence + if (/^(\x1b\[[0-9;]*[A-Za-z~]|\x1bO[A-Z])$/.test(escapeBuf)) { + handleEscapeSeq(escapeBuf); + escapeBuf = ""; + } else if (escapeBuf.length > 10 || /^[A-Za-z~]$/.test(key)) { + // Timeout or terminator that didn't match — discard + escapeBuf = ""; + } + return; + } + + // Enter + if (key === "\r" || key === "\n") { + const result = buffer.trim(); + finish(result || null); + return; + } + + // Backspace + if (key === "\x7f") { + if (cursor > 0) { + buffer = buffer.slice(0, cursor - 1) + buffer.slice(cursor); + cursor--; + render(); + } + return; + } + + // Ctrl+A → beginning of line + if (key === "\x01") { + if (cursor > 0) { process.stdout.write(`\x1b[${cursor}D`); cursor = 0; } + return; + } + // Ctrl+E → end of line + if (key === "\x05") { + if (cursor < buffer.length) { process.stdout.write(`\x1b[${buffer.length - cursor}C`); cursor = buffer.length; } + return; + } + // Ctrl+K → kill to end + if (key === "\x0b") { buffer = buffer.slice(0, cursor); render(); return; } + // Ctrl+U → kill to start + if (key === "\x15") { buffer = buffer.slice(cursor); cursor = 0; render(); return; } + + // Printable characters + if (key >= " " && key !== "\x7f") { + buffer = buffer.slice(0, cursor) + key + buffer.slice(cursor); + cursor += key.length; + render(); + } + }); + }); +}