feat: add shared tty-input module (ask, editLine)
Extract duplicated ask() from commit.ts and pr.ts, and extract the 100+ line inline raw-mode editor editMessage() from commit.ts into a reusable editLine() with proper escape sequence buffering. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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<string> {
|
||||||
|
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<string | null> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user