@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 +7 -0
- package/README.md +6 -7
- package/docs/architecture.md +3 -0
- package/docs/contract.md +8 -9
- package/package.json +1 -1
- package/src/ask-tool-helpers.ts +2 -2
- package/src/rpc/controller.ts +66 -506
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
|
-
-
|
|
205
|
-
-
|
|
206
|
-
-
|
|
207
|
-
-
|
|
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
|
-
-
|
|
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
|
|
package/docs/architecture.md
CHANGED
|
@@ -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
|
-
-
|
|
263
|
-
-
|
|
264
|
-
-
|
|
265
|
-
-
|
|
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
|
|
270
|
-
-
|
|
271
|
-
- `select` and `input` observe the tool abort signal;
|
|
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
package/src/ask-tool-helpers.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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/rpc/controller.ts
CHANGED
|
@@ -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"], "
|
|
17
|
+
type RpcUi = Pick<ExtensionContext["ui"], "input" | "select">;
|
|
22
18
|
|
|
23
19
|
interface RpcFlowOptions {
|
|
24
20
|
signal?: AbortSignal;
|
|
25
21
|
}
|
|
26
22
|
|
|
27
|
-
type
|
|
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
|
-
|
|
51
|
-
kind:
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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
|
|
53
|
+
const step = await askQuestion(
|
|
90
54
|
ctx.ui,
|
|
91
55
|
state,
|
|
92
56
|
questionIndex,
|
|
93
57
|
options.signal
|
|
94
58
|
);
|
|
95
|
-
state =
|
|
96
|
-
if (
|
|
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<
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
188
|
-
|
|
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
|
|
108
|
+
return selectOption(state, question, selection.action.optionIndex);
|
|
205
109
|
}
|
|
206
110
|
|
|
207
|
-
function
|
|
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
|
-
):
|
|
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<
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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 {
|
|
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: "
|
|
172
|
+
kind: "completed",
|
|
558
173
|
state: updateAnswer(state, question.id, (answer) =>
|
|
559
|
-
|
|
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<
|
|
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
|
-
|
|
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 `${
|
|
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
|
-
}
|