@janvitos/pi-plan-build 0.1.27 → 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.
package/README.md CHANGED
@@ -74,7 +74,7 @@ When planning is complete, `plan_exit` displays the entire persisted plan and as
74
74
  2. start a clean linked implementation session; or
75
75
  3. stay in Plan mode.
76
76
 
77
- Selecting **Start fresh and implement** stops the current run and automatically dispatches `/build-fresh`. Pi 0.84.2 or newer is required for extension command dispatch from an injected user message. The command creates a linked child session, copies the approved plan to its canonical plan file, switches it to Build, and starts implementation without transferring the planning conversation.
77
+ Selecting **Start fresh and implement** stops the current run and automatically dispatches `/build-fresh`. Pi 0.84.2 or newer is required for extension command dispatch from an injected user message. The command creates a linked child session, copies the approved plan to its canonical plan file, preserves the model and thinking level selected for the action, switches it to Build, and starts implementation without transferring the planning conversation.
78
78
 
79
79
  Selecting **Stay in Plan mode**, or pressing Escape while the approval dialog is open, displays:
80
80
 
@@ -111,7 +111,7 @@ npm test
111
111
  npm pack --dry-run
112
112
  ```
113
113
 
114
- The tests cover state decoding, safe plan paths, mutation restrictions, deferred transitions, mode and provider rendering, session-based prompt history restoration, complete plan rendering, approval decisions, stop behavior, fresh-session handoff content, and question formatting.
114
+ The tests cover state decoding, safe plan paths, mutation restrictions, deferred transitions, mode and provider rendering, session-based prompt history restoration, complete plan rendering, approval decisions, stop behavior, fresh-session settings and handoff content, and question formatting.
115
115
 
116
116
  ### Publishing
117
117
 
package/index.ts CHANGED
@@ -14,10 +14,12 @@ import {
14
14
  import {
15
15
  applyManualSelection,
16
16
  buildFreshImplementationHandoff,
17
+ buildFreshImplementationRequest,
17
18
  buildPlanExitFreshResult,
18
19
  buildPlanExitStayResult,
19
20
  buildPlanReviewMessage,
20
21
  classifyPlanExitChoice,
22
+ type FreshImplementationRequest,
21
23
  decodeModeState,
22
24
  extractPromptHistory,
23
25
  formatFooterCwd,
@@ -73,7 +75,7 @@ export default function planBuildModes(pi: ExtensionAPI): void {
73
75
  let toolsBeforeModes: string[] = [];
74
76
  let currentContext: ExtensionContext | undefined;
75
77
  let requestEditorRender: (() => void) | undefined;
76
- let freshImplementationPlan: string | undefined;
78
+ let freshImplementationRequest: FreshImplementationRequest | undefined;
77
79
 
78
80
  pi.registerFlag("plan", {
79
81
  description: "Start in Plan mode",
@@ -160,28 +162,47 @@ export default function planBuildModes(pi: ExtensionAPI): void {
160
162
  pi.registerCommand("build-fresh", {
161
163
  description: "Start a clean linked session and implement the plan selected in plan_exit",
162
164
  handler: async (_args, ctx) => {
163
- const plan = freshImplementationPlan;
164
- if (!plan) {
165
+ const request = freshImplementationRequest;
166
+ if (!request) {
165
167
  ctx.ui.notify("No fresh implementation is pending. Choose ‘Start fresh and implement’ from plan_exit first.", "warning");
166
168
  return;
167
169
  }
168
170
  if (selectedMode !== "plan") {
169
- freshImplementationPlan = undefined;
171
+ freshImplementationRequest = undefined;
170
172
  ctx.ui.notify("Fresh implementation is no longer available because Plan mode is not active.", "warning");
171
173
  return;
172
174
  }
173
175
  if (ctx.mode === "print" || ctx.mode === "json") {
174
176
  throw new Error("Fresh implementation requires TUI or RPC mode");
175
177
  }
176
- if (!ctx.model) {
178
+ if (!request.model) {
177
179
  ctx.ui.notify("Cannot start implementation because no model is selected.", "warning");
178
180
  return;
179
181
  }
182
+ const currentModel = ctx.model;
183
+ const implementationModel = ctx.modelRegistry.find(request.model.provider, request.model.id)
184
+ ?? (currentModel?.provider === request.model.provider && currentModel.id === request.model.id ? currentModel : undefined);
185
+ if (!implementationModel) {
186
+ ctx.ui.notify(`Cannot start implementation because ${request.model.provider}/${request.model.id} is unavailable.`, "warning");
187
+ return;
188
+ }
189
+ try {
190
+ const modelSelected = await pi.setModel(implementationModel);
191
+ if (modelSelected === false) {
192
+ ctx.ui.notify(`Cannot start implementation because no API key is available for ${request.model.provider}/${request.model.id}.`, "warning");
193
+ return;
194
+ }
195
+ pi.setThinkingLevel(request.thinkingLevel);
196
+ } catch (error: unknown) {
197
+ const detail = error instanceof Error ? error.message : String(error);
198
+ ctx.ui.notify(`Cannot start implementation with ${request.model.provider}/${request.model.id}: ${detail}`, "warning");
199
+ return;
200
+ }
180
201
 
181
- freshImplementationPlan = undefined;
202
+ freshImplementationRequest = undefined;
182
203
  const parentSession = ctx.sessionManager.getSessionFile();
183
204
  const sourceTools = [...toolsBeforeModes];
184
- const handoff = buildFreshImplementationHandoff(plan);
205
+ const handoff = buildFreshImplementationHandoff(request.plan);
185
206
  let destinationPlanPath = "";
186
207
  let setupError: string | undefined;
187
208
  let kickoffError: string | undefined;
@@ -195,7 +216,9 @@ export default function planBuildModes(pi: ExtensionAPI): void {
195
216
  sessionManager.getSessionId(),
196
217
  );
197
218
  await fs.promises.mkdir(path.dirname(destinationPlanPath), { recursive: true });
198
- await fs.promises.writeFile(destinationPlanPath, plan, "utf8");
219
+ await fs.promises.writeFile(destinationPlanPath, request.plan, "utf8");
220
+ sessionManager.appendModelChange(request.model.provider, request.model.id);
221
+ sessionManager.appendThinkingLevelChange(request.thinkingLevel);
199
222
  sessionManager.appendCustomEntry(STATE_TYPE, {
200
223
  version: 1,
201
224
  selectedMode: "build",
@@ -232,11 +255,11 @@ export default function planBuildModes(pi: ExtensionAPI): void {
232
255
  },
233
256
  });
234
257
  if (result.cancelled) {
235
- freshImplementationPlan = plan;
258
+ freshImplementationRequest = request;
236
259
  ctx.ui.notify("Fresh implementation cancelled; the source plan remains available.", "info");
237
260
  }
238
261
  } catch (error: unknown) {
239
- freshImplementationPlan = plan;
262
+ freshImplementationRequest = request;
240
263
  const detail = error instanceof Error ? error.message : String(error);
241
264
  try {
242
265
  ctx.ui.notify(`Unable to start a fresh implementation session: ${detail}`, "error");
@@ -296,19 +319,23 @@ export default function planBuildModes(pi: ExtensionAPI): void {
296
319
  ));
297
320
  const decision = classifyPlanExitChoice(selection.choice);
298
321
  if (decision === "stay") {
299
- freshImplementationPlan = undefined;
322
+ freshImplementationRequest = undefined;
300
323
  pi.appendEntry(MODE_NOTICE_ENTRY_TYPE, { message: PLAN_EXIT_STAY_ACKNOWLEDGEMENT });
301
324
  return buildPlanExitStayResult(planPath, selection.cancelled);
302
325
  }
303
326
  if (decision === "implement-fresh") {
304
- freshImplementationPlan = plan;
327
+ freshImplementationRequest = buildFreshImplementationRequest(
328
+ plan,
329
+ ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : undefined,
330
+ pi.getThinkingLevel(),
331
+ );
305
332
  pi.sendUserMessage("/build-fresh", {
306
333
  deliverAs: "followUp",
307
334
  expandPromptTemplates: true,
308
335
  });
309
336
  return buildPlanExitFreshResult(planPath);
310
337
  }
311
- freshImplementationPlan = undefined;
338
+ freshImplementationRequest = undefined;
312
339
  await selectMode("build", ctx, "tool");
313
340
  return {
314
341
  content: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@janvitos/pi-plan-build",
3
- "version": "0.1.27",
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
  },
package/utils.ts CHANGED
@@ -174,6 +174,20 @@ export function buildPlanExitFreshResult(planPath: string) {
174
174
  };
175
175
  }
176
176
 
177
+ export interface FreshImplementationRequest {
178
+ plan: string;
179
+ model?: { provider: string; id: string };
180
+ thinkingLevel: string;
181
+ }
182
+
183
+ export function buildFreshImplementationRequest(
184
+ plan: string,
185
+ model: { provider: string; id: string } | undefined,
186
+ thinkingLevel: string,
187
+ ): FreshImplementationRequest {
188
+ return { plan, model, thinkingLevel };
189
+ }
190
+
177
191
  export function buildFreshImplementationHandoff(plan: string): string {
178
192
  return `Plan mode is now disabled. Full tool access is restored. Implement this approved plan now:\n\n${plan}`;
179
193
  }