@black942026/pi-ask-user 0.1.1 → 0.1.3

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/src/index.ts CHANGED
@@ -23,7 +23,7 @@ export * from "./types.ts";
23
23
  export { askUser, askUserNormalized } from "./core.ts";
24
24
  export { createPiHost, createPiCustomRenderer, type PiHostOptions } from "./adapters/pi.ts";
25
25
  export { createAskUser, type AskUser, type CreateAskUserOptions } from "./ask.ts";
26
- export { createNativeRunner, type NativeDialogUI } from "./ui/native.ts";
26
+ export { createNativeRunner, type NativeDialogUI, type NativeDialogOptions } from "./ui/native.ts";
27
27
  export {
28
28
  AskUserComponent,
29
29
  MIN_USABLE_ROWS,
@@ -38,6 +38,7 @@ export {
38
38
  DEFAULT_TIMEOUT_PER_QUESTION_MS,
39
39
  LIMITS,
40
40
  MAX_OPTIONS,
41
+ MAX_FORM_FIELDS,
41
42
  MAX_QUESTIONS,
42
43
  normalizeAskUserRequest,
43
44
  } from "./schema.ts";
@@ -77,6 +78,7 @@ export {
77
78
  emptyDraft,
78
79
  finalizeAnswers,
79
80
  toAnswer,
81
+ validateAnswer,
80
82
  type DraftAnswer,
81
83
  type FinalizeResult,
82
84
  } from "./answers.ts";
@@ -98,7 +100,7 @@ export interface AskUserToolDetails {
98
100
  progress?: string;
99
101
  }
100
102
 
101
- const DESCRIPTION = "Ask the user one or more structured questions through the host's interactive UI.";
103
+ const DESCRIPTION = "Ask the user structured single-choice, multi-choice, text, boolean, or flat form questions through the host's interactive UI.";
102
104
 
103
105
  const PROMPT_SNIPPET = "Ask the user structured questions when you would otherwise guess.";
104
106
 
@@ -106,6 +108,8 @@ const PROMPT_GUIDELINES = [
106
108
  "Use ask_user when a choice is high-impact or ambiguous and you cannot infer the answer.",
107
109
  "If the tool reports the UI could not run, ask the user the question yourself in ordinary text instead.",
108
110
  "Never invent an answer; on aborted or timeout, report it plainly and do not re-ask.",
111
+ "Use kind=bool for a real boolean. Use kind=simple-form with flat uniquely named fields when several typed values belong in one logical question; forms cannot nest.",
112
+ "Set allowFreeText=false to require option selections without supplemental text. Native single/bool then use selectors; native multi still uses a numbers-only text box.",
109
113
  ];
110
114
 
111
115
  /**
@@ -136,6 +140,12 @@ function describeRequestAsText(request: NormalizedRequest): string {
136
140
  const description = option.description?.trim();
137
141
  lines.push(` ${optionIndex + 1}) ${option.label}${description ? ` — ${description}` : ""}`);
138
142
  });
143
+ if (question.kind === "bool") lines.push(" answer: true or false");
144
+ for (const field of question.fields ?? []) {
145
+ lines.push(` - ${field.label} [${field.name}: ${field.kind}]${field.required ? " required" : " optional"}`);
146
+ field.options.forEach((option, optionIndex) => lines.push(` ${optionIndex + 1}) ${option.label}`));
147
+ if (field.hasDefault) lines.push(` default: ${JSON.stringify(field.default)}`);
148
+ }
139
149
  if (question.hasDefault && question.default !== undefined) lines.push(` default: ${question.default}`);
140
150
  });
141
151
  return lines.join("\n");
@@ -151,6 +161,10 @@ function buildModelContent(result: AskUserResult, request: NormalizedRequest): s
151
161
  switch (result.status) {
152
162
  case "answered": {
153
163
  const lines = result.answers.map((answer) => {
164
+ if (answer.value !== undefined) {
165
+ const suffix = answer.usedDefault ? " (default used)" : "";
166
+ return `- ${answer.title}: ${JSON.stringify(answer.value)}${suffix}`;
167
+ }
154
168
  const parts: string[] = [];
155
169
  if (answer.selections.length > 0) parts.push(answer.selections.join(", "));
156
170
  if (answer.freeText) parts.push(answer.freeText);
@@ -211,7 +225,9 @@ export interface RegisterAskUserOptions {
211
225
  timeoutPerQuestionMs?: number;
212
226
  /**
213
227
  * Path to the user config file, or `false` to skip reading it. Defaults to
214
- * `~/.pi/ask-user/config.json`. There are no environment variables.
228
+ * `<Pi agent directory>/ask-user.json` (normally `~/.pi/agent/ask-user.json`).
229
+ * The directory follows Pi's `PI_CODING_AGENT_DIR`; there are no AskUser-specific
230
+ * environment-variable preferences.
215
231
  */
216
232
  configFile?: string | false;
217
233
  }
package/src/parse.ts CHANGED
@@ -54,6 +54,9 @@ export function parseNativeAnswer(question: NormalizedQuestion, raw: string): Pa
54
54
  if (question.kind === "input") {
55
55
  return { ok: true, value: { selections: [], freeText: trimmed, empty: false } };
56
56
  }
57
+ if (question.kind === "bool" || question.kind === "simple-form") {
58
+ return { ok: false, error: `${question.kind} uses typed native input and cannot be parsed as a legacy answer.` };
59
+ }
57
60
 
58
61
  const pipeIndex = raw.indexOf("|");
59
62
  const left = pipeIndex >= 0 ? raw.slice(0, pipeIndex) : raw;
@@ -63,12 +66,14 @@ export function parseNativeAnswer(question: NormalizedQuestion, raw: string): Pa
63
66
 
64
67
  // No number in the box: the (optional) right side is the free input.
65
68
  if (leftTrim === "") {
69
+ if (question.allowFreeText === false) return { ok: false, error: "Free text is disabled; enter option numbers only." };
66
70
  return { ok: true, value: { selections: [], freeText: comment, empty: comment === "" } };
67
71
  }
68
72
 
69
73
  const isStrictList = DIGIT_SEPARATOR_ONLY.test(leftTrim);
70
74
  const isListAttempt = isStrictList || NUMBER_LIST_ATTEMPT.test(leftTrim);
71
75
  if (!isListAttempt) {
76
+ if (question.allowFreeText === false) return { ok: false, error: "Free text is disabled; enter option numbers only." };
72
77
  // Free text, taken verbatim including any `|`.
73
78
  return { ok: true, value: { selections: [], freeText: trimmed, empty: false } };
74
79
  }
@@ -93,6 +98,9 @@ export function parseNativeAnswer(question: NormalizedQuestion, raw: string): Pa
93
98
  return { ok: false, error: "This is a single-select question; enter only one number." };
94
99
  }
95
100
 
101
+ if (question.allowFreeText === false && pipeIndex >= 0) {
102
+ return { ok: false, error: "Notes are disabled; enter option numbers only." };
103
+ }
96
104
  const ordered = question.kind === "single" ? unique : [...unique].sort((a, b) => a - b);
97
105
  const value: ParsedAnswer = {
98
106
  selections: question.kind === "single" ? [ordered[0]!] : ordered,
@@ -104,6 +112,11 @@ export function parseNativeAnswer(question: NormalizedQuestion, raw: string): Pa
104
112
 
105
113
  /** Render the per-question input instruction used by the native route. */
106
114
  export function nativeInputHint(question: NormalizedQuestion): string {
115
+ if (question.allowFreeText === false && (question.kind === "single" || question.kind === "multi")) {
116
+ return question.kind === "single"
117
+ ? "Single select. Reply with one option number only (e.g. 2)."
118
+ : "Multi select. Reply with option numbers only, separated by commas (e.g. 1,3).";
119
+ }
107
120
  switch (question.kind) {
108
121
  case "single":
109
122
  return question.options.length > 0
@@ -115,5 +128,9 @@ export function nativeInputHint(question: NormalizedQuestion): string {
115
128
  : "Free input. Reply with text to answer.";
116
129
  case "input":
117
130
  return "Free input. Reply with text to answer.";
131
+ case "bool":
132
+ return "Boolean. Reply with true or false.";
133
+ case "simple-form":
134
+ return "Form fields are asked one at a time.";
118
135
  }
119
136
  }
package/src/schema.ts CHANGED
@@ -1,8 +1,16 @@
1
1
  import { Type } from "typebox";
2
- import type { AskUserOption, AskUserQuestion, NormalizedQuestion, NormalizedRequest, QuestionKind } from "./types.ts";
2
+ import type {
3
+ AskUserOption,
4
+ FormFieldKind,
5
+ NormalizedFormField,
6
+ NormalizedQuestion,
7
+ NormalizedRequest,
8
+ QuestionKind,
9
+ } from "./types.ts";
3
10
 
4
11
  export const MAX_QUESTIONS = 5;
5
12
  export const MAX_OPTIONS = 5;
13
+ export const MAX_FORM_FIELDS = 10;
6
14
  export const DEFAULT_TIMEOUT_PER_QUESTION_MS = 60_000;
7
15
 
8
16
  export const LIMITS = {
@@ -13,18 +21,20 @@ export const LIMITS = {
13
21
  preview: 4000,
14
22
  defaultValue: 500,
15
23
  header: 200,
24
+ fieldName: 64,
16
25
  } as const;
17
26
 
18
27
  const ID_MAX_LENGTH = 64;
19
- const QUESTION_KINDS = ["single", "multi", "input"] as const;
20
- const QUESTION_FIELDS = ["id", "title", "prompt", "kind", "options", "default"] as const;
28
+ const QUESTION_KINDS = ["single", "multi", "input", "bool", "simple-form"] as const;
29
+ const FIELD_KINDS = ["single", "multi", "input", "bool"] as const;
30
+ const QUESTION_FIELDS = ["id", "title", "prompt", "kind", "options", "default", "fields"] as const;
31
+ const FORM_FIELD_FIELDS = ["name", "label", "kind", "required", "options", "default"] as const;
21
32
  const OPTION_FIELDS = ["label", "description", "preview"] as const;
22
- const REQUEST_FIELDS = ["questions", "header", "displayMode", "timeoutPerQuestionMs"] as const;
33
+ const REQUEST_FIELDS = ["questions", "header", "displayMode", "timeoutPerQuestionMs", "allowFreeText"] as const;
34
+ const SAFE_FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_-]*$/;
35
+ const UNSAFE_OBJECT_KEYS = new Set(["__proto__", "constructor", "prototype"]);
23
36
 
24
- /**
25
- * Flat string enum. Emitted as `{ type: "string", enum: [...] }` because some
26
- * provider proxies reject `anyOf`/`union` shapes.
27
- */
37
+ /** Flat string enum: provider proxies commonly reject unions used only as enums. */
28
38
  function StringEnum<T extends readonly string[]>(values: T, options: Record<string, unknown> = {}) {
29
39
  return Type.Unsafe<T[number]>({ type: "string", enum: [...values], ...options });
30
40
  }
@@ -33,7 +43,25 @@ const OptionSchema = Type.Object({
33
43
  label: Type.String({ minLength: 1, maxLength: LIMITS.label }),
34
44
  description: Type.Optional(Type.String({ maxLength: LIMITS.description })),
35
45
  preview: Type.Optional(Type.String({ maxLength: LIMITS.preview })),
36
- });
46
+ }, { additionalProperties: false });
47
+
48
+ // Field defaults use three JSON types across the four field kinds. Keep that honest in
49
+ // the provider schema; kind-specific membership and type rules are enforced by
50
+ // the same direct normalizer used by tool execution.
51
+ const FieldDefaultSchema = Type.Union([
52
+ Type.Boolean(),
53
+ Type.String({ maxLength: LIMITS.defaultValue }),
54
+ Type.Array(Type.String({ minLength: 1, maxLength: LIMITS.label }), { maxItems: MAX_OPTIONS }),
55
+ ]);
56
+
57
+ const FormFieldSchema = Type.Object({
58
+ name: Type.String({ minLength: 1, maxLength: LIMITS.fieldName, pattern: SAFE_FIELD_NAME.source }),
59
+ label: Type.String({ minLength: 1, maxLength: LIMITS.label }),
60
+ kind: StringEnum(FIELD_KINDS),
61
+ required: Type.Optional(Type.Boolean()),
62
+ options: Type.Optional(Type.Array(OptionSchema, { maxItems: MAX_OPTIONS })),
63
+ default: Type.Optional(FieldDefaultSchema),
64
+ }, { additionalProperties: false });
37
65
 
38
66
  const QuestionSchema = Type.Object({
39
67
  id: Type.Optional(Type.String({ minLength: 1, maxLength: ID_MAX_LENGTH })),
@@ -41,27 +69,21 @@ const QuestionSchema = Type.Object({
41
69
  prompt: Type.Optional(Type.String({ maxLength: LIMITS.prompt })),
42
70
  kind: Type.Optional(StringEnum(QUESTION_KINDS)),
43
71
  options: Type.Optional(Type.Array(OptionSchema, { maxItems: MAX_OPTIONS })),
44
- default: Type.Optional(Type.String({ maxLength: LIMITS.defaultValue })),
45
- });
46
-
47
- /**
48
- * Parameter schema registered for the `ask_user` tool, and the single input
49
- * contract for direct `askUser` calls too. Only these fields and types are read;
50
- * anything else is rejected as `invalid_request` rather than coerced.
51
- *
52
- * The UI route is host configuration, resolved by the host adapter — it is never
53
- * part of a request.
54
- */
72
+ default: Type.Optional(Type.Union([Type.String({ maxLength: LIMITS.defaultValue }), Type.Boolean()])),
73
+ fields: Type.Optional(Type.Array(FormFieldSchema, { minItems: 1, maxItems: MAX_FORM_FIELDS })),
74
+ }, { additionalProperties: false });
75
+
76
+ /** Strict tool/direct-call parameter contract. */
55
77
  export const AskUserParams = Type.Object({
56
78
  questions: Type.Array(QuestionSchema, { minItems: 1, maxItems: MAX_QUESTIONS }),
57
79
  header: Type.Optional(Type.String({ maxLength: LIMITS.header })),
80
+ allowFreeText: Type.Optional(Type.Boolean({ description: "Allow supplemental/free-text answers on single/multi questions (default true). False hides the extra text input and requires option selections; native single/bool use selectors (including form fields), while native multi/text keep input boxes." })),
58
81
  displayMode: Type.Optional(StringEnum(["overlay", "inline"] as const)),
59
82
  timeoutPerQuestionMs: Type.Optional(Type.Number({ minimum: 0 })),
60
- });
83
+ }, { additionalProperties: false });
61
84
 
62
85
  export class AskUserValidationError extends Error {
63
86
  readonly code = "invalid_request";
64
-
65
87
  constructor(message: string) {
66
88
  super(message);
67
89
  this.name = "AskUserValidationError";
@@ -72,14 +94,12 @@ function isRecord(value: unknown): value is Record<string, unknown> {
72
94
  return typeof value === "object" && value !== null && !Array.isArray(value);
73
95
  }
74
96
 
75
- /** Reject any field that is not part of the declared contract. */
76
97
  function assertKnownKeys(record: Record<string, unknown>, allowed: readonly string[], where: string): void {
77
98
  for (const key of Object.keys(record)) {
78
99
  if (!allowed.includes(key)) throw new AskUserValidationError(`${where} has an unknown field "${key}".`);
79
100
  }
80
101
  }
81
102
 
82
- /** A required, non-empty, length-bounded string; returns the trimmed value. */
83
103
  function requireText(value: unknown, where: string, maxLength: number): string {
84
104
  if (typeof value !== "string") throw new AskUserValidationError(`${where} must be a string.`);
85
105
  const text = value.trim();
@@ -88,7 +108,6 @@ function requireText(value: unknown, where: string, maxLength: number): string {
88
108
  return text;
89
109
  }
90
110
 
91
- /** An optional, length-bounded string; absent stays absent. */
92
111
  function optionalText(value: unknown, where: string, maxLength: number): string | undefined {
93
112
  if (value === undefined) return undefined;
94
113
  if (typeof value !== "string") throw new AskUserValidationError(`${where} must be a string.`);
@@ -105,7 +124,6 @@ function optionalQuestionId(value: unknown, where: string): string | undefined {
105
124
  return id;
106
125
  }
107
126
 
108
- /** A canonical option object: `{ label, description?, preview? }`. */
109
127
  function readOption(value: unknown, where: string, position: number): AskUserOption {
110
128
  const at = `${where} option ${position + 1}`;
111
129
  if (!isRecord(value)) throw new AskUserValidationError(`${at} must be an object with a "label".`);
@@ -119,137 +137,230 @@ function readOption(value: unknown, where: string, position: number): AskUserOpt
119
137
  return option;
120
138
  }
121
139
 
122
- function readQuestion(value: unknown, index: number): { question: AskUserQuestion; warnings: string[] } {
140
+ function readOptions(value: unknown, where: string, warnings: string[]): AskUserOption[] {
141
+ if (value !== undefined && !Array.isArray(value)) throw new AskUserValidationError(`${where} "options" must be an array.`);
142
+ const raw = value ?? [];
143
+ if (raw.length > MAX_OPTIONS) throw new AskUserValidationError(`${where} has more than ${MAX_OPTIONS} options.`);
144
+ const options: AskUserOption[] = [];
145
+ const seen = new Set<string>();
146
+ raw.forEach((entry: unknown, position: number) => {
147
+ const option = readOption(entry, where, position);
148
+ if (seen.has(option.label)) {
149
+ warnings.push(`${where} has a duplicate option "${option.label}"; de-duplicated.`);
150
+ return;
151
+ }
152
+ seen.add(option.label);
153
+ options.push(option);
154
+ });
155
+ return options;
156
+ }
157
+
158
+ function readFieldName(value: unknown, where: string): string {
159
+ if (typeof value !== "string") throw new AskUserValidationError(`${where} name must be a string.`);
160
+ if (value.length === 0) throw new AskUserValidationError(`${where} name must not be empty.`);
161
+ if (value.length > LIMITS.fieldName) throw new AskUserValidationError(`${where} name exceeds the ${LIMITS.fieldName}-character limit.`);
162
+ if (!SAFE_FIELD_NAME.test(value)) {
163
+ throw new AskUserValidationError(`${where} name must be a literal key using letters, digits, "_", or "-", and may not contain a path.`);
164
+ }
165
+ if (UNSAFE_OBJECT_KEYS.has(value)) throw new AskUserValidationError(`${where} name "${value}" is reserved and unsafe.`);
166
+ return value;
167
+ }
168
+
169
+ function readFormField(value: unknown, questionWhere: string, position: number, warnings: string[]): NormalizedFormField {
170
+ const where = `${questionWhere} field ${position + 1}`;
171
+ if (!isRecord(value)) throw new AskUserValidationError(`${where} must be an object.`);
172
+ assertKnownKeys(value, FORM_FIELD_FIELDS, where);
173
+ const name = readFieldName(value.name, where);
174
+ const label = requireText(value.label, `${where} label`, LIMITS.label);
175
+ if (typeof value.kind !== "string" || !FIELD_KINDS.includes(value.kind as FormFieldKind)) {
176
+ throw new AskUserValidationError(`${where} "kind" must be "bool", "single", "multi", or "input"; forms cannot nest.`);
177
+ }
178
+ const kind = value.kind as FormFieldKind;
179
+ if (value.required !== undefined && typeof value.required !== "boolean") {
180
+ throw new AskUserValidationError(`${where} "required" must be a boolean.`);
181
+ }
182
+ const required = value.required === true;
183
+ if ((kind === "bool" || kind === "input") && value.options !== undefined) {
184
+ throw new AskUserValidationError(`${where} is a "${kind}" field and must not have "options".`);
185
+ }
186
+ const options = readOptions(value.options, where, warnings);
187
+ if ((kind === "single" || kind === "multi") && options.length === 0) {
188
+ throw new AskUserValidationError(`${where} is a "${kind}" field but has no options.`);
189
+ }
190
+
191
+ const field: NormalizedFormField = { name, label, kind, required, options, hasDefault: value.default !== undefined };
192
+ if (value.default === undefined) return field;
193
+ if (kind === "bool") {
194
+ if (typeof value.default !== "boolean") throw new AskUserValidationError(`${where} bool default must be a boolean.`);
195
+ field.default = value.default;
196
+ return field;
197
+ }
198
+ if (kind === "input") {
199
+ if (typeof value.default !== "string") throw new AskUserValidationError(`${where} input default must be a string.`);
200
+ if (value.default.length > LIMITS.defaultValue) throw new AskUserValidationError(`${where} default exceeds the ${LIMITS.defaultValue}-character limit.`);
201
+ if (required && value.default.trim() === "") throw new AskUserValidationError(`${where} required input default must not be empty.`);
202
+ field.default = value.default;
203
+ return field;
204
+ }
205
+ if (kind === "single") {
206
+ if (typeof value.default !== "string") throw new AskUserValidationError(`${where} single default must be an option-label string.`);
207
+ if (!options.some((option) => option.label === value.default)) throw new AskUserValidationError(`${where} default must match one of its option labels.`);
208
+ field.default = value.default;
209
+ return field;
210
+ }
211
+ if (!Array.isArray(value.default)) throw new AskUserValidationError(`${where} multi default must be an array of option-label strings.`);
212
+ if (value.default.length > MAX_OPTIONS) throw new AskUserValidationError(`${where} multi default has more than ${MAX_OPTIONS} labels.`);
213
+ if (required && value.default.length === 0) throw new AskUserValidationError(`${where} required multi default must select at least one label.`);
214
+ const selected: string[] = [];
215
+ for (const [defaultIndex, entry] of value.default.entries()) {
216
+ const labelValue = requireText(entry, `${where} default item ${defaultIndex + 1}`, LIMITS.label);
217
+ if (!options.some((option) => option.label === labelValue)) throw new AskUserValidationError(`${where} default item "${labelValue}" is not an option label.`);
218
+ if (selected.includes(labelValue)) throw new AskUserValidationError(`${where} multi default contains duplicate label "${labelValue}".`);
219
+ selected.push(labelValue);
220
+ }
221
+ field.default = selected;
222
+ return field;
223
+ }
224
+
225
+ interface ParsedQuestion {
226
+ id?: string;
227
+ title: string;
228
+ prompt?: string;
229
+ kind: QuestionKind;
230
+ options: AskUserOption[];
231
+ default?: string | boolean;
232
+ hasDefault: boolean;
233
+ fields: NormalizedFormField[];
234
+ }
235
+
236
+ function readQuestion(value: unknown, index: number): { question: ParsedQuestion; warnings: string[] } {
123
237
  const warnings: string[] = [];
124
238
  const where = `Question ${index + 1}`;
125
239
  if (!isRecord(value)) throw new AskUserValidationError(`${where} must be an object.`);
126
240
  assertKnownKeys(value, QUESTION_FIELDS, where);
127
-
128
241
  const title = requireText(value.title, `${where} title`, LIMITS.title);
129
242
 
130
243
  let kind: QuestionKind | undefined;
131
244
  if (value.kind !== undefined) {
132
245
  if (typeof value.kind !== "string" || !QUESTION_KINDS.includes(value.kind as QuestionKind)) {
133
- throw new AskUserValidationError(`${where} "kind" must be "single", "multi", or "input".`);
246
+ throw new AskUserValidationError(`${where} "kind" must be "single", "multi", "input", "bool", or "simple-form".`);
134
247
  }
135
248
  kind = value.kind as QuestionKind;
136
249
  }
137
-
138
- const rawOptions = value.options;
139
- if (rawOptions !== undefined && !Array.isArray(rawOptions)) {
140
- throw new AskUserValidationError(`${where} "options" must be an array.`);
250
+ if ((kind === "bool" || kind === "simple-form") && value.options !== undefined) {
251
+ throw new AskUserValidationError(`${where} is a "${kind}" question and must not have "options".`);
141
252
  }
142
- const rawOptionList = rawOptions ?? [];
143
- if (rawOptionList.length > MAX_OPTIONS) {
144
- throw new AskUserValidationError(`${where} has more than ${MAX_OPTIONS} options.`);
145
- }
146
-
147
- const options: AskUserOption[] = [];
148
- const seen = new Set<string>();
149
- rawOptionList.forEach((rawOption, position) => {
150
- const option = readOption(rawOption, where, position);
151
- if (seen.has(option.label)) {
152
- warnings.push(`${where} has a duplicate option "${option.label}"; de-duplicated.`);
153
- return;
154
- }
155
- seen.add(option.label);
156
- options.push(option);
157
- });
158
-
159
- // When `kind` is omitted it is inferred from `options`: present ⇒ `single`,
160
- // absent ⇒ `input`. `multi` is only ever explicit.
253
+ const options = readOptions(value.options, where, warnings);
161
254
  if (kind === undefined) kind = options.length > 0 ? "single" : "input";
162
- if (kind !== "input" && options.length === 0) {
163
- throw new AskUserValidationError(`${where} is a "${kind}" question but has no options.`);
255
+ if ((kind === "single" || kind === "multi") && options.length === 0) throw new AskUserValidationError(`${where} is a "${kind}" question but has no options.`);
256
+ if (kind !== "simple-form" && value.fields !== undefined) throw new AskUserValidationError(`${where} is a "${kind}" question and must not have "fields".`);
257
+
258
+ const fields: NormalizedFormField[] = [];
259
+ if (kind === "simple-form") {
260
+ if (value.default !== undefined) throw new AskUserValidationError(`${where} simple-form uses field defaults and must not have a top-level "default".`);
261
+ if (!Array.isArray(value.fields)) throw new AskUserValidationError(`${where} simple-form must have a "fields" array.`);
262
+ if (value.fields.length === 0) throw new AskUserValidationError(`${where} simple-form must have at least 1 field.`);
263
+ if (value.fields.length > MAX_FORM_FIELDS) throw new AskUserValidationError(`${where} has more than ${MAX_FORM_FIELDS} fields.`);
264
+ const names = new Set<string>();
265
+ value.fields.forEach((entry, fieldIndex) => {
266
+ const field = readFormField(entry, where, fieldIndex, warnings);
267
+ if (names.has(field.name)) throw new AskUserValidationError(`${where} has duplicate field name "${field.name}".`);
268
+ names.add(field.name);
269
+ fields.push(field);
270
+ });
271
+ } else if (kind === "bool") {
272
+ if (value.default !== undefined && typeof value.default !== "boolean") throw new AskUserValidationError(`${where} bool default must be a boolean.`);
273
+ } else if (value.default !== undefined && typeof value.default !== "string") {
274
+ throw new AskUserValidationError(`${where} default must be a string.`);
164
275
  }
165
276
 
166
- const question: AskUserQuestion = { title, kind };
277
+ const question: ParsedQuestion = {
278
+ title,
279
+ kind,
280
+ options: kind === "input" ? [] : options,
281
+ hasDefault: value.default !== undefined,
282
+ fields,
283
+ };
167
284
  const prompt = optionalText(value.prompt, `${where} prompt`, LIMITS.prompt);
168
285
  if (prompt !== undefined) question.prompt = prompt;
169
- if (kind === "input") {
170
- delete question.options;
171
- } else {
172
- question.options = options;
173
- }
174
286
  const id = optionalQuestionId(value.id, where);
175
287
  if (id) question.id = id;
176
- const fallback = optionalText(value.default, `${where} default`, LIMITS.defaultValue);
177
- if (fallback !== undefined) question.default = fallback;
288
+ if (value.default !== undefined) {
289
+ if (typeof value.default === "string" && value.default.length > LIMITS.defaultValue) throw new AskUserValidationError(`${where} default exceeds the ${LIMITS.defaultValue}-character limit.`);
290
+ question.default = value.default as string | boolean;
291
+ }
178
292
  return { question, warnings };
179
293
  }
180
294
 
181
295
  function readDisplayMode(value: unknown): "overlay" | "inline" {
182
296
  if (value === undefined) return "overlay";
183
- if (value !== "overlay" && value !== "inline") {
184
- throw new AskUserValidationError('displayMode must be "overlay" or "inline".');
185
- }
297
+ if (value !== "overlay" && value !== "inline") throw new AskUserValidationError('displayMode must be "overlay" or "inline".');
186
298
  return value;
187
299
  }
188
300
 
189
301
  function readTimeoutPerQuestionMs(value: unknown): number {
190
302
  if (value === undefined) return DEFAULT_TIMEOUT_PER_QUESTION_MS;
191
- if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
192
- throw new AskUserValidationError("timeoutPerQuestionMs must be a non-negative finite number.");
193
- }
303
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new AskUserValidationError("timeoutPerQuestionMs must be a non-negative finite number.");
194
304
  return Math.floor(value);
195
305
  }
196
306
 
197
- /** Normalize and validate a request against the {@link AskUserParams} contract. */
307
+ /** Normalize and validate a request against the exact tool/direct-call contract. */
198
308
  export function normalizeAskUserRequest(raw: unknown): { request: NormalizedRequest; warnings: string[] } {
199
309
  if (!isRecord(raw)) throw new AskUserValidationError("Missing the questions array.");
200
- for (const key of Object.keys(raw)) {
201
- if (!REQUEST_FIELDS.includes(key as (typeof REQUEST_FIELDS)[number])) {
202
- throw new AskUserValidationError(`Unknown field "${key}".`);
203
- }
204
- }
310
+ assertKnownKeys(raw, REQUEST_FIELDS, "Request");
205
311
  const rawQuestions = raw.questions;
206
312
  if (!Array.isArray(rawQuestions)) throw new AskUserValidationError("Missing the questions array.");
207
313
  if (rawQuestions.length === 0) throw new AskUserValidationError("At least 1 question is required.");
208
- if (rawQuestions.length > MAX_QUESTIONS) {
209
- throw new AskUserValidationError(`At most ${MAX_QUESTIONS} questions; received ${rawQuestions.length}.`);
210
- }
314
+ if (rawQuestions.length > MAX_QUESTIONS) throw new AskUserValidationError(`At most ${MAX_QUESTIONS} questions; received ${rawQuestions.length}.`);
211
315
 
316
+ if (raw.allowFreeText !== undefined && typeof raw.allowFreeText !== "boolean") {
317
+ throw new AskUserValidationError("allowFreeText must be a boolean.");
318
+ }
319
+ const allowFreeText = raw.allowFreeText !== false;
212
320
  const warnings: string[] = [];
213
321
  const usedIds = new Set<string>();
214
322
  const questions: NormalizedQuestion[] = [];
215
323
  rawQuestions.forEach((rawQuestion, index) => {
216
- const { question, warnings: questionWarnings } = readQuestion(rawQuestion, index);
217
- warnings.push(...questionWarnings);
218
-
324
+ const parsed = readQuestion(rawQuestion, index);
325
+ warnings.push(...parsed.warnings);
326
+ const question = parsed.question;
219
327
  let id = question.id ?? "";
220
328
  if (id === "" || usedIds.has(id)) {
221
329
  let candidate = `q${index + 1}`;
222
330
  let suffix = 1;
223
- while (usedIds.has(candidate)) {
224
- candidate = `q${index + 1}_${suffix++}`;
225
- }
331
+ while (usedIds.has(candidate)) candidate = `q${index + 1}_${suffix++}`;
226
332
  id = candidate;
227
333
  }
228
334
  usedIds.add(id);
229
-
230
335
  const normalized: NormalizedQuestion = {
231
336
  id,
232
337
  index,
233
338
  title: question.title,
234
339
  kind: question.kind,
235
- options: question.options ?? [],
236
- hasDefault: question.default !== undefined,
340
+ options: question.options,
341
+ hasDefault: question.hasDefault,
342
+ fields: question.fields,
237
343
  };
238
344
  if (question.prompt !== undefined) normalized.prompt = question.prompt;
239
345
  if (question.default !== undefined) normalized.default = question.default;
346
+ if (question.kind === "single" || question.kind === "multi") {
347
+ normalized.allowFreeText = allowFreeText;
348
+ if (!allowFreeText && question.hasDefault && !question.options.some((option) => option.label === String(question.default).trim())) {
349
+ throw new AskUserValidationError(`Question ${index + 1} default must match an option label when allowFreeText is false.`);
350
+ }
351
+ }
240
352
  questions.push(normalized);
241
353
  });
242
354
 
243
- const header = optionalText(raw.header, "header", LIMITS.header);
244
- const displayMode = readDisplayMode(raw.displayMode);
245
355
  const timeoutPerQuestionMs = readTimeoutPerQuestionMs(raw.timeoutPerQuestionMs);
246
-
247
356
  const request: NormalizedRequest = {
248
357
  questions,
249
- displayMode,
358
+ allowFreeText,
359
+ displayMode: readDisplayMode(raw.displayMode),
250
360
  timeoutPerQuestionMs,
251
361
  totalTimeoutMs: timeoutPerQuestionMs * questions.length,
252
362
  };
363
+ const header = optionalText(raw.header, "header", LIMITS.header);
253
364
  if (header !== undefined) request.header = header;
254
365
  return { request, warnings };
255
366
  }