From 68e98be653896dd6dcbc690c1cd6c5fa9f9a90d9 Mon Sep 17 00:00:00 2001 From: Mplan Date: Tue, 9 Jun 2026 17:31:52 +0800 Subject: [PATCH] refactor(cli): replace external editor with inline terminal editing --- index.ts | 165 ++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 138 insertions(+), 27 deletions(-) diff --git a/index.ts b/index.ts index ca8b14b..23973df 100644 --- a/index.ts +++ b/index.ts @@ -1,9 +1,6 @@ #!/usr/bin/env bun import * as readline from "node:readline"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { unlink } from "node:fs/promises"; import { loadConfig, saveConfig } from "./src/config"; import { isGitRepo, @@ -108,38 +105,152 @@ async function confirmCommit(message: string): Promise<"y" | "n" | "e"> { } async function editMessage(current: string): Promise { - const tmpPath = join(tmpdir(), `gai-msg-${Date.now()}`); + if (!process.stdin.isTTY) return null; - const header = `# Edit commit message below. Save and close to confirm.\n# Delete all lines to abort.\n`; - await Bun.write(tmpPath, header + current); + process.stdout.write(` ${DIM}Edit message (Enter to confirm, Esc to abort):${RESET}\n`); - const editor = - process.env.VISUAL || - process.env.EDITOR || - (process.platform === "darwin" ? "vi" : "nano"); + const savedRaw = process.stdin.isRaw; + process.stdin.setRawMode(true); + process.stdin.resume(); - const proc = Bun.spawn([editor, tmpPath], { - stdout: "inherit", - stderr: "inherit", - stdin: "inherit", - }); + let buffer = current; + let cursor = current.length; + const ESC = "\x1b"; + const ENTER = "\r"; + const CTRL_C = "\x03"; + const BACKSPACE = "\x7f"; + const DELETE = "\x1b[3~"; + const LEFT = "\x1b[D"; + const RIGHT = "\x1b[C"; + const HOME = "\x1b[H" /* or \x01 */; + const END = "\x1b[F" /* or \x05 */; - const exitCode = await proc.exited; - if (exitCode !== 0) { - await unlink(tmpPath).catch(() => {}); - return null; + function render() { + process.stdout.write("\x1b[2K\r > " + buffer); + if (cursor < buffer.length) { + process.stdout.write(`\x1b[${buffer.length - cursor}D`); + } } - const content = await Bun.file(tmpPath).text(); - await unlink(tmpPath).catch(() => {}); + process.stdout.write(" > "); + process.stdout.write(buffer); - const lines = content - .split("\n") - .filter((line) => !line.startsWith("#")) - .join("\n") - .trim(); + return new Promise((resolve) => { + let escapeBuf = ""; - return lines || null; + function handleSeq(seq: string) { + if (seq === "\x1b[D" || seq === "\x1bOD") { + if (cursor > 0) { + cursor--; + process.stdout.write("\x1b[D"); + } + } else if (seq === "\x1b[C" || seq === "\x1bOC") { + if (cursor < buffer.length) { + cursor++; + process.stdout.write("\x1b[C"); + } + } else if (seq === "\x1b[H" || seq === "\x1b[1~" || seq === "\x1bOH") { + if (cursor > 0) { + process.stdout.write(`\x1b[${cursor}D`); + cursor = 0; + } + } else if (seq === "\x1b[F" || seq === "\x1b[4~" || seq === "\x1bOF") { + if (cursor < buffer.length) { + process.stdout.write(`\x1b[${buffer.length - cursor}C`); + cursor = buffer.length; + } + } else if (seq === "\x1b[3~") { + if (cursor < buffer.length) { + buffer = buffer.slice(0, cursor) + buffer.slice(cursor + 1); + render(); + } + } + } + + process.stdin.on("data", (data: Buffer) => { + const key = data.toString(); + + if (key === CTRL_C) { + process.stdin.setRawMode(savedRaw === true); + process.stdin.pause(); + process.stdin.removeAllListeners("data"); + process.stdout.write("\n"); + resolve(null); + return; + } + + if (key === ESC || key.startsWith("\x1b[")) { + escapeBuf = key; + if (key.length >= 3) { + handleSeq(key); + escapeBuf = ""; + } + return; + } + + if (escapeBuf) { + escapeBuf += key; + if (/^[A-Za-z~]$/.test(key)) { + handleSeq(escapeBuf); + escapeBuf = ""; + } else if (escapeBuf.length > 8) { + escapeBuf = ""; + } + return; + } + + if (key === ENTER) { + process.stdin.setRawMode(savedRaw === true); + process.stdin.pause(); + process.stdin.removeAllListeners("data"); + process.stdout.write("\n"); + const result = buffer.trim(); + resolve(result || null); + return; + } + + if (key === BACKSPACE) { + if (cursor > 0) { + buffer = buffer.slice(0, cursor - 1) + buffer.slice(cursor); + cursor--; + render(); + } + return; + } + + if (key === "\x01") { + if (cursor > 0) { + process.stdout.write(`\x1b[${cursor}D`); + cursor = 0; + } + return; + } + if (key === "\x05") { + if (cursor < buffer.length) { + process.stdout.write(`\x1b[${buffer.length - cursor}C`); + cursor = buffer.length; + } + return; + } + if (key === "\x0b") { + buffer = buffer.slice(0, cursor); + render(); + return; + } + if (key === "\x15") { + buffer = buffer.slice(cursor); + cursor = 0; + render(); + return; + } + + if (key.charCodeAt(0) >= 32 && key.charCodeAt(0) < 127) { + buffer = buffer.slice(0, cursor) + key + buffer.slice(cursor); + cursor++; + render(); + } + }); + }); } async function main() {