feat: overhaul CLI with new AI commands and mole-style menu #6

Merged
Mplan merged 24 commits from v0.1.3 into main 2026-06-17 00:17:31 +08:00
4 changed files with 299 additions and 1075 deletions
Showing only changes of commit 4b384a7581 - Show all commits
+240 -961
View File
File diff suppressed because it is too large Load Diff
+21 -21
View File
@@ -1,26 +1,26 @@
export async function copyToClipboard(text: string): Promise<boolean> { export async function copyToClipboard(text: string): Promise<boolean> {
const commands: string[][] = []; const commands: string[][] = [];
if (process.platform === "darwin") { if (process.platform === "darwin") {
commands.push(["pbcopy"]); commands.push(["pbcopy"]);
} else if (process.platform === "linux") { } else if (process.platform === "linux") {
commands.push(["xclip", "-selection", "clipboard"]); commands.push(["xclip", "-selection", "clipboard"]);
commands.push(["xsel", "--clipboard", "--input"]); commands.push(["xsel", "--clipboard", "--input"]);
} }
for (const cmd of commands) { for (const cmd of commands) {
try { try {
const proc = Bun.spawn(cmd, { const proc = Bun.spawn(cmd, {
stdin: "pipe", stdin: "pipe",
stdout: "ignore", stdout: "ignore",
stderr: "ignore", stderr: "ignore",
}); });
proc.stdin.write(text); proc.stdin.write(text);
proc.stdin.end(); proc.stdin.end();
const exitCode = await proc.exited; const exitCode = await proc.exited;
if (exitCode === 0) return true; if (exitCode === 0) return true;
} catch {} } catch {}
} }
return false; return false;
} }
+33 -89
View File
@@ -1,4 +1,5 @@
import { BOLD, GREEN, CYAN, DIM, RESET } from "./terminal"; import { BOLD, GREEN, CYAN, DIM, RESET } from "./terminal";
import { isStdinTTY } from "./tty";
const UP = "\x1b[A"; const UP = "\x1b[A";
const DOWN = "\x1b[B"; const DOWN = "\x1b[B";
@@ -38,13 +39,8 @@ interface MultiPromptOptions<T> extends BasePromptOptions {
doneLabel?: string; doneLabel?: string;
} }
function hideCursor() { function hideCursor() { process.stdout.write("\x1b[?25l"); }
process.stdout.write("\x1b[?25l"); function showCursor() { process.stdout.write("\x1b[?25h"); }
}
function showCursor() {
process.stdout.write("\x1b[?25h");
}
function moveUp(lines: number) { function moveUp(lines: number) {
if (lines > 0) process.stdout.write(`\x1b[${lines}A`); if (lines > 0) process.stdout.write(`\x1b[${lines}A`);
@@ -65,10 +61,10 @@ function padLabel(label: string, width: number) {
function controls(mode: "single" | "multi", showBackHint = true) { function controls(mode: "single" | "multi", showBackHint = true) {
if (mode === "single") { if (mode === "single") {
const backHint = showBackHint ? " · ←/backspace back" : ""; const backHint = showBackHint ? " · ←/backspace back" : "";
return `${DIM}↑/↓ navigate · enter/space select${backHint} · ctrl+c cancel${RESET}`; return `${DIM()}↑/↓ navigate · enter/space select${backHint} · ctrl+c cancel${RESET()}`;
} }
const backHint = showBackHint ? " · ←/backspace back" : ""; const backHint = showBackHint ? " · ←/backspace back" : "";
return `${DIM}↑/↓ navigate · space toggle · enter confirm${backHint} · ctrl+c cancel${RESET}`; return `${DIM()}↑/↓ navigate · space toggle · enter confirm${backHint} · ctrl+c cancel${RESET()}`;
} }
function renderPrompt(lines: string[], previousLines: number) { function renderPrompt(lines: string[], previousLines: number) {
@@ -79,10 +75,7 @@ function renderPrompt(lines: string[], previousLines: number) {
} }
moveUp(previousLines); moveUp(previousLines);
} }
for (const line of lines) process.stdout.write(`${line}\n`);
for (const line of lines) {
process.stdout.write(`${line}\n`);
}
moveUp(lines.length); moveUp(lines.length);
return lines.length; return lines.length;
} }
@@ -98,28 +91,19 @@ function clearPrompt(lines: number) {
function normalizeKey(key: string, escapeBuf: string) { function normalizeKey(key: string, escapeBuf: string) {
if (key === UP || key === ALT_UP) return { action: "up", escapeBuf: "" }; if (key === UP || key === ALT_UP) return { action: "up", escapeBuf: "" };
if (key === DOWN || key === ALT_DOWN) return { action: "down", escapeBuf: "" }; if (key === DOWN || key === ALT_DOWN) return { action: "down", escapeBuf: "" };
if (key === LEFT || key === ALT_LEFT || key === BACKSPACE) { if (key === LEFT || key === ALT_LEFT || key === BACKSPACE) return { action: "back", escapeBuf: "" };
return { action: "back", escapeBuf: "" };
}
if (key === SPACE) return { action: "space", escapeBuf: "" }; if (key === SPACE) return { action: "space", escapeBuf: "" };
if (key === ENTER) return { action: "enter", escapeBuf: "" }; if (key === ENTER) return { action: "enter", escapeBuf: "" };
if (key === CTRL_C) return { action: "cancel", escapeBuf: "" }; if (key === CTRL_C) return { action: "cancel", escapeBuf: "" };
if (key === "\x1b" || key.startsWith("\x1b[")) { if (key === "\x1b" || key.startsWith("\x1b[")) return { action: null, escapeBuf: key };
return { action: null, escapeBuf: key };
}
if (escapeBuf) { if (escapeBuf) {
const next = escapeBuf + key; const next = escapeBuf + key;
if (next === UP || next === ALT_UP) return { action: "up", escapeBuf: "" }; if (next === UP || next === ALT_UP) return { action: "up", escapeBuf: "" };
if (next === DOWN || next === ALT_DOWN) return { action: "down", escapeBuf: "" }; if (next === DOWN || next === ALT_DOWN) return { action: "down", escapeBuf: "" };
if (next === LEFT || next === ALT_LEFT) { if (next === LEFT || next === ALT_LEFT) return { action: "back", escapeBuf: "" };
return { action: "back", escapeBuf: "" }; return { action: null, escapeBuf: /^[A-Za-z~]$/.test(key) || next.length > 8 ? "" : next };
}
return {
action: null,
escapeBuf: /^[A-Za-z~]$/.test(key) || next.length > 8 ? "" : next,
};
} }
return { action: null, escapeBuf: "" }; return { action: null, escapeBuf: "" };
@@ -130,41 +114,35 @@ function createLines<T>(
mode: "single" | "multi", mode: "single" | "multi",
cursor: number, cursor: number,
) { ) {
const labelWidth = Math.max( const labelWidth = Math.max(...options.items.map((item) => visibleLength(item.label)), 0) + 2;
...options.items.map((item) => visibleLength(item.label)),
0,
) + 2;
const lines = [ const lines = [
"", "",
` ${BOLD}${options.title}${RESET}`, ` ${BOLD()}${options.title}${RESET()}`,
]; ];
if (options.subtitle) lines.push(` ${DIM}${options.subtitle}${RESET}`); if (options.subtitle) lines.push(` ${DIM()}${options.subtitle}${RESET()}`);
lines.push(` ${controls(mode, options.allowBack !== false)}`, ""); lines.push(` ${controls(mode, options.allowBack !== false)}`, "");
for (let i = 0; i < options.items.length; i++) { for (let i = 0; i < options.items.length; i++) {
const item = options.items[i]!; const item = options.items[i]!;
const active = i === cursor; const active = i === cursor;
const pointer = active ? `${CYAN}${RESET}` : " "; const pointer = active ? `${CYAN()}${RESET()}` : " ";
const marker = mode === "single" const marker = mode === "single"
? active ? `${GREEN}${RESET}` : `${DIM}${RESET}` ? active ? `${GREEN()}${RESET()}` : `${DIM()}${RESET()}`
: item.selected ? `${GREEN}${RESET}` : `${DIM}${RESET}`; : item.selected ? `${GREEN()}${RESET()}` : `${DIM()}${RESET()}`;
const label = active ? `${BOLD}${item.label}${RESET}` : item.label; const label = active ? `${BOLD()}${item.label}${RESET()}` : item.label;
const description = item.description const description = item.description
? active ? item.description : `${DIM}${item.description}${RESET}` ? active ? item.description : `${DIM()}${item.description}${RESET()}`
: ""; : "";
lines.push( lines.push(` ${pointer} ${marker} ${padLabel(label, labelWidth)}${description}`);
` ${pointer} ${marker} ${padLabel(label, labelWidth)}${description}`,
);
} }
return lines; return lines;
} }
function ensureTTY(title: string) { function ensureTTY(title: string) {
if (process.stdin.isTTY !== true) { if (!isStdinTTY()) {
throw new Error(`${title} requires a TTY.`); throw new Error(`${title} requires a TTY.`);
} }
} }
@@ -184,12 +162,8 @@ export async function selectOne<T>(
hideCursor(); hideCursor();
const render = () => { const render = () => {
renderedLines = renderPrompt( renderedLines = renderPrompt(createLines(options, "single", cursor), renderedLines);
createLines(options, "single", cursor),
renderedLines,
);
}; };
render(); render();
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -210,16 +184,10 @@ export async function selectOne<T>(
escapeBuf = result.escapeBuf; escapeBuf = result.escapeBuf;
if (result.action === "cancel") return finish(null); if (result.action === "cancel") return finish(null);
if (result.action === "back" && options.allowBack !== false) { if (result.action === "back" && options.allowBack !== false) return finish(BACK);
return finish(BACK); if (result.action === "up" && cursor > 0) { cursor--; render(); }
} else if (result.action === "down" && cursor < options.items.length - 1) { cursor++; render(); }
if (result.action === "up" && cursor > 0) { else if (result.action === "space" || result.action === "enter") {
cursor--;
render();
} else if (result.action === "down" && cursor < options.items.length - 1) {
cursor++;
render();
} else if (result.action === "space" || result.action === "enter") {
finish(options.items[cursor]!.value); finish(options.items[cursor]!.value);
} }
}; };
@@ -257,11 +225,8 @@ export async function selectMany<T>(
const toggle = (index: number) => { const toggle = (index: number) => {
const item = items[index]!; const item = items[index]!;
item.selected = !item.selected; item.selected = !item.selected;
if (options.selectAllLabel && index === 0) { if (options.selectAllLabel && index === 0) {
for (let i = 1; i < items.length; i++) { for (let i = 1; i < items.length; i++) items[i]!.selected = item.selected;
items[i]!.selected = item.selected;
}
} else { } else {
syncSelectAll(); syncSelectAll();
} }
@@ -269,19 +234,10 @@ export async function selectMany<T>(
const render = () => { const render = () => {
renderedLines = renderPrompt( renderedLines = renderPrompt(
createLines( createLines({ title: options.title, subtitle: options.subtitle, items }, "multi", cursor),
{
title: options.title,
subtitle: options.subtitle,
items,
},
"multi",
cursor,
),
renderedLines, renderedLines,
); );
}; };
render(); render();
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -302,24 +258,12 @@ export async function selectMany<T>(
escapeBuf = result.escapeBuf; escapeBuf = result.escapeBuf;
if (result.action === "cancel") return finish(null); if (result.action === "cancel") return finish(null);
if (result.action === "back" && options.allowBack !== false) { if (result.action === "back" && options.allowBack !== false) return finish(BACK);
return finish(BACK); if (result.action === "up" && cursor > 0) { cursor--; render(); }
} else if (result.action === "down" && cursor < items.length - 1) { cursor++; render(); }
if (result.action === "up" && cursor > 0) { else if (result.action === "space") { toggle(cursor); render(); }
cursor--; else if (result.action === "enter") {
render(); finish(items.filter((item) => item.selected && item.value !== null).map((item) => item.value as T));
} else if (result.action === "down" && cursor < items.length - 1) {
cursor++;
render();
} else if (result.action === "space") {
toggle(cursor);
render();
} else if (result.action === "enter") {
finish(
items
.filter((item) => item.selected && item.value !== null)
.map((item) => item.value as T),
);
} }
}; };
+5 -4
View File
@@ -1,5 +1,6 @@
import type { FileEntry } from "./types"; import type { FileEntry } from "./types";
import { BOLD, GREEN, YELLOW, RESET } from "./terminal"; import { BOLD, GREEN, YELLOW, RESET } from "./terminal";
import { isStdinTTY } from "./tty";
import { BACK, selectMany } from "./menu"; import { BACK, selectMany } from "./menu";
import type { PromptBack } from "./menu"; import type { PromptBack } from "./menu";
@@ -10,13 +11,13 @@ export async function selectFiles(
if (unstagedFiles.length === 0) return []; if (unstagedFiles.length === 0) return [];
if (stagedFiles.length > 0) { if (stagedFiles.length > 0) {
process.stdout.write(`\n ${BOLD}Staged files (will be included):${RESET}\n`); process.stdout.write(`\n ${BOLD()}Staged files (will be included):${RESET()}\n`);
for (const f of stagedFiles) { for (const f of stagedFiles) {
process.stdout.write(` ${GREEN}${RESET} ${f.path} (${YELLOW}${f.label}${RESET})\n`); process.stdout.write(` ${GREEN()}${RESET()} ${f.path} (${YELLOW()}${f.label}${RESET()})\n`);
} }
} }
if (process.stdin.isTTY !== true) return []; if (!isStdinTTY()) return [];
const selected = await selectMany({ const selected = await selectMany({
title: "Select files to stage", title: "Select files to stage",
@@ -35,7 +36,7 @@ export async function selectFiles(
if (selected.length > 0) { if (selected.length > 0) {
process.stdout.write( process.stdout.write(
` ${GREEN}Staged ${selected.length} file(s):${RESET} ${selected.join(", ")}\n`, ` ${GREEN()}Staged ${selected.length} file(s):${RESET()} ${selected.join(", ")}\n`,
); );
} }