@geoqiao/pi-ask 1.2.0 → 1.2.1

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,3 +1,10 @@
1
+ ## [1.2.1](https://github.com/geoqiao/pi-ask/compare/v1.2.0...v1.2.1) (2026-08-16)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * harden RPC fallback interactions ([4cceb3b](https://github.com/geoqiao/pi-ask/commit/4cceb3b2ab8d19b46f4688a53f8b7ae52e2b06ae))
7
+
1
8
  # [1.2.0](https://github.com/geoqiao/pi-ask/compare/v1.1.0...v1.2.0) (2026-08-16)
2
9
 
3
10
 
package/README.md CHANGED
@@ -3,6 +3,7 @@
3
3
  # @geoqiao/pi-ask
4
4
 
5
5
  [![npm downloads](https://badgen.net/npm/dm/@geoqiao/pi-ask)](https://www.npmjs.com/package/@geoqiao/pi-ask)
6
+ [![CI](https://github.com/geoqiao/pi-ask/actions/workflows/ci.yml/badge.svg)](https://github.com/geoqiao/pi-ask/actions/workflows/ci.yml)
6
7
  [![last commit](https://badgen.net/github/last-commit/geoqiao/pi-ask)](https://github.com/geoqiao/pi-ask/commits/main)
7
8
  [![stars](https://badgen.net/github/stars/geoqiao/pi-ask)](https://github.com/geoqiao/pi-ask/stargazers)
8
9
 
@@ -206,6 +207,8 @@ When Pi runs in RPC mode with portable extension UI support, `ask_user` keeps th
206
207
  - multi-select repeats `select` with `[ ]` / `[x]` markers until `Finish selection` is chosen
207
208
  - descriptions and preview content are flattened into readable option strings
208
209
  - multiple questions include `[current/total]` progress in each dialog title
210
+ - dismissing a question or notes-menu `select` cancels the flow, while dismissing a nested answer/note editor returns to its parent dialog
211
+ - tool abort signals close portable `select`/`input` dialogs; Pi's portable `editor` cannot be interrupted until it resolves
209
212
 
210
213
  RPC intentionally does not reproduce the tabbed same-screen form, native checkbox cards, custom preview pane, question-type hotkeys, settings overlay, or final Submit/Elaborate review tab. The fallback completes in `submit` mode after the sequential questions. `/answer`, `/answer:again`, `/ask:replay`, and `/ask-settings` remain TUI-only.
211
214
 
package/docs/contract.md CHANGED
@@ -160,7 +160,7 @@ This document defines the stable external behavior. It does not explain internal
160
160
 
161
161
  ## Output rules
162
162
 
163
- - `cancelled: true` means the user dismissed the flow, UI was unavailable, or the payload was invalid before UI opened
163
+ - `cancelled: true` means the user dismissed a flow-level selection, chose Cancel, aborted the flow, UI was unavailable, or the payload was invalid before UI opened
164
164
  - invalid payloads return `error.kind === "invalid_input"` with structured `issues` and a transcript-friendly `Invalid ask_user payload:` message
165
165
  - `mode: "submit"` is normal completion; `mode: "elaborate"` means the user asked the agent to continue with follow-up clarification based on notes
166
166
  - unanswered questions are omitted from `answers`
@@ -175,7 +175,7 @@ This document defines the stable external behavior. It does not explain internal
175
175
  - `customText` stores the free-form answer
176
176
  - on single-select questions, saving free-form text clears selected options for that question
177
177
  - on multi-select questions, `values` and `labels` include both selected options and `customText` when both are present
178
- - on multi-select questions, selected options keep their original order and `customText` is appended last
178
+ - on multi-select questions, selected options keep the user's selection order and `customText` is appended last
179
179
  - submitting free-form text on a multi-select question stays on the same question tab and marks the custom row selected
180
180
  - on multi-select questions, toggling an empty custom row opens the free-form editor, while toggling a custom row with saved free-form text selects or deselects it without opening the editor or clearing the text
181
181
  - saving or clearing free-form text on a multi-select question does not clear other selected options
@@ -263,10 +263,12 @@ The rich ask flow uses `ctx.ui.custom()` only in TUI mode. RPC mode never calls
263
263
  - short and multiline custom answers use `input` and `editor`
264
264
  - every question exposes Skip explicitly; required remains advisory and its Skip label says so
265
265
  - after each question, a portable action dialog can add/edit a short or multiline question note, or an editor note for a selected option
266
- - multi-select repeatedly calls `select`, showing `[ ]` / `[x]` option markers and a `Finish selection` action; selections are collected locally in original option order
266
+ - multi-select repeatedly calls `select`, showing `[ ]` / `[x]` option markers and a `Finish selection` action; selections are collected locally in the user's selection order
267
267
  - descriptions and preview content are flattened into option strings instead of using a custom preview pane
268
268
  - multiple questions are sequential and every title includes `[current/total]` progress
269
- - dismissing a value dialog or choosing Cancel returns `cancelled: true`
269
+ - dismissing a question or optional-notes action `select`, choosing Cancel, or aborting the tool returns `cancelled: true`
270
+ - dismissing a nested custom-answer or note `input`/`editor` abandons that edit and returns to its parent action dialog without changing the answer
271
+ - `select` and `input` observe the tool abort signal; Pi's portable `editor` API has no signal or timeout option, so an abort is observed only after an open editor resolves
270
272
 
271
273
  RPC does not provide the tabbed same-screen form, native checkbox cards, custom preview pane, question-type hotkeys, settings overlay, or final Submit/Elaborate review tab. RPC completion uses `mode: "submit"`. `/answer`, `/answer:again`, `/ask:replay`, and `/ask-settings` remain TUI-only.
272
274
 
@@ -18,7 +18,7 @@ Remote submit:
18
18
 
19
19
  ## Started
20
20
 
21
- Emitted after a validated ask UI flow opens.
21
+ Emitted after a validated TUI ask flow opens. The RPC portable-dialog fallback does not start the remote event bridge or emit lifecycle events.
22
22
 
23
23
  ```ts
24
24
  type PiAskStartedEvent = {
@@ -104,7 +104,7 @@ Correlate by `requestId` and `flowId`. Do not depend on strict ordering between
104
104
 
105
105
  ## Completed
106
106
 
107
- Emitted when the flow resolves.
107
+ Emitted when the TUI flow resolves. RPC portable-dialog completion is returned through the normal tool result instead.
108
108
 
109
109
  ```ts
110
110
  type PiAskCompletedEvent = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geoqiao/pi-ask",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "Pi package that adds an interactive ask_user clarification tool.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/ask-tool.ts CHANGED
@@ -51,7 +51,7 @@ async function executeAskTool(
51
51
  pi: Pick<ExtensionAPI, "appendEntry">,
52
52
  toolCallId: string,
53
53
  params: AskParams,
54
- _signal: AbortSignal | undefined,
54
+ signal: AbortSignal | undefined,
55
55
  _onUpdate: unknown,
56
56
  ctx: ExtensionContext,
57
57
  remoteAsk?: RemoteAskRuntime
@@ -70,7 +70,9 @@ async function executeAskTool(
70
70
  });
71
71
  if (ctx.mode !== "tui") {
72
72
  if (ctx.mode === "rpc" && ctx.hasUI) {
73
- return successfulResponse(await runRpcAskFlow(ctx, validation.state));
73
+ return successfulResponse(
74
+ await runRpcAskFlow(ctx, validation.state, { signal })
75
+ );
74
76
  }
75
77
  return nonInteractiveResponse(validation.state);
76
78
  }
@@ -20,11 +20,21 @@ import type {
20
20
 
21
21
  type RpcUi = Pick<ExtensionContext["ui"], "editor" | "input" | "select">;
22
22
 
23
- interface RpcStepResult {
24
- cancelled: boolean;
25
- state: AskState;
23
+ interface RpcFlowOptions {
24
+ signal?: AbortSignal;
26
25
  }
27
26
 
27
+ type RpcCompletionResult =
28
+ | { kind: "cancelled"; state: AskState }
29
+ | { kind: "completed"; state: AskState };
30
+
31
+ type RpcActionResult = RpcCompletionResult | { kind: "back"; state: AskState };
32
+
33
+ type RpcEditResult =
34
+ | { kind: "back"; state: AskState }
35
+ | { kind: "cancelled"; state: AskState }
36
+ | { kind: "saved"; state: AskState };
37
+
28
38
  interface SelectAction {
29
39
  kind:
30
40
  | "cancel"
@@ -56,23 +66,40 @@ const FINISH_SELECTION_LABEL = "Finish selection";
56
66
 
57
67
  export async function runRpcAskFlow(
58
68
  ctx: { ui: RpcUi },
59
- initialState: AskState
69
+ initialState: AskState,
70
+ options: RpcFlowOptions = {}
60
71
  ): Promise<AskResult> {
61
72
  let state = initialState;
62
73
 
63
74
  for (const [questionIndex] of state.questions.entries()) {
64
- const answerStep = await askQuestion(ctx.ui, state, questionIndex);
75
+ if (options.signal?.aborted) {
76
+ return cancelledResult(state);
77
+ }
78
+ const answerStep = await askQuestion(
79
+ ctx.ui,
80
+ state,
81
+ questionIndex,
82
+ options.signal
83
+ );
65
84
  state = answerStep.state;
66
- if (answerStep.cancelled) {
85
+ if (answerStep.kind === "cancelled") {
67
86
  return cancelledResult(state);
68
87
  }
69
88
 
70
- const noteStep = await askForOptionalNotes(ctx.ui, state, questionIndex);
89
+ const noteStep = await askForOptionalNotes(
90
+ ctx.ui,
91
+ state,
92
+ questionIndex,
93
+ options.signal
94
+ );
71
95
  state = noteStep.state;
72
- if (noteStep.cancelled) {
96
+ if (noteStep.kind === "cancelled") {
73
97
  return cancelledResult(state);
74
98
  }
75
99
  }
100
+ if (options.signal?.aborted) {
101
+ return cancelledResult(state);
102
+ }
76
103
 
77
104
  return toAskResult({
78
105
  ...state,
@@ -84,72 +111,94 @@ export async function runRpcAskFlow(
84
111
  async function askQuestion(
85
112
  ui: RpcUi,
86
113
  state: AskState,
87
- questionIndex: number
88
- ): Promise<RpcStepResult> {
114
+ questionIndex: number,
115
+ signal?: AbortSignal
116
+ ): Promise<RpcCompletionResult> {
89
117
  const question = state.questions[questionIndex];
90
118
  if (!question) {
91
- return { cancelled: false, state };
119
+ return { kind: "completed", state };
92
120
  }
93
121
  if (question.type === "multi") {
94
- return await askMultiQuestion(ui, state, question, questionIndex);
122
+ return await askMultiQuestion(ui, state, question, questionIndex, signal);
95
123
  }
96
- return await askSingleQuestion(ui, state, question, questionIndex);
124
+ return await askSingleQuestion(ui, state, question, questionIndex, signal);
97
125
  }
98
126
 
99
127
  async function askSingleQuestion(
100
128
  ui: RpcUi,
101
- state: AskState,
129
+ initialState: AskState,
102
130
  question: AskQuestion,
103
- questionIndex: number
104
- ): Promise<RpcStepResult> {
105
- const actions: SelectAction[] = question.options
106
- .filter((option) => !option.freeform)
107
- .map((option, optionIndex) => ({
108
- kind: "option" as const,
109
- label: formatOption(option, optionIndex),
110
- optionIndex,
111
- }));
112
- appendCommonActions(actions, question);
131
+ questionIndex: number,
132
+ signal?: AbortSignal
133
+ ): Promise<RpcCompletionResult> {
134
+ let state = initialState;
135
+ while (true) {
136
+ if (signal?.aborted) {
137
+ return { kind: "cancelled", state };
138
+ }
139
+ const actions: SelectAction[] = [...question.options.entries()]
140
+ .filter(([, option]) => !option.freeform)
141
+ .map(([optionIndex, option]) => ({
142
+ kind: "option" as const,
143
+ label: formatOption(option, optionIndex),
144
+ optionIndex,
145
+ }));
146
+ appendCommonActions(actions, question);
113
147
 
114
- const action = await selectAction(
115
- ui,
116
- formatTitle(state, question, questionIndex),
117
- actions
118
- );
119
- return applySingleAction({
120
- action,
121
- question,
122
- questionIndex,
123
- state,
124
- ui,
125
- });
148
+ const action = await selectAction(
149
+ ui,
150
+ formatTitle(state, question, questionIndex),
151
+ actions,
152
+ signal
153
+ );
154
+ const result = await applySingleAction({
155
+ action,
156
+ question,
157
+ questionIndex,
158
+ signal,
159
+ state,
160
+ ui,
161
+ });
162
+ if (result.kind !== "back") {
163
+ return result;
164
+ }
165
+ state = result.state;
166
+ }
126
167
  }
127
168
 
128
169
  async function applySingleAction(args: {
129
170
  action: SelectAction | undefined;
130
171
  question: AskQuestion;
131
172
  questionIndex: number;
173
+ signal?: AbortSignal;
132
174
  state: AskState;
133
175
  ui: RpcUi;
134
- }): Promise<RpcStepResult> {
135
- const { action, question, questionIndex, state, ui } = args;
176
+ }): Promise<RpcActionResult> {
177
+ const { action, question, questionIndex, signal, state, ui } = args;
136
178
  if (!action || action.kind === "cancel") {
137
- return { cancelled: true, state };
179
+ return { kind: "cancelled", state };
138
180
  }
139
181
  if (action.kind === "skip") {
140
- return { cancelled: false, state: clearAnswer(state, question.id) };
182
+ return {
183
+ kind: "completed",
184
+ state: clearAnswer(state, question.id),
185
+ };
141
186
  }
142
187
  if (action.kind === "custom-input" || action.kind === "custom-editor") {
143
- return await askForCustomAnswer(
188
+ const editResult = await askForCustomAnswer(
144
189
  ui,
145
190
  state,
146
191
  question,
147
192
  questionIndex,
148
- action.kind === "custom-editor"
193
+ action.kind === "custom-editor",
194
+ signal
149
195
  );
196
+ return editResult.kind === "saved"
197
+ ? { kind: "completed", state: editResult.state }
198
+ : editResult;
150
199
  }
151
200
  if (action.kind !== "option") {
152
- return { cancelled: true, state };
201
+ return { kind: "cancelled", state };
153
202
  }
154
203
 
155
204
  return selectSingleOption(state, question, action.optionIndex);
@@ -159,14 +208,14 @@ function selectSingleOption(
159
208
  state: AskState,
160
209
  question: AskQuestion,
161
210
  optionIndex: number | undefined
162
- ): RpcStepResult {
211
+ ): RpcCompletionResult {
163
212
  const option =
164
213
  optionIndex === undefined ? undefined : question.options[optionIndex];
165
214
  if (!option || optionIndex === undefined) {
166
- return { cancelled: true, state };
215
+ return { kind: "cancelled", state };
167
216
  }
168
217
  return {
169
- cancelled: false,
218
+ kind: "completed",
170
219
  state: updateAnswer(state, question.id, (answer) =>
171
220
  setSingleSelection(answer, option, optionIndex)
172
221
  ),
@@ -177,25 +226,31 @@ async function askMultiQuestion(
177
226
  ui: RpcUi,
178
227
  initialState: AskState,
179
228
  question: AskQuestion,
180
- questionIndex: number
181
- ): Promise<RpcStepResult> {
229
+ questionIndex: number,
230
+ signal?: AbortSignal
231
+ ): Promise<RpcCompletionResult> {
182
232
  let state = initialState;
183
233
  while (true) {
234
+ if (signal?.aborted) {
235
+ return { kind: "cancelled", state };
236
+ }
184
237
  const actions = createMultiActions(question, state.answers[question.id]);
185
238
  const action = await selectAction(
186
239
  ui,
187
240
  formatTitle(state, question, questionIndex, "Select all that apply"),
188
- actions
241
+ actions,
242
+ signal
189
243
  );
190
244
  const actionResult = await applyMultiAction({
191
245
  action,
192
246
  question,
193
247
  questionIndex,
248
+ signal,
194
249
  state,
195
250
  ui,
196
251
  });
197
- if (actionResult.done) {
198
- return actionResult.step;
252
+ if (actionResult.kind !== "back") {
253
+ return actionResult;
199
254
  }
200
255
  state = actionResult.state;
201
256
  }
@@ -205,9 +260,9 @@ function createMultiActions(
205
260
  question: AskQuestion,
206
261
  answer: AskStateAnswer | undefined
207
262
  ): SelectAction[] {
208
- const actions: SelectAction[] = question.options
209
- .filter((option) => !option.freeform)
210
- .map((option, optionIndex) => ({
263
+ const actions: SelectAction[] = [...question.options.entries()]
264
+ .filter(([, option]) => !option.freeform)
265
+ .map(([optionIndex, option]) => ({
211
266
  kind: "option" as const,
212
267
  label: formatOption(option, optionIndex, {
213
268
  selected: isOptionSelected(answer, option.value),
@@ -228,41 +283,39 @@ function createMultiActions(
228
283
  return actions;
229
284
  }
230
285
 
231
- type MultiActionResult =
232
- | { done: false; state: AskState }
233
- | { done: true; step: RpcStepResult };
234
-
235
286
  async function applyMultiAction(args: {
236
287
  action: SelectAction | undefined;
237
288
  question: AskQuestion;
238
289
  questionIndex: number;
290
+ signal?: AbortSignal;
239
291
  state: AskState;
240
292
  ui: RpcUi;
241
- }): Promise<MultiActionResult> {
242
- const { action, question, questionIndex, state, ui } = args;
293
+ }): Promise<RpcActionResult> {
294
+ const { action, question, questionIndex, signal, state, ui } = args;
243
295
  if (!action || action.kind === "cancel") {
244
- return { done: true, step: { cancelled: true, state } };
296
+ return { kind: "cancelled", state };
245
297
  }
246
298
  if (action.kind === "finish") {
247
- return { done: true, step: { cancelled: false, state } };
299
+ return { kind: "completed", state };
248
300
  }
249
301
  if (action.kind === "skip") {
250
302
  return {
251
- done: true,
252
- step: { cancelled: false, state: clearAnswer(state, question.id) },
303
+ kind: "completed",
304
+ state: clearAnswer(state, question.id),
253
305
  };
254
306
  }
255
307
  if (action.kind === "custom-input" || action.kind === "custom-editor") {
256
- const step = await askForCustomAnswer(
308
+ const editResult = await askForCustomAnswer(
257
309
  ui,
258
310
  state,
259
311
  question,
260
312
  questionIndex,
261
- action.kind === "custom-editor"
313
+ action.kind === "custom-editor",
314
+ signal
262
315
  );
263
- return step.cancelled
264
- ? { done: true, step }
265
- : { done: false, state: step.state };
316
+ return editResult.kind === "cancelled"
317
+ ? editResult
318
+ : { kind: "back", state: editResult.state };
266
319
  }
267
320
  return applyMultiOptionAction(state, question, action);
268
321
  }
@@ -271,24 +324,18 @@ function applyMultiOptionAction(
271
324
  state: AskState,
272
325
  question: AskQuestion,
273
326
  action: SelectAction
274
- ): MultiActionResult {
327
+ ): RpcActionResult {
275
328
  const optionIndex = action.optionIndex;
276
329
  const option =
277
330
  optionIndex === undefined ? undefined : question.options[optionIndex];
278
331
  if (!option || optionIndex === undefined) {
279
- return { done: true, step: { cancelled: true, state } };
332
+ return { kind: "cancelled", state };
280
333
  }
281
334
  return {
282
- done: false,
283
- state: updateAnswer(state, question.id, (currentAnswer) => {
284
- const nextAnswer = toggleSelection(currentAnswer, option, optionIndex);
285
- return {
286
- ...nextAnswer,
287
- selected: [...nextAnswer.selected].sort(
288
- (left, right) => left.index - right.index
289
- ),
290
- };
291
- }),
335
+ kind: "back",
336
+ state: updateAnswer(state, question.id, (currentAnswer) =>
337
+ toggleSelection(currentAnswer, option, optionIndex)
338
+ ),
292
339
  };
293
340
  }
294
341
 
@@ -297,8 +344,12 @@ async function askForCustomAnswer(
297
344
  state: AskState,
298
345
  question: AskQuestion,
299
346
  questionIndex: number,
300
- multiline: boolean
301
- ): Promise<RpcStepResult> {
347
+ multiline: boolean,
348
+ signal?: AbortSignal
349
+ ): Promise<RpcEditResult> {
350
+ if (signal?.aborted) {
351
+ return { kind: "cancelled", state };
352
+ }
302
353
  const currentText = state.answers[question.id]?.customText;
303
354
  const title = formatTitle(
304
355
  state,
@@ -308,13 +359,16 @@ async function askForCustomAnswer(
308
359
  );
309
360
  const value = multiline
310
361
  ? await ui.editor(title, currentText ?? "")
311
- : await ui.input(title, formatInputPlaceholder(currentText));
362
+ : await ui.input(title, formatInputPlaceholder(currentText), { signal });
363
+ if (signal?.aborted) {
364
+ return { kind: "cancelled", state };
365
+ }
312
366
  if (value === undefined) {
313
- return { cancelled: true, state };
367
+ return { kind: "back", state };
314
368
  }
315
369
 
316
370
  return {
317
- cancelled: false,
371
+ kind: "saved",
318
372
  state: updateAnswer(state, question.id, (answer) =>
319
373
  saveCustomText(
320
374
  answer,
@@ -328,31 +382,37 @@ async function askForCustomAnswer(
328
382
  async function askForOptionalNotes(
329
383
  ui: RpcUi,
330
384
  initialState: AskState,
331
- questionIndex: number
332
- ): Promise<RpcStepResult> {
385
+ questionIndex: number,
386
+ signal?: AbortSignal
387
+ ): Promise<RpcCompletionResult> {
333
388
  let state = initialState;
334
389
  const question = state.questions[questionIndex];
335
390
  if (!question) {
336
- return { cancelled: false, state };
391
+ return { kind: "completed", state };
337
392
  }
338
393
 
339
394
  while (true) {
395
+ if (signal?.aborted) {
396
+ return { kind: "cancelled", state };
397
+ }
340
398
  const answer = state.answers[question.id];
341
399
  const actions = createNoteActions(answer);
342
400
  const action = await selectNoteAction(
343
401
  ui,
344
402
  formatTitle(state, question, questionIndex, "Optional notes"),
345
- actions
403
+ actions,
404
+ signal
346
405
  );
347
406
  const actionResult = await applyNoteAction({
348
407
  action,
349
408
  question,
350
409
  questionIndex,
410
+ signal,
351
411
  state,
352
412
  ui,
353
413
  });
354
- if (actionResult.done) {
355
- return actionResult.step;
414
+ if (actionResult.kind !== "back") {
415
+ return actionResult;
356
416
  }
357
417
  state = actionResult.state;
358
418
  }
@@ -387,38 +447,47 @@ function createNoteActions(answer: AskStateAnswer | undefined): NoteAction[] {
387
447
  kind: "option-note" as const,
388
448
  optionValue: selection.value,
389
449
  label: answer?.optionNotes?.[selection.value]
390
- ? `Edit note for selected option: ${compactText(selection.label)}…`
391
- : `Add note for selected option: ${compactText(selection.label)}…`,
450
+ ? `Edit note for selected option: ${selection.index}. ${compactText(selection.label)}…`
451
+ : `Add note for selected option: ${selection.index}. ${compactText(selection.label)}…`,
392
452
  })),
393
453
  { kind: "cancel", label: CANCEL_LABEL },
394
454
  ];
395
455
  }
396
456
 
397
- type NoteActionResult =
398
- | { done: false; state: AskState }
399
- | { done: true; step: RpcStepResult };
400
-
401
457
  async function applyNoteAction(args: {
402
458
  action: NoteAction | undefined;
403
459
  question: AskQuestion;
404
460
  questionIndex: number;
461
+ signal?: AbortSignal;
405
462
  state: AskState;
406
463
  ui: RpcUi;
407
- }): Promise<NoteActionResult> {
408
- const { action, question, questionIndex, state, ui } = args;
464
+ }): Promise<RpcActionResult> {
465
+ const { action, question, questionIndex, signal, state, ui } = args;
409
466
  if (!action || action.kind === "cancel") {
410
- return { done: true, step: { cancelled: true, state } };
467
+ return { kind: "cancelled", state };
411
468
  }
412
469
  if (action.kind === "continue") {
413
- return { done: true, step: { cancelled: false, state } };
470
+ return { kind: "completed", state };
414
471
  }
415
472
  if (action.kind === "question-input") {
416
- return await editQuestionNote(ui, state, question, questionIndex, false);
473
+ return noteEditActionResult(
474
+ await editQuestionNote(ui, state, question, questionIndex, false, signal)
475
+ );
417
476
  }
418
477
  if (action.kind === "question-editor") {
419
- return await editQuestionNote(ui, state, question, questionIndex, true);
478
+ return noteEditActionResult(
479
+ await editQuestionNote(ui, state, question, questionIndex, true, signal)
480
+ );
420
481
  }
421
- return await editOptionNote(ui, state, question, questionIndex, action);
482
+ return noteEditActionResult(
483
+ await editOptionNote(ui, state, question, questionIndex, action, signal)
484
+ );
485
+ }
486
+
487
+ function noteEditActionResult(result: RpcEditResult): RpcActionResult {
488
+ return result.kind === "cancelled"
489
+ ? result
490
+ : { kind: "back", state: result.state };
422
491
  }
423
492
 
424
493
  async function editQuestionNote(
@@ -426,18 +495,25 @@ async function editQuestionNote(
426
495
  state: AskState,
427
496
  question: AskQuestion,
428
497
  questionIndex: number,
429
- multiline: boolean
430
- ): Promise<NoteActionResult> {
498
+ multiline: boolean,
499
+ signal?: AbortSignal
500
+ ): Promise<RpcEditResult> {
501
+ if (signal?.aborted) {
502
+ return { kind: "cancelled", state };
503
+ }
431
504
  const currentNote = state.answers[question.id]?.note;
432
505
  const title = formatTitle(state, question, questionIndex, "Question note");
433
506
  const value = multiline
434
507
  ? await ui.editor(title, currentNote ?? "")
435
- : await ui.input(title, formatNotePlaceholder(currentNote));
508
+ : await ui.input(title, formatNotePlaceholder(currentNote), { signal });
509
+ if (signal?.aborted) {
510
+ return { kind: "cancelled", state };
511
+ }
436
512
  if (value === undefined) {
437
- return { done: true, step: { cancelled: true, state } };
513
+ return { kind: "back", state };
438
514
  }
439
515
  return {
440
- done: false,
516
+ kind: "saved",
441
517
  state: updateAnswer(state, question.id, (answer) =>
442
518
  saveQuestionNote(answer, value)
443
519
  ),
@@ -449,14 +525,18 @@ async function editOptionNote(
449
525
  state: AskState,
450
526
  question: AskQuestion,
451
527
  questionIndex: number,
452
- action: NoteAction
453
- ): Promise<NoteActionResult> {
528
+ action: NoteAction,
529
+ signal?: AbortSignal
530
+ ): Promise<RpcEditResult> {
531
+ if (signal?.aborted) {
532
+ return { kind: "cancelled", state };
533
+ }
454
534
  const optionValue = action.optionValue;
455
535
  const option = question.options.find(
456
536
  (candidate) => candidate.value === optionValue
457
537
  );
458
538
  if (!(optionValue && option)) {
459
- return { done: true, step: { cancelled: true, state } };
539
+ return { kind: "cancelled", state };
460
540
  }
461
541
  const value = await ui.editor(
462
542
  formatTitle(
@@ -467,11 +547,14 @@ async function editOptionNote(
467
547
  ),
468
548
  state.answers[question.id]?.optionNotes?.[optionValue] ?? ""
469
549
  );
550
+ if (signal?.aborted) {
551
+ return { kind: "cancelled", state };
552
+ }
470
553
  if (value === undefined) {
471
- return { done: true, step: { cancelled: true, state } };
554
+ return { kind: "back", state };
472
555
  }
473
556
  return {
474
- done: false,
557
+ kind: "saved",
475
558
  state: updateAnswer(state, question.id, (answer) =>
476
559
  saveOptionNote(answer, optionValue, value)
477
560
  ),
@@ -481,24 +564,40 @@ async function editOptionNote(
481
564
  async function selectAction(
482
565
  ui: RpcUi,
483
566
  title: string,
484
- actions: SelectAction[]
567
+ actions: SelectAction[],
568
+ signal?: AbortSignal
485
569
  ): Promise<SelectAction | undefined> {
570
+ if (signal?.aborted) {
571
+ return;
572
+ }
486
573
  const selected = await ui.select(
487
574
  title,
488
- actions.map((action) => action.label)
575
+ actions.map((action) => action.label),
576
+ { signal }
489
577
  );
578
+ if (signal?.aborted) {
579
+ return;
580
+ }
490
581
  return actions.find((action) => action.label === selected);
491
582
  }
492
583
 
493
584
  async function selectNoteAction(
494
585
  ui: RpcUi,
495
586
  title: string,
496
- actions: NoteAction[]
587
+ actions: NoteAction[],
588
+ signal?: AbortSignal
497
589
  ): Promise<NoteAction | undefined> {
590
+ if (signal?.aborted) {
591
+ return;
592
+ }
498
593
  const selected = await ui.select(
499
594
  title,
500
- actions.map((action) => action.label)
595
+ actions.map((action) => action.label),
596
+ { signal }
501
597
  );
598
+ if (signal?.aborted) {
599
+ return;
600
+ }
502
601
  return actions.find((action) => action.label === selected);
503
602
  }
504
603