@hank-warren/pi-plan-mode 1.4.0 → 1.5.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @hank-warren/pi-plan-mode
2
2
 
3
+ ## 1.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - c079c51: Keep the completed plan out of model context, stage the Plan tools, and fail honestly when a plan cannot be saved.
8
+
9
+ The completed-plan card is a display-only session entry rendered through `registerEntryRenderer` instead of a message, so the plan stays visible and restorable in the transcript while never entering model context or compaction. `plan_mode_complete` returns a one-line `Plan saved to <path>.` pointer; the durable file remains the handoff.
10
+
11
+ `plan_mode_complete` now writes the file first and throws when the write fails, rather than reporting success and returning `undefined`. Prior state stays intact and the call is retryable.
12
+
13
+ `plan_mode_complete` and the `plan_mode_question` fallback activate when Plan mode is entered or restored, so a session that never plans does not carry their schemas. Ownership of `ask_user_question` is resolved by package directory and read back from the host rather than assumed from the write, and every reconcile is announced on an event, so the fallback no longer depends on hook order between packages. A headless run has no legitimate question tool, so the prompt and the finalize steer switch to a plain-text variant instead of naming a tool that both packages strip.
14
+
15
+ `AbortSignal` is wired through the question tool with exactly-once cleanup.
16
+
3
17
  ## 1.4.0
4
18
 
5
19
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-plan-mode",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Plan mode for Pi: research and design with a durable plan file that survives compaction.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -57,7 +57,12 @@ export function planFromCompletionDetails(value: unknown) {
57
57
 
58
58
  export function planModeCompleted(plan: string, planPath?: string) {
59
59
  return {
60
- content: [{ type: "text" as const, text: `**Proposed Plan**\n\n${plan}` }],
60
+ content: [
61
+ {
62
+ type: "text" as const,
63
+ text: planPath ? `Plan saved to ${planPath}.` : "Plan saved.",
64
+ },
65
+ ],
61
66
  details: {
62
67
  version: PLAN_MODE_COMPLETE_VERSION,
63
68
  source: PLAN_MODE_COMPLETE_TOOL_NAME,
package/src/plan-mode.ts CHANGED
@@ -28,6 +28,8 @@ import { createPlanExportController } from "./plan-export-controller.js";
28
28
  import {
29
29
  clearPlanModeUi,
30
30
  planModeStatusText as formatPlanModeStatusText,
31
+ registerPlanModeCardRenderer,
32
+ showPlanModePlan,
31
33
  showStoredPlan,
32
34
  updatePlanModeUi,
33
35
  } from "./presentation.js";
@@ -53,6 +55,7 @@ import {
53
55
  import { type PlanModeState, readLegacyThinkingCapture, restorePlanModeState } from "./state.js";
54
56
 
55
57
  const STATE_ENTRY_TYPE = "plan-mode-state";
58
+ const ASK_USER_AVAILABILITY_EVENT = "hank:ask-user:availability";
56
59
  /**
57
60
  * Plan mode's entire enforcement surface. Everything else — bash, subagents,
58
61
  * MCP, and other extension tools — is left to the session's normal permission
@@ -72,38 +75,18 @@ const BLOCKED_TOOLS = new Set(["edit", "write"]);
72
75
  const SETTINGS_RELOAD_DEBOUNCE_MS = 75;
73
76
 
74
77
  /**
75
- * Which question tool the prompt should name this turn.
78
+ * Which question tool the prompt may name this turn, read from the tool set
79
+ * the model will actually see.
76
80
  *
77
- * Detection is by tool NAME only, with no dependency on
78
- * `@hank-warren/pi-ask-user-question`: any extension registering
79
- * `ask_user_question` is treated as the preferred implementation. It offers
80
- * previews, notes, question tabs, digit hotkeys and checkbox multi-select;
81
- * `plan_mode_question` renders through plain `ctx.ui.select` + `ctx.ui.editor`
82
- * and has none of them.
83
- *
84
- * Evaluated per turn rather than once at mode entry, so the prompt cannot go
85
- * stale if the tool set changes mid-session.
81
+ * `null` means neither is active a headless run, where both interactive
82
+ * tools are deliberately stripped. Naming one there would send the model after
83
+ * a tool it cannot call, so the prompt switches to asking in plain text.
86
84
  */
87
- function preferredQuestionTool(pi: ExtensionAPI): string {
88
- return pi.getActiveTools().includes(ASK_USER_QUESTION_TOOL)
89
- ? ASK_USER_QUESTION_TOOL
90
- : PLAN_MODE_QUESTION_TOOL;
91
- }
92
-
93
- /**
94
- * Hide `plan_mode_question` from the model whenever the better tool is present,
95
- * so it never sees two overlapping question tools and cannot call the weaker
96
- * one. The tool stays *registered* either way, so a historical transcript still
97
- * resolves it, and a host without `ask_user_question` keeps it fully functional.
98
- *
99
- * Idempotent, and writes only when something actually changes: siblings are
100
- * untouched, and repeated `before_agent_start` events are free.
101
- */
102
- function reconcileQuestionTool(pi: ExtensionAPI, preferred: string): void {
103
- if (preferred === PLAN_MODE_QUESTION_TOOL) return;
85
+ function preferredQuestionTool(pi: ExtensionAPI): string | null {
104
86
  const active = pi.getActiveTools();
105
- if (!active.includes(PLAN_MODE_QUESTION_TOOL)) return;
106
- pi.setActiveTools(active.filter((name) => name !== PLAN_MODE_QUESTION_TOOL));
87
+ if (active.includes(ASK_USER_QUESTION_TOOL)) return ASK_USER_QUESTION_TOOL;
88
+ if (active.includes(PLAN_MODE_QUESTION_TOOL)) return PLAN_MODE_QUESTION_TOOL;
89
+ return null;
107
90
  }
108
91
 
109
92
  type InteractiveUi = typeof import("./interactive-ui.js");
@@ -138,7 +121,41 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
138
121
  let menuController = new AbortController();
139
122
  let settingsWatch: ReturnType<typeof watch> | undefined;
140
123
  let settingsReloadTimer: ReturnType<typeof setTimeout> | undefined;
124
+ let planToolsActivated = false;
125
+ let currentHasUI = false;
126
+ let globalQuestionAvailable = false;
141
127
  const persistState = () => pi.appendEntry<PlanModeState>(STATE_ENTRY_TYPE, state);
128
+
129
+ const reconcilePlanToolSurface = (hasUI: boolean, availability?: boolean) => {
130
+ currentHasUI = hasUI;
131
+ const active = pi.getActiveTools();
132
+ globalQuestionAvailable = availability ?? (hasUI && active.includes(ASK_USER_QUESTION_TOOL));
133
+ const wanted = new Set(active);
134
+ const completeWanted = planToolsActivated;
135
+ const fallbackWanted = planToolsActivated && hasUI && !globalQuestionAvailable;
136
+ if (completeWanted) wanted.add(PLAN_MODE_COMPLETE_TOOL_NAME);
137
+ else wanted.delete(PLAN_MODE_COMPLETE_TOOL_NAME);
138
+ if (fallbackWanted) wanted.add(PLAN_MODE_QUESTION_TOOL);
139
+ else wanted.delete(PLAN_MODE_QUESTION_TOOL);
140
+ const next = [...wanted];
141
+ if (next.length !== active.length || next.some((name, index) => name !== active[index])) {
142
+ pi.setActiveTools(next);
143
+ }
144
+ };
145
+ const activatePlanTools = (hasUI: boolean) => {
146
+ planToolsActivated = true;
147
+ reconcilePlanToolSurface(hasUI);
148
+ };
149
+
150
+ registerPlanModeCardRenderer(pi);
151
+ pi.events.on(ASK_USER_AVAILABILITY_EVENT, (payload: unknown) => {
152
+ const available =
153
+ typeof payload === "object" && payload !== null &&
154
+ typeof (payload as { available?: unknown }).available === "boolean"
155
+ ? (payload as { available: boolean }).available
156
+ : undefined;
157
+ if (available !== undefined) reconcilePlanToolSurface(currentHasUI, available);
158
+ });
142
159
  const planExports = createPlanExportController({
143
160
  getState: () => state,
144
161
  getSettings: () => settings,
@@ -183,12 +200,14 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
183
200
  label: "Plan question",
184
201
  description:
185
202
  "Ask the user one to three Plan-mode clarification questions with meaningful options, then wait for the answer. Only available while Plan mode is active.",
203
+ // Kept, now that the tool is staged: this guidance reaches the model only
204
+ // in a session that has actually entered Plan mode.
186
205
  promptSnippet: "Ask user decision questions while Plan mode is active",
187
206
  promptGuidelines: [
188
207
  "In Plan mode, use plan_mode_question for important preferences, tradeoffs, or assumptions that cannot be discovered from read-only exploration.",
189
208
  ],
190
209
  parameters: PLAN_MODE_QUESTION_PARAMS,
191
- async execute(_toolCallId, params: unknown, _signal, _onUpdate, ctx) {
210
+ async execute(_toolCallId, params: unknown, signal, _onUpdate, ctx) {
192
211
  if (!state.enabled) {
193
212
  return planModeQuestionCancelled(
194
213
  [],
@@ -212,11 +231,20 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
212
231
 
213
232
  const sessionGeneration = menuGeneration;
214
233
  const questionWorkflowGeneration = workflowGeneration;
215
- return answerPlanModeQuestions(parsed.questions, ctx, {
216
- isCurrent: () =>
217
- sessionGeneration === menuGeneration && questionWorkflowGeneration === workflowGeneration,
218
- isEnabled: () => state.enabled,
219
- });
234
+ const questionSignal = signal
235
+ ? AbortSignal.any([signal, menuController.signal])
236
+ : menuController.signal;
237
+ return answerPlanModeQuestions(
238
+ parsed.questions,
239
+ ctx,
240
+ {
241
+ isCurrent: () =>
242
+ sessionGeneration === menuGeneration &&
243
+ questionWorkflowGeneration === workflowGeneration,
244
+ isEnabled: () => state.enabled,
245
+ },
246
+ questionSignal,
247
+ );
220
248
  },
221
249
  });
222
250
 
@@ -243,6 +271,10 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
243
271
  },
244
272
  });
245
273
 
274
+ // Registered tools remain available for transcript replay; the active set is
275
+ // narrowed at session_start rather than here, because Pi refuses action
276
+ // methods (getActiveTools/setActiveTools) during extension loading.
277
+
246
278
  pi.registerCommand("plan", {
247
279
  description: "Enter or manage Plan mode",
248
280
  getArgumentCompletions: completePlanArguments,
@@ -384,6 +416,9 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
384
416
 
385
417
  pi.on("session_start", async (event, ctx) => {
386
418
  const generation = ++menuGeneration;
419
+ planToolsActivated = false;
420
+ currentHasUI = ctx.hasUI;
421
+ reconcilePlanToolSurface(ctx.hasUI);
387
422
  refreshStateBeforeFirstAgentStart = event.reason === "new";
388
423
  menuController.abort(new DOMException("Plan-mode session replaced", "AbortError"));
389
424
  menuController = new AbortController();
@@ -401,6 +436,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
401
436
  state = { ...state, enabled: true, awaitingAction: state.planPath !== undefined };
402
437
  }
403
438
  if (persistFlagActivation) persistState();
439
+ if (state.enabled) activatePlanTools(ctx.hasUI);
404
440
  updateUi(ctx);
405
441
  });
406
442
 
@@ -430,6 +466,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
430
466
  });
431
467
 
432
468
  pi.on("before_agent_start", (event, ctx) => {
469
+ currentHasUI = ctx.hasUI;
433
470
  if (refreshStateBeforeFirstAgentStart) {
434
471
  refreshStateBeforeFirstAgentStart = false;
435
472
  restoreState(ctx);
@@ -443,11 +480,12 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
443
480
  persistState();
444
481
  updateUi(ctx);
445
482
  }
446
- // Read fresh and write immediately, exactly like pi-ask-user-question's own
447
- // reconciler, so the two hooks converge on the same result in either
448
- // execution order.
449
- const questionTool = preferredQuestionTool(pi);
450
- reconcileQuestionTool(pi, questionTool);
483
+ if (state.enabled && !planToolsActivated) activatePlanTools(ctx.hasUI);
484
+ else reconcilePlanToolSurface(ctx.hasUI);
485
+ // A headless run has no legitimate question tool, whatever the active set
486
+ // still says: pi-ask-user-question strips its own tool on this same hook,
487
+ // and hook order between the two packages is not ours to depend on.
488
+ const questionTool = ctx.hasUI ? preferredQuestionTool(pi) : null;
451
489
  if (state.enabled) {
452
490
  return { systemPrompt: `${event.systemPrompt}\n\n${buildPlanModePrompt(questionTool)}` };
453
491
  }
@@ -476,6 +514,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
476
514
 
477
515
  function enterPlanMode(ctx: ExtensionContext) {
478
516
  workflowGeneration += 1;
517
+ activatePlanTools(ctx.hasUI);
479
518
  state = { ...state, enabled: true, awaitingAction: false };
480
519
  persistState();
481
520
  updateUi(ctx);
@@ -525,20 +564,20 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
525
564
  * Writes the durable plan file and marks the plan ready. A write failure
526
565
  * keeps Plan mode active rather than silently losing the plan.
527
566
  */
528
- async function acceptCompletedPlan(plan: string, ctx: ExtensionContext) {
567
+ async function acceptCompletedPlan(plan: string, ctx: ExtensionContext): Promise<string> {
529
568
  const planPath = sessionPlanPath ?? resolveSessionPlanPath(ctx);
530
- sessionPlanPath = planPath;
531
569
  try {
532
570
  await writePlanFile(planPath, plan);
533
571
  } catch (error: unknown) {
534
572
  const detail = error instanceof Error ? error.message : String(error);
535
- ctx.ui.notify(`Unable to save the plan to ${planPath}: ${detail}`, "error");
536
- return undefined;
573
+ throw new Error(`Unable to save the plan to ${planPath}: ${detail}`);
537
574
  }
575
+ sessionPlanPath = planPath;
538
576
  state = { ...state, planPath, awaitingAction: true };
539
577
  pendingReadyNonce = ++readyPresentationNonce;
540
578
  persistState();
541
579
  updateUi(ctx);
580
+ showPlanModePlan(pi, ctx, "Proposed Plan", plan);
542
581
  return planPath;
543
582
  }
544
583
 
@@ -551,8 +590,12 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
551
590
  ctx.ui.notify("Plan mode is not active. Use /plan first.", "warning");
552
591
  return;
553
592
  }
593
+ // Same rule as the prompt: a headless run has no question tool to name.
594
+ const questionTool = ctx.hasUI ? preferredQuestionTool(pi) : null;
554
595
  sendPlanModeUserMessage(
555
- `Finalize the current implementation plan now. If any material decision remains, use ${preferredQuestionTool(pi)} instead. Otherwise call plan_mode_complete alone as your final action with the complete decision-ready plan.`,
596
+ `Finalize the current implementation plan now. If any material decision remains, ${
597
+ questionTool === null ? "ask it in plain text" : `use ${questionTool}`
598
+ } instead. Otherwise call plan_mode_complete alone as your final action with the complete decision-ready plan.`,
556
599
  ctx,
557
600
  );
558
601
  }
@@ -1,10 +1,59 @@
1
- import { Text } from "@earendil-works/pi-tui";
2
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import { Markdown, Text } from "@earendil-works/pi-tui";
2
+ import {
3
+ getMarkdownTheme,
4
+ type ExtensionAPI,
5
+ type ExtensionContext,
6
+ } from "@earendil-works/pi-coding-agent";
3
7
  import { readPlanFile } from "./plan-file.js";
4
8
  import type { PlanModeState } from "./state.js";
5
9
 
6
10
  const STATUS_KEY = "plan-mode";
7
11
  const PLAN_WIDGET_KEY = "plan-mode-plan";
12
+ export const PLAN_CARD_ENTRY_TYPE = "plan-mode-card";
13
+
14
+ type PlanCardData = { title: string; plan: string };
15
+
16
+ /**
17
+ * Persisted entry data is input, not a guarantee.
18
+ *
19
+ * The renderer runs against whatever is on disk, which may predate a field, be
20
+ * truncated by a partial write, or have been hand-edited. Pi contains a
21
+ * renderer throw as an inline `[plan-mode-card] renderer failed: …` box —
22
+ * survivable, but a needlessly ugly way to say "this card is old".
23
+ */
24
+ function planCardData(value: unknown): PlanCardData | undefined {
25
+ if (typeof value !== "object" || value === null) return undefined;
26
+ const { title, plan } = value as { title?: unknown; plan?: unknown };
27
+ return typeof title === "string" && typeof plan === "string"
28
+ ? (value as PlanCardData)
29
+ : undefined;
30
+ }
31
+
32
+ /**
33
+ * The completed-plan card, as a display-only session entry.
34
+ *
35
+ * A custom *entry* rather than a message, which is what buys the property a
36
+ * message could not: Pi maps a `custom` entry to no context messages at all
37
+ * and skips it during compaction, so the plan stays visible and restorable in
38
+ * the transcript while never entering model context and never costing a
39
+ * compaction budget. The model gets a one-line `Plan saved to <path>.` from
40
+ * `plan_mode_complete` instead, and reads the durable file when it implements.
41
+ *
42
+ * pi-loop's approval card is the same mechanism for the same reason
43
+ * (`packages/pi-loop/src/presentation.ts`).
44
+ */
45
+ export function registerPlanModeCardRenderer(pi: ExtensionAPI): void {
46
+ pi.registerEntryRenderer(PLAN_CARD_ENTRY_TYPE, (entry) => {
47
+ const data = planCardData(entry.data);
48
+ if (!data) return new Text("Plan card unavailable.", 0, 0);
49
+ return new Markdown(
50
+ `**${data.title}**\n\n${data.plan}`,
51
+ 0,
52
+ 0,
53
+ getMarkdownTheme(),
54
+ );
55
+ });
56
+ }
8
57
 
9
58
  /**
10
59
  * The one thing both surfaces render.
@@ -152,14 +201,7 @@ export function showPlanModePlan(
152
201
  plan: string,
153
202
  ) {
154
203
  try {
155
- pi.sendMessage(
156
- {
157
- customType: "proposed-plan",
158
- content: `**${title}**\n\n${plan}`,
159
- display: true,
160
- },
161
- { triggerTurn: false },
162
- );
204
+ pi.appendEntry<PlanCardData>(PLAN_CARD_ENTRY_TYPE, { title, plan });
163
205
  } catch (error: unknown) {
164
206
  const detail = error instanceof Error ? error.message : String(error);
165
207
  ctx.ui.notify(`Unable to show completed plan: ${detail}`, "error");
package/src/prompt.ts CHANGED
@@ -41,9 +41,29 @@ const QUESTION_TOOL_PROFILES: Record<string, QuestionToolProfile> = {
41
41
  *
42
42
  * The default keeps the exported function callable with no arguments and keeps
43
43
  * a standalone `pi-plan-mode` install reading exactly as it did before.
44
+ *
45
+ * Passing `null` builds the headless variant: no interactive question tool is
46
+ * active in that session, so naming one would tell the model to call a tool it
47
+ * cannot see. It asks in plain text instead.
44
48
  */
45
- export function buildPlanModePrompt(questionTool: string = PLAN_MODE_QUESTION_TOOL) {
46
- const tool = QUESTION_TOOL_PROFILES[questionTool] ?? QUESTION_TOOL_PROFILES[PLAN_MODE_QUESTION_TOOL];
49
+ export function buildPlanModePrompt(questionTool: string | null = PLAN_MODE_QUESTION_TOOL) {
50
+ const tool =
51
+ questionTool === null
52
+ ? undefined
53
+ : (QUESTION_TOOL_PROFILES[questionTool] ??
54
+ QUESTION_TOOL_PROFILES[PLAN_MODE_QUESTION_TOOL]);
55
+ const askBullet = tool
56
+ ? `Use ${tool.name} for important preferences, tradeoffs, or assumption locks that cannot be discovered by non-mutating exploration. ${tool.bounds} Do not include filler options.`
57
+ : "This session has no interactive question tool, so ask in plain text: put important preferences, tradeoffs, or assumption locks that non-mutating exploration cannot settle in your reply as 1-3 concise questions with 2-4 meaningful options each. Do not include filler options, and never call a question tool that is not in your tool set.";
58
+ const declineBullet = tool
59
+ ? `${tool.decline}, do not jump straight to a final plan when the missing answer is high impact. Ask one concise plain-text question or proceed only with a clearly stated low-risk assumption.`
60
+ : "If the question goes unanswered, do not jump straight to a final plan when the missing answer is high impact. Ask it again more concisely, or proceed only with a clearly stated low-risk assumption recorded in the plan.";
61
+ const endingBullet = tool
62
+ ? `If a material decision remains, use ${tool.name}. If interactive UI is unavailable, ask one concise plain-text question instead.`
63
+ : "If a material decision remains, ask one concise plain-text question instead.";
64
+ const revisionClause = tool
65
+ ? `continue planning with ${tool.name} instead of calling plan_mode_complete`
66
+ : "continue planning with a plain-text question instead of calling plan_mode_complete";
47
67
  return `${PLAN_CONTEXT_MARKER}
48
68
  # Plan Mode (Conversational)
49
69
 
@@ -73,14 +93,14 @@ You are in Plan Mode, a collaboration mode for producing a decision-complete imp
73
93
  ## Phase 3 — Implementation chat
74
94
 
75
95
  - Once intent is stable, keep asking until the spec is decision-complete: approach, interfaces, data flow, edge cases/failure modes, testing and acceptance criteria, and any migration or compatibility constraints.
76
- - Use ${tool.name} for important preferences, tradeoffs, or assumption locks that cannot be discovered by non-mutating exploration. ${tool.bounds} Do not include filler options.
77
- - ${tool.decline}, do not jump straight to a final plan when the missing answer is high impact. Ask one concise plain-text question or proceed only with a clearly stated low-risk assumption.
96
+ - ${askBullet}
97
+ - ${declineBullet}
78
98
 
79
99
  ## Ending each turn
80
100
 
81
101
  Every Plan-mode turn that advances or finalizes the plan must end in exactly one of these ways:
82
102
 
83
- - If a material decision remains, use ${tool.name}. If interactive UI is unavailable, ask one concise plain-text question instead.
103
+ - ${endingBullet}
84
104
  - If the implementation plan is decision-complete, call plan_mode_complete alone as your final action. Do not call other tools in the same batch and do not emit a normal assistant response after it.
85
105
 
86
106
  If a follow-up asks only for clarification and does not change or challenge the plan, answer it directly, then call plan_mode_complete alone as the final action with the complete unchanged plan so it remains available for implementation.
@@ -101,7 +121,7 @@ Keep the plan concise, human and agent digestible, and free of open decisions. P
101
121
 
102
122
  The plan is saved to a durable file, so it survives compaction and can be re-read at any time.
103
123
 
104
- If the user requests revisions after a completed plan, the next plan_mode_complete call must contain a complete replacement, not a delta. If there is not enough information for a complete replacement, continue planning with ${tool.name} instead of calling plan_mode_complete.`;
124
+ If the user requests revisions after a completed plan, the next plan_mode_complete call must contain a complete replacement, not a delta. If there is not enough information for a complete replacement, ${revisionClause}.`;
105
125
  }
106
126
 
107
127
  /**
@@ -152,11 +152,13 @@ export async function answerPlanModeQuestions(
152
152
  questions: PlanModeQuestion[],
153
153
  ctx: ExtensionContext,
154
154
  lifecycle: { isCurrent(): boolean; isEnabled(): boolean },
155
+ signal?: AbortSignal,
155
156
  ) {
156
157
  const answers = await askPlanModeQuestions(
157
158
  questions,
158
159
  ctx,
159
- () => lifecycle.isCurrent() && lifecycle.isEnabled(),
160
+ () => lifecycle.isCurrent() && lifecycle.isEnabled() && !signal?.aborted,
161
+ signal,
160
162
  );
161
163
  if (!lifecycle.isCurrent()) {
162
164
  return planModeQuestionCancelled(
@@ -186,18 +188,22 @@ export async function askPlanModeQuestions(
186
188
  questions: PlanModeQuestion[],
187
189
  ctx: ExtensionContext,
188
190
  shouldContinue: () => boolean = () => true,
191
+ signal?: AbortSignal,
189
192
  ): Promise<PlanModeQuestionAnswer[] | undefined> {
190
193
  const answers: PlanModeQuestionAnswer[] = [];
191
194
  for (const question of questions) {
195
+ if (!shouldContinue() || signal?.aborted) return undefined;
192
196
  const choices = question.options.map(formatPlanModeQuestionChoice);
193
197
  const otherChoice = `${question.options.length + 1}. Other (free-form)`;
194
- const choice = await ctx.ui.select(`${question.header}: ${question.question}`, [
195
- ...choices,
196
- otherChoice,
197
- ]);
198
+ const choice = await raceWithAbort(
199
+ ctx.ui.select(`${question.header}: ${question.question}`, [...choices, otherChoice]),
200
+ signal,
201
+ );
198
202
  if (!shouldContinue() || !choice) return undefined;
199
203
  if (choice === otherChoice) {
200
- const customAnswer = (await ctx.ui.editor(question.question, ""))?.trim();
204
+ const customAnswer = (
205
+ await raceWithAbort(ctx.ui.editor(question.question, ""), signal)
206
+ )?.trim();
201
207
  if (!shouldContinue() || !customAnswer) return undefined;
202
208
  answers.push({
203
209
  id: question.id,
@@ -223,6 +229,16 @@ export async function askPlanModeQuestions(
223
229
  return answers;
224
230
  }
225
231
 
232
+ async function raceWithAbort<T>(operation: Promise<T>, signal?: AbortSignal): Promise<T | undefined> {
233
+ if (!signal) return operation;
234
+ if (signal.aborted) return undefined;
235
+ return new Promise<T | undefined>((resolve, reject) => {
236
+ const abort = () => resolve(undefined);
237
+ signal.addEventListener("abort", abort, { once: true });
238
+ operation.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
239
+ });
240
+ }
241
+
226
242
  function formatPlanModeQuestionChoice(option: PlanModeQuestionOption, index: number) {
227
243
  return `${index + 1}. ${option.label}${option.description ? ` — ${option.description}` : ""}`;
228
244
  }