refactor: use shared helpers in all command handlers

- commit.ts, pr.ts: use ask() and editLine() from tty-input module
- config.ts: use shared terminal helpers and escape buffering
- explain.ts, review.ts, suggest.ts: use collectDiff() from
  diff-source module, eliminating ~400 lines of duplicated logic

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-18 01:56:36 +08:00
parent 4fbac6a6e1
commit f3b5c631de
6 changed files with 198 additions and 456 deletions
+2 -103
View File
@@ -1,4 +1,3 @@
import * as readline from "node:readline";
import {
isGitRepo,
getRepoRoot,
@@ -18,20 +17,11 @@ import { generateCommitMessage } from "../ai";
import { copyToClipboard } from "../clipboard";
import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal";
import { isStdinTTY } from "../tty";
import { ask, editLine } from "../tty-input";
import type { Config, CommitResult, StreamCallbacks } from "../types";
import { loadConfig } from "../config";
import type { ParsedArgs } from "../cli";
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());
});
});
}
function printCommitResult(result: CommitResult, msg: string) {
console.log(`\n ${GREEN()}${BOLD()}✔ Committed successfully!${RESET()}`);
const id = result.branch && result.hash
@@ -75,99 +65,8 @@ async function confirmCommit(message: string): Promise<"y" | "n" | "e"> {
}
async function editMessage(current: string): Promise<string | null> {
if (!isStdinTTY()) return null;
process.stdout.write(` ${DIM()}Edit message (Enter to confirm, Esc to abort):${RESET()}\n`);
const savedRaw = process.stdin.isRaw;
process.stdin.setRawMode(true);
process.stdin.resume();
let buffer = current;
let cursor = current.length;
function render() {
process.stdout.write("\x1b[2K\r > " + buffer);
if (cursor < buffer.length) {
process.stdout.write(`\x1b[${buffer.length - cursor}D`);
}
}
process.stdout.write(" > ");
process.stdout.write(buffer);
return new Promise((resolve) => {
let escapeBuf = "";
process.stdin.on("data", (data: Buffer) => {
const key = data.toString();
if (key === "\x03") {
process.stdin.setRawMode(savedRaw === true);
process.stdin.pause();
process.stdin.removeAllListeners("data");
process.stdout.write("\n");
resolve(null);
return;
}
if (key === "\x1b" || 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 === "\r") {
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 === "\x7f") {
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();
}
});
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(); }
}
}
});
return editLine(current);
}
export async function handleCommit(args: ParsedArgs): Promise<number> {
+7 -18
View File
@@ -1,7 +1,8 @@
import { loadConfig, saveConfig } from "../config";
import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal";
import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET, hideCursor, showCursor, clearLine, moveUp, visibleLength } from "../terminal";
import { isStdinTTY } from "../tty";
import { SKIP_WAIT } from "../menu";
import { editLine } from "../tty-input";
import type { Config } from "../types";
import type { ParsedArgs } from "../cli";
@@ -66,18 +67,6 @@ const CONFIG_FIELDS: ConfigField[] = [
},
];
function visibleLength(value: string) {
return value.replace(/\x1b\[[0-9;]*m/g, "").length;
}
function clearLine() {
process.stdout.write("\r\x1b[2K");
}
function moveUp(lines: number) {
if (lines > 0) process.stdout.write(`\x1b[${lines}A`);
}
function renderConfigPage(
config: Config,
cursor: number,
@@ -151,29 +140,29 @@ async function interactiveConfig(): Promise<"done" | "back"> {
if (wasRaw !== true) process.stdin.setRawMode(true);
process.stdin.resume();
process.stdout.write("\x1b[?25l");
hideCursor();
const render = () => {
moveUp(renderedCursorRow);
renderedLines = renderConfigPage(config, cursor, renderedLines, status, editState);
renderedCursorRow = editState ? 4 + cursor : 0;
process.stdout.write(editState ? "\x1b[?25h" : "\x1b[?25l");
editState ? showCursor() : hideCursor();
};
render();
return new Promise((resolve) => {
return new Promise((resolve, reject) => {
const finish = (value: "done" | "back") => {
process.stdin.removeListener("data", onData);
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdin.removeListener("data", onData);
moveUp(renderedCursorRow);
for (let i = 0; i < renderedLines; i++) {
clearLine();
process.stdout.write("\n");
}
moveUp(renderedLines);
process.stdout.write("\x1b[?25h");
showCursor();
resolve(value);
};
+11 -66
View File
@@ -1,15 +1,6 @@
import { loadConfig } from "../config";
import {
isGitRepo,
getStagedFiles,
getStagedDiff,
getUnstagedFiles,
getRepoRoot,
applyFileSelection,
} from "../git";
import { selectFiles } from "../selector";
import { collectDiff } from "../diff-source";
import { BACK, SKIP_WAIT } from "../menu";
import { collectProjectContext } from "../context";
import { EXPLAIN_SYSTEM_PROMPT, buildExplainPrompt } from "../prompt";
import { callAI } from "../ai";
import { BOLD, GREEN, RED, DIM, RESET, CYAN } from "../terminal";
@@ -28,76 +19,31 @@ export async function handleExplain(args: ParsedArgs): Promise<number> {
const unstaged = args.flags["unstaged"] as boolean;
const verbose = args.flags["verbose"] as boolean;
// Determine which diff to explain
let diff: string;
let sourceLabel: string;
let contextPrefix: string;
if (unstaged) {
if (!(await isGitRepo())) {
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
return 1;
}
try {
diff = (await Bun.$`git diff`.quiet().text()).trim();
} catch {
diff = "";
}
sourceLabel = "unstaged changes";
} else {
// Default: staged changes (or piped)
if (isStdinTTY()) {
if (!(await isGitRepo())) {
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
const result = await collectDiff({ unstaged, includeProjectContext: true });
if (result.back) return SKIP_WAIT as unknown as number;
diff = result.diff;
sourceLabel = result.sourceLabel;
contextPrefix = result.contextPrefix;
} catch (err) {
console.error(`\n ${RED()}Error: ${err instanceof Error ? err.message : err}${RESET()}\n`);
return 1;
}
const stagedFiles = await getStagedFiles();
const unstagedFiles = await getUnstagedFiles();
sourceLabel = "selected changes";
if (stagedFiles.length > 0 || unstagedFiles.length > 0) {
const selected = await selectFiles(stagedFiles, unstagedFiles);
if (selected === BACK) return SKIP_WAIT as unknown as number;
await applyFileSelection(stagedFiles, unstagedFiles, selected);
}
diff = await getStagedDiff();
} else {
// Read from pipe
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
}
diff = Buffer.concat(chunks).toString("utf-8").trim();
sourceLabel = "piped input";
}
}
if (!diff) {
console.log(` ${DIM()}No ${sourceLabel} to explain.${RESET()}`);
return 0;
}
if (args.flags["verbose"]) {
if (verbose) {
console.log(` ${DIM()}Explaining ${sourceLabel} (${diff.length} bytes)${RESET()}`);
}
const MAX_DIFF_SIZE = 15000;
const truncatedDiff = diff.length > MAX_DIFF_SIZE
? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)"
: diff;
// Collect project context for better explanations
let contextPrefix = "";
try {
if (await isGitRepo()) {
const repoRoot = await getRepoRoot();
const ctx = await collectProjectContext(repoRoot);
if (ctx.packageDescription) {
contextPrefix = `Project: ${ctx.packageDescription}\n\n`;
}
}
} catch {}
const userPrompt = contextPrefix + buildExplainPrompt(truncatedDiff);
const userPrompt = contextPrefix + buildExplainPrompt(diff);
if (verbose) {
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`);
@@ -117,7 +63,6 @@ export async function handleExplain(args: ParsedArgs): Promise<number> {
if (callbacks) {
process.stdout.write("\n");
} else {
// Non-TTY: print the result directly
process.stdout.write(explanation + "\n");
}
} catch (err) {
+1 -11
View File
@@ -1,4 +1,3 @@
import * as readline from "node:readline";
import { loadConfig } from "../config";
import { isGitRepo, getRepoRoot } from "../git";
import { collectProjectContext } from "../context";
@@ -19,19 +18,10 @@ import {
import type { Platform } from "../pr";
import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal";
import { isStdinTTY } from "../tty";
import { ask } from "../tty-input";
import { copyToClipboard } from "../clipboard";
import type { ParsedArgs } from "../cli";
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());
});
});
}
async function selectPlatform(hostname: string): Promise<Platform | null | typeof BACK> {
if (!isStdinTTY()) {
console.error(`\n ${RED()}Error: Platform selection requires a TTY.${RESET()}\n`);
+10 -58
View File
@@ -1,15 +1,6 @@
import { loadConfig } from "../config";
import {
isGitRepo,
getStagedFiles,
getStagedDiff,
getUnstagedFiles,
getRepoRoot,
applyFileSelection,
} from "../git";
import { selectFiles } from "../selector";
import { collectDiff } from "../diff-source";
import { BACK, SKIP_WAIT } from "../menu";
import { collectProjectContext } from "../context";
import { REVIEW_SYSTEM_PROMPT, buildReviewPrompt } from "../prompt";
import { callAI } from "../ai";
import { BOLD, GREEN, YELLOW, RED, DIM, RESET, CYAN } from "../terminal";
@@ -36,64 +27,25 @@ export async function handleReview(args: ParsedArgs): Promise<number> {
let diff: string;
let sourceLabel: string;
let contextPrefix: string;
if (unstaged) {
if (!(await isGitRepo())) {
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
return 1;
}
try {
diff = (await Bun.$`git diff`.quiet().text()).trim();
} catch {
diff = "";
}
sourceLabel = "unstaged changes";
} else if (!isStdinTTY()) {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
}
diff = Buffer.concat(chunks).toString("utf-8").trim();
sourceLabel = "piped input";
} else {
if (!(await isGitRepo())) {
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
const result = await collectDiff({ unstaged, includeProjectContext: true });
if (result.back) return SKIP_WAIT as unknown as number;
diff = result.diff;
sourceLabel = result.sourceLabel;
contextPrefix = result.contextPrefix;
} catch (err) {
console.error(`\n ${RED()}Error: ${err instanceof Error ? err.message : err}${RESET()}\n`);
return 1;
}
const stagedFiles = await getStagedFiles();
const unstagedFiles = await getUnstagedFiles();
sourceLabel = "selected changes";
if (stagedFiles.length > 0 || unstagedFiles.length > 0) {
const selected = await selectFiles(stagedFiles, unstagedFiles);
if (selected === BACK) return SKIP_WAIT as unknown as number;
await applyFileSelection(stagedFiles, unstagedFiles, selected);
}
diff = await getStagedDiff();
}
if (!diff) {
console.log(` ${DIM()}No ${sourceLabel} to review.${RESET()}`);
return 0;
}
const MAX_DIFF_SIZE = 15000;
const truncatedDiff = diff.length > MAX_DIFF_SIZE
? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)"
: diff;
let contextPrefix = "";
try {
if (await isGitRepo()) {
const repoRoot = await getRepoRoot();
const ctx = await collectProjectContext(repoRoot);
if (ctx.packageDescription) {
contextPrefix = `Project: ${ctx.packageDescription}\n\n`;
}
}
} catch {}
const userPrompt = contextPrefix + buildReviewPrompt(truncatedDiff, strictnessFlag);
const userPrompt = contextPrefix + buildReviewPrompt(diff, strictnessFlag);
const strictnessLabel = strictnessFlag === "strict"
? `${RED()}strict${RESET()}`
+11 -44
View File
@@ -1,12 +1,5 @@
import { loadConfig } from "../config";
import {
isGitRepo,
getStagedFiles,
getStagedDiff,
getUnstagedFiles,
applyFileSelection,
} from "../git";
import { selectFiles } from "../selector";
import { collectDiff } from "../diff-source";
import { BACK, SKIP_WAIT } from "../menu";
import {
SUGGEST_SYSTEM_PROMPT,
@@ -36,37 +29,17 @@ export async function handleSuggest(args: ParsedArgs): Promise<number> {
return 1;
}
// Get diff (staged, or unstaged if --unstaged, or piped)
const unstaged = args.flags["unstaged"] as boolean;
let diff: string;
if (!isStdinTTY()) {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
}
diff = Buffer.concat(chunks).toString("utf-8").trim();
} else {
if (!(await isGitRepo())) {
console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`);
return 1;
}
if (args.flags["unstaged"] as boolean) {
try {
diff = (await Bun.$`git diff`.quiet().text()).trim();
} catch {
diff = "";
}
} else {
const stagedFiles = await getStagedFiles();
const unstagedFiles = await getUnstagedFiles();
if (stagedFiles.length > 0 || unstagedFiles.length > 0) {
const selected = await selectFiles(stagedFiles, unstagedFiles);
if (selected === BACK) return SKIP_WAIT as unknown as number;
await applyFileSelection(stagedFiles, unstagedFiles, selected);
}
diff = await getStagedDiff();
}
const result = await collectDiff({ unstaged, includeProjectContext: false });
if (result.back) return SKIP_WAIT as unknown as number;
diff = result.diff;
} catch (err) {
console.error(`\n ${RED()}Error: ${err instanceof Error ? err.message : err}${RESET()}\n`);
return 1;
}
if (!diff) {
@@ -74,20 +47,14 @@ export async function handleSuggest(args: ParsedArgs): Promise<number> {
return 0;
}
const MAX_DIFF_SIZE = 15000;
const truncatedDiff = diff.length > MAX_DIFF_SIZE
? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)"
: diff;
if (verbose) {
console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`);
}
if (mode === "branch") {
return handleSuggestBranch(config, truncatedDiff);
} else {
return handleSuggestType(config, truncatedDiff);
return handleSuggestBranch(config, diff);
}
return handleSuggestType(config, diff);
}
async function handleSuggestBranch(config: Config, diff: string): Promise<number> {