@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 +14 -0
- package/README.md +7 -5
- package/docs/architecture.md +3 -0
- package/docs/contract.md +10 -9
- package/docs/remote-events.md +2 -2
- package/package.json +1 -1
- package/src/ask-tool-helpers.ts +2 -2
- package/src/ask-tool.ts +4 -2
- package/src/rpc/controller.ts +103 -444
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
|
[](https://www.npmjs.com/package/@geoqiao/pi-ask)
|
|
6
|
+
[](https://github.com/geoqiao/pi-ask/actions/workflows/ci.yml)
|
|
6
7
|
[](https://github.com/geoqiao/pi-ask/commits/main)
|
|
7
8
|
[](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
|
-
-
|
|
204
|
-
-
|
|
205
|
-
-
|
|
206
|
-
-
|
|
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
|
|
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
|
@@ -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
|
|
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
|
-
-
|
|
263
|
-
-
|
|
264
|
-
-
|
|
265
|
-
-
|
|
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
|
|
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
|
|
package/docs/remote-events.md
CHANGED
|
@@ -18,7 +18,7 @@ Remote submit:
|
|
|
18
18
|
|
|
19
19
|
## Started
|
|
20
20
|
|
|
21
|
-
Emitted after a validated ask
|
|
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
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/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
|
-
|
|
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(
|
|
73
|
+
return successfulResponse(
|
|
74
|
+
await runRpcAskFlow(ctx, validation.state, { signal })
|
|
75
|
+
);
|
|
74
76
|
}
|
|
75
77
|
return nonInteractiveResponse(validation.state);
|
|
76
78
|
}
|
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,62 +14,58 @@ 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
|
-
interface
|
|
24
|
-
|
|
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
|
-
|
|
41
|
-
kind:
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
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
|
-
|
|
65
|
-
state = answerStep.state;
|
|
66
|
-
if (answerStep.cancelled) {
|
|
49
|
+
if (options.signal?.aborted) {
|
|
67
50
|
return cancelledResult(state);
|
|
68
51
|
}
|
|
69
52
|
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
79
|
+
questionIndex: number,
|
|
80
|
+
signal?: AbortSignal
|
|
81
|
+
): Promise<RpcQuestionResult> {
|
|
89
82
|
const question = state.questions[questionIndex];
|
|
90
83
|
if (!question) {
|
|
91
|
-
return {
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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 (
|
|
140
|
-
return {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
124
|
+
function selectOption(
|
|
271
125
|
state: AskState,
|
|
272
126
|
question: AskQuestion,
|
|
273
|
-
|
|
274
|
-
):
|
|
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 {
|
|
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
|
-
|
|
136
|
+
kind: "completed",
|
|
318
137
|
state: updateAnswer(state, question.id, (answer) =>
|
|
319
|
-
|
|
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
|
|
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
|
-
|
|
430
|
-
): Promise<
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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 (
|
|
459
|
-
return {
|
|
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 {
|
|
165
|
+
return {
|
|
166
|
+
kind: "completed",
|
|
167
|
+
state: clearAnswer(state, question.id),
|
|
168
|
+
};
|
|
472
169
|
}
|
|
170
|
+
|
|
473
171
|
return {
|
|
474
|
-
|
|
172
|
+
kind: "completed",
|
|
475
173
|
state: updateAnswer(state, question.id, (answer) =>
|
|
476
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
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 `${
|
|
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
|
-
}
|