@mrclrchtr/supi-ask-user 1.3.1 → 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.
Files changed (48) hide show
  1. package/README.md +125 -67
  2. package/node_modules/@mrclrchtr/supi-core/README.md +52 -41
  3. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  4. package/node_modules/@mrclrchtr/supi-core/src/api.ts +13 -13
  5. package/node_modules/@mrclrchtr/supi-core/src/{config-settings.ts → config/config-settings.ts} +2 -2
  6. package/node_modules/@mrclrchtr/supi-core/src/{context-provider-registry.ts → context/context-provider-registry.ts} +1 -1
  7. package/node_modules/@mrclrchtr/supi-core/src/extension.ts +1 -1
  8. package/node_modules/@mrclrchtr/supi-core/src/index.ts +13 -13
  9. package/node_modules/@mrclrchtr/supi-core/src/{settings-registry.ts → settings/settings-registry.ts} +1 -1
  10. package/package.json +2 -2
  11. package/src/api.ts +19 -0
  12. package/src/ask-user.ts +65 -131
  13. package/src/index.ts +23 -1
  14. package/src/normalize.ts +153 -142
  15. package/src/render/result.ts +98 -0
  16. package/src/render/transcript.ts +65 -0
  17. package/src/render/tree-summary.ts +10 -0
  18. package/src/schema.ts +41 -38
  19. package/src/session/controller.ts +163 -0
  20. package/src/session/lock.ts +19 -0
  21. package/src/tool/guidance.ts +15 -0
  22. package/src/types.ts +50 -56
  23. package/src/ui/choose-renderer.ts +11 -0
  24. package/src/ui/overlay-actions.ts +42 -0
  25. package/src/ui/overlay-render.ts +196 -0
  26. package/src/ui/overlay-view.ts +216 -0
  27. package/src/ui/overlay.ts +388 -0
  28. package/src/ui/types.ts +35 -0
  29. package/src/flow.ts +0 -224
  30. package/src/format.ts +0 -66
  31. package/src/render/ui-rich-render-editor.ts +0 -51
  32. package/src/render/ui-rich-render-env.ts +0 -15
  33. package/src/render/ui-rich-render-footer.ts +0 -55
  34. package/src/render/ui-rich-render-markdown.ts +0 -33
  35. package/src/render/ui-rich-render-notes.ts +0 -80
  36. package/src/render/ui-rich-render-types.ts +0 -17
  37. package/src/render/ui-rich-render.ts +0 -323
  38. package/src/render.ts +0 -95
  39. package/src/result.ts +0 -90
  40. package/src/ui/ui-rich-handlers.ts +0 -369
  41. package/src/ui/ui-rich-inline.ts +0 -77
  42. package/src/ui/ui-rich-state.ts +0 -179
  43. package/src/ui/ui-rich.ts +0 -144
  44. /package/node_modules/@mrclrchtr/supi-core/src/{config.ts → config/config.ts} +0 -0
  45. /package/node_modules/@mrclrchtr/supi-core/src/{context-messages.ts → context/context-messages.ts} +0 -0
  46. /package/node_modules/@mrclrchtr/supi-core/src/{context-tag.ts → context/context-tag.ts} +0 -0
  47. /package/node_modules/@mrclrchtr/supi-core/src/{settings-command.ts → settings/settings-command.ts} +0 -0
  48. /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
- // `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 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 { renderAskUserCall, renderAskUserResult } from "./render.ts";
20
- import { buildErrorResult, buildResult, type HybridResult } from "./result.ts";
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 type { NormalizedQuestionnaire } from "./types.ts";
23
- import { type RichUiHost, runRichQuestionnaire } from "./ui/ui-rich.ts";
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
- const TOOL_DESCRIPTION =
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?: RichUiHost["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: TOOL_DESCRIPTION,
71
- promptSnippet: PROMPT_SNIPPET,
72
- promptGuidelines: PROMPT_GUIDELINES,
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 as 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: pi context + pi reference for appendEntry
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: ExtensionUi,
48
+ ctx: AskUserExecutionContext,
98
49
  lock: ActiveQuestionnaireLock,
99
50
  pi: ExtensionAPI,
100
- ): Promise<HybridResult> {
101
- let normalized: NormalizedQuestionnaire;
51
+ ): Promise<AskUserToolResult> {
52
+ let questionnaire: NormalizedQuestionnaire;
102
53
  try {
103
- normalized = normalizeQuestionnaire(params);
104
- } catch (err) {
105
- if (err instanceof AskUserValidationError) return buildErrorResult(`Error: ${err.message}`);
106
- throw err;
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 but the current session has none.",
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 interaction is already in flight. Wait for it to complete before calling ask_user again.",
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 result = await driveQuestionnaire(normalized, signal, ctx);
124
- if (
125
- result.details.terminalState !== "submitted" &&
126
- result.details.terminalState !== "skipped"
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
- // Append a tree-friendly custom entry so /tree shows a readable
131
- // summary (question headers) instead of raw JSON tool-call arguments.
132
- // Custom entries are hidden in the default tree filter, visible in
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
- /** Set terminal title and play alert bell to signal the user needs to respond. */
146
- function signalAttention(ctx: ExtensionUi): void {
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
- /** Restore the terminal title to pi's native format (session name + cwd). */
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
- /** Build a concise custom-entry label readable in the /tree "all" filter. */
156
- function treeSummaryLabel(q: NormalizedQuestionnaire): string {
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 { PROMPT_GUIDELINES as askUserPromptGuidelines, PROMPT_SNIPPET as askUserPromptSnippet };
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 { default } from "./ask-user.ts";
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
- // 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.
1
+ // Validation and normalization for ask_user tool calls.
4
2
 
5
- import type { AskUserParams, ExternalQuestion } from "./schema.ts";
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
- QUESTION_LIMITS,
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
- validateQuestionnaireShape(params);
22
- return {
23
- questions: params.questions.map((q) => normalizeQuestion(q)),
24
- allowSkip: params.allowSkip ?? false,
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
- for (const q of params.questions) {
37
- const questionId = q.id.trim();
38
- if (questionId.length === 0) continue;
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 "${questionId}" — question ids must be unique within a questionnaire.`,
41
+ `Duplicate question id "${normalized.id}" — ids must be unique within one form.`,
42
42
  );
43
43
  }
44
- seen.add(questionId);
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(q: ExternalQuestion): NormalizedQuestion {
49
- validateCommonFields(q);
50
- switch (q.type) {
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 validateCommonFields(q: ExternalQuestion): void {
59
- if (q.id.trim().length === 0) {
60
- throw new AskUserValidationError("Question id must be a non-empty string.");
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
- if (q.header.trim().length === 0) {
63
- throw new AskUserValidationError(`Question "${q.id}" must include a non-empty header.`);
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 (q.header.length > QUESTION_LIMITS.maxHeaderLength) {
79
+ if (header.length > ASK_USER_LIMITS.maxHeaderLength) {
66
80
  throw new AskUserValidationError(
67
- `Question "${q.id}" header exceeds ${QUESTION_LIMITS.maxHeaderLength} characters.`,
81
+ `Question "${question.id}" header exceeds ${ASK_USER_LIMITS.maxHeaderLength} characters.`,
68
82
  );
69
83
  }
70
- if (q.prompt.trim().length === 0) {
71
- throw new AskUserValidationError(`Question "${q.id}" must include a non-empty prompt.`);
84
+ if (!prompt) {
85
+ throw new AskUserValidationError(`Question "${question.id}" must include a non-empty prompt.`);
72
86
  }
73
- if (q.prompt.length > QUESTION_LIMITS.maxPromptLength) {
87
+ if (prompt.length > ASK_USER_LIMITS.maxPromptLength) {
74
88
  throw new AskUserValidationError(
75
- `Question "${q.id}" prompt exceeds ${QUESTION_LIMITS.maxPromptLength} characters.`,
89
+ `Question "${question.id}" prompt exceeds ${ASK_USER_LIMITS.maxPromptLength} characters.`,
76
90
  );
77
91
  }
78
92
  }
79
93
 
80
- function normalizeChoice(q: {
81
- type: "choice";
82
- id: string;
83
- header: string;
84
- prompt: string;
85
- required?: boolean;
86
- multi?: boolean;
87
- options: { value: string; label: string; description?: string; preview?: string }[];
88
- allowOther?: boolean;
89
- allowDiscuss?: boolean;
90
- recommendation?: string | string[];
91
- default?: string | string[];
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: q.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
- allowOther: q.allowOther ?? false,
111
- allowDiscuss: q.allowDiscuss ?? false,
112
- recommendedIndexes: resolveRecDefault(id, options, recommendation, multi, "recommendation"),
113
- defaultIndexes: resolveRecDefault(id, options, defaultValue, multi, "default"),
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 validateRecDefaultShape(
118
- questionId: string,
119
- value: string | string[] | undefined,
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
- `multi-select question "${questionId}" ${kind} must be an array, not a string.`,
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: q.id.trim(),
145
- header: q.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
- prompt: q.prompt,
148
- required: q.required ?? true,
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 normalizeStructuredOptions(
152
+ function normalizeOptions(
155
153
  questionId: string,
156
- options: { value: string; label: string; description?: string; preview?: string }[],
154
+ options: ExternalChoiceQuestion["options"],
157
155
  ): NormalizedOption[] {
158
- const optionCount = options.length;
159
156
  if (
160
- optionCount < QUESTION_LIMITS.minChoiceOptions ||
161
- optionCount > QUESTION_LIMITS.maxChoiceOptions
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 ${QUESTION_LIMITS.minChoiceOptions}-${QUESTION_LIMITS.maxChoiceOptions} options (got ${optionCount}).`,
161
+ `choice question "${questionId}" must have ${ASK_USER_LIMITS.minChoiceOptions}-${ASK_USER_LIMITS.maxChoiceOptions} options (got ${options.length}).`,
165
162
  );
166
163
  }
167
- const seenValues = new Set<string>();
168
- return options.map((opt) => {
169
- const value = opt.value.trim();
170
- if (value.length === 0 || opt.label.trim().length === 0) {
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 (seenValues.has(value)) {
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
- seenValues.add(value);
179
+ seen.add(value);
181
180
  return {
182
181
  value,
183
- label: opt.label,
184
- description: opt.description,
185
- preview: opt.preview,
182
+ label,
183
+ description: trimOptional(option.description),
184
+ preview: trimOptional(option.preview),
186
185
  };
187
186
  });
188
187
  }
189
188
 
190
- // biome-ignore lint/complexity/useMaxParams: five distinct positional params are cleaner than a non-reusable options object for this internal helper
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" | "default",
197
- ): number[] {
198
- if (value === undefined) return [];
199
- const verb = kind === "recommendation" ? "recommends" : "defaults to";
200
- if (!multi) {
201
- const trimmed = (value as string).trim();
202
- const idx = options.findIndex((opt) => opt.value === trimmed);
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
- const values = value as string[];
211
- if (values.length === 0) return [];
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((v) => {
214
- const trimmed = v.trim();
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 === "recommendation" ? "recommended" : "default"} value "${trimmed}".`,
223
+ `choice question "${questionId}" has duplicate ${kind} value "${trimmed}".`,
218
224
  );
219
225
  }
220
226
  seen.add(trimmed);
221
- const idx = options.findIndex((opt) => opt.value === trimmed);
222
- if (idx < 0) {
227
+ const index = options.findIndex((option) => option.value === trimmed);
228
+ if (index < 0) {
223
229
  throw new AskUserValidationError(
224
- `choice question "${questionId}" ${verb} "${trimmed}", which is not one of its option values.`,
230
+ `choice question "${questionId}" ${kind} value "${trimmed}" does not match any option value.`,
225
231
  );
226
232
  }
227
- return idx;
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
+ }