@geoqiao/pi-ask 1.2.1 → 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,10 @@
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
+
1
8
  ## [1.2.1](https://github.com/geoqiao/pi-ask/compare/v1.2.0...v1.2.1) (2026-08-16)
2
9
 
3
10
 
package/README.md CHANGED
@@ -201,16 +201,15 @@ Agents can auto-discover and call `ask_user` when they need clarification instea
201
201
 
202
202
  When Pi runs in RPC mode with portable extension UI support, `ask_user` keeps the same normalized result contract but uses sequential dialogs:
203
203
 
204
- - single choices, including Yes/No pairs, use cancellable `select` dialogs
205
- - short custom answers use `input`; multiline answers use `editor`
206
- - each question offers explicit Skip and Cancel actions, followed by optional question/selected-option note actions
207
- - 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
208
208
  - descriptions and preview content are flattened into readable option strings
209
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
210
+ - tool abort signals close portable `select`/`input` dialogs and return `cancelled: true`
212
211
 
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.
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.
214
213
 
215
214
  ### Answer and replay commands
216
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
@@ -259,18 +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 the user's selection 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 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
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`
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
+ 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.
274
273
 
275
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.
276
275
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geoqiao/pi-ask",
3
- "version": "1.2.1",
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 {
@@ -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,51 +14,29 @@ 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
19
  interface RpcFlowOptions {
24
20
  signal?: AbortSignal;
25
21
  }
26
22
 
27
- type RpcCompletionResult =
23
+ type RpcQuestionResult =
28
24
  | { kind: "cancelled"; state: AskState }
29
25
  | { kind: "completed"; state: AskState };
30
26
 
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
-
38
27
  interface SelectAction {
39
- kind:
40
- | "cancel"
41
- | "custom-editor"
42
- | "custom-input"
43
- | "finish"
44
- | "option"
45
- | "skip";
28
+ kind: "custom" | "option";
46
29
  label: string;
47
30
  optionIndex?: number;
48
31
  }
49
32
 
50
- interface NoteAction {
51
- kind:
52
- | "cancel"
53
- | "continue"
54
- | "option-note"
55
- | "question-editor"
56
- | "question-input";
57
- label: string;
58
- optionValue?: string;
59
- }
33
+ type SelectResult =
34
+ | { kind: "aborted" }
35
+ | { kind: "dismissed" }
36
+ | { kind: "invalid" }
37
+ | { action: SelectAction; kind: "selected" };
60
38
 
61
- const CANCEL_LABEL = "Cancel ask";
62
- const CONTINUE_LABEL = "Continue";
63
- const CUSTOM_EDITOR_LABEL = "Write or edit a multiline answer…";
64
- const CUSTOM_INPUT_LABEL = "Type or edit a short answer…";
65
- const FINISH_SELECTION_LABEL = "Finish selection";
39
+ const TYPE_SOMETHING_LABEL = "Type something…";
66
40
 
67
41
  export async function runRpcAskFlow(
68
42
  ctx: { ui: RpcUi },
@@ -75,28 +49,19 @@ export async function runRpcAskFlow(
75
49
  if (options.signal?.aborted) {
76
50
  return cancelledResult(state);
77
51
  }
78
- const answerStep = await askQuestion(
79
- ctx.ui,
80
- state,
81
- questionIndex,
82
- options.signal
83
- );
84
- state = answerStep.state;
85
- if (answerStep.kind === "cancelled") {
86
- return cancelledResult(state);
87
- }
88
52
 
89
- const noteStep = await askForOptionalNotes(
53
+ const step = await askQuestion(
90
54
  ctx.ui,
91
55
  state,
92
56
  questionIndex,
93
57
  options.signal
94
58
  );
95
- state = noteStep.state;
96
- if (noteStep.kind === "cancelled") {
59
+ state = step.state;
60
+ if (step.kind === "cancelled") {
97
61
  return cancelledResult(state);
98
62
  }
99
63
  }
64
+
100
65
  if (options.signal?.aborted) {
101
66
  return cancelledResult(state);
102
67
  }
@@ -113,107 +78,60 @@ async function askQuestion(
113
78
  state: AskState,
114
79
  questionIndex: number,
115
80
  signal?: AbortSignal
116
- ): Promise<RpcCompletionResult> {
81
+ ): Promise<RpcQuestionResult> {
117
82
  const question = state.questions[questionIndex];
118
83
  if (!question) {
119
84
  return { kind: "completed", state };
120
85
  }
121
- if (question.type === "multi") {
122
- return await askMultiQuestion(ui, state, question, questionIndex, signal);
123
- }
124
- return await askSingleQuestion(ui, state, question, questionIndex, signal);
125
- }
126
-
127
- async function askSingleQuestion(
128
- ui: RpcUi,
129
- initialState: AskState,
130
- question: AskQuestion,
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);
147
86
 
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
- }
167
- }
87
+ const actions = createQuestionActions(question);
88
+ const selection = await selectAction(
89
+ ui,
90
+ formatTitle(state, question, questionIndex),
91
+ actions,
92
+ signal
93
+ );
168
94
 
169
- async function applySingleAction(args: {
170
- action: SelectAction | undefined;
171
- question: AskQuestion;
172
- questionIndex: number;
173
- signal?: AbortSignal;
174
- state: AskState;
175
- ui: RpcUi;
176
- }): Promise<RpcActionResult> {
177
- const { action, question, questionIndex, signal, state, ui } = args;
178
- if (!action || action.kind === "cancel") {
95
+ if (selection.kind === "aborted" || selection.kind === "invalid") {
179
96
  return { kind: "cancelled", state };
180
97
  }
181
- if (action.kind === "skip") {
98
+ if (selection.kind === "dismissed") {
182
99
  return {
183
100
  kind: "completed",
184
101
  state: clearAnswer(state, question.id),
185
102
  };
186
103
  }
187
- if (action.kind === "custom-input" || action.kind === "custom-editor") {
188
- const editResult = await askForCustomAnswer(
189
- ui,
190
- state,
191
- question,
192
- questionIndex,
193
- action.kind === "custom-editor",
194
- signal
195
- );
196
- return editResult.kind === "saved"
197
- ? { kind: "completed", state: editResult.state }
198
- : editResult;
199
- }
200
- if (action.kind !== "option") {
201
- return { kind: "cancelled", state };
104
+ if (selection.action.kind === "custom") {
105
+ return await askForCustomAnswer(ui, state, question, questionIndex, signal);
202
106
  }
203
107
 
204
- return selectSingleOption(state, question, action.optionIndex);
108
+ return selectOption(state, question, selection.action.optionIndex);
205
109
  }
206
110
 
207
- function selectSingleOption(
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
+ ];
122
+ }
123
+
124
+ function selectOption(
208
125
  state: AskState,
209
126
  question: AskQuestion,
210
127
  optionIndex: number | undefined
211
- ): RpcCompletionResult {
128
+ ): RpcQuestionResult {
212
129
  const option =
213
130
  optionIndex === undefined ? undefined : question.options[optionIndex];
214
131
  if (!option || optionIndex === undefined) {
215
132
  return { kind: "cancelled", state };
216
133
  }
134
+
217
135
  return {
218
136
  kind: "completed",
219
137
  state: updateAnswer(state, question.id, (answer) =>
@@ -222,341 +140,38 @@ function selectSingleOption(
222
140
  };
223
141
  }
224
142
 
225
- async function askMultiQuestion(
226
- ui: RpcUi,
227
- initialState: AskState,
228
- question: AskQuestion,
229
- questionIndex: number,
230
- signal?: AbortSignal
231
- ): Promise<RpcCompletionResult> {
232
- let state = initialState;
233
- while (true) {
234
- if (signal?.aborted) {
235
- return { kind: "cancelled", state };
236
- }
237
- const actions = createMultiActions(question, state.answers[question.id]);
238
- const action = await selectAction(
239
- ui,
240
- formatTitle(state, question, questionIndex, "Select all that apply"),
241
- actions,
242
- signal
243
- );
244
- const actionResult = await applyMultiAction({
245
- action,
246
- question,
247
- questionIndex,
248
- signal,
249
- state,
250
- ui,
251
- });
252
- if (actionResult.kind !== "back") {
253
- return actionResult;
254
- }
255
- state = actionResult.state;
256
- }
257
- }
258
-
259
- function createMultiActions(
260
- question: AskQuestion,
261
- answer: AskStateAnswer | undefined
262
- ): SelectAction[] {
263
- const actions: SelectAction[] = [...question.options.entries()]
264
- .filter(([, option]) => !option.freeform)
265
- .map(([optionIndex, option]) => ({
266
- kind: "option" as const,
267
- label: formatOption(option, optionIndex, {
268
- selected: isOptionSelected(answer, option.value),
269
- }),
270
- optionIndex,
271
- }));
272
- actions.push({ kind: "finish", label: FINISH_SELECTION_LABEL });
273
- actions.push({
274
- kind: "custom-input",
275
- label: formatCustomAction(CUSTOM_INPUT_LABEL, answer),
276
- });
277
- actions.push({
278
- kind: "custom-editor",
279
- label: formatCustomAction(CUSTOM_EDITOR_LABEL, answer),
280
- });
281
- actions.push({ kind: "skip", label: formatSkipLabel(question) });
282
- actions.push({ kind: "cancel", label: CANCEL_LABEL });
283
- return actions;
284
- }
285
-
286
- async function applyMultiAction(args: {
287
- action: SelectAction | undefined;
288
- question: AskQuestion;
289
- questionIndex: number;
290
- signal?: AbortSignal;
291
- state: AskState;
292
- ui: RpcUi;
293
- }): Promise<RpcActionResult> {
294
- const { action, question, questionIndex, signal, state, ui } = args;
295
- if (!action || action.kind === "cancel") {
296
- return { kind: "cancelled", state };
297
- }
298
- if (action.kind === "finish") {
299
- return { kind: "completed", state };
300
- }
301
- if (action.kind === "skip") {
302
- return {
303
- kind: "completed",
304
- state: clearAnswer(state, question.id),
305
- };
306
- }
307
- if (action.kind === "custom-input" || action.kind === "custom-editor") {
308
- const editResult = await askForCustomAnswer(
309
- ui,
310
- state,
311
- question,
312
- questionIndex,
313
- action.kind === "custom-editor",
314
- signal
315
- );
316
- return editResult.kind === "cancelled"
317
- ? editResult
318
- : { kind: "back", state: editResult.state };
319
- }
320
- return applyMultiOptionAction(state, question, action);
321
- }
322
-
323
- function applyMultiOptionAction(
324
- state: AskState,
325
- question: AskQuestion,
326
- action: SelectAction
327
- ): RpcActionResult {
328
- const optionIndex = action.optionIndex;
329
- const option =
330
- optionIndex === undefined ? undefined : question.options[optionIndex];
331
- if (!option || optionIndex === undefined) {
332
- return { kind: "cancelled", state };
333
- }
334
- return {
335
- kind: "back",
336
- state: updateAnswer(state, question.id, (currentAnswer) =>
337
- toggleSelection(currentAnswer, option, optionIndex)
338
- ),
339
- };
340
- }
341
-
342
143
  async function askForCustomAnswer(
343
144
  ui: RpcUi,
344
145
  state: AskState,
345
146
  question: AskQuestion,
346
147
  questionIndex: number,
347
- multiline: boolean,
348
148
  signal?: AbortSignal
349
- ): Promise<RpcEditResult> {
350
- if (signal?.aborted) {
351
- return { kind: "cancelled", state };
352
- }
353
- const currentText = state.answers[question.id]?.customText;
354
- const title = formatTitle(
355
- state,
356
- question,
357
- questionIndex,
358
- multiline ? "Multiline answer" : "Short answer"
359
- );
360
- const value = multiline
361
- ? await ui.editor(title, currentText ?? "")
362
- : await ui.input(title, formatInputPlaceholder(currentText), { signal });
149
+ ): Promise<RpcQuestionResult> {
363
150
  if (signal?.aborted) {
364
151
  return { kind: "cancelled", state };
365
152
  }
366
- if (value === undefined) {
367
- return { kind: "back", state };
368
- }
369
-
370
- return {
371
- kind: "saved",
372
- state: updateAnswer(state, question.id, (answer) =>
373
- saveCustomText(
374
- answer,
375
- value,
376
- question.type === "multi" ? "multi" : "single"
377
- )
378
- ),
379
- };
380
- }
381
-
382
- async function askForOptionalNotes(
383
- ui: RpcUi,
384
- initialState: AskState,
385
- questionIndex: number,
386
- signal?: AbortSignal
387
- ): Promise<RpcCompletionResult> {
388
- let state = initialState;
389
- const question = state.questions[questionIndex];
390
- if (!question) {
391
- return { kind: "completed", state };
392
- }
393
-
394
- while (true) {
395
- if (signal?.aborted) {
396
- return { kind: "cancelled", state };
397
- }
398
- const answer = state.answers[question.id];
399
- const actions = createNoteActions(answer);
400
- const action = await selectNoteAction(
401
- ui,
402
- formatTitle(state, question, questionIndex, "Optional notes"),
403
- actions,
404
- signal
405
- );
406
- const actionResult = await applyNoteAction({
407
- action,
408
- question,
409
- questionIndex,
410
- signal,
411
- state,
412
- ui,
413
- });
414
- if (actionResult.kind !== "back") {
415
- return actionResult;
416
- }
417
- state = actionResult.state;
418
- }
419
- }
420
-
421
- function appendCommonActions(
422
- actions: SelectAction[],
423
- question: AskQuestion
424
- ): void {
425
- actions.push({ kind: "custom-input", label: CUSTOM_INPUT_LABEL });
426
- actions.push({ kind: "custom-editor", label: CUSTOM_EDITOR_LABEL });
427
- actions.push({ kind: "skip", label: formatSkipLabel(question) });
428
- actions.push({ kind: "cancel", label: CANCEL_LABEL });
429
- }
430
-
431
- function createNoteActions(answer: AskStateAnswer | undefined): NoteAction[] {
432
- return [
433
- { kind: "continue", label: CONTINUE_LABEL },
434
- {
435
- kind: "question-input",
436
- label: answer?.note
437
- ? "Replace or clear the short question note…"
438
- : "Add a short question note…",
439
- },
440
- {
441
- kind: "question-editor",
442
- label: answer?.note
443
- ? "Edit the question note in a multiline editor…"
444
- : "Add a multiline question note…",
445
- },
446
- ...(answer?.selected ?? []).map((selection) => ({
447
- kind: "option-note" as const,
448
- optionValue: selection.value,
449
- label: answer?.optionNotes?.[selection.value]
450
- ? `Edit note for selected option: ${selection.index}. ${compactText(selection.label)}…`
451
- : `Add note for selected option: ${selection.index}. ${compactText(selection.label)}…`,
452
- })),
453
- { kind: "cancel", label: CANCEL_LABEL },
454
- ];
455
- }
456
153
 
457
- async function applyNoteAction(args: {
458
- action: NoteAction | undefined;
459
- question: AskQuestion;
460
- questionIndex: number;
461
- signal?: AbortSignal;
462
- state: AskState;
463
- ui: RpcUi;
464
- }): Promise<RpcActionResult> {
465
- const { action, question, questionIndex, signal, state, ui } = args;
466
- if (!action || action.kind === "cancel") {
467
- return { kind: "cancelled", state };
468
- }
469
- if (action.kind === "continue") {
470
- return { kind: "completed", state };
471
- }
472
- if (action.kind === "question-input") {
473
- return noteEditActionResult(
474
- await editQuestionNote(ui, state, question, questionIndex, false, signal)
475
- );
476
- }
477
- if (action.kind === "question-editor") {
478
- return noteEditActionResult(
479
- await editQuestionNote(ui, state, question, questionIndex, true, signal)
480
- );
481
- }
482
- return noteEditActionResult(
483
- await editOptionNote(ui, state, question, questionIndex, action, signal)
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 }
484
160
  );
485
- }
486
-
487
- function noteEditActionResult(result: RpcEditResult): RpcActionResult {
488
- return result.kind === "cancelled"
489
- ? result
490
- : { kind: "back", state: result.state };
491
- }
492
-
493
- async function editQuestionNote(
494
- ui: RpcUi,
495
- state: AskState,
496
- question: AskQuestion,
497
- questionIndex: number,
498
- multiline: boolean,
499
- signal?: AbortSignal
500
- ): Promise<RpcEditResult> {
501
- if (signal?.aborted) {
502
- return { kind: "cancelled", state };
503
- }
504
- const currentNote = state.answers[question.id]?.note;
505
- const title = formatTitle(state, question, questionIndex, "Question note");
506
- const value = multiline
507
- ? await ui.editor(title, currentNote ?? "")
508
- : await ui.input(title, formatNotePlaceholder(currentNote), { signal });
509
161
  if (signal?.aborted) {
510
162
  return { kind: "cancelled", state };
511
163
  }
512
164
  if (value === undefined) {
513
- return { kind: "back", state };
165
+ return {
166
+ kind: "completed",
167
+ state: clearAnswer(state, question.id),
168
+ };
514
169
  }
515
- return {
516
- kind: "saved",
517
- state: updateAnswer(state, question.id, (answer) =>
518
- saveQuestionNote(answer, value)
519
- ),
520
- };
521
- }
522
170
 
523
- async function editOptionNote(
524
- ui: RpcUi,
525
- state: AskState,
526
- question: AskQuestion,
527
- questionIndex: number,
528
- action: NoteAction,
529
- signal?: AbortSignal
530
- ): Promise<RpcEditResult> {
531
- if (signal?.aborted) {
532
- return { kind: "cancelled", state };
533
- }
534
- const optionValue = action.optionValue;
535
- const option = question.options.find(
536
- (candidate) => candidate.value === optionValue
537
- );
538
- if (!(optionValue && option)) {
539
- return { kind: "cancelled", state };
540
- }
541
- const value = await ui.editor(
542
- formatTitle(
543
- state,
544
- question,
545
- questionIndex,
546
- `Note for ${compactText(option.label)}`
547
- ),
548
- state.answers[question.id]?.optionNotes?.[optionValue] ?? ""
549
- );
550
- if (signal?.aborted) {
551
- return { kind: "cancelled", state };
552
- }
553
- if (value === undefined) {
554
- return { kind: "back", state };
555
- }
556
171
  return {
557
- kind: "saved",
172
+ kind: "completed",
558
173
  state: updateAnswer(state, question.id, (answer) =>
559
- saveOptionNote(answer, optionValue, value)
174
+ saveCustomText(answer, value, "single")
560
175
  ),
561
176
  };
562
177
  }
@@ -566,39 +181,25 @@ async function selectAction(
566
181
  title: string,
567
182
  actions: SelectAction[],
568
183
  signal?: AbortSignal
569
- ): Promise<SelectAction | undefined> {
570
- if (signal?.aborted) {
571
- return;
572
- }
573
- const selected = await ui.select(
574
- title,
575
- actions.map((action) => action.label),
576
- { signal }
577
- );
184
+ ): Promise<SelectResult> {
578
185
  if (signal?.aborted) {
579
- return;
186
+ return { kind: "aborted" };
580
187
  }
581
- return actions.find((action) => action.label === selected);
582
- }
583
188
 
584
- async function selectNoteAction(
585
- ui: RpcUi,
586
- title: string,
587
- actions: NoteAction[],
588
- signal?: AbortSignal
589
- ): Promise<NoteAction | undefined> {
590
- if (signal?.aborted) {
591
- return;
592
- }
593
189
  const selected = await ui.select(
594
190
  title,
595
191
  actions.map((action) => action.label),
596
192
  { signal }
597
193
  );
598
194
  if (signal?.aborted) {
599
- return;
195
+ return { kind: "aborted" };
600
196
  }
601
- return actions.find((action) => action.label === selected);
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" };
602
203
  }
603
204
 
604
205
  function updateAnswer(
@@ -645,57 +246,16 @@ function formatTitle(
645
246
  return `${progress} ${flowTitle}${compactText(question.label)}: ${compactText(question.prompt)}${detail}`;
646
247
  }
647
248
 
648
- function formatOption(
649
- option: AskDisplayOption,
650
- optionIndex: number,
651
- options: { selected?: boolean } = {}
652
- ): string {
653
- let marker = "";
654
- if (options.selected !== undefined) {
655
- marker = options.selected ? "[x] " : "[ ] ";
656
- }
249
+ function formatOption(option: AskDisplayOption, optionIndex: number): string {
657
250
  const description = option.description
658
251
  ? ` — ${compactText(option.description)}`
659
252
  : "";
660
253
  const preview = option.preview
661
254
  ? ` — Preview: ${compactText(option.preview)}`
662
255
  : "";
663
- return `${marker}${optionIndex + 1}. ${compactText(option.label)}${description}${preview}`;
664
- }
665
-
666
- function formatCustomAction(
667
- baseLabel: string,
668
- answer: AskStateAnswer | undefined
669
- ): string {
670
- if (!(answer?.customSelected && answer.customText?.trim())) {
671
- return `[ ] ${baseLabel}`;
672
- }
673
- return `[x] ${baseLabel} — Current: ${truncateText(answer.customText)}`;
674
- }
675
-
676
- function formatSkipLabel(question: AskQuestion): string {
677
- return question.required
678
- ? "Skip this question (required is advisory)"
679
- : "Skip this question (optional)";
680
- }
681
-
682
- function formatInputPlaceholder(currentText: string | undefined): string {
683
- return currentText?.trim()
684
- ? `Current: ${truncateText(currentText)}. Enter a replacement or leave blank to clear.`
685
- : "Enter a short free-form answer";
686
- }
687
-
688
- function formatNotePlaceholder(currentNote: string | undefined): string {
689
- return currentNote?.trim()
690
- ? `Current: ${truncateText(currentNote)}. Enter a replacement or leave blank to clear.`
691
- : "Add an optional note or comment";
256
+ return `${optionIndex + 1}. ${compactText(option.label)}${description}${preview}`;
692
257
  }
693
258
 
694
259
  function compactText(value: string): string {
695
260
  return value.replace(/\s+/g, " ").trim();
696
261
  }
697
-
698
- function truncateText(value: string): string {
699
- const compact = compactText(value);
700
- return compact.length > 80 ? `${compact.slice(0, 77)}…` : compact;
701
- }