@geoqiao/pi-ask 1.2.0 → 1.2.2

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,17 @@
1
+ ## [1.2.2](https://github.com/geoqiao/pi-ask/compare/v1.2.1...v1.2.2) (2026-08-16)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * simplify RPC question flow ([68a0f29](https://github.com/geoqiao/pi-ask/commit/68a0f29cc88dae906fe4a105714eb71d45654776))
7
+
8
+ ## [1.2.1](https://github.com/geoqiao/pi-ask/compare/v1.2.0...v1.2.1) (2026-08-16)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * harden RPC fallback interactions ([4cceb3b](https://github.com/geoqiao/pi-ask/commit/4cceb3b2ab8d19b46f4688a53f8b7ae52e2b06ae))
14
+
1
15
  # [1.2.0](https://github.com/geoqiao/pi-ask/compare/v1.1.0...v1.2.0) (2026-08-16)
2
16
 
3
17
 
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
 
@@ -200,14 +201,15 @@ Agents can auto-discover and call `ask_user` when they need clarification instea
200
201
 
201
202
  When Pi runs in RPC mode with portable extension UI support, `ask_user` keeps the same normalized result contract but uses sequential dialogs:
202
203
 
203
- - single choices, including Yes/No pairs, use cancellable `select` dialogs
204
- - short custom answers use `input`; multiline answers use `editor`
205
- - each question offers explicit Skip and Cancel actions, followed by optional question/selected-option note actions
206
- - multi-select repeats `select` with `[ ]` / `[x]` markers until `Finish selection` is chosen
204
+ - each question card contains only its real options plus `Type something…`
205
+ - selecting a real option and submitting advances directly to the next question or completes the flow
206
+ - `Type something…` opens one `input` dialog; on multi questions it is the fallback for entering multiple choices
207
+ - dismissing a question or its input skips that question instead of cancelling the flow
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
+ - tool abort signals close portable `select`/`input` dialogs and return `cancelled: true`
209
211
 
210
- 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.
212
+ RPC intentionally does not reproduce the tabbed same-screen form, native checkbox cards, repeated multi-select cards, notes, Skip/Cancel option rows, 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
213
 
212
214
  ### Answer and replay commands
213
215
 
@@ -69,6 +69,9 @@ The codebase is split so the implementation reads through file boundaries and na
69
69
  - submit is never blocked by unanswered questions
70
70
  - TUI state and controller behavior stay isolated from the RPC portable-dialog controller
71
71
  - RPC answers are written through shared answer helpers and serialized through the shared result builder
72
+ - each RPC question card contains only real options plus one `Type something…` fallback; submitting a choice advances without a second confirmation card
73
+ - RPC dismissal skips the current question, while an actual tool abort cancels the flow
74
+ - RPC multi questions accept one scalar option or free-form text containing multiple choices; they do not emulate checkbox state with repeated dialogs
72
75
  - single-select answers serialize as arrays
73
76
  - when `behaviour.presentSingleAsMulti` is enabled, future single-select questions are handled with multi-select state semantics while result metadata preserves the requested `type` and adds `presentedType`
74
77
  - active-flow question type changes are per-question runtime overrides handled in state/controller logic; they do not mutate the stored source payload or global config
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
@@ -259,16 +259,17 @@ Dirty dismiss:
259
259
 
260
260
  The rich ask flow uses `ctx.ui.custom()` only in TUI mode. RPC mode never calls `custom()`; when portable extension dialogs are available it asks questions sequentially and serializes answers through the same state/result helpers:
261
261
 
262
- - single choices, including Yes/No pairs, use `select` so dismissal remains distinct from choosing `No`
263
- - short and multiline custom answers use `input` and `editor`
264
- - every question exposes Skip explicitly; required remains advisory and its Skip label says so
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
262
+ - every question uses one `select` card containing the real options followed by `Type something…`
263
+ - selecting and submitting a real option advances directly; no confirmation, notes, review, or continuation card follows it
264
+ - `Type something…` opens one `input` dialog and serializes the entered text through the existing `customText`, `values`, `labels`, and `indices` result fields
265
+ - RPC does not emulate native multi-select: choosing one real option records that option, while `Type something…` lets the user enter multiple choices as free-form text
267
266
  - descriptions and preview content are flattened into option strings instead of using a custom preview pane
268
267
  - multiple questions are sequential and every title includes `[current/total]` progress
269
- - dismissing a value dialog or choosing Cancel returns `cancelled: true`
268
+ - dismissing a question card or its custom input skips that question; a dismissed required question is also left unanswered because required remains advisory
269
+ - RPC does not add Skip or Cancel rows because the portable client's native Dismiss control already provides the skip behavior
270
+ - `select` and `input` observe the tool abort signal; an actual tool abort returns `cancelled: true`
270
271
 
271
- 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
+ RPC does not provide the tabbed same-screen form, native checkbox cards, repeated multi-select cards, notes, 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
273
 
273
274
  In print, JSON, other non-TUI modes, or RPC without portable UI availability, the tool returns a `Needs user input: ask_user requires interactive TUI mode.` message in `content` and a cancelled result in `details` instead of opening UI.
274
275
 
@@ -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.2",
4
4
  "description": "Pi package that adds an interactive ask_user clarification tool.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -13,7 +13,7 @@ import type {
13
13
  } from "./types.ts";
14
14
 
15
15
  export const ASK_TOOL_DESCRIPTION =
16
- "Interactive clarification tool for cases where the next step depends on user preferences, missing requirements, or choosing between multiple valid directions. Ask a short structured interview, collect normalized answers, and continue using those answers explicitly instead of guessing. TUI mode supports single-select, multi-select, and preview-pane questions; RPC mode presents questions sequentially, implements multi-select with repeated portable dialogs, and flattens preview details into option text. Always include a machine-readable `value` for every option. Use `preview` only when every option includes `preview` text; descriptions alone are not enough.";
16
+ "Interactive clarification tool for cases where the next step depends on user preferences, missing requirements, or choosing between multiple valid directions. Ask a short structured interview, collect normalized answers, and continue using those answers explicitly instead of guessing. TUI mode supports single-select, multi-select, and preview-pane questions; RPC mode presents questions sequentially, offers one portable choice per question plus a typed-answer fallback, and flattens preview details into option text. Always include a machine-readable `value` for every option. Use `preview` only when every option includes `preview` text; descriptions alone are not enough.";
17
17
 
18
18
  export const ASK_TOOL_PROMPT_GUIDELINES = [
19
19
  "Use `ask_user` before making preference-sensitive decisions about scope, tone, UX, naming, architecture, docs, or implementation direction.",
@@ -25,7 +25,7 @@ export const ASK_TOOL_PROMPT_GUIDELINES = [
25
25
  "After an `ask_user` elaboration or follow-up note, prefer another structured `ask_user` follow-up if a choice is still needed instead of switching to plain-text multiple choice in chat.",
26
26
  "When prior `ask_user` answers narrow the branch, bundle the next 2-3 related unresolved decisions into one follow-up `ask_user` call when possible.",
27
27
  "Use one-at-a-time `ask_user` follow-up calls only when the next question materially depends on the previous answer.",
28
- "Do not promise same-screen forms, native checkbox cards, or a custom preview pane when `ask_user` is rendered through RPC; the portable fallback asks questions sequentially and uses a repeated-select loop for multi-select.",
28
+ "Do not promise same-screen forms, native checkbox cards, or a custom preview pane when `ask_user` is rendered through RPC; the portable fallback asks questions sequentially, and users type multiple choices through `Type something…` when one scalar selection is insufficient.",
29
29
  ] as const;
30
30
 
31
31
  interface ValidateParamsOptions {
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
  }
@@ -2,12 +2,8 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import {
3
3
  emptyAnswer,
4
4
  isAnswerEmpty,
5
- isOptionSelected,
6
5
  saveCustomText,
7
- saveOptionNote,
8
- saveQuestionNote,
9
6
  setSingleSelection,
10
- toggleSelection,
11
7
  } from "../state/answers.ts";
12
8
  import { toAskResult } from "../state/result.ts";
13
9
  import type {
@@ -18,62 +14,58 @@ import type {
18
14
  AskStateAnswer,
19
15
  } from "../types.ts";
20
16
 
21
- type RpcUi = Pick<ExtensionContext["ui"], "editor" | "input" | "select">;
17
+ type RpcUi = Pick<ExtensionContext["ui"], "input" | "select">;
22
18
 
23
- interface RpcStepResult {
24
- cancelled: boolean;
25
- state: AskState;
19
+ interface RpcFlowOptions {
20
+ signal?: AbortSignal;
26
21
  }
27
22
 
23
+ type RpcQuestionResult =
24
+ | { kind: "cancelled"; state: AskState }
25
+ | { kind: "completed"; state: AskState };
26
+
28
27
  interface SelectAction {
29
- kind:
30
- | "cancel"
31
- | "custom-editor"
32
- | "custom-input"
33
- | "finish"
34
- | "option"
35
- | "skip";
28
+ kind: "custom" | "option";
36
29
  label: string;
37
30
  optionIndex?: number;
38
31
  }
39
32
 
40
- interface NoteAction {
41
- kind:
42
- | "cancel"
43
- | "continue"
44
- | "option-note"
45
- | "question-editor"
46
- | "question-input";
47
- label: string;
48
- optionValue?: string;
49
- }
33
+ type SelectResult =
34
+ | { kind: "aborted" }
35
+ | { kind: "dismissed" }
36
+ | { kind: "invalid" }
37
+ | { action: SelectAction; kind: "selected" };
50
38
 
51
- const CANCEL_LABEL = "Cancel ask";
52
- const CONTINUE_LABEL = "Continue";
53
- const CUSTOM_EDITOR_LABEL = "Write or edit a multiline answer…";
54
- const CUSTOM_INPUT_LABEL = "Type or edit a short answer…";
55
- const FINISH_SELECTION_LABEL = "Finish selection";
39
+ const TYPE_SOMETHING_LABEL = "Type something…";
56
40
 
57
41
  export async function runRpcAskFlow(
58
42
  ctx: { ui: RpcUi },
59
- initialState: AskState
43
+ initialState: AskState,
44
+ options: RpcFlowOptions = {}
60
45
  ): Promise<AskResult> {
61
46
  let state = initialState;
62
47
 
63
48
  for (const [questionIndex] of state.questions.entries()) {
64
- const answerStep = await askQuestion(ctx.ui, state, questionIndex);
65
- state = answerStep.state;
66
- if (answerStep.cancelled) {
49
+ if (options.signal?.aborted) {
67
50
  return cancelledResult(state);
68
51
  }
69
52
 
70
- const noteStep = await askForOptionalNotes(ctx.ui, state, questionIndex);
71
- state = noteStep.state;
72
- if (noteStep.cancelled) {
53
+ const step = await askQuestion(
54
+ ctx.ui,
55
+ state,
56
+ questionIndex,
57
+ options.signal
58
+ );
59
+ state = step.state;
60
+ if (step.kind === "cancelled") {
73
61
  return cancelledResult(state);
74
62
  }
75
63
  }
76
64
 
65
+ if (options.signal?.aborted) {
66
+ return cancelledResult(state);
67
+ }
68
+
77
69
  return toAskResult({
78
70
  ...state,
79
71
  activeTabIndex: state.questions.length,
@@ -84,396 +76,102 @@ export async function runRpcAskFlow(
84
76
  async function askQuestion(
85
77
  ui: RpcUi,
86
78
  state: AskState,
87
- questionIndex: number
88
- ): Promise<RpcStepResult> {
79
+ questionIndex: number,
80
+ signal?: AbortSignal
81
+ ): Promise<RpcQuestionResult> {
89
82
  const question = state.questions[questionIndex];
90
83
  if (!question) {
91
- return { cancelled: false, state };
84
+ return { kind: "completed", state };
92
85
  }
93
- if (question.type === "multi") {
94
- return await askMultiQuestion(ui, state, question, questionIndex);
95
- }
96
- return await askSingleQuestion(ui, state, question, questionIndex);
97
- }
98
86
 
99
- async function askSingleQuestion(
100
- ui: RpcUi,
101
- state: AskState,
102
- 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);
113
-
114
- const action = await selectAction(
87
+ const actions = createQuestionActions(question);
88
+ const selection = await selectAction(
115
89
  ui,
116
90
  formatTitle(state, question, questionIndex),
117
- actions
91
+ actions,
92
+ signal
118
93
  );
119
- return applySingleAction({
120
- action,
121
- question,
122
- questionIndex,
123
- state,
124
- ui,
125
- });
126
- }
127
94
 
128
- async function applySingleAction(args: {
129
- action: SelectAction | undefined;
130
- question: AskQuestion;
131
- questionIndex: number;
132
- state: AskState;
133
- ui: RpcUi;
134
- }): Promise<RpcStepResult> {
135
- const { action, question, questionIndex, state, ui } = args;
136
- if (!action || action.kind === "cancel") {
137
- return { cancelled: true, state };
95
+ if (selection.kind === "aborted" || selection.kind === "invalid") {
96
+ return { kind: "cancelled", state };
138
97
  }
139
- if (action.kind === "skip") {
140
- return { cancelled: false, state: clearAnswer(state, question.id) };
141
- }
142
- if (action.kind === "custom-input" || action.kind === "custom-editor") {
143
- return await askForCustomAnswer(
144
- ui,
145
- state,
146
- question,
147
- questionIndex,
148
- action.kind === "custom-editor"
149
- );
150
- }
151
- if (action.kind !== "option") {
152
- return { cancelled: true, state };
153
- }
154
-
155
- return selectSingleOption(state, question, action.optionIndex);
156
- }
157
-
158
- function selectSingleOption(
159
- state: AskState,
160
- question: AskQuestion,
161
- optionIndex: number | undefined
162
- ): RpcStepResult {
163
- const option =
164
- optionIndex === undefined ? undefined : question.options[optionIndex];
165
- if (!option || optionIndex === undefined) {
166
- return { cancelled: true, state };
98
+ if (selection.kind === "dismissed") {
99
+ return {
100
+ kind: "completed",
101
+ state: clearAnswer(state, question.id),
102
+ };
167
103
  }
168
- return {
169
- cancelled: false,
170
- state: updateAnswer(state, question.id, (answer) =>
171
- setSingleSelection(answer, option, optionIndex)
172
- ),
173
- };
174
- }
175
-
176
- async function askMultiQuestion(
177
- ui: RpcUi,
178
- initialState: AskState,
179
- question: AskQuestion,
180
- questionIndex: number
181
- ): Promise<RpcStepResult> {
182
- let state = initialState;
183
- while (true) {
184
- const actions = createMultiActions(question, state.answers[question.id]);
185
- const action = await selectAction(
186
- ui,
187
- formatTitle(state, question, questionIndex, "Select all that apply"),
188
- actions
189
- );
190
- const actionResult = await applyMultiAction({
191
- action,
192
- question,
193
- questionIndex,
194
- state,
195
- ui,
196
- });
197
- if (actionResult.done) {
198
- return actionResult.step;
199
- }
200
- state = actionResult.state;
104
+ if (selection.action.kind === "custom") {
105
+ return await askForCustomAnswer(ui, state, question, questionIndex, signal);
201
106
  }
202
- }
203
107
 
204
- function createMultiActions(
205
- question: AskQuestion,
206
- answer: AskStateAnswer | undefined
207
- ): SelectAction[] {
208
- const actions: SelectAction[] = question.options
209
- .filter((option) => !option.freeform)
210
- .map((option, optionIndex) => ({
211
- kind: "option" as const,
212
- label: formatOption(option, optionIndex, {
213
- selected: isOptionSelected(answer, option.value),
214
- }),
215
- optionIndex,
216
- }));
217
- actions.push({ kind: "finish", label: FINISH_SELECTION_LABEL });
218
- actions.push({
219
- kind: "custom-input",
220
- label: formatCustomAction(CUSTOM_INPUT_LABEL, answer),
221
- });
222
- actions.push({
223
- kind: "custom-editor",
224
- label: formatCustomAction(CUSTOM_EDITOR_LABEL, answer),
225
- });
226
- actions.push({ kind: "skip", label: formatSkipLabel(question) });
227
- actions.push({ kind: "cancel", label: CANCEL_LABEL });
228
- return actions;
108
+ return selectOption(state, question, selection.action.optionIndex);
229
109
  }
230
110
 
231
- type MultiActionResult =
232
- | { done: false; state: AskState }
233
- | { done: true; step: RpcStepResult };
234
-
235
- async function applyMultiAction(args: {
236
- action: SelectAction | undefined;
237
- question: AskQuestion;
238
- questionIndex: number;
239
- state: AskState;
240
- ui: RpcUi;
241
- }): Promise<MultiActionResult> {
242
- const { action, question, questionIndex, state, ui } = args;
243
- if (!action || action.kind === "cancel") {
244
- return { done: true, step: { cancelled: true, state } };
245
- }
246
- if (action.kind === "finish") {
247
- return { done: true, step: { cancelled: false, state } };
248
- }
249
- if (action.kind === "skip") {
250
- return {
251
- done: true,
252
- step: { cancelled: false, state: clearAnswer(state, question.id) },
253
- };
254
- }
255
- if (action.kind === "custom-input" || action.kind === "custom-editor") {
256
- const step = await askForCustomAnswer(
257
- ui,
258
- state,
259
- question,
260
- questionIndex,
261
- action.kind === "custom-editor"
262
- );
263
- return step.cancelled
264
- ? { done: true, step }
265
- : { done: false, state: step.state };
266
- }
267
- return applyMultiOptionAction(state, question, action);
111
+ function createQuestionActions(question: AskQuestion): SelectAction[] {
112
+ return [
113
+ ...[...question.options.entries()]
114
+ .filter(([, option]) => !option.freeform)
115
+ .map(([optionIndex, option]) => ({
116
+ kind: "option" as const,
117
+ label: formatOption(option, optionIndex),
118
+ optionIndex,
119
+ })),
120
+ { kind: "custom", label: TYPE_SOMETHING_LABEL },
121
+ ];
268
122
  }
269
123
 
270
- function applyMultiOptionAction(
124
+ function selectOption(
271
125
  state: AskState,
272
126
  question: AskQuestion,
273
- action: SelectAction
274
- ): MultiActionResult {
275
- const optionIndex = action.optionIndex;
127
+ optionIndex: number | undefined
128
+ ): RpcQuestionResult {
276
129
  const option =
277
130
  optionIndex === undefined ? undefined : question.options[optionIndex];
278
131
  if (!option || optionIndex === undefined) {
279
- return { done: true, step: { cancelled: true, state } };
280
- }
281
- 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
- }),
292
- };
293
- }
294
-
295
- async function askForCustomAnswer(
296
- ui: RpcUi,
297
- state: AskState,
298
- question: AskQuestion,
299
- questionIndex: number,
300
- multiline: boolean
301
- ): Promise<RpcStepResult> {
302
- const currentText = state.answers[question.id]?.customText;
303
- const title = formatTitle(
304
- state,
305
- question,
306
- questionIndex,
307
- multiline ? "Multiline answer" : "Short answer"
308
- );
309
- const value = multiline
310
- ? await ui.editor(title, currentText ?? "")
311
- : await ui.input(title, formatInputPlaceholder(currentText));
312
- if (value === undefined) {
313
- return { cancelled: true, state };
132
+ return { kind: "cancelled", state };
314
133
  }
315
134
 
316
135
  return {
317
- cancelled: false,
136
+ kind: "completed",
318
137
  state: updateAnswer(state, question.id, (answer) =>
319
- saveCustomText(
320
- answer,
321
- value,
322
- question.type === "multi" ? "multi" : "single"
323
- )
138
+ setSingleSelection(answer, option, optionIndex)
324
139
  ),
325
140
  };
326
141
  }
327
142
 
328
- async function askForOptionalNotes(
329
- ui: RpcUi,
330
- initialState: AskState,
331
- questionIndex: number
332
- ): Promise<RpcStepResult> {
333
- let state = initialState;
334
- const question = state.questions[questionIndex];
335
- if (!question) {
336
- return { cancelled: false, state };
337
- }
338
-
339
- while (true) {
340
- const answer = state.answers[question.id];
341
- const actions = createNoteActions(answer);
342
- const action = await selectNoteAction(
343
- ui,
344
- formatTitle(state, question, questionIndex, "Optional notes"),
345
- actions
346
- );
347
- const actionResult = await applyNoteAction({
348
- action,
349
- question,
350
- questionIndex,
351
- state,
352
- ui,
353
- });
354
- if (actionResult.done) {
355
- return actionResult.step;
356
- }
357
- state = actionResult.state;
358
- }
359
- }
360
-
361
- function appendCommonActions(
362
- actions: SelectAction[],
363
- question: AskQuestion
364
- ): void {
365
- actions.push({ kind: "custom-input", label: CUSTOM_INPUT_LABEL });
366
- actions.push({ kind: "custom-editor", label: CUSTOM_EDITOR_LABEL });
367
- actions.push({ kind: "skip", label: formatSkipLabel(question) });
368
- actions.push({ kind: "cancel", label: CANCEL_LABEL });
369
- }
370
-
371
- function createNoteActions(answer: AskStateAnswer | undefined): NoteAction[] {
372
- return [
373
- { kind: "continue", label: CONTINUE_LABEL },
374
- {
375
- kind: "question-input",
376
- label: answer?.note
377
- ? "Replace or clear the short question note…"
378
- : "Add a short question note…",
379
- },
380
- {
381
- kind: "question-editor",
382
- label: answer?.note
383
- ? "Edit the question note in a multiline editor…"
384
- : "Add a multiline question note…",
385
- },
386
- ...(answer?.selected ?? []).map((selection) => ({
387
- kind: "option-note" as const,
388
- optionValue: selection.value,
389
- label: answer?.optionNotes?.[selection.value]
390
- ? `Edit note for selected option: ${compactText(selection.label)}…`
391
- : `Add note for selected option: ${compactText(selection.label)}…`,
392
- })),
393
- { kind: "cancel", label: CANCEL_LABEL },
394
- ];
395
- }
396
-
397
- type NoteActionResult =
398
- | { done: false; state: AskState }
399
- | { done: true; step: RpcStepResult };
400
-
401
- async function applyNoteAction(args: {
402
- action: NoteAction | undefined;
403
- question: AskQuestion;
404
- questionIndex: number;
405
- state: AskState;
406
- ui: RpcUi;
407
- }): Promise<NoteActionResult> {
408
- const { action, question, questionIndex, state, ui } = args;
409
- if (!action || action.kind === "cancel") {
410
- return { done: true, step: { cancelled: true, state } };
411
- }
412
- if (action.kind === "continue") {
413
- return { done: true, step: { cancelled: false, state } };
414
- }
415
- if (action.kind === "question-input") {
416
- return await editQuestionNote(ui, state, question, questionIndex, false);
417
- }
418
- if (action.kind === "question-editor") {
419
- return await editQuestionNote(ui, state, question, questionIndex, true);
420
- }
421
- return await editOptionNote(ui, state, question, questionIndex, action);
422
- }
423
-
424
- async function editQuestionNote(
143
+ async function askForCustomAnswer(
425
144
  ui: RpcUi,
426
145
  state: AskState,
427
146
  question: AskQuestion,
428
147
  questionIndex: number,
429
- multiline: boolean
430
- ): Promise<NoteActionResult> {
431
- const currentNote = state.answers[question.id]?.note;
432
- const title = formatTitle(state, question, questionIndex, "Question note");
433
- const value = multiline
434
- ? await ui.editor(title, currentNote ?? "")
435
- : await ui.input(title, formatNotePlaceholder(currentNote));
436
- if (value === undefined) {
437
- return { done: true, step: { cancelled: true, state } };
148
+ signal?: AbortSignal
149
+ ): Promise<RpcQuestionResult> {
150
+ if (signal?.aborted) {
151
+ return { kind: "cancelled", state };
438
152
  }
439
- return {
440
- done: false,
441
- state: updateAnswer(state, question.id, (answer) =>
442
- saveQuestionNote(answer, value)
443
- ),
444
- };
445
- }
446
153
 
447
- async function editOptionNote(
448
- ui: RpcUi,
449
- state: AskState,
450
- question: AskQuestion,
451
- questionIndex: number,
452
- action: NoteAction
453
- ): Promise<NoteActionResult> {
454
- const optionValue = action.optionValue;
455
- const option = question.options.find(
456
- (candidate) => candidate.value === optionValue
154
+ const value = await ui.input(
155
+ formatTitle(state, question, questionIndex, TYPE_SOMETHING_LABEL),
156
+ question.type === "multi"
157
+ ? "Enter one or more answers"
158
+ : "Enter your answer",
159
+ { signal }
457
160
  );
458
- if (!(optionValue && option)) {
459
- return { done: true, step: { cancelled: true, state } };
161
+ if (signal?.aborted) {
162
+ return { kind: "cancelled", state };
460
163
  }
461
- const value = await ui.editor(
462
- formatTitle(
463
- state,
464
- question,
465
- questionIndex,
466
- `Note for ${compactText(option.label)}`
467
- ),
468
- state.answers[question.id]?.optionNotes?.[optionValue] ?? ""
469
- );
470
164
  if (value === undefined) {
471
- return { done: true, step: { cancelled: true, state } };
165
+ return {
166
+ kind: "completed",
167
+ state: clearAnswer(state, question.id),
168
+ };
472
169
  }
170
+
473
171
  return {
474
- done: false,
172
+ kind: "completed",
475
173
  state: updateAnswer(state, question.id, (answer) =>
476
- saveOptionNote(answer, optionValue, value)
174
+ saveCustomText(answer, value, "single")
477
175
  ),
478
176
  };
479
177
  }
@@ -481,25 +179,27 @@ async function editOptionNote(
481
179
  async function selectAction(
482
180
  ui: RpcUi,
483
181
  title: string,
484
- actions: SelectAction[]
485
- ): Promise<SelectAction | undefined> {
486
- const selected = await ui.select(
487
- title,
488
- actions.map((action) => action.label)
489
- );
490
- return actions.find((action) => action.label === selected);
491
- }
182
+ actions: SelectAction[],
183
+ signal?: AbortSignal
184
+ ): Promise<SelectResult> {
185
+ if (signal?.aborted) {
186
+ return { kind: "aborted" };
187
+ }
492
188
 
493
- async function selectNoteAction(
494
- ui: RpcUi,
495
- title: string,
496
- actions: NoteAction[]
497
- ): Promise<NoteAction | undefined> {
498
189
  const selected = await ui.select(
499
190
  title,
500
- actions.map((action) => action.label)
191
+ actions.map((action) => action.label),
192
+ { signal }
501
193
  );
502
- return actions.find((action) => action.label === selected);
194
+ if (signal?.aborted) {
195
+ return { kind: "aborted" };
196
+ }
197
+ if (selected === undefined) {
198
+ return { kind: "dismissed" };
199
+ }
200
+
201
+ const action = actions.find((candidate) => candidate.label === selected);
202
+ return action ? { action, kind: "selected" } : { kind: "invalid" };
503
203
  }
504
204
 
505
205
  function updateAnswer(
@@ -546,57 +246,16 @@ function formatTitle(
546
246
  return `${progress} ${flowTitle}${compactText(question.label)}: ${compactText(question.prompt)}${detail}`;
547
247
  }
548
248
 
549
- function formatOption(
550
- option: AskDisplayOption,
551
- optionIndex: number,
552
- options: { selected?: boolean } = {}
553
- ): string {
554
- let marker = "";
555
- if (options.selected !== undefined) {
556
- marker = options.selected ? "[x] " : "[ ] ";
557
- }
249
+ function formatOption(option: AskDisplayOption, optionIndex: number): string {
558
250
  const description = option.description
559
251
  ? ` — ${compactText(option.description)}`
560
252
  : "";
561
253
  const preview = option.preview
562
254
  ? ` — Preview: ${compactText(option.preview)}`
563
255
  : "";
564
- return `${marker}${optionIndex + 1}. ${compactText(option.label)}${description}${preview}`;
565
- }
566
-
567
- function formatCustomAction(
568
- baseLabel: string,
569
- answer: AskStateAnswer | undefined
570
- ): string {
571
- if (!(answer?.customSelected && answer.customText?.trim())) {
572
- return `[ ] ${baseLabel}`;
573
- }
574
- return `[x] ${baseLabel} — Current: ${truncateText(answer.customText)}`;
575
- }
576
-
577
- function formatSkipLabel(question: AskQuestion): string {
578
- return question.required
579
- ? "Skip this question (required is advisory)"
580
- : "Skip this question (optional)";
581
- }
582
-
583
- function formatInputPlaceholder(currentText: string | undefined): string {
584
- return currentText?.trim()
585
- ? `Current: ${truncateText(currentText)}. Enter a replacement or leave blank to clear.`
586
- : "Enter a short free-form answer";
587
- }
588
-
589
- function formatNotePlaceholder(currentNote: string | undefined): string {
590
- return currentNote?.trim()
591
- ? `Current: ${truncateText(currentNote)}. Enter a replacement or leave blank to clear.`
592
- : "Add an optional note or comment";
256
+ return `${optionIndex + 1}. ${compactText(option.label)}${description}${preview}`;
593
257
  }
594
258
 
595
259
  function compactText(value: string): string {
596
260
  return value.replace(/\s+/g, " ").trim();
597
261
  }
598
-
599
- function truncateText(value: string): string {
600
- const compact = compactText(value);
601
- return compact.length > 80 ? `${compact.slice(0, 77)}…` : compact;
602
- }