@mrclrchtr/supi-ask-user 1.3.0 → 1.4.0
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/README.md +125 -67
- package/node_modules/@mrclrchtr/supi-core/README.md +52 -41
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/api.ts +13 -13
- package/node_modules/@mrclrchtr/supi-core/src/{config-settings.ts → config/config-settings.ts} +2 -2
- package/node_modules/@mrclrchtr/supi-core/src/{context-provider-registry.ts → context/context-provider-registry.ts} +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/extension.ts +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/index.ts +13 -13
- package/node_modules/@mrclrchtr/supi-core/src/{settings-registry.ts → settings/settings-registry.ts} +1 -1
- package/package.json +4 -3
- package/src/api.ts +19 -0
- package/src/ask-user.ts +65 -131
- package/src/index.ts +23 -1
- package/src/normalize.ts +153 -142
- package/src/render/result.ts +98 -0
- package/src/render/transcript.ts +65 -0
- package/src/render/tree-summary.ts +10 -0
- package/src/schema.ts +41 -38
- package/src/session/controller.ts +163 -0
- package/src/session/lock.ts +19 -0
- package/src/tool/guidance.ts +15 -0
- package/src/types.ts +50 -56
- package/src/ui/choose-renderer.ts +11 -0
- package/src/ui/overlay-actions.ts +42 -0
- package/src/ui/overlay-render.ts +196 -0
- package/src/ui/overlay-view.ts +216 -0
- package/src/ui/overlay.ts +388 -0
- package/src/ui/types.ts +35 -0
- package/src/flow.ts +0 -224
- package/src/format.ts +0 -66
- package/src/render/ui-rich-render-editor.ts +0 -51
- package/src/render/ui-rich-render-env.ts +0 -15
- package/src/render/ui-rich-render-footer.ts +0 -55
- package/src/render/ui-rich-render-markdown.ts +0 -33
- package/src/render/ui-rich-render-notes.ts +0 -80
- package/src/render/ui-rich-render-types.ts +0 -17
- package/src/render/ui-rich-render.ts +0 -323
- package/src/render.ts +0 -95
- package/src/result.ts +0 -90
- package/src/ui/ui-rich-handlers.ts +0 -369
- package/src/ui/ui-rich-inline.ts +0 -77
- package/src/ui/ui-rich-state.ts +0 -179
- package/src/ui/ui-rich.ts +0 -144
- /package/node_modules/@mrclrchtr/supi-core/src/{config.ts → config/config.ts} +0 -0
- /package/node_modules/@mrclrchtr/supi-core/src/{context-messages.ts → context/context-messages.ts} +0 -0
- /package/node_modules/@mrclrchtr/supi-core/src/{context-tag.ts → context/context-tag.ts} +0 -0
- /package/node_modules/@mrclrchtr/supi-core/src/{settings-command.ts → settings/settings-command.ts} +0 -0
- /package/node_modules/@mrclrchtr/supi-core/src/{settings-ui.ts → settings/settings-ui.ts} +0 -0
package/src/ask-user.ts
CHANGED
|
@@ -1,140 +1,101 @@
|
|
|
1
|
-
|
|
2
|
-
// for focused interactive decisions during an agent run. Holds the per-session
|
|
3
|
-
// single-active-questionnaire lock and drives the rich overlay UI.
|
|
4
|
-
//
|
|
5
|
-
// Implementation modules:
|
|
6
|
-
// schema.ts — external (LLM-facing) parameter schema
|
|
7
|
-
// normalize.ts — validation + normalization into the shared internal model
|
|
8
|
-
// flow.ts — shared questionnaire flow + concurrency lock
|
|
9
|
-
// ui-rich.ts — overlay UI via ctx.ui.custom()
|
|
10
|
-
// ui-rich-render.ts — overlay rendering helpers
|
|
11
|
-
// result.ts — hybrid (content + details) result formatting
|
|
12
|
-
// render.ts — custom renderCall / renderResult for the transcript
|
|
13
|
-
|
|
14
|
-
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
15
|
-
import type { Component } from "@earendil-works/pi-tui";
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
2
|
import { formatTitle, signalWaiting } from "@mrclrchtr/supi-core/api";
|
|
17
|
-
import { ActiveQuestionnaireLock } from "./flow.ts";
|
|
18
3
|
import { AskUserValidationError, normalizeQuestionnaire } from "./normalize.ts";
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
4
|
+
import { type AskUserToolResult, buildErrorResult, buildResult } from "./render/result.ts";
|
|
5
|
+
import { renderAskUserCall, renderAskUserResult } from "./render/transcript.ts";
|
|
6
|
+
import { buildTreeSummaryLabel } from "./render/tree-summary.ts";
|
|
21
7
|
import { type AskUserParams, AskUserParamsSchema } from "./schema.ts";
|
|
22
|
-
import
|
|
23
|
-
import {
|
|
8
|
+
import { ActiveQuestionnaireLock } from "./session/lock.ts";
|
|
9
|
+
import { promptGuidelines, promptSnippet, toolDescription } from "./tool/guidance.ts";
|
|
10
|
+
import type { AskUserToolDetails, NormalizedQuestionnaire } from "./types.ts";
|
|
11
|
+
import { runQuestionnaire } from "./ui/choose-renderer.ts";
|
|
24
12
|
|
|
25
13
|
const TOOL_NAME = "ask_user";
|
|
26
14
|
const TOOL_LABEL = "Ask User";
|
|
27
15
|
|
|
28
|
-
|
|
29
|
-
"Ask the user a focused decision question (or up to 4 grouped questions) when explicit user input is required to proceed safely. Use for clarifying intent, picking between options, prioritizing a short set of features, or confirming a destructive action — not for surveys or open-ended discovery. Questions are `choice` (with options; set `multi: true` for multi-select) or `text` (freeform input). Structured questions can add `recommendation`, `default`, `allowOther`, `allowDiscuss`, and option `preview` content.";
|
|
30
|
-
|
|
31
|
-
const PROMPT_SNIPPET =
|
|
32
|
-
"ask_user — pause and request a focused decision (1-4 typed questions) when explicit user input is required to proceed, including rich choice and discuss flows";
|
|
33
|
-
|
|
34
|
-
const PROMPT_GUIDELINES = [
|
|
35
|
-
"Use ask_user only for decisions that require explicit user input — never as a substitute for reading code or thinking through a problem.",
|
|
36
|
-
"Keep questionnaires bounded: 1-4 focused questions with short headers; prefer one decision per call when possible.",
|
|
37
|
-
'There are two question types: `choice` for picking from options (single-select by default; set `multi: true` for multi-select — use this instead of the now-removed `multichoice`) and `text` for freeform input. For yes/no questions, use `choice` with options `{value: "yes", label: "Yes"}` and `{value: "no", label: "No"}`.',
|
|
38
|
-
"Set `recommendation` when one option or a small set of options is clearly preferable, so the UI can surface that guidance.",
|
|
39
|
-
"Set `default` to pre-select a starting value or option; the user can accept it with a single keystroke. Use it for safe/common defaults, distinct from `recommendation` which highlights what you think is best.",
|
|
40
|
-
"Enable `allowOther` only when a custom answer is genuinely useful, and `allowDiscuss` only when the user may need to talk through the choice instead of deciding immediately.",
|
|
41
|
-
"Use `description` to explain what each option means — it wraps naturally and a few sentences is fine. Reserve `preview` for code, config, or diagrams that need dedicated rendering space in a side pane.",
|
|
42
|
-
"Do not call ask_user while another ask_user interaction is in flight — wait for the previous result before issuing another.",
|
|
43
|
-
];
|
|
44
|
-
|
|
45
|
-
/** Minimal ui subset needed by executeAskUser — extended with setTitle/notify from ExtensionUIContext. */
|
|
46
|
-
interface ExtensionUi {
|
|
16
|
+
export type AskUserExecutionContext = Pick<ExtensionContext, "cwd" | "hasUI" | "abort"> & {
|
|
47
17
|
ui: {
|
|
48
|
-
custom?:
|
|
18
|
+
custom?: unknown;
|
|
19
|
+
notify?(message: string, type?: "info" | "warning" | "error"): void;
|
|
49
20
|
setWorkingVisible?(visible: boolean): void;
|
|
50
|
-
/** Set the terminal window/tab title. */
|
|
51
21
|
setTitle?(title: string): void;
|
|
52
|
-
/** Show a notification to the user. */
|
|
53
|
-
notify?(message: string, type?: "info" | "warning" | "error"): void;
|
|
54
22
|
};
|
|
55
|
-
|
|
56
|
-
* Absolute path to the current working directory, available on the full
|
|
57
|
-
* ExtensionContext. Marked optional here to tolerate partial mocks.
|
|
58
|
-
*/
|
|
59
|
-
cwd?: string;
|
|
60
|
-
hasUI: boolean;
|
|
61
|
-
abort(): void;
|
|
62
|
-
}
|
|
23
|
+
};
|
|
63
24
|
|
|
64
25
|
export default function askUserExtension(pi: ExtensionAPI): void {
|
|
65
26
|
const lock = new ActiveQuestionnaireLock();
|
|
66
27
|
|
|
67
|
-
pi.registerTool({
|
|
28
|
+
pi.registerTool<typeof AskUserParamsSchema, AskUserToolDetails>({
|
|
68
29
|
name: TOOL_NAME,
|
|
69
30
|
label: TOOL_LABEL,
|
|
70
|
-
description:
|
|
71
|
-
promptSnippet
|
|
72
|
-
promptGuidelines
|
|
31
|
+
description: toolDescription,
|
|
32
|
+
promptSnippet,
|
|
33
|
+
promptGuidelines,
|
|
73
34
|
parameters: AskUserParamsSchema,
|
|
74
35
|
// biome-ignore lint/complexity/useMaxParams: pi ToolDefinition.execute signature
|
|
75
36
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
76
|
-
return executeAskUser(
|
|
77
|
-
params as AskUserParams,
|
|
78
|
-
signal,
|
|
79
|
-
ctx as unknown as ExtensionUi,
|
|
80
|
-
lock,
|
|
81
|
-
pi,
|
|
82
|
-
);
|
|
37
|
+
return executeAskUser(params, signal, ctx, lock, pi);
|
|
83
38
|
},
|
|
84
|
-
renderCall: (args, theme) => renderAskUserCall(args, theme
|
|
85
|
-
renderResult: (result, _options, theme) =>
|
|
86
|
-
renderAskUserResult(
|
|
87
|
-
result as { details?: unknown; content: { type: string; text?: string }[] },
|
|
88
|
-
theme as Theme,
|
|
89
|
-
) as unknown as Component,
|
|
39
|
+
renderCall: (args, theme) => renderAskUserCall(args, theme),
|
|
40
|
+
renderResult: (result, _options, theme) => renderAskUserResult(result, theme),
|
|
90
41
|
});
|
|
91
42
|
}
|
|
92
43
|
|
|
93
|
-
// biome-ignore lint/complexity/useMaxParams:
|
|
94
|
-
async function executeAskUser(
|
|
44
|
+
// biome-ignore lint/complexity/useMaxParams: keep the execution boundary explicit for tests
|
|
45
|
+
export async function executeAskUser(
|
|
95
46
|
params: AskUserParams,
|
|
96
47
|
signal: AbortSignal | undefined,
|
|
97
|
-
ctx:
|
|
48
|
+
ctx: AskUserExecutionContext,
|
|
98
49
|
lock: ActiveQuestionnaireLock,
|
|
99
50
|
pi: ExtensionAPI,
|
|
100
|
-
): Promise<
|
|
101
|
-
let
|
|
51
|
+
): Promise<AskUserToolResult> {
|
|
52
|
+
let questionnaire: NormalizedQuestionnaire;
|
|
102
53
|
try {
|
|
103
|
-
|
|
104
|
-
} catch (
|
|
105
|
-
if (
|
|
106
|
-
|
|
54
|
+
questionnaire = normalizeQuestionnaire(params);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (error instanceof AskUserValidationError) {
|
|
57
|
+
return buildErrorResult(`Error: ${error.message}`);
|
|
58
|
+
}
|
|
59
|
+
throw error;
|
|
107
60
|
}
|
|
61
|
+
|
|
108
62
|
if (!ctx.hasUI) {
|
|
109
63
|
return buildErrorResult(
|
|
110
|
-
"Error: ask_user requires interactive UI
|
|
64
|
+
"Error: ask_user requires an interactive UI session. No user-facing UI is available in the current mode.",
|
|
111
65
|
);
|
|
112
66
|
}
|
|
113
67
|
if (!lock.acquire()) {
|
|
114
68
|
return buildErrorResult(
|
|
115
|
-
"Error: another ask_user
|
|
69
|
+
"Error: another ask_user form is already in flight. Wait for it to complete before calling ask_user again.",
|
|
116
70
|
);
|
|
117
71
|
}
|
|
72
|
+
|
|
118
73
|
signalAttention(ctx);
|
|
119
74
|
pi.events.emit("supi:ask-user:start", { source: "supi-ask-user" });
|
|
75
|
+
|
|
120
76
|
try {
|
|
121
|
-
// Hide the built-in working loader so it doesn't compete with the overlay.
|
|
122
77
|
ctx.ui.setWorkingVisible?.(false);
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
78
|
+
const outcome = await runQuestionnaire(questionnaire, {
|
|
79
|
+
ui: {
|
|
80
|
+
custom: asFunction(ctx.ui.custom),
|
|
81
|
+
notify: ctx.ui.notify,
|
|
82
|
+
},
|
|
83
|
+
signal,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (outcome === "unsupported") {
|
|
87
|
+
return buildErrorResult(
|
|
88
|
+
"Error: ask_user requires a TUI with custom overlay support. Do not use ask_user in non-interactive or degraded UI sessions.",
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (outcome.status === "cancelled" || outcome.status === "aborted") {
|
|
128
93
|
ctx.abort();
|
|
129
94
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
// "all" mode (Ctrl+O).
|
|
134
|
-
pi.appendEntry(treeSummaryLabel(normalized));
|
|
135
|
-
return result;
|
|
95
|
+
|
|
96
|
+
pi.appendEntry(buildTreeSummaryLabel(questionnaire));
|
|
97
|
+
return buildResult(questionnaire, outcome);
|
|
136
98
|
} finally {
|
|
137
|
-
// Restore the working loader regardless of how the overlay closed.
|
|
138
99
|
ctx.ui.setWorkingVisible?.(true);
|
|
139
100
|
pi.events.emit("supi:ask-user:end", { source: "supi-ask-user" });
|
|
140
101
|
restoreTerminalTitle(ctx, pi);
|
|
@@ -142,50 +103,23 @@ async function executeAskUser(
|
|
|
142
103
|
}
|
|
143
104
|
}
|
|
144
105
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
signalWaiting(ctx, `pi — waiting for your input`);
|
|
106
|
+
function signalAttention(ctx: AskUserExecutionContext): void {
|
|
107
|
+
signalWaiting(ctx, "pi — waiting for your input");
|
|
148
108
|
}
|
|
149
109
|
|
|
150
|
-
|
|
151
|
-
function restoreTerminalTitle(ctx: ExtensionUi, pi: ExtensionAPI): void {
|
|
110
|
+
function restoreTerminalTitle(ctx: AskUserExecutionContext, pi: ExtensionAPI): void {
|
|
152
111
|
ctx.ui.setTitle?.(formatTitle(pi.getSessionName(), ctx.cwd));
|
|
153
112
|
}
|
|
154
113
|
|
|
155
|
-
|
|
156
|
-
function
|
|
157
|
-
const count = q.questions.length;
|
|
158
|
-
const s = count === 1 ? "" : "s";
|
|
159
|
-
const headers = q.questions.map((q) => q.header).join(", ");
|
|
160
|
-
if (headers.length > 70) {
|
|
161
|
-
return `ask_user · ${count} question${s} · ${headers.slice(0, 67)}...`;
|
|
162
|
-
}
|
|
163
|
-
return `ask_user · ${count} question${s} · ${headers}`;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
async function driveQuestionnaire(
|
|
167
|
-
questionnaire: NormalizedQuestionnaire,
|
|
168
|
-
signal: AbortSignal | undefined,
|
|
169
|
-
ctx: ExtensionUi,
|
|
170
|
-
): Promise<HybridResult> {
|
|
171
|
-
const questions = questionnaire.questions;
|
|
172
|
-
if (typeof ctx.ui.custom !== "function") {
|
|
173
|
-
return buildErrorResult(
|
|
174
|
-
"Error: ask_user requires a TUI with custom overlay support. Do not use ask_user in non-interactive or degraded UI sessions.",
|
|
175
|
-
);
|
|
176
|
-
}
|
|
177
|
-
const richHost: RichUiHost = { custom: ctx.ui.custom.bind(ctx.ui) };
|
|
178
|
-
const outcome = await runRichQuestionnaire(questionnaire, { ui: richHost, signal });
|
|
179
|
-
if (outcome === "unsupported") {
|
|
180
|
-
return buildErrorResult(
|
|
181
|
-
"Error: ask_user requires a TUI with custom overlay support. Do not use ask_user in non-interactive or degraded UI sessions.",
|
|
182
|
-
);
|
|
183
|
-
}
|
|
184
|
-
return buildResult(questions, outcome);
|
|
114
|
+
function asFunction<T extends (...args: never[]) => unknown>(value: unknown): T | undefined {
|
|
115
|
+
return typeof value === "function" ? (value as T) : undefined;
|
|
185
116
|
}
|
|
186
117
|
|
|
187
|
-
export { ActiveQuestionnaireLock, QuestionnaireFlow } from "./flow.ts";
|
|
188
|
-
// Re-exports used by tests.
|
|
189
118
|
export { AskUserValidationError, normalizeQuestionnaire } from "./normalize.ts";
|
|
190
|
-
export { buildResult } from "./result.ts";
|
|
191
|
-
export {
|
|
119
|
+
export { buildErrorResult, buildResult } from "./render/result.ts";
|
|
120
|
+
export { AskUserController } from "./session/controller.ts";
|
|
121
|
+
export { ActiveQuestionnaireLock } from "./session/lock.ts";
|
|
122
|
+
export {
|
|
123
|
+
promptGuidelines as askUserPromptGuidelines,
|
|
124
|
+
promptSnippet as askUserPromptSnippet,
|
|
125
|
+
} from "./tool/guidance.ts";
|
package/src/index.ts
CHANGED
|
@@ -1 +1,23 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export type {
|
|
2
|
+
Answer,
|
|
3
|
+
AskUserDetails,
|
|
4
|
+
AskUserErrorDetails,
|
|
5
|
+
AskUserOutcome,
|
|
6
|
+
AskUserStatus,
|
|
7
|
+
AskUserToolDetails,
|
|
8
|
+
ChoiceAnswer,
|
|
9
|
+
CustomAnswer,
|
|
10
|
+
NormalizedChoiceQuestion,
|
|
11
|
+
NormalizedQuestion,
|
|
12
|
+
NormalizedQuestionnaire,
|
|
13
|
+
NormalizedTextQuestion,
|
|
14
|
+
TextAnswer,
|
|
15
|
+
} from "./api.ts";
|
|
16
|
+
export {
|
|
17
|
+
ActiveQuestionnaireLock,
|
|
18
|
+
AskUserController,
|
|
19
|
+
AskUserParamsSchema,
|
|
20
|
+
AskUserValidationError,
|
|
21
|
+
default,
|
|
22
|
+
normalizeQuestionnaire,
|
|
23
|
+
} from "./api.ts";
|
package/src/normalize.ts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
|
-
//
|
|
2
|
-
// questionnaire model. Both UI paths and result formatting consume only this
|
|
3
|
-
// normalized model, so the overlay and dialog flows cannot drift apart.
|
|
1
|
+
// Validation and normalization for ask_user tool calls.
|
|
4
2
|
|
|
5
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
AskUserParams,
|
|
5
|
+
ExternalChoiceQuestion,
|
|
6
|
+
ExternalQuestion,
|
|
7
|
+
ExternalTextQuestion,
|
|
8
|
+
} from "./schema.ts";
|
|
6
9
|
import {
|
|
10
|
+
ASK_USER_LIMITS,
|
|
11
|
+
type NormalizedChoiceQuestion,
|
|
7
12
|
type NormalizedOption,
|
|
8
13
|
type NormalizedQuestion,
|
|
9
14
|
type NormalizedQuestionnaire,
|
|
10
|
-
|
|
15
|
+
type NormalizedTextQuestion,
|
|
11
16
|
} from "./types.ts";
|
|
12
17
|
|
|
13
18
|
export class AskUserValidationError extends Error {
|
|
@@ -18,212 +23,218 @@ export class AskUserValidationError extends Error {
|
|
|
18
23
|
}
|
|
19
24
|
|
|
20
25
|
export function normalizeQuestionnaire(params: AskUserParams): NormalizedQuestionnaire {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function validateQuestionnaireShape(params: AskUserParams): void {
|
|
29
|
-
const count = params.questions.length;
|
|
30
|
-
if (count < QUESTION_LIMITS.minQuestions || count > QUESTION_LIMITS.maxQuestions) {
|
|
31
|
-
throw new AskUserValidationError(
|
|
32
|
-
`ask_user supports ${QUESTION_LIMITS.minQuestions}-${QUESTION_LIMITS.maxQuestions} questions only (got ${count}).`,
|
|
33
|
-
);
|
|
26
|
+
validateQuestionCount(params.questions.length);
|
|
27
|
+
const title = trimOptional(params.title);
|
|
28
|
+
const intro = trimOptional(params.intro);
|
|
29
|
+
if (title && title.length > ASK_USER_LIMITS.maxTitleLength) {
|
|
30
|
+
throw new AskUserValidationError(`title exceeds ${ASK_USER_LIMITS.maxTitleLength} characters.`);
|
|
34
31
|
}
|
|
32
|
+
if (intro && intro.length > ASK_USER_LIMITS.maxIntroLength) {
|
|
33
|
+
throw new AskUserValidationError(`intro exceeds ${ASK_USER_LIMITS.maxIntroLength} characters.`);
|
|
34
|
+
}
|
|
35
|
+
|
|
35
36
|
const seen = new Set<string>();
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
39
|
-
if (seen.has(questionId)) {
|
|
37
|
+
const questions = params.questions.map((question) => {
|
|
38
|
+
const normalized = normalizeQuestion(question);
|
|
39
|
+
if (seen.has(normalized.id)) {
|
|
40
40
|
throw new AskUserValidationError(
|
|
41
|
-
`Duplicate question id "${
|
|
41
|
+
`Duplicate question id "${normalized.id}" — ids must be unique within one form.`,
|
|
42
42
|
);
|
|
43
43
|
}
|
|
44
|
-
seen.add(
|
|
45
|
-
|
|
44
|
+
seen.add(normalized.id);
|
|
45
|
+
return normalized;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
...(title ? { title } : {}),
|
|
50
|
+
...(intro ? { intro } : {}),
|
|
51
|
+
questions,
|
|
52
|
+
allowPartialSubmit: params.allowPartialSubmit ?? false,
|
|
53
|
+
allowDiscuss: params.allowDiscuss ?? false,
|
|
54
|
+
};
|
|
46
55
|
}
|
|
47
56
|
|
|
48
|
-
function normalizeQuestion(
|
|
49
|
-
validateCommonFields(
|
|
50
|
-
|
|
51
|
-
case "choice":
|
|
52
|
-
return normalizeChoice(q);
|
|
53
|
-
case "text":
|
|
54
|
-
return normalizeText(q);
|
|
55
|
-
}
|
|
57
|
+
function normalizeQuestion(question: ExternalQuestion): NormalizedQuestion {
|
|
58
|
+
validateCommonFields(question);
|
|
59
|
+
return question.type === "choice" ? normalizeChoice(question) : normalizeText(question);
|
|
56
60
|
}
|
|
57
61
|
|
|
58
|
-
function
|
|
59
|
-
if (
|
|
60
|
-
throw new AskUserValidationError(
|
|
62
|
+
function validateQuestionCount(count: number): void {
|
|
63
|
+
if (count < ASK_USER_LIMITS.minQuestions || count > ASK_USER_LIMITS.maxQuestions) {
|
|
64
|
+
throw new AskUserValidationError(
|
|
65
|
+
`ask_user supports ${ASK_USER_LIMITS.minQuestions}-${ASK_USER_LIMITS.maxQuestions} questions only (got ${count}).`,
|
|
66
|
+
);
|
|
61
67
|
}
|
|
62
|
-
|
|
63
|
-
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function validateCommonFields(question: ExternalQuestion): void {
|
|
71
|
+
const id = question.id.trim();
|
|
72
|
+
const header = question.header.trim();
|
|
73
|
+
const prompt = question.prompt.trim();
|
|
74
|
+
|
|
75
|
+
if (!id) throw new AskUserValidationError("Question id must be a non-empty string.");
|
|
76
|
+
if (!header) {
|
|
77
|
+
throw new AskUserValidationError(`Question "${question.id}" must include a non-empty header.`);
|
|
64
78
|
}
|
|
65
|
-
if (
|
|
79
|
+
if (header.length > ASK_USER_LIMITS.maxHeaderLength) {
|
|
66
80
|
throw new AskUserValidationError(
|
|
67
|
-
`Question "${
|
|
81
|
+
`Question "${question.id}" header exceeds ${ASK_USER_LIMITS.maxHeaderLength} characters.`,
|
|
68
82
|
);
|
|
69
83
|
}
|
|
70
|
-
if (
|
|
71
|
-
throw new AskUserValidationError(`Question "${
|
|
84
|
+
if (!prompt) {
|
|
85
|
+
throw new AskUserValidationError(`Question "${question.id}" must include a non-empty prompt.`);
|
|
72
86
|
}
|
|
73
|
-
if (
|
|
87
|
+
if (prompt.length > ASK_USER_LIMITS.maxPromptLength) {
|
|
74
88
|
throw new AskUserValidationError(
|
|
75
|
-
`Question "${
|
|
89
|
+
`Question "${question.id}" prompt exceeds ${ASK_USER_LIMITS.maxPromptLength} characters.`,
|
|
76
90
|
);
|
|
77
91
|
}
|
|
78
92
|
}
|
|
79
93
|
|
|
80
|
-
function normalizeChoice(
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
recommendation
|
|
91
|
-
|
|
92
|
-
}): NormalizedQuestion {
|
|
93
|
-
const id = q.id.trim();
|
|
94
|
-
const options = normalizeStructuredOptions(id, q.options);
|
|
95
|
-
const multi = q.multi ?? false;
|
|
96
|
-
const recommendation = q.recommendation;
|
|
97
|
-
const defaultValue = q.default;
|
|
98
|
-
|
|
99
|
-
validateRecDefaultShape(id, recommendation, multi, "recommendation");
|
|
100
|
-
validateRecDefaultShape(id, defaultValue, multi, "default");
|
|
94
|
+
function normalizeChoice(question: ExternalChoiceQuestion): NormalizedChoiceQuestion {
|
|
95
|
+
const options = normalizeOptions(question.id.trim(), question.options);
|
|
96
|
+
const multi = question.multi ?? false;
|
|
97
|
+
const allowOther = question.allowOther ?? false;
|
|
98
|
+
if (multi && allowOther) {
|
|
99
|
+
throw new AskUserValidationError(
|
|
100
|
+
`choice question "${question.id}" cannot use allowOther together with multi-select.`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
validateSelectionShape(question.id, question.recommendation, multi, "recommendation");
|
|
105
|
+
validateSelectionShape(question.id, question.initial, multi, "initial");
|
|
101
106
|
|
|
102
107
|
return {
|
|
103
|
-
id,
|
|
104
|
-
header:
|
|
108
|
+
id: question.id.trim(),
|
|
109
|
+
header: question.header.trim(),
|
|
110
|
+
prompt: question.prompt.trim(),
|
|
111
|
+
required: question.required ?? true,
|
|
105
112
|
type: "choice",
|
|
106
|
-
prompt: q.prompt,
|
|
107
|
-
required: q.required ?? true,
|
|
108
|
-
multi,
|
|
109
113
|
options,
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
recommendedIndexes:
|
|
113
|
-
|
|
114
|
+
multi,
|
|
115
|
+
allowOther,
|
|
116
|
+
recommendedIndexes: resolveIndexes({
|
|
117
|
+
questionId: question.id,
|
|
118
|
+
options,
|
|
119
|
+
value: question.recommendation,
|
|
120
|
+
multi,
|
|
121
|
+
kind: "recommendation",
|
|
122
|
+
}),
|
|
123
|
+
initialIndexes: resolveIndexes({
|
|
124
|
+
questionId: question.id,
|
|
125
|
+
options,
|
|
126
|
+
value: question.initial,
|
|
127
|
+
multi,
|
|
128
|
+
kind: "initial",
|
|
129
|
+
}),
|
|
114
130
|
};
|
|
115
131
|
}
|
|
116
132
|
|
|
117
|
-
function
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
multi: boolean,
|
|
121
|
-
kind: string,
|
|
122
|
-
): void {
|
|
123
|
-
if (value === undefined) return;
|
|
124
|
-
if (!multi && Array.isArray(value)) {
|
|
125
|
-
throw new AskUserValidationError(
|
|
126
|
-
`single-select question "${questionId}" ${kind} must be a string, not an array.`,
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
if (multi && !Array.isArray(value)) {
|
|
133
|
+
function normalizeText(question: ExternalTextQuestion): NormalizedTextQuestion {
|
|
134
|
+
const placeholder = trimOptional(question.placeholder);
|
|
135
|
+
if (placeholder && placeholder.length > ASK_USER_LIMITS.maxPlaceholderLength) {
|
|
130
136
|
throw new AskUserValidationError(
|
|
131
|
-
`
|
|
137
|
+
`Question "${question.id}" placeholder exceeds ${ASK_USER_LIMITS.maxPlaceholderLength} characters.`,
|
|
132
138
|
);
|
|
133
139
|
}
|
|
134
|
-
}
|
|
135
140
|
|
|
136
|
-
function normalizeText(q: {
|
|
137
|
-
id: string;
|
|
138
|
-
header: string;
|
|
139
|
-
prompt: string;
|
|
140
|
-
required?: boolean;
|
|
141
|
-
default?: string;
|
|
142
|
-
}): NormalizedQuestion {
|
|
143
141
|
return {
|
|
144
|
-
id:
|
|
145
|
-
header:
|
|
142
|
+
id: question.id.trim(),
|
|
143
|
+
header: question.header.trim(),
|
|
144
|
+
prompt: question.prompt.trim(),
|
|
145
|
+
required: question.required ?? true,
|
|
146
146
|
type: "text",
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
options: [],
|
|
150
|
-
...(q.default !== undefined ? { default: q.default.trim() } : {}),
|
|
147
|
+
...(question.initial !== undefined ? { initial: question.initial } : {}),
|
|
148
|
+
...(placeholder ? { placeholder } : {}),
|
|
151
149
|
};
|
|
152
150
|
}
|
|
153
151
|
|
|
154
|
-
function
|
|
152
|
+
function normalizeOptions(
|
|
155
153
|
questionId: string,
|
|
156
|
-
options:
|
|
154
|
+
options: ExternalChoiceQuestion["options"],
|
|
157
155
|
): NormalizedOption[] {
|
|
158
|
-
const optionCount = options.length;
|
|
159
156
|
if (
|
|
160
|
-
|
|
161
|
-
|
|
157
|
+
options.length < ASK_USER_LIMITS.minChoiceOptions ||
|
|
158
|
+
options.length > ASK_USER_LIMITS.maxChoiceOptions
|
|
162
159
|
) {
|
|
163
160
|
throw new AskUserValidationError(
|
|
164
|
-
`choice question "${questionId}" must have ${
|
|
161
|
+
`choice question "${questionId}" must have ${ASK_USER_LIMITS.minChoiceOptions}-${ASK_USER_LIMITS.maxChoiceOptions} options (got ${options.length}).`,
|
|
165
162
|
);
|
|
166
163
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
164
|
+
|
|
165
|
+
const seen = new Set<string>();
|
|
166
|
+
return options.map((option) => {
|
|
167
|
+
const value = option.value.trim();
|
|
168
|
+
const label = option.label.trim();
|
|
169
|
+
if (!value || !label) {
|
|
171
170
|
throw new AskUserValidationError(
|
|
172
171
|
`choice question "${questionId}" has an option with empty value or label.`,
|
|
173
172
|
);
|
|
174
173
|
}
|
|
175
|
-
if (
|
|
174
|
+
if (seen.has(value)) {
|
|
176
175
|
throw new AskUserValidationError(
|
|
177
176
|
`choice question "${questionId}" has duplicate option value "${value}".`,
|
|
178
177
|
);
|
|
179
178
|
}
|
|
180
|
-
|
|
179
|
+
seen.add(value);
|
|
181
180
|
return {
|
|
182
181
|
value,
|
|
183
|
-
label
|
|
184
|
-
description:
|
|
185
|
-
preview:
|
|
182
|
+
label,
|
|
183
|
+
description: trimOptional(option.description),
|
|
184
|
+
preview: trimOptional(option.preview),
|
|
186
185
|
};
|
|
187
186
|
});
|
|
188
187
|
}
|
|
189
188
|
|
|
190
|
-
|
|
191
|
-
function resolveRecDefault(
|
|
189
|
+
function validateSelectionShape(
|
|
192
190
|
questionId: string,
|
|
193
|
-
options: NormalizedOption[],
|
|
194
191
|
value: string | string[] | undefined,
|
|
195
192
|
multi: boolean,
|
|
196
|
-
kind: "recommendation" | "
|
|
197
|
-
):
|
|
198
|
-
if (value === undefined) return
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
if (idx < 0) {
|
|
204
|
-
throw new AskUserValidationError(
|
|
205
|
-
`choice question "${questionId}" ${verb} "${trimmed}", which is not one of its option values.`,
|
|
206
|
-
);
|
|
207
|
-
}
|
|
208
|
-
return [idx];
|
|
193
|
+
kind: "recommendation" | "initial",
|
|
194
|
+
): void {
|
|
195
|
+
if (value === undefined) return;
|
|
196
|
+
if (multi && !Array.isArray(value)) {
|
|
197
|
+
throw new AskUserValidationError(
|
|
198
|
+
`multi-select question "${questionId}" ${kind} must be an array, not a string.`,
|
|
199
|
+
);
|
|
209
200
|
}
|
|
210
|
-
|
|
211
|
-
|
|
201
|
+
if (!multi && Array.isArray(value)) {
|
|
202
|
+
throw new AskUserValidationError(
|
|
203
|
+
`single-select question "${questionId}" ${kind} must be a string, not an array.`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function resolveIndexes(args: {
|
|
209
|
+
questionId: string;
|
|
210
|
+
options: NormalizedOption[];
|
|
211
|
+
value: string | string[] | undefined;
|
|
212
|
+
multi: boolean;
|
|
213
|
+
kind: "recommendation" | "initial";
|
|
214
|
+
}): number[] {
|
|
215
|
+
const { questionId, options, value, multi, kind } = args;
|
|
216
|
+
if (value === undefined) return [];
|
|
217
|
+
const values = multi ? (value as string[]) : [value as string];
|
|
212
218
|
const seen = new Set<string>();
|
|
213
|
-
return values.map((
|
|
214
|
-
const trimmed =
|
|
219
|
+
return values.map((entry: string) => {
|
|
220
|
+
const trimmed = entry.trim();
|
|
215
221
|
if (seen.has(trimmed)) {
|
|
216
222
|
throw new AskUserValidationError(
|
|
217
|
-
`choice question "${questionId}" has duplicate ${kind
|
|
223
|
+
`choice question "${questionId}" has duplicate ${kind} value "${trimmed}".`,
|
|
218
224
|
);
|
|
219
225
|
}
|
|
220
226
|
seen.add(trimmed);
|
|
221
|
-
const
|
|
222
|
-
if (
|
|
227
|
+
const index = options.findIndex((option) => option.value === trimmed);
|
|
228
|
+
if (index < 0) {
|
|
223
229
|
throw new AskUserValidationError(
|
|
224
|
-
`choice question "${questionId}" ${
|
|
230
|
+
`choice question "${questionId}" ${kind} value "${trimmed}" does not match any option value.`,
|
|
225
231
|
);
|
|
226
232
|
}
|
|
227
|
-
return
|
|
233
|
+
return index;
|
|
228
234
|
});
|
|
229
235
|
}
|
|
236
|
+
|
|
237
|
+
function trimOptional(value: string | undefined): string | undefined {
|
|
238
|
+
const trimmed = value?.trim();
|
|
239
|
+
return trimmed ? trimmed : undefined;
|
|
240
|
+
}
|