@mrclrchtr/supi-ask-user 0.1.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/ask-user.ts ADDED
@@ -0,0 +1,131 @@
1
+ // `ask_user` extension entry point. Registers a single model-callable tool
2
+ // for focused interactive decisions during an agent run. Holds the per-session
3
+ // single-active-questionnaire lock and chooses between the rich overlay and
4
+ // the dialog fallback at execute time.
5
+ //
6
+ // Implementation modules:
7
+ // schema.ts — external (LLM-facing) parameter schema
8
+ // normalize.ts — validation + normalization into the shared internal model
9
+ // flow.ts — shared questionnaire flow + concurrency lock
10
+ // ui-rich.ts — overlay UI via ctx.ui.custom()
11
+ // ui-rich-render.ts — overlay rendering helpers
12
+ // ui-fallback.ts — dialog/input fallback adapter
13
+ // result.ts — hybrid (content + details) result formatting
14
+ // render.ts — custom renderCall / renderResult for the transcript
15
+
16
+ import type { ExtensionAPI, Theme } from "@mariozechner/pi-coding-agent";
17
+ import type { Component } from "@mariozechner/pi-tui";
18
+ import { ActiveQuestionnaireLock } from "./flow.ts";
19
+ import { AskUserValidationError, normalizeQuestionnaire } from "./normalize.ts";
20
+ import { renderAskUserCall, renderAskUserResult } from "./render.ts";
21
+ import { buildErrorResult, buildResult, type HybridResult } from "./result.ts";
22
+ import { type AskUserParams, AskUserParamsSchema } from "./schema.ts";
23
+ import type { NormalizedQuestion } from "./types.ts";
24
+ import { type FallbackUi, runFallbackQuestionnaire } from "./ui-fallback.ts";
25
+ import { type RichUiHost, runRichQuestionnaire } from "./ui-rich.ts";
26
+
27
+ const TOOL_NAME = "ask_user";
28
+ const TOOL_LABEL = "Ask User";
29
+
30
+ const TOOL_DESCRIPTION =
31
+ "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. Each question is `choice`, `multichoice`, `text`, or `yesno`; structured questions can add `recommendation`, `allowOther`, `allowDiscuss`, and option `preview` content.";
32
+
33
+ const PROMPT_SNIPPET =
34
+ "ask_user — pause and request a focused decision (1-4 typed questions) when explicit user input is required to proceed, including rich choice, multichoice, and discuss flows";
35
+
36
+ const PROMPT_GUIDELINES = [
37
+ "Use ask_user only for decisions that require explicit user input — never as a substitute for reading code or thinking through a problem.",
38
+ "Keep questionnaires bounded: 1-4 focused questions with short headers; prefer one decision per call when possible.",
39
+ "Choose the narrowest type that fits: yesno for binary decisions, choice for one known option, multichoice for short pick-many lists, and text only when freeform input is genuinely needed.",
40
+ "Set `recommendation` when one option or a small set of options is clearly preferable, so the UI can surface that guidance.",
41
+ "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.",
42
+ "Use option `preview` content when the user would understand choices better from code, config, markdown, or ASCII mockups than from one-line descriptions alone.",
43
+ "Do not call ask_user while another ask_user interaction is in flight — wait for the previous result before issuing another.",
44
+ ];
45
+
46
+ interface ExtensionUi {
47
+ ui: {
48
+ select: FallbackUi["select"];
49
+ input: FallbackUi["input"];
50
+ custom?: RichUiHost["custom"];
51
+ };
52
+ hasUI: boolean;
53
+ }
54
+
55
+ export default function askUserExtension(pi: ExtensionAPI): void {
56
+ const lock = new ActiveQuestionnaireLock();
57
+
58
+ pi.registerTool({
59
+ name: TOOL_NAME,
60
+ label: TOOL_LABEL,
61
+ description: TOOL_DESCRIPTION,
62
+ promptSnippet: PROMPT_SNIPPET,
63
+ promptGuidelines: PROMPT_GUIDELINES,
64
+ parameters: AskUserParamsSchema,
65
+ // biome-ignore lint/complexity/useMaxParams: pi ToolDefinition.execute signature
66
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
67
+ return executeAskUser(params as AskUserParams, signal, ctx as ExtensionUi, lock);
68
+ },
69
+ renderCall: (args, theme) => renderAskUserCall(args, theme as Theme),
70
+ renderResult: (result, _options, theme) =>
71
+ renderAskUserResult(
72
+ result as { details?: unknown; content: { type: string; text?: string }[] },
73
+ theme as Theme,
74
+ ) as unknown as Component,
75
+ });
76
+ }
77
+
78
+ async function executeAskUser(
79
+ params: AskUserParams,
80
+ signal: AbortSignal | undefined,
81
+ ctx: ExtensionUi,
82
+ lock: ActiveQuestionnaireLock,
83
+ ): Promise<HybridResult> {
84
+ let normalized: NormalizedQuestion[];
85
+ try {
86
+ normalized = normalizeQuestionnaire(params).questions;
87
+ } catch (err) {
88
+ if (err instanceof AskUserValidationError) return buildErrorResult(`Error: ${err.message}`);
89
+ throw err;
90
+ }
91
+ if (!ctx.hasUI) {
92
+ return buildErrorResult(
93
+ "Error: ask_user requires interactive UI but the current session has none.",
94
+ );
95
+ }
96
+ if (!lock.acquire()) {
97
+ return buildErrorResult(
98
+ "Error: another ask_user interaction is already in flight. Wait for it to complete before calling ask_user again.",
99
+ );
100
+ }
101
+ try {
102
+ return await driveQuestionnaire(normalized, signal, ctx);
103
+ } finally {
104
+ lock.release();
105
+ }
106
+ }
107
+
108
+ async function driveQuestionnaire(
109
+ questions: NormalizedQuestion[],
110
+ signal: AbortSignal | undefined,
111
+ ctx: ExtensionUi,
112
+ ): Promise<HybridResult> {
113
+ if (typeof ctx.ui.custom === "function") {
114
+ const richHost: RichUiHost = { custom: ctx.ui.custom.bind(ctx.ui) };
115
+ const outcome = await runRichQuestionnaire(questions, { ui: richHost, signal });
116
+ if (outcome !== "unsupported") return buildResult(questions, outcome);
117
+ }
118
+ const fallbackUi: FallbackUi = {
119
+ select: ctx.ui.select.bind(ctx.ui),
120
+ input: ctx.ui.input.bind(ctx.ui),
121
+ };
122
+ const outcome = await runFallbackQuestionnaire(questions, { ui: fallbackUi, signal });
123
+ return buildResult(questions, outcome);
124
+ }
125
+
126
+ export { ActiveQuestionnaireLock, QuestionnaireFlow } from "./flow.ts";
127
+ // Re-exports used by tests.
128
+ export { AskUserValidationError, normalizeQuestionnaire } from "./normalize.ts";
129
+ export { buildResult } from "./result.ts";
130
+ export { runFallbackQuestionnaire } from "./ui-fallback.ts";
131
+ export { PROMPT_GUIDELINES as askUserPromptGuidelines, PROMPT_SNIPPET as askUserPromptSnippet };
package/flow.ts ADDED
@@ -0,0 +1,212 @@
1
+ // Shared questionnaire flow state used by both UI paths and the
2
+ // single-active-questionnaire concurrency guard. The flow owns terminal-state
3
+ // transitions (`submitted`, `cancelled`, `aborted`) so overlay and fallback
4
+ // cannot drift apart on cancellation/abort semantics.
5
+
6
+ import type { Answer, NormalizedQuestion, QuestionnaireOutcome, TerminalState } from "./types.ts";
7
+ import { needsReview } from "./types.ts";
8
+
9
+ export type FlowMode = "answering" | "reviewing" | "terminal";
10
+
11
+ export class QuestionnaireFlow {
12
+ private readonly answers = new Map<string, Answer>();
13
+ private index = 0;
14
+ private mode: FlowMode = "answering";
15
+ private terminalState: TerminalState | null = null;
16
+
17
+ constructor(public readonly questions: NormalizedQuestion[]) {
18
+ if (questions.length === 0) {
19
+ throw new Error("QuestionnaireFlow requires at least one question.");
20
+ }
21
+ }
22
+
23
+ get currentIndex(): number {
24
+ return this.index;
25
+ }
26
+
27
+ get currentMode(): FlowMode {
28
+ return this.mode;
29
+ }
30
+
31
+ get isMultiQuestion(): boolean {
32
+ return this.questions.length > 1;
33
+ }
34
+
35
+ get currentQuestion(): NormalizedQuestion | undefined {
36
+ return this.questions[this.index];
37
+ }
38
+
39
+ hasAnswer(questionId: string): boolean {
40
+ return this.answers.has(questionId);
41
+ }
42
+
43
+ getAnswer(questionId: string): Answer | undefined {
44
+ return this.answers.get(questionId);
45
+ }
46
+
47
+ allAnswered(): boolean {
48
+ return this.questions.every((q) => this.answers.has(q.id));
49
+ }
50
+
51
+ setAnswer(answer: Answer): void {
52
+ this.answers.set(answer.questionId, normalizeAnswer(answer));
53
+ }
54
+
55
+ advance(): boolean {
56
+ if (this.mode !== "answering") return false;
57
+ const current = this.currentQuestion;
58
+ if (current && !this.answers.has(current.id)) return false;
59
+ if (this.index < this.questions.length - 1) {
60
+ this.index += 1;
61
+ return true;
62
+ }
63
+ if (needsReview(this.questions)) {
64
+ this.mode = "reviewing";
65
+ return true;
66
+ }
67
+ this.markSubmitted();
68
+ return true;
69
+ }
70
+
71
+ goBack(): boolean {
72
+ if (this.mode === "terminal") return false;
73
+ if (this.mode === "reviewing") {
74
+ this.mode = "answering";
75
+ this.index = this.questions.length - 1;
76
+ return true;
77
+ }
78
+ if (this.index > 0) {
79
+ this.index -= 1;
80
+ return true;
81
+ }
82
+ return false;
83
+ }
84
+
85
+ enterReview(): boolean {
86
+ if (this.mode === "terminal") return false;
87
+ if (!needsReview(this.questions)) return false;
88
+ if (!this.allAnswered()) return false;
89
+ this.mode = "reviewing";
90
+ return true;
91
+ }
92
+
93
+ submit(): boolean {
94
+ if (this.mode === "terminal") return false;
95
+ if (!this.allAnswered()) return false;
96
+ this.markSubmitted();
97
+ return true;
98
+ }
99
+
100
+ cancel(): void {
101
+ if (this.mode === "terminal") return;
102
+ this.mode = "terminal";
103
+ this.terminalState = "cancelled";
104
+ }
105
+
106
+ abort(): void {
107
+ if (this.mode === "terminal") return;
108
+ this.mode = "terminal";
109
+ this.terminalState = "aborted";
110
+ }
111
+
112
+ isTerminal(): boolean {
113
+ return this.mode === "terminal";
114
+ }
115
+
116
+ outcome(): QuestionnaireOutcome {
117
+ const state = this.terminalState ?? "cancelled";
118
+ return {
119
+ terminalState: state,
120
+ answers: state === "submitted" ? this.collectAnswers() : [...this.answers.values()],
121
+ };
122
+ }
123
+
124
+ private markSubmitted(): void {
125
+ this.mode = "terminal";
126
+ this.terminalState = "submitted";
127
+ }
128
+
129
+ private collectAnswers(): Answer[] {
130
+ return this.questions.flatMap((q) => {
131
+ const answer = this.answers.get(q.id);
132
+ return answer ? [answer] : [];
133
+ });
134
+ }
135
+ }
136
+
137
+ function normalizeAnswer(answer: Answer): Answer {
138
+ switch (answer.source) {
139
+ case "option":
140
+ return {
141
+ questionId: answer.questionId,
142
+ source: "option",
143
+ value: answer.value.trim(),
144
+ optionIndex: answer.optionIndex,
145
+ note: trimOptional(answer.note),
146
+ };
147
+ case "options":
148
+ return {
149
+ questionId: answer.questionId,
150
+ source: "options",
151
+ values: answer.values.map((value) => value.trim()),
152
+ optionIndexes: [...answer.optionIndexes],
153
+ selections: answer.selections.map((selection) => ({
154
+ value: selection.value.trim(),
155
+ optionIndex: selection.optionIndex,
156
+ note: trimOptional(selection.note),
157
+ })),
158
+ };
159
+ case "other":
160
+ return {
161
+ questionId: answer.questionId,
162
+ source: "other",
163
+ value: answer.value.trim(),
164
+ };
165
+ case "discuss": {
166
+ const value = trimOptional(answer.value);
167
+ return value
168
+ ? { questionId: answer.questionId, source: "discuss", value }
169
+ : { questionId: answer.questionId, source: "discuss" };
170
+ }
171
+ case "text":
172
+ return {
173
+ questionId: answer.questionId,
174
+ source: "text",
175
+ value: answer.value.trim(),
176
+ };
177
+ case "yesno":
178
+ return {
179
+ questionId: answer.questionId,
180
+ source: "yesno",
181
+ value: answer.value,
182
+ optionIndex: answer.optionIndex,
183
+ note: trimOptional(answer.note),
184
+ };
185
+ }
186
+ }
187
+
188
+ function trimOptional(value: string | undefined): string | undefined {
189
+ const trimmed = value?.trim();
190
+ return trimmed && trimmed.length > 0 ? trimmed : undefined;
191
+ }
192
+
193
+ // Session-scoped lock: only one in-flight `ask_user` interaction at a time.
194
+ // Stored at module scope per extension instance (the extension factory runs
195
+ // once per session in pi).
196
+ export class ActiveQuestionnaireLock {
197
+ private active = false;
198
+
199
+ acquire(): boolean {
200
+ if (this.active) return false;
201
+ this.active = true;
202
+ return true;
203
+ }
204
+
205
+ release(): void {
206
+ this.active = false;
207
+ }
208
+
209
+ isActive(): boolean {
210
+ return this.active;
211
+ }
212
+ }
package/format.ts ADDED
@@ -0,0 +1,95 @@
1
+ // Shared formatting helpers used by both UI paths and result rendering.
2
+ // Keeps summary/review formatting in one place so the rich overlay, dialog
3
+ // fallback, transcript renderer, and tool-content summary cannot accidentally
4
+ // diverge.
5
+
6
+ import type { Answer, MultiSelection, NormalizedQuestion } from "./types.ts";
7
+
8
+ export const OTHER_LABEL = "Other answer";
9
+ export const DISCUSS_LABEL = "Discuss instead";
10
+ export const SUBMIT_SELECTIONS_LABEL = "Submit selections";
11
+ export const NOTE_MARKER = "✎";
12
+
13
+ export function decorateOption(label: string, recommended: boolean): string {
14
+ return recommended ? `${label} (recommended)` : label;
15
+ }
16
+
17
+ export function formatSummaryBody(question: NormalizedQuestion, answer: Answer): string {
18
+ switch (answer.source) {
19
+ case "option": {
20
+ const label = question.options[answer.optionIndex]?.label ?? answer.value;
21
+ return withNote(label, answer.note);
22
+ }
23
+ case "options": {
24
+ const selections = resolveSelections(question, answer);
25
+ return selections.map((selection) => withNote(selection.label, selection.note)).join("; ");
26
+ }
27
+ case "other":
28
+ return `Other — ${answer.value}`;
29
+ case "discuss":
30
+ return answer.value ? `Discuss — ${answer.value}` : "Discuss";
31
+ case "text":
32
+ return answer.value;
33
+ case "yesno":
34
+ return withNote(answer.value === "yes" ? "Yes" : "No", answer.note);
35
+ }
36
+ }
37
+
38
+ export function formatReviewBody(question: NormalizedQuestion, answer: Answer): string {
39
+ return formatReviewLines(question, answer).join("; ");
40
+ }
41
+
42
+ export function formatReviewLines(question: NormalizedQuestion, answer: Answer): string[] {
43
+ switch (answer.source) {
44
+ case "option": {
45
+ const label = question.options[answer.optionIndex]?.label ?? answer.value;
46
+ return [withNote(label, answer.note)];
47
+ }
48
+ case "options": {
49
+ const selections = resolveSelections(question, answer);
50
+ return selections.length > 0
51
+ ? selections.map((selection) => withNote(selection.label, selection.note))
52
+ : ["(no selections)"];
53
+ }
54
+ case "other":
55
+ return [`Other: ${answer.value}`];
56
+ case "discuss":
57
+ return [answer.value ? `Discuss: ${answer.value}` : "Discuss"];
58
+ case "text":
59
+ return [answer.value];
60
+ case "yesno":
61
+ return [withNote(answer.value === "yes" ? "Yes" : "No", answer.note)];
62
+ }
63
+ }
64
+
65
+ export function formatReviewLine(question: NormalizedQuestion, answer: Answer | undefined): string {
66
+ if (!answer) return "(no answer)";
67
+ return formatReviewBody(question, answer);
68
+ }
69
+
70
+ interface ResolvedSelection {
71
+ label: string;
72
+ note?: string;
73
+ }
74
+
75
+ function resolveSelections(
76
+ question: NormalizedQuestion,
77
+ answer: Extract<Answer, { source: "options" }>,
78
+ ): ResolvedSelection[] {
79
+ const selections = answer.selections.length > 0 ? answer.selections : legacySelections(answer);
80
+ return selections.map((selection) => ({
81
+ label: question.options[selection.optionIndex]?.label ?? selection.value,
82
+ note: selection.note,
83
+ }));
84
+ }
85
+
86
+ function legacySelections(answer: Extract<Answer, { source: "options" }>): MultiSelection[] {
87
+ return answer.optionIndexes.map((optionIndex, index) => ({
88
+ value: answer.values[index] ?? "",
89
+ optionIndex,
90
+ }));
91
+ }
92
+
93
+ function withNote(body: string, note: string | undefined): string {
94
+ return note ? `${body} — ${note}` : body;
95
+ }
package/normalize.ts ADDED
@@ -0,0 +1,218 @@
1
+ // Validates raw `ask_user` parameters and lowers them into the shared internal
2
+ // questionnaire model. Both UI paths and result formatting consume only this
3
+ // normalized model, so the overlay and dialog flows cannot drift apart.
4
+
5
+ import type {
6
+ AskUserParams,
7
+ ExternalChoiceQuestion,
8
+ ExternalMultiChoiceQuestion,
9
+ ExternalQuestion,
10
+ ExternalYesNoQuestion,
11
+ } from "./schema.ts";
12
+ import {
13
+ type NormalizedOption,
14
+ type NormalizedQuestion,
15
+ type NormalizedQuestionnaire,
16
+ QUESTION_LIMITS,
17
+ } from "./types.ts";
18
+
19
+ const YES_NO_OPTIONS: readonly NormalizedOption[] = [
20
+ { value: "yes", label: "Yes" },
21
+ { value: "no", label: "No" },
22
+ ] as const;
23
+
24
+ export class AskUserValidationError extends Error {
25
+ constructor(message: string) {
26
+ super(message);
27
+ this.name = "AskUserValidationError";
28
+ }
29
+ }
30
+
31
+ export function normalizeQuestionnaire(params: AskUserParams): NormalizedQuestionnaire {
32
+ validateQuestionnaireShape(params);
33
+ return { questions: params.questions.map((q) => normalizeQuestion(q)) };
34
+ }
35
+
36
+ function validateQuestionnaireShape(params: AskUserParams): void {
37
+ const count = params.questions.length;
38
+ if (count < QUESTION_LIMITS.minQuestions || count > QUESTION_LIMITS.maxQuestions) {
39
+ throw new AskUserValidationError(
40
+ `ask_user supports ${QUESTION_LIMITS.minQuestions}-${QUESTION_LIMITS.maxQuestions} questions only (got ${count}).`,
41
+ );
42
+ }
43
+ const seen = new Set<string>();
44
+ for (const q of params.questions) {
45
+ if (seen.has(q.id)) {
46
+ throw new AskUserValidationError(
47
+ `Duplicate question id "${q.id}" — question ids must be unique within a questionnaire.`,
48
+ );
49
+ }
50
+ seen.add(q.id);
51
+ }
52
+ }
53
+
54
+ function normalizeQuestion(q: ExternalQuestion): NormalizedQuestion {
55
+ validateCommonFields(q);
56
+ switch (q.type) {
57
+ case "choice":
58
+ return normalizeChoice(q);
59
+ case "multichoice":
60
+ return normalizeMultiChoice(q);
61
+ case "yesno":
62
+ return normalizeYesNo(q);
63
+ case "text":
64
+ return normalizeText(q);
65
+ }
66
+ }
67
+
68
+ function validateCommonFields(q: ExternalQuestion): void {
69
+ if (q.id.trim().length === 0) {
70
+ throw new AskUserValidationError("Question id must be a non-empty string.");
71
+ }
72
+ if (q.header.trim().length === 0) {
73
+ throw new AskUserValidationError(`Question "${q.id}" must include a non-empty header.`);
74
+ }
75
+ if (q.header.length > QUESTION_LIMITS.maxHeaderLength) {
76
+ throw new AskUserValidationError(
77
+ `Question "${q.id}" header exceeds ${QUESTION_LIMITS.maxHeaderLength} characters.`,
78
+ );
79
+ }
80
+ if (q.prompt.trim().length === 0) {
81
+ throw new AskUserValidationError(`Question "${q.id}" must include a non-empty prompt.`);
82
+ }
83
+ if (q.prompt.length > QUESTION_LIMITS.maxPromptLength) {
84
+ throw new AskUserValidationError(
85
+ `Question "${q.id}" prompt exceeds ${QUESTION_LIMITS.maxPromptLength} characters.`,
86
+ );
87
+ }
88
+ }
89
+
90
+ function normalizeChoice(q: ExternalChoiceQuestion): NormalizedQuestion {
91
+ const options = normalizeStructuredOptions(q.id, q.options);
92
+ return {
93
+ id: q.id,
94
+ header: q.header,
95
+ type: "choice",
96
+ prompt: q.prompt,
97
+ options,
98
+ allowOther: q.allowOther ?? false,
99
+ allowDiscuss: q.allowDiscuss ?? false,
100
+ recommendedIndexes: resolveSingleRecommendation(q.id, options, q.recommendation),
101
+ };
102
+ }
103
+
104
+ function normalizeMultiChoice(q: ExternalMultiChoiceQuestion): NormalizedQuestion {
105
+ if (q.allowOther) {
106
+ throw new AskUserValidationError(
107
+ `multichoice question "${q.id}" does not support allowOther in this version.`,
108
+ );
109
+ }
110
+ const options = normalizeStructuredOptions(q.id, q.options);
111
+ return {
112
+ id: q.id,
113
+ header: q.header,
114
+ type: "multichoice",
115
+ prompt: q.prompt,
116
+ options,
117
+ allowOther: false,
118
+ allowDiscuss: q.allowDiscuss ?? false,
119
+ recommendedIndexes: resolveMultiRecommendation(q.id, options, q.recommendation),
120
+ };
121
+ }
122
+
123
+ function normalizeYesNo(q: ExternalYesNoQuestion): NormalizedQuestion {
124
+ return {
125
+ id: q.id,
126
+ header: q.header,
127
+ type: "yesno",
128
+ prompt: q.prompt,
129
+ options: YES_NO_OPTIONS.map((option) => ({ ...option })),
130
+ allowOther: q.allowOther ?? false,
131
+ allowDiscuss: q.allowDiscuss ?? false,
132
+ recommendedIndexes: q.recommendation === undefined ? [] : [q.recommendation === "yes" ? 0 : 1],
133
+ };
134
+ }
135
+
136
+ function normalizeText(q: { id: string; header: string; prompt: string }): NormalizedQuestion {
137
+ return {
138
+ id: q.id,
139
+ header: q.header,
140
+ type: "text",
141
+ prompt: q.prompt,
142
+ options: [],
143
+ };
144
+ }
145
+
146
+ function normalizeStructuredOptions(
147
+ questionId: string,
148
+ options: ExternalChoiceQuestion["options"] | ExternalMultiChoiceQuestion["options"],
149
+ ): NormalizedOption[] {
150
+ const optionCount = options.length;
151
+ if (
152
+ optionCount < QUESTION_LIMITS.minChoiceOptions ||
153
+ optionCount > QUESTION_LIMITS.maxChoiceOptions
154
+ ) {
155
+ throw new AskUserValidationError(
156
+ `structured question "${questionId}" must have ${QUESTION_LIMITS.minChoiceOptions}-${QUESTION_LIMITS.maxChoiceOptions} options (got ${optionCount}).`,
157
+ );
158
+ }
159
+ const seenValues = new Set<string>();
160
+ return options.map((opt) => {
161
+ if (opt.value.trim().length === 0 || opt.label.trim().length === 0) {
162
+ throw new AskUserValidationError(
163
+ `structured question "${questionId}" has an option with empty value or label.`,
164
+ );
165
+ }
166
+ if (seenValues.has(opt.value)) {
167
+ throw new AskUserValidationError(
168
+ `structured question "${questionId}" has duplicate option value "${opt.value}".`,
169
+ );
170
+ }
171
+ seenValues.add(opt.value);
172
+ return {
173
+ value: opt.value,
174
+ label: opt.label,
175
+ description: opt.description,
176
+ preview: opt.preview,
177
+ };
178
+ });
179
+ }
180
+
181
+ function resolveSingleRecommendation(
182
+ questionId: string,
183
+ options: NormalizedOption[],
184
+ recommendation: string | undefined,
185
+ ): number[] {
186
+ if (recommendation === undefined) return [];
187
+ const idx = options.findIndex((opt) => opt.value === recommendation);
188
+ if (idx < 0) {
189
+ throw new AskUserValidationError(
190
+ `choice question "${questionId}" recommends "${recommendation}", which is not one of its option values.`,
191
+ );
192
+ }
193
+ return [idx];
194
+ }
195
+
196
+ function resolveMultiRecommendation(
197
+ questionId: string,
198
+ options: NormalizedOption[],
199
+ recommendation: string[] | undefined,
200
+ ): number[] {
201
+ if (!recommendation || recommendation.length === 0) return [];
202
+ const seen = new Set<string>();
203
+ return recommendation.map((value) => {
204
+ if (seen.has(value)) {
205
+ throw new AskUserValidationError(
206
+ `multichoice question "${questionId}" has duplicate recommended value "${value}".`,
207
+ );
208
+ }
209
+ seen.add(value);
210
+ const idx = options.findIndex((opt) => opt.value === value);
211
+ if (idx < 0) {
212
+ throw new AskUserValidationError(
213
+ `multichoice question "${questionId}" recommends "${value}", which is not one of its option values.`,
214
+ );
215
+ }
216
+ return idx;
217
+ });
218
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@mrclrchtr/supi-ask-user",
3
+ "version": "0.1.0",
4
+ "description": "SuPi ask-user extension — rich questionnaire UI for structured agent-user decisions",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/mrclrchtr/supi.git"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "keywords": [
14
+ "pi-package",
15
+ "pi",
16
+ "pi-coding-agent"
17
+ ],
18
+ "files": [
19
+ "*.ts",
20
+ "!__tests__"
21
+ ],
22
+ "peerDependencies": {
23
+ "@mariozechner/pi-coding-agent": "~0.66.0",
24
+ "@mariozechner/pi-tui": "~0.66.0",
25
+ "@sinclair/typebox": ">=0.34.0"
26
+ },
27
+ "devDependencies": {
28
+ "vitest": "^4.1.4"
29
+ },
30
+ "pi": {
31
+ "extensions": [
32
+ "./ask-user.ts"
33
+ ]
34
+ }
35
+ }