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
Showing only changes of commit 12e71a0af7 - Show all commits
+326 -34
View File
@@ -76,41 +76,333 @@ function ask(question: string): Promise<string> {
}); });
} }
async function handleConfig() { type ConfigKey = keyof Config;
const config = await loadConfig();
console.log(`\n ${BOLD}Current configuration:${RESET}`); interface ConfigField {
console.log( key: ConfigKey;
` API Key: ${config.apiKey ? "****" + config.apiKey.slice(-4) : `${YELLOW}(not set)${RESET}`}`, label: string;
); format: (config: Config) => string;
console.log(` API Base: ${config.apiBase}`); initialEditValue: (config: Config) => string;
console.log(` Model: ${config.model}`); parse: (value: string) => { value: Config[ConfigKey] } | { error: string };
console.log(` Max Tokens: ${config.maxTokens}`);
console.log(` Temperature: ${config.temperature}`);
console.log(
`\n ${BOLD}Enter new values (leave empty to keep current):${RESET}`,
);
const apiKey = await ask(" API Key: ");
const apiBase = await ask(` API Base [${config.apiBase}]: `);
const model = await ask(` Model [${config.model}]: `);
const maxTokens = await ask(` Max Tokens [${config.maxTokens}]: `);
const temperature = await ask(` Temperature [${config.temperature}]: `);
const updates: Partial<Config> = {};
if (apiKey) updates.apiKey = apiKey;
if (apiBase) updates.apiBase = apiBase;
if (model) updates.model = model;
if (maxTokens) updates.maxTokens = parseInt(maxTokens);
if (temperature) updates.temperature = parseFloat(temperature);
if (Object.keys(updates).length > 0) {
await saveConfig(updates);
console.log(`\n ${GREEN}Configuration saved!${RESET}`);
} else {
console.log("\n No changes.");
} }
const CONFIG_FIELDS: ConfigField[] = [
{
key: "apiKey",
label: "API Key",
format: (config) =>
config.apiKey ? `****${config.apiKey.slice(-4)}` : `${YELLOW}(not set)${RESET}`,
initialEditValue: () => "",
parse: (value) => ({ value }),
},
{
key: "apiBase",
label: "API Base",
format: (config) => config.apiBase,
initialEditValue: (config) => config.apiBase,
parse: (value) => ({ value }),
},
{
key: "model",
label: "Model",
format: (config) => config.model,
initialEditValue: (config) => config.model,
parse: (value) => ({ value }),
},
{
key: "maxTokens",
label: "Max Tokens",
format: (config) => String(config.maxTokens),
initialEditValue: (config) => String(config.maxTokens),
parse: (value) => {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
return { error: "Max Tokens must be a positive integer." };
}
return { value: parsed };
},
},
{
key: "temperature",
label: "Temperature",
format: (config) => String(config.temperature),
initialEditValue: (config) => String(config.temperature),
parse: (value) => {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return { error: "Temperature must be a finite number." };
}
return { value: parsed };
},
},
];
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,
previousLines: number,
status: string | null,
editState: { buffer: string; cursor: number } | null,
) {
if (previousLines > 0) {
for (let i = 0; i < previousLines; i++) {
clearLine();
process.stdout.write("\n");
}
moveUp(previousLines);
}
const labelWidth = Math.max(...CONFIG_FIELDS.map((field) => field.label.length)) + 2;
const lines = [
"",
` ${BOLD}Configuration${RESET}`,
editState
? ` ${DIM}editing · enter save · esc cancel · ctrl+c cancel${RESET}`
: ` ${DIM}↑/↓ navigate · space edit · ←/backspace back · ctrl+c cancel${RESET}`,
"",
];
let activeValueOffset = 0;
for (let i = 0; i < CONFIG_FIELDS.length; i++) {
const field = CONFIG_FIELDS[i]!;
const active = i === cursor;
const pointer = active ? `${CYAN}${RESET}` : " ";
const marker = active ? `${GREEN}${RESET}` : `${DIM}${RESET}`;
const label = active ? `${BOLD}${field.label}${RESET}` : field.label;
const padding = " ".repeat(Math.max(1, labelWidth - visibleLength(field.label)));
const value = active && editState ? editState.buffer : field.format(config);
if (active && editState) {
activeValueOffset = visibleLength(` ${pointer} ${marker} ${label}${padding}`);
}
lines.push(` ${pointer} ${marker} ${label}${padding}${value}`);
}
if (status) {
lines.push("", ` ${status}`);
}
for (const line of lines) {
process.stdout.write(`${line}\n`);
}
if (editState) {
moveUp(lines.length - (4 + cursor));
const column = activeValueOffset + editState.cursor;
process.stdout.write(`\r${column > 0 ? `\x1b[${column}C` : ""}`);
} else {
moveUp(lines.length);
}
return lines.length;
}
async function handleConfig(): Promise<"done" | "back"> {
if (process.stdin.isTTY !== true) {
console.error(` ${RED}Error: Configuration requires a TTY.${RESET}`);
process.exit(1);
}
let config = await loadConfig();
let cursor = 0;
let renderedLines = 0;
let escapeBuf = "";
let status: string | null = null;
let editState: { buffer: string; cursor: number } | null = null;
let renderedCursorRow = 0;
const wasRaw = process.stdin.isRaw;
if (wasRaw !== true) process.stdin.setRawMode(true);
process.stdin.resume();
process.stdout.write("\x1b[?25l");
const render = () => {
moveUp(renderedCursorRow);
renderedLines = renderConfigPage(config, cursor, renderedLines, status, editState);
renderedCursorRow = editState ? 4 + cursor : 0;
process.stdout.write(editState ? "\x1b[?25h" : "\x1b[?25l");
};
render();
return new Promise((resolve) => {
const finish = (value: "done" | "back") => {
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");
resolve(value);
};
const saveEdit = async () => {
if (!editState) return;
const field = CONFIG_FIELDS[cursor]!;
const value = editState.buffer.trim();
editState = null;
if (value === "") {
status = `${DIM}No changes.${RESET}`;
} else {
const parsed = field.parse(value);
if ("error" in parsed) {
status = `${RED}${parsed.error}${RESET}`;
} else {
await saveConfig({ [field.key]: parsed.value } as Partial<Config>);
config = await loadConfig();
status = `${GREEN}${field.label} saved.${RESET}`;
}
}
render();
};
const onData = (data: Buffer) => {
const key = data.toString();
const UP = "\x1b[A";
const DOWN = "\x1b[B";
const LEFT = "\x1b[D";
const ALT_UP = "\x1bOA";
const ALT_DOWN = "\x1bOB";
const ALT_LEFT = "\x1bOD";
const SPACE = " ";
const ENTER = "\r";
const ESC = "\x1b";
const RIGHT = "\x1b[C";
const ALT_RIGHT = "\x1bOC";
const CTRL_C = "\x03";
const BACKSPACE = "\x7f";
if (editState) {
if (key === CTRL_C || key === ESC) {
editState = null;
status = `${DIM}No changes.${RESET}`;
render();
return;
}
if (key === ENTER) {
void saveEdit();
return;
}
if (key === "\x01") {
editState.cursor = 0;
render();
return;
}
if (key === "\x05") {
editState.cursor = editState.buffer.length;
render();
return;
}
if (key === "\x0b") {
editState.buffer = editState.buffer.slice(0, editState.cursor);
render();
return;
}
if (key === "\x15") {
editState.buffer = editState.buffer.slice(editState.cursor);
editState.cursor = 0;
render();
return;
}
if (key === BACKSPACE) {
if (editState.cursor > 0) {
editState.buffer =
editState.buffer.slice(0, editState.cursor - 1) +
editState.buffer.slice(editState.cursor);
editState.cursor--;
render();
}
return;
}
if (key === LEFT || key === ALT_LEFT) {
if (editState.cursor > 0) editState.cursor--;
render();
return;
}
if (key === RIGHT || key === ALT_RIGHT) {
if (editState.cursor < editState.buffer.length) editState.cursor++;
render();
return;
}
if (key.startsWith("\x1b[")) {
if (key === "\x1b[H" || key === "\x1b[1~") {
editState.cursor = 0;
} else if (key === "\x1b[F" || key === "\x1b[4~") {
editState.cursor = editState.buffer.length;
} else if (key === "\x1b[3~" && editState.cursor < editState.buffer.length) {
editState.buffer =
editState.buffer.slice(0, editState.cursor) +
editState.buffer.slice(editState.cursor + 1);
}
render();
return;
}
if (key >= " " && key !== "\x7f") {
editState.buffer =
editState.buffer.slice(0, editState.cursor) +
key +
editState.buffer.slice(editState.cursor);
editState.cursor += key.length;
render();
}
return;
}
const action = (() => {
if (key === UP || key === ALT_UP) return "up";
if (key === DOWN || key === ALT_DOWN) return "down";
if (key === LEFT || key === ALT_LEFT || key === BACKSPACE) return "back";
if (key === SPACE) return "edit";
if (key === CTRL_C) return "cancel";
if (key === "\x1b" || key.startsWith("\x1b[")) {
escapeBuf = key;
return null;
}
if (escapeBuf) {
const next = escapeBuf + key;
escapeBuf = /^[A-Za-z~]$/.test(key) || next.length > 8 ? "" : next;
if (next === UP || next === ALT_UP) return "up";
if (next === DOWN || next === ALT_DOWN) return "down";
if (next === LEFT || next === ALT_LEFT) return "back";
}
return null;
})();
if (action === "cancel") return finish("done");
if (action === "back") return finish("back");
if (action === "up" && cursor > 0) {
cursor--;
status = null;
render();
} else if (action === "down" && cursor < CONFIG_FIELDS.length - 1) {
cursor++;
status = null;
render();
} else if (action === "edit") {
const value = CONFIG_FIELDS[cursor]!.initialEditValue(config);
editState = { buffer: value, cursor: value.length };
status = null;
render();
}
};
process.stdin.on("data", onData);
});
} }
async function confirmCommit(message: string): Promise<"y" | "n" | "e"> { async function confirmCommit(message: string): Promise<"y" | "n" | "e"> {
@@ -324,7 +616,7 @@ async function showMenu(): Promise<void> {
? await handleCommit(false, false) ? await handleCommit(false, false)
: selected === "pr" : selected === "pr"
? await handlePR(false) ? await handlePR(false)
: await handleConfig().then(() => "done" as const); : await handleConfig();
if (result !== "back") return; if (result !== "back") return;
} }