@janvitos/pi-plan-build 0.1.28 → 0.1.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/question-ui.ts +36 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@janvitos/pi-plan-build",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "description": "Plan safely, approve explicitly, then implement here or in a clean session.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,7 +29,7 @@
29
29
  "access": "public"
30
30
  },
31
31
  "scripts": {
32
- "test": "node --experimental-strip-types --test utils.test.ts",
32
+ "test": "node --experimental-strip-types --test utils.test.ts question-ui.test.ts",
33
33
  "prepublishOnly": "npm test"
34
34
  },
35
35
  "peerDependencies": {
package/question-ui.ts CHANGED
@@ -22,6 +22,16 @@ export const QuestionParameters = Type.Object({
22
22
 
23
23
  export type QuestionAnswer = QuestionAnswerData;
24
24
 
25
+ const QUESTION_NOTICE_ENTRY_TYPE = "pi-plan-build-question-notice";
26
+ const QUESTION_CANCELLED_MESSAGE = "You chose not to answer the question(s). Awaiting your instructions.";
27
+
28
+ class QuestionCancelledError extends Error {
29
+ constructor() {
30
+ super("User cancelled the question");
31
+ this.name = "QuestionCancelledError";
32
+ }
33
+ }
34
+
25
35
  async function askOne(
26
36
  ctx: any,
27
37
  prompt: {
@@ -31,6 +41,7 @@ async function askOne(
31
41
  multiple?: boolean;
32
42
  custom?: boolean;
33
43
  },
44
+ signal: AbortSignal | undefined,
34
45
  ): Promise<QuestionAnswer> {
35
46
  const allowCustom = prompt.custom !== false;
36
47
  const labels = prompt.options.map((option) =>
@@ -41,11 +52,11 @@ async function askOne(
41
52
 
42
53
  if (!prompt.multiple) {
43
54
  const choices = [...labels, ...(allowCustom ? ["Type your own answer"] : [])];
44
- const choice = await ctx.ui.select(`${prompt.header}: ${prompt.question}`, choices);
45
- if (!choice) throw new Error("User cancelled the question");
55
+ const choice = await ctx.ui.select(`${prompt.header}: ${prompt.question}`, choices, { signal });
56
+ if (!choice) throw new QuestionCancelledError();
46
57
  if (allowCustom && choice === "Type your own answer") {
47
- const custom = await ctx.ui.input(prompt.header, prompt.question);
48
- if (!custom?.trim()) throw new Error("User cancelled the question");
58
+ const custom = await ctx.ui.input(prompt.header, prompt.question, { signal });
59
+ if (!custom?.trim()) throw new QuestionCancelledError();
49
60
  selected.push(custom.trim());
50
61
  usedCustom = true;
51
62
  } else {
@@ -59,11 +70,11 @@ async function askOne(
59
70
  ...(allowCustom ? ["Add a custom answer"] : []),
60
71
  ...(selected.length ? [`Done (${selected.join(", ")})`] : []),
61
72
  ];
62
- const choice = await ctx.ui.select(`${prompt.header}: ${prompt.question}`, choices);
63
- if (!choice) throw new Error("User cancelled the question");
73
+ const choice = await ctx.ui.select(`${prompt.header}: ${prompt.question}`, choices, { signal });
74
+ if (!choice) throw new QuestionCancelledError();
64
75
  if (choice.startsWith("Done (")) break;
65
76
  if (choice === "Add a custom answer") {
66
- const custom = await ctx.ui.input(prompt.header, prompt.question);
77
+ const custom = await ctx.ui.input(prompt.header, prompt.question, { signal });
67
78
  if (custom?.trim()) {
68
79
  selected.push(custom.trim());
69
80
  usedCustom = true;
@@ -81,17 +92,31 @@ async function askOne(
81
92
  }
82
93
 
83
94
  export function registerQuestionTool(pi: ExtensionAPI): void {
95
+ pi.registerEntryRenderer<{ message: string }>(QUESTION_NOTICE_ENTRY_TYPE, (entry, _options, theme) => {
96
+ const message = typeof entry.data?.message === "string" ? entry.data.message : QUESTION_CANCELLED_MESSAGE;
97
+ return new Text(theme.fg("warning", message), 0, 0);
98
+ });
84
99
  pi.registerTool({
85
100
  name: "question",
86
101
  label: "Question",
87
102
  description: `Use this tool when you need to ask the user questions during execution. This allows you to gather preferences, clarify ambiguous instructions, get implementation decisions, or offer choices. When custom is enabled (default), do not add an Other option yourself. Put the recommended option first and suffix its label with "(Recommended)".`,
88
103
  parameters: QuestionParameters,
89
104
  executionMode: "sequential",
90
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
105
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
91
106
  if (!ctx.hasUI) throw new Error("The question tool requires an interactive TUI or RPC client");
92
107
  if (params.questions.length === 0) throw new Error("At least one question is required");
93
108
  const answers: QuestionAnswer[] = [];
94
- for (const prompt of params.questions) answers.push(await askOne(ctx, prompt));
109
+ try {
110
+ for (const prompt of params.questions) answers.push(await askOne(ctx, prompt, signal));
111
+ } catch (error: unknown) {
112
+ if (!(error instanceof QuestionCancelledError)) throw error;
113
+ pi.appendEntry(QUESTION_NOTICE_ENTRY_TYPE, { message: QUESTION_CANCELLED_MESSAGE });
114
+ return {
115
+ content: [{ type: "text", text: QUESTION_CANCELLED_MESSAGE }],
116
+ details: { cancelled: true },
117
+ terminate: true,
118
+ };
119
+ }
95
120
  const formatted = formatQuestionAnswers(answers);
96
121
  return {
97
122
  content: [{ type: "text", text: `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.` }],
@@ -103,7 +128,8 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
103
128
  return new Text(theme.fg("toolTitle", theme.bold(`question (${count})`)), 0, 0);
104
129
  },
105
130
  renderResult(result, _options, theme) {
106
- const details = result.details as { answers?: QuestionAnswer[] } | undefined;
131
+ const details = result.details as { answers?: QuestionAnswer[]; cancelled?: boolean } | undefined;
132
+ if (details?.cancelled) return new Text(theme.fg("warning", "Question(s) skipped"), 0, 0);
107
133
  if (!details?.answers) return new Text(theme.fg("warning", "Question cancelled"), 0, 0);
108
134
  return new Text(details.answers.map((a) => `${theme.fg("success", "✓")} ${a.header}: ${a.answers.join(", ")}`).join("\n"), 0, 0);
109
135
  },