feat: /plans interactive picker in CLI
This commit is contained in:
parent
e8287571e2
commit
ecf4b1a527
@ -1,6 +1,8 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { confirm, isCancel, select, text } from "@clack/prompts";
|
||||||
|
|
||||||
import type { CommandHandler } from "./commands-types.js";
|
import type { CommandHandler } from "./commands-types.js";
|
||||||
|
|
||||||
type PlansArgs = {
|
type PlansArgs = {
|
||||||
@ -69,6 +71,11 @@ export const handlePlansCommand: CommandHandler = async (params, allowTextComman
|
|||||||
|
|
||||||
const plansDir = path.join(params.workspaceDir, "plans");
|
const plansDir = path.join(params.workspaceDir, "plans");
|
||||||
|
|
||||||
|
const isInteractiveCli =
|
||||||
|
params.ctx.CommandSource === "cli" &&
|
||||||
|
Boolean(process.stdin.isTTY) &&
|
||||||
|
Boolean(process.stdout.isTTY);
|
||||||
|
|
||||||
if (args.action === "help") {
|
if (args.action === "help") {
|
||||||
return {
|
return {
|
||||||
shouldContinue: false,
|
shouldContinue: false,
|
||||||
@ -79,6 +86,8 @@ export const handlePlansCommand: CommandHandler = async (params, allowTextComman
|
|||||||
" /plans show <name>\n" +
|
" /plans show <name>\n" +
|
||||||
" /plans rename <old> <new>\n" +
|
" /plans rename <old> <new>\n" +
|
||||||
" /plans trash <name>\n\n" +
|
" /plans trash <name>\n\n" +
|
||||||
|
"TUI:\n" +
|
||||||
|
" /plans (with no args) opens an interactive picker in the CLI.\n\n" +
|
||||||
"Notes:\n" +
|
"Notes:\n" +
|
||||||
"- Plans are stored under workspace/plans/.\n" +
|
"- Plans are stored under workspace/plans/.\n" +
|
||||||
"- Use /plan <goal> to start a new plan; /plans helps manage saved plan folders.",
|
"- Use /plan <goal> to start a new plan; /plans helps manage saved plan folders.",
|
||||||
@ -95,6 +104,85 @@ export const handlePlansCommand: CommandHandler = async (params, allowTextComman
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Interactive picker only when running in the CLI TUI.
|
||||||
|
// For chat channels (Discord/Telegram/etc.), return plain text.
|
||||||
|
const wantsInteractive =
|
||||||
|
isInteractiveCli && (raw.trim() === "/plans" || raw.trim() === "/plans list");
|
||||||
|
if (wantsInteractive) {
|
||||||
|
const selected = await select({
|
||||||
|
message: "Select a plan",
|
||||||
|
options: names
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
|
.map((n) => ({ label: n, value: n })),
|
||||||
|
});
|
||||||
|
if (isCancel(selected)) {
|
||||||
|
return { shouldContinue: false, reply: { text: "Cancelled." } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const planName = String(selected);
|
||||||
|
const action = await select({
|
||||||
|
message: `Action for ${planName}`,
|
||||||
|
options: [
|
||||||
|
{ label: "Show", value: "show" },
|
||||||
|
{ label: "Rename", value: "rename" },
|
||||||
|
{ label: "Trash", value: "trash" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (isCancel(action)) {
|
||||||
|
return { shouldContinue: false, reply: { text: "Cancelled." } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "show") {
|
||||||
|
const planDir = path.join(plansDir, planName);
|
||||||
|
const planMd = await readFirstExisting([
|
||||||
|
path.join(planDir, "plan.md"),
|
||||||
|
path.join(planDir, "README.md"),
|
||||||
|
]);
|
||||||
|
if (!planMd) {
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: { text: `No plan.md found for '${planName}'. Looked in: ${planDir}` },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const trimmed = planMd.trim();
|
||||||
|
const preview =
|
||||||
|
trimmed.length > 6000 ? `${trimmed.slice(0, 6000)}\n\n…(truncated)` : trimmed;
|
||||||
|
return { shouldContinue: false, reply: { text: `Plan: ${planName}\n\n${preview}` } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "rename") {
|
||||||
|
const next = await text({
|
||||||
|
message: `Rename '${planName}' to:`,
|
||||||
|
initialValue: planName,
|
||||||
|
validate: (v) => (!v?.trim() ? "Name required" : undefined),
|
||||||
|
});
|
||||||
|
if (isCancel(next)) return { shouldContinue: false, reply: { text: "Cancelled." } };
|
||||||
|
const toName = String(next).trim();
|
||||||
|
if (toName && toName !== planName) {
|
||||||
|
await fs.rename(path.join(plansDir, planName), path.join(plansDir, toName));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: { text: `Renamed plan '${planName}' → '${toName}'.` },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "trash") {
|
||||||
|
const ok = await confirm({ message: `Move '${planName}' to trash?` });
|
||||||
|
if (isCancel(ok) || ok === false) {
|
||||||
|
return { shouldContinue: false, reply: { text: "Cancelled." } };
|
||||||
|
}
|
||||||
|
const fromDir = path.join(plansDir, planName);
|
||||||
|
const trashDir = path.join(plansDir, ".trash");
|
||||||
|
await fs.mkdir(trashDir, { recursive: true });
|
||||||
|
const stamped = `${planName}__${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
||||||
|
const toDir = path.join(trashDir, stamped);
|
||||||
|
await fs.rename(fromDir, toDir);
|
||||||
|
return { shouldContinue: false, reply: { text: `Moved plan '${planName}' to trash.` } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const lines = ["Plans:", ...names.map((n) => `- ${n}`), "", "Tip: /plans show <name>"];
|
const lines = ["Plans:", ...names.map((n) => `- ${n}`), "", "Tip: /plans show <name>"];
|
||||||
return { shouldContinue: false, reply: { text: lines.join("\n") } };
|
return { shouldContinue: false, reply: { text: lines.join("\n") } };
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user