@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/ui/native.ts CHANGED
@@ -1,82 +1,261 @@
1
1
  import { draftFromDefault, toAnswer } from "../answers.ts";
2
2
  import { nativeInputHint, parseNativeAnswer } from "../parse.ts";
3
- import type { AskUIInput, AskUIOutcome, AskUserAnswer, NativeDialogRunner, NormalizedQuestion, NormalizedRequest } from "../types.ts";
3
+ import type {
4
+ AskUIInput,
5
+ AskUIOutcome,
6
+ AskUserAnswer,
7
+ AskUserFormValue,
8
+ NativeDialogRunner,
9
+ NormalizedFormField,
10
+ NormalizedQuestion,
11
+ NormalizedRequest,
12
+ } from "../types.ts";
4
13
 
5
- /** Minimal shape of the Pi UI context needed by the native route. */
14
+ export interface NativeDialogOptions {
15
+ signal?: AbortSignal;
16
+ timeout?: number;
17
+ }
18
+
19
+ /** Native select is required for typed controls when allowFreeText is false. */
6
20
  export interface NativeDialogUI {
7
- input(
8
- title: string,
9
- placeholder?: string,
10
- opts?: { signal?: AbortSignal; timeout?: number },
11
- ): Promise<string | undefined>;
21
+ input(title: string, placeholder?: string, opts?: NativeDialogOptions): Promise<string | undefined>;
22
+ select?(title: string, options: string[], opts?: NativeDialogOptions): Promise<string | undefined>;
12
23
  }
13
24
 
14
25
  function placeholderFor(question: NormalizedQuestion): string {
26
+ if (question.allowFreeText === false && (question.kind === "single" || question.kind === "multi")) {
27
+ return question.kind === "single" ? "one option number, e.g. 2" : "option numbers, e.g. 1,3";
28
+ }
15
29
  switch (question.kind) {
16
- case "single":
17
- return question.options.length > 0 ? 'e.g. 2 or "2 | note" or type free text' : "type free text";
18
- case "multi":
19
- return question.options.length > 0 ? 'e.g. 1,3 or "1,3 | note" or type free text' : "type free text";
20
- case "input":
21
- return "type free text";
30
+ case "single": return 'e.g. 2 or "2 | note" or type free text';
31
+ case "multi": return 'e.g. 1,3 or "1,3 | note" or type free text';
32
+ case "input": return "type free text";
33
+ case "bool": return "true or false";
34
+ case "simple-form": return "complete each field";
35
+ }
36
+ }
37
+
38
+ function buildPrompt(request: NormalizedRequest, question: NormalizedQuestion, invalid: string | undefined, typed = false): string {
39
+ const lines: string[] = [];
40
+ if (request.header?.trim()) lines.push(request.header.trim());
41
+ lines.push(`[Question ${question.index + 1}/${request.questions.length}] ${question.title}`);
42
+ if (question.prompt?.trim()) lines.push(question.prompt.trim());
43
+ question.options.forEach((option, index) => {
44
+ const description = option.description?.trim();
45
+ lines.push(` ${index + 1}. ${option.label}${description ? ` — ${description}` : ""}`);
46
+ });
47
+ if (question.hasDefault && question.default !== undefined) lines.push(`Default: ${String(question.default)}${typed ? "" : " (empty box uses it)"}`);
48
+ if (!typed) lines.push(nativeInputHint(question));
49
+ const body = lines.join("\n");
50
+ return invalid ? `⚠ Invalid input: ${invalid}\n\n${body}` : body;
51
+ }
52
+
53
+ function fieldPlaceholder(field: NormalizedFormField): string {
54
+ switch (field.kind) {
55
+ case "bool": return "true or false";
56
+ case "single": return "one option number";
57
+ case "multi": return "option numbers, e.g. 1,3";
58
+ case "input": return field.required ? "type a value" : "empty skips this optional field";
22
59
  }
23
60
  }
24
61
 
25
- function buildPrompt(
62
+ function buildFieldPrompt(
26
63
  request: NormalizedRequest,
27
64
  question: NormalizedQuestion,
65
+ field: NormalizedFormField,
66
+ fieldIndex: number,
28
67
  invalid: string | undefined,
68
+ typed = false,
29
69
  ): string {
30
- const total = request.questions.length;
31
70
  const lines: string[] = [];
32
- if (request.header && request.header.trim() !== "") lines.push(request.header.trim());
33
- lines.push(`[Question ${question.index + 1}/${total}] ${question.title}`);
34
- if (question.prompt && question.prompt.trim() !== "") lines.push(question.prompt.trim());
35
- question.options.forEach((option, index) => {
71
+ if (request.header?.trim()) lines.push(request.header.trim());
72
+ lines.push(`[Question ${question.index + 1}/${request.questions.length} · Field ${fieldIndex + 1}/${question.fields?.length ?? 0}] ${question.title}`);
73
+ if (question.prompt?.trim()) lines.push(question.prompt.trim());
74
+ lines.push(`${field.label}${field.required ? " (required)" : " (optional)"}`);
75
+ field.options.forEach((option, index) => {
36
76
  const description = option.description?.trim();
37
77
  lines.push(` ${index + 1}. ${option.label}${description ? ` — ${description}` : ""}`);
38
78
  });
39
- if (question.hasDefault && question.default !== undefined) {
40
- lines.push(`Default: ${question.default} (press Enter on an empty box to use it)`);
41
- }
42
- lines.push(nativeInputHint(question));
79
+ if (field.hasDefault) lines.push(`Default: ${Array.isArray(field.default) ? field.default.join(", ") : String(field.default)}${typed ? "" : " (empty box uses it)"}`);
80
+ if (typed) return lines.join("\n");
81
+ if (field.kind === "bool") lines.push("Enter exactly true or false (case-insensitive).");
82
+ else if (field.kind === "single") lines.push("Enter exactly one option number; arbitrary text is not accepted in a form.");
83
+ else if (field.kind === "multi") lines.push("Enter option numbers separated by commas/spaces; arbitrary text is not accepted in a form.");
84
+ else lines.push(field.required ? "Enter text." : "Enter text, or leave empty to omit this field.");
43
85
  const body = lines.join("\n");
44
- if (invalid) return `⚠ Invalid input: ${invalid}\n\n${body}`;
45
- return body;
86
+ return invalid ? `⚠ Invalid input: ${invalid}\n\n${body}` : body;
87
+ }
88
+
89
+ type TypedParse = { ok: true; value: boolean | string | string[] } | { ok: false; error: string };
90
+
91
+ function parseFormField(field: NormalizedFormField, raw: string): TypedParse {
92
+ const text = raw.trim();
93
+ if (field.kind === "input") return { ok: true, value: text };
94
+ if (field.kind === "bool") {
95
+ if (text.toLowerCase() === "true") return { ok: true, value: true };
96
+ if (text.toLowerCase() === "false") return { ok: true, value: false };
97
+ return { ok: false, error: "Enter exactly true or false." };
98
+ }
99
+ if (!/^[\d\s,\uFF0C\u3001]+$/.test(text)) return { ok: false, error: "Enter option numbers only." };
100
+ const tokens = text.split(/[,\uFF0C\u3001\s]+/).filter(Boolean);
101
+ const indices = tokens.map((token) => Number.parseInt(token, 10) - 1);
102
+ if (indices.some((index) => !Number.isInteger(index) || index < 0 || index >= field.options.length)) {
103
+ return { ok: false, error: `Number out of range: enter ${field.kind === "single" ? "one number" : "numbers"} between 1 and ${field.options.length}.` };
104
+ }
105
+ const unique = [...new Set(indices)];
106
+ if (field.kind === "single") {
107
+ if (unique.length !== 1) return { ok: false, error: "This field accepts exactly one option number." };
108
+ return { ok: true, value: field.options[unique[0]!]!.label };
109
+ }
110
+ return { ok: true, value: unique.sort((a, b) => a - b).map((index) => field.options[index]!.label) };
46
111
  }
47
112
 
48
- /**
49
- * The native route: exactly one Pi native input dialog per question. The option
50
- * number and any trailing free text are typed into the same box. Invalid input
51
- * re-prompts; the whole questionnaire shares one deadline and each dialog only
52
- * receives the remaining time.
53
- */
113
+ function cloneDefault(value: boolean | string | string[]): boolean | string | string[] {
114
+ return Array.isArray(value) ? [...value] : value;
115
+ }
116
+
117
+ /** Sequential native dialogs with one shared questionnaire deadline. */
54
118
  export function createNativeRunner(ui: NativeDialogUI): NativeDialogRunner {
55
119
  return {
56
120
  async run({ request, deadline, signal, onUpdate }: AskUIInput): Promise<AskUIOutcome> {
57
121
  const answers: AskUserAnswer[] = [];
58
- const total = request.questions.length;
122
+ const usesTypedControl = (question: NormalizedQuestion): boolean =>
123
+ question.kind !== "input" && question.kind !== "multi" && (request.allowFreeText === false || question.allowFreeText === false);
124
+ // Refuse before showing any dialogs; never substitute text input for a missing selector.
125
+ const needsSelect = request.questions.some((question) => usesTypedControl(question) &&
126
+ (question.kind !== "simple-form" || question.fields?.some((field) => field.kind === "bool" || field.kind === "single")));
127
+ if (needsSelect && typeof ui.select !== "function") {
128
+ return { kind: "error", message: "allowFreeText=false requires native select dialogs, but this host does not provide ui.select. No text-input fallback was used." };
129
+ }
130
+ const dialog = async (invoke: (opts: NativeDialogOptions) => Promise<string | undefined>): Promise<{ raw?: string; outcome?: AskUIOutcome }> => {
131
+ if (signal?.aborted) return { outcome: { kind: "abort" } };
132
+ if (deadline.expired()) return { outcome: { kind: "timeout" } };
133
+ const opts: { signal?: AbortSignal; timeout?: number } = { timeout: deadline.remainingMs() };
134
+ if (signal) opts.signal = signal;
135
+ try {
136
+ const raw = await invoke(opts);
137
+ if (signal?.aborted) return { outcome: { kind: "abort" } };
138
+ if (deadline.expired()) return { outcome: { kind: "timeout" } };
139
+ if (raw !== undefined) return { raw };
140
+ return { outcome: { kind: "cancelled" } };
141
+ } catch (error) {
142
+ if (signal?.aborted) return { outcome: { kind: "abort" } };
143
+ if (deadline.expired()) return { outcome: { kind: "timeout" } };
144
+ return { outcome: { kind: "error", message: error instanceof Error ? error.message : String(error) } };
145
+ }
146
+ };
59
147
 
60
- for (const question of request.questions) {
61
- let invalid: string | undefined;
62
- for (;;) {
63
- if (deadline.expired()) return { kind: "timeout" };
64
- onUpdate?.(`Waiting for the user to answer question ${question.index + 1}/${total}…`);
65
- const remaining = deadline.remainingMs();
66
- const opts: { signal?: AbortSignal; timeout?: number } = { timeout: remaining };
67
- if (signal) opts.signal = signal;
148
+ const ask = (title: string, placeholder: string) => dialog((opts) => ui.input(title, placeholder, opts));
149
+ const select = (title: string, options: string[]) => dialog((opts) => ui.select!(title, options, opts));
150
+ const typedField = async (title: string, field: NormalizedFormField): Promise<{
151
+ value?: boolean | string | string[]; usedDefault?: boolean; outcome?: AskUIOutcome;
152
+ }> => {
153
+ const options = field.kind === "bool" ? ["✓ True", "✗ False"] : field.options.map((option, index) =>
154
+ `${index + 1}. ${option.label}${option.description ? ` — ${option.description}` : ""}`);
155
+ const defaultOption = `Use default: ${String(field.default)}`;
156
+ const skip = "Skip optional field";
157
+ const response = await select(title, [
158
+ ...options, ...(field.hasDefault ? [defaultOption] : []), ...(!field.required ? [skip] : []),
159
+ ]);
160
+ if (response.outcome) return { outcome: response.outcome };
161
+ if (field.hasDefault && response.raw === defaultOption) return { value: cloneDefault(field.default!), usedDefault: true };
162
+ if (!field.required && response.raw === skip) return {};
163
+ const index = options.indexOf(response.raw!);
164
+ if (index < 0) return { outcome: { kind: "error", message: "Native select returned an unknown option." } };
165
+ return { value: field.kind === "bool" ? index === 0 : field.options[index]!.label, usedDefault: false };
166
+ };
68
167
 
69
- let raw: string | undefined;
70
- try {
71
- raw = await ui.input(buildPrompt(request, question, invalid), placeholderFor(question), opts);
72
- } catch (error) {
73
- return { kind: "error", message: error instanceof Error ? error.message : String(error) };
168
+ for (const question of request.questions) {
169
+ if (question.kind === "simple-form") {
170
+ const value: AskUserFormValue = {};
171
+ let usedDefault = false;
172
+ const fields = question.fields ?? [];
173
+ for (let fieldIndex = 0; fieldIndex < fields.length; fieldIndex += 1) {
174
+ const field = fields[fieldIndex]!;
175
+ if (usesTypedControl(question) && (field.kind === "bool" || field.kind === "single")) {
176
+ onUpdate?.(`Waiting for the user to answer question ${question.index + 1}/${request.questions.length}, field ${fieldIndex + 1}/${fields.length}…`);
177
+ const response = await typedField(buildFieldPrompt(request, question, field, fieldIndex, undefined, true), field);
178
+ if (response.outcome) return response.outcome;
179
+ if (response.value !== undefined) value[field.name] = response.value;
180
+ usedDefault ||= response.usedDefault === true;
181
+ continue;
182
+ }
183
+ let invalid: string | undefined;
184
+ for (;;) {
185
+ onUpdate?.(`Waiting for the user to answer question ${question.index + 1}/${request.questions.length}, field ${fieldIndex + 1}/${fields.length}…`);
186
+ const response = await ask(buildFieldPrompt(request, question, field, fieldIndex, invalid), fieldPlaceholder(field));
187
+ if (response.outcome) return response.outcome;
188
+ const raw = response.raw!;
189
+ if (raw.trim() === "") {
190
+ if (field.hasDefault && field.default !== undefined) {
191
+ value[field.name] = cloneDefault(field.default);
192
+ usedDefault = true;
193
+ break;
194
+ }
195
+ if (!field.required) break;
196
+ invalid = "This field is required and cannot be skipped.";
197
+ continue;
198
+ }
199
+ const parsed = parseFormField(field, raw);
200
+ if (!parsed.ok) {
201
+ invalid = parsed.error;
202
+ continue;
203
+ }
204
+ if (field.required && ((typeof parsed.value === "string" && parsed.value.trim() === "") || (Array.isArray(parsed.value) && parsed.value.length === 0))) {
205
+ invalid = "This field is required and cannot be empty.";
206
+ continue;
207
+ }
208
+ value[field.name] = parsed.value;
209
+ break;
210
+ }
74
211
  }
212
+ answers.push({ index: question.index, id: question.id, title: question.title, kind: question.kind, selections: [], usedDefault, value });
213
+ continue;
214
+ }
215
+
216
+ if (usesTypedControl(question)) {
217
+ onUpdate?.(`Waiting for the user to answer question ${question.index + 1}/${request.questions.length}…`);
218
+ const field: NormalizedFormField = {
219
+ name: question.id, label: question.title, kind: question.kind as "bool" | "single",
220
+ required: true, options: question.options, hasDefault: question.hasDefault,
221
+ default: typeof question.default === "string" ? question.default.trim() : question.default,
222
+ };
223
+ const response = await typedField(buildPrompt(request, question, undefined, true), field);
224
+ if (response.outcome) return response.outcome;
225
+ const selections = question.kind === "bool" ? [] : question.options
226
+ .map((option, index) => ({ option, index }))
227
+ .filter(({ option }) => response.value === option.label)
228
+ .map(({ index }) => index);
229
+ answers.push(toAnswer(question, {
230
+ selections, freeText: "", usedDefault: response.usedDefault === true,
231
+ ...(question.kind === "bool" ? { value: response.value as boolean, valueSet: true } : {}),
232
+ }));
233
+ continue;
234
+ }
75
235
 
76
- if (raw === undefined) {
77
- if (deadline.expired()) return { kind: "timeout" };
78
- if (signal?.aborted) return { kind: "abort" };
79
- return { kind: "cancelled" };
236
+ let invalid: string | undefined;
237
+ for (;;) {
238
+ onUpdate?.(`Waiting for the user to answer question ${question.index + 1}/${request.questions.length}…`);
239
+ const response = await ask(buildPrompt(request, question, invalid), placeholderFor(question));
240
+ if (response.outcome) return response.outcome;
241
+ const raw = response.raw!;
242
+ if (question.kind === "bool") {
243
+ if (raw.trim() === "") {
244
+ const fallback = draftFromDefault(question);
245
+ if (fallback) {
246
+ answers.push(toAnswer(question, fallback));
247
+ break;
248
+ }
249
+ invalid = "This boolean is required; enter true or false.";
250
+ continue;
251
+ }
252
+ const lower = raw.trim().toLowerCase();
253
+ if (lower !== "true" && lower !== "false") {
254
+ invalid = "Enter exactly true or false.";
255
+ continue;
256
+ }
257
+ answers.push(toAnswer(question, { selections: [], freeText: "", usedDefault: false, value: lower === "true", valueSet: true }));
258
+ break;
80
259
  }
81
260
 
82
261
  const parsed = parseNativeAnswer(question, raw);
@@ -90,19 +269,12 @@ export function createNativeRunner(ui: NativeDialogUI): NativeDialogRunner {
90
269
  answers.push(toAnswer(question, fallback));
91
270
  break;
92
271
  }
93
- invalid =
94
- question.kind === "input"
95
- ? "This question is required and cannot be empty."
96
- : "This question is required; enter an option number or free text.";
272
+ invalid = question.kind === "input" ? "This question is required and cannot be empty."
273
+ : question.allowFreeText === false ? "This question is required; enter option numbers only."
274
+ : "This question is required; enter an option number or free text.";
97
275
  continue;
98
276
  }
99
- answers.push(
100
- toAnswer(question, {
101
- selections: parsed.value.selections,
102
- freeText: parsed.value.freeText ?? "",
103
- usedDefault: false,
104
- }),
105
- );
277
+ answers.push(toAnswer(question, { selections: parsed.value.selections, freeText: parsed.value.freeText ?? "", usedDefault: false, valueSet: false }));
106
278
  break;
107
279
  }
108
280
  }