feat: add retry logic to AI client and interactive unstaged file selector

This commit is contained in:
2026-06-09 17:13:34 +08:00
parent 9f33d0f2ed
commit de96c8862e
4 changed files with 242 additions and 68 deletions
+145 -39
View File
@@ -1,6 +1,37 @@
import * as readline from "node:readline";
import type { FileEntry } from "./types";
import { BOLD, GREEN, YELLOW, CYAN, RESET } from "./terminal";
import { BOLD, GREEN, YELLOW, CYAN, DIM, RESET } from "./terminal";
const UP = "\x1b[A";
const DOWN = "\x1b[B";
const SPACE = " ";
const ENTER = "\r";
const CTRL_C = "\x03";
function hideCursor() {
process.stdout.write("\x1b[?25l");
}
function showCursor() {
process.stdout.write("\x1b[?25h");
}
function moveUp(n: number) {
process.stdout.write(`\x1b[${n}A`);
}
function moveDown(n: number) {
process.stdout.write(`\x1b[${n}B`);
}
function clearLine() {
process.stdout.write("\x1b[2K\r");
}
interface SelectItem {
label: string;
path: string;
selected: boolean;
}
export async function selectFiles(
stagedFiles: FileEntry[],
@@ -9,54 +40,129 @@ export async function selectFiles(
if (unstagedFiles.length === 0) return [];
if (stagedFiles.length > 0) {
console.log(`\n ${BOLD}Staged files (will be included):${RESET}`);
process.stdout.write(`\n ${BOLD}Staged files (will be included):${RESET}\n`);
for (const f of stagedFiles) {
console.log(` ${GREEN}${RESET} ${f.path} (${YELLOW}${f.label}${RESET})`);
process.stdout.write(` ${GREEN}${RESET} ${f.path} (${YELLOW}${f.label}${RESET})\n`);
}
}
console.log(`\n ${BOLD}Unstaged files:${RESET}`);
for (let i = 0; i < unstagedFiles.length; i++) {
const f = unstagedFiles[i]!;
console.log(
` ${CYAN}${i + 1}.${RESET} ${f.path} (${YELLOW}${f.label}${RESET})`,
);
const items: SelectItem[] = [
{ label: "Select all", path: "__all__", selected: false },
...unstagedFiles.map((f) => ({
label: `${f.path} (${f.label})`,
path: f.path,
selected: false,
})),
];
let cursor = 0;
process.stdout.write(`\n ${BOLD}Select files to stage:${RESET}\n`);
process.stdout.write(` ${DIM}↑/↓ navigate, space select, enter confirm${RESET}\n\n`);
const itemStartRow = 4 + (stagedFiles.length > 0 ? stagedFiles.length + 2 : 0);
function render() {
for (let i = 0; i < items.length; i++) {
process.stdout.write("\x1b[2K\r");
const item = items[i]!;
const isAll = i === 0;
const cursor_ = i === cursor ? `${CYAN}${RESET} ` : " ";
const checkbox = item.selected ? `${GREEN}${RESET}` : `${DIM}${RESET}`;
if (isAll) {
process.stdout.write(`${cursor_} ${checkbox} ${BOLD}${item.label}${RESET}\n`);
} else {
process.stdout.write(`${cursor_} ${checkbox} ${item.path.includes("(") ? item.label : `${item.label}`}\n`);
}
}
moveUp(items.length);
}
console.log("");
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
function toggleItem(index: number) {
const item = items[index]!;
item.selected = !item.selected;
if (index === 0) {
for (let i = 1; i < items.length; i++) {
items[i]!.selected = item.selected;
}
} else {
const allSelected = items.slice(1).every((it) => it.selected);
items[0]!.selected = allSelected;
}
}
return new Promise((resolve) => {
rl.question(
" Enter files to stage (e.g. 1,3) or 'a' for all: ",
(answer) => {
rl.close();
const trimmed = answer.trim().toLowerCase();
if (process.stdin.isTTY !== true) {
resolve([]);
return;
}
if (trimmed === "a" || trimmed === "all") {
resolve(unstagedFiles.map((f) => f.path));
return;
const wasRaw = process.stdin.isRaw;
if (wasRaw !== true) {
process.stdin.setRawMode(true);
}
process.stdin.resume();
hideCursor();
render();
const onData = (data: Buffer) => {
const key = data.toString();
if (key === CTRL_C) {
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdin.removeListener("data", onData);
showCursor();
for (let i = 0; i < items.length; i++) {
process.stdout.write("\x1b[2K\n");
}
moveUp(items.length);
process.stdout.write(`\n Aborted.\n`);
process.exit(1);
}
if (key === UP) {
if (cursor > 0) {
cursor--;
render();
}
} else if (key === DOWN) {
if (cursor < items.length - 1) {
cursor++;
render();
}
} else if (key === SPACE) {
toggleItem(cursor);
render();
} else if (key === ENTER) {
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdin.removeListener("data", onData);
for (let i = 0; i < items.length; i++) {
process.stdout.write("\x1b[2K\n");
}
moveUp(items.length);
const selected = items
.slice(1)
.filter((it) => it.selected)
.map((it) => it.path);
if (selected.length > 0) {
process.stdout.write(
` ${GREEN}Staged ${selected.length} file(s):${RESET} ${selected.join(", ")}\n`,
);
}
if (trimmed === "") {
resolve([]);
return;
}
showCursor();
resolve(selected);
}
};
const indices = trimmed
.split(/[,\s]+/)
.map((s) => parseInt(s.trim()))
.filter(
(n) => !isNaN(n) && n >= 1 && n <= unstagedFiles.length,
)
.map((n) => n - 1);
const uniqueIndices = [...new Set(indices)];
resolve(uniqueIndices.map((i) => unstagedFiles[i]!.path));
},
);
process.stdin.on("data", onData);
});
}