@black942026/pi-ask-user 0.1.0 → 0.1.2

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,194 @@
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
14
  /** Minimal shape of the Pi UI context needed by the native route. */
6
15
  export interface NativeDialogUI {
7
- input(
8
- title: string,
9
- placeholder?: string,
10
- opts?: { signal?: AbortSignal; timeout?: number },
11
- ): Promise<string | undefined>;
16
+ input(title: string, placeholder?: string, opts?: { signal?: AbortSignal; timeout?: number }): Promise<string | undefined>;
12
17
  }
13
18
 
14
19
  function placeholderFor(question: NormalizedQuestion): string {
15
20
  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";
21
+ case "single": return 'e.g. 2 or "2 | note" or type free text';
22
+ case "multi": return 'e.g. 1,3 or "1,3 | note" or type free text';
23
+ case "input": return "type free text";
24
+ case "bool": return "true or false";
25
+ case "simple-form": return "complete each field";
22
26
  }
23
27
  }
24
28
 
25
- function buildPrompt(
29
+ function buildPrompt(request: NormalizedRequest, question: NormalizedQuestion, invalid: string | undefined): string {
30
+ const lines: string[] = [];
31
+ if (request.header?.trim()) lines.push(request.header.trim());
32
+ lines.push(`[Question ${question.index + 1}/${request.questions.length}] ${question.title}`);
33
+ if (question.prompt?.trim()) lines.push(question.prompt.trim());
34
+ question.options.forEach((option, index) => {
35
+ const description = option.description?.trim();
36
+ lines.push(` ${index + 1}. ${option.label}${description ? ` — ${description}` : ""}`);
37
+ });
38
+ if (question.hasDefault && question.default !== undefined) lines.push(`Default: ${String(question.default)} (empty box uses it)`);
39
+ lines.push(nativeInputHint(question));
40
+ const body = lines.join("\n");
41
+ return invalid ? `⚠ Invalid input: ${invalid}\n\n${body}` : body;
42
+ }
43
+
44
+ function fieldPlaceholder(field: NormalizedFormField): string {
45
+ switch (field.kind) {
46
+ case "bool": return "true or false";
47
+ case "single": return "one option number";
48
+ case "multi": return "option numbers, e.g. 1,3";
49
+ case "input": return field.required ? "type a value" : "empty skips this optional field";
50
+ }
51
+ }
52
+
53
+ function buildFieldPrompt(
26
54
  request: NormalizedRequest,
27
55
  question: NormalizedQuestion,
56
+ field: NormalizedFormField,
57
+ fieldIndex: number,
28
58
  invalid: string | undefined,
29
59
  ): string {
30
- const total = request.questions.length;
31
60
  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) => {
61
+ if (request.header?.trim()) lines.push(request.header.trim());
62
+ lines.push(`[Question ${question.index + 1}/${request.questions.length} · Field ${fieldIndex + 1}/${question.fields?.length ?? 0}] ${question.title}`);
63
+ if (question.prompt?.trim()) lines.push(question.prompt.trim());
64
+ lines.push(`${field.label}${field.required ? " (required)" : " (optional)"}`);
65
+ field.options.forEach((option, index) => {
36
66
  const description = option.description?.trim();
37
67
  lines.push(` ${index + 1}. ${option.label}${description ? ` — ${description}` : ""}`);
38
68
  });
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));
69
+ if (field.hasDefault) lines.push(`Default: ${Array.isArray(field.default) ? field.default.join(", ") : String(field.default)} (empty box uses it)`);
70
+ if (field.kind === "bool") lines.push("Enter exactly true or false (case-insensitive).");
71
+ else if (field.kind === "single") lines.push("Enter exactly one option number; arbitrary text is not accepted in a form.");
72
+ else if (field.kind === "multi") lines.push("Enter option numbers separated by commas/spaces; arbitrary text is not accepted in a form.");
73
+ else lines.push(field.required ? "Enter text." : "Enter text, or leave empty to omit this field.");
43
74
  const body = lines.join("\n");
44
- if (invalid) return `⚠ Invalid input: ${invalid}\n\n${body}`;
45
- return body;
75
+ return invalid ? `⚠ Invalid input: ${invalid}\n\n${body}` : body;
76
+ }
77
+
78
+ type TypedParse = { ok: true; value: boolean | string | string[] } | { ok: false; error: string };
79
+
80
+ function parseFormField(field: NormalizedFormField, raw: string): TypedParse {
81
+ const text = raw.trim();
82
+ if (field.kind === "input") return { ok: true, value: text };
83
+ if (field.kind === "bool") {
84
+ if (text.toLowerCase() === "true") return { ok: true, value: true };
85
+ if (text.toLowerCase() === "false") return { ok: true, value: false };
86
+ return { ok: false, error: "Enter exactly true or false." };
87
+ }
88
+ if (!/^[\d\s,\uFF0C\u3001]+$/.test(text)) return { ok: false, error: "Enter option numbers only." };
89
+ const tokens = text.split(/[,\uFF0C\u3001\s]+/).filter(Boolean);
90
+ const indices = tokens.map((token) => Number.parseInt(token, 10) - 1);
91
+ if (indices.some((index) => !Number.isInteger(index) || index < 0 || index >= field.options.length)) {
92
+ return { ok: false, error: `Number out of range: enter ${field.kind === "single" ? "one number" : "numbers"} between 1 and ${field.options.length}.` };
93
+ }
94
+ const unique = [...new Set(indices)];
95
+ if (field.kind === "single") {
96
+ if (unique.length !== 1) return { ok: false, error: "This field accepts exactly one option number." };
97
+ return { ok: true, value: field.options[unique[0]!]!.label };
98
+ }
99
+ return { ok: true, value: unique.sort((a, b) => a - b).map((index) => field.options[index]!.label) };
46
100
  }
47
101
 
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
- */
102
+ function cloneDefault(value: boolean | string | string[]): boolean | string | string[] {
103
+ return Array.isArray(value) ? [...value] : value;
104
+ }
105
+
106
+ /** Sequential native dialogs with one shared questionnaire deadline. */
54
107
  export function createNativeRunner(ui: NativeDialogUI): NativeDialogRunner {
55
108
  return {
56
109
  async run({ request, deadline, signal, onUpdate }: AskUIInput): Promise<AskUIOutcome> {
57
110
  const answers: AskUserAnswer[] = [];
58
- const total = request.questions.length;
111
+ const ask = async (title: string, placeholder: string): Promise<{ raw?: string; outcome?: AskUIOutcome }> => {
112
+ if (signal?.aborted) return { outcome: { kind: "abort" } };
113
+ if (deadline.expired()) return { outcome: { kind: "timeout" } };
114
+ const opts: { signal?: AbortSignal; timeout?: number } = { timeout: deadline.remainingMs() };
115
+ if (signal) opts.signal = signal;
116
+ try {
117
+ const raw = await ui.input(title, placeholder, opts);
118
+ if (signal?.aborted) return { outcome: { kind: "abort" } };
119
+ if (deadline.expired()) return { outcome: { kind: "timeout" } };
120
+ if (raw !== undefined) return { raw };
121
+ return { outcome: { kind: "cancelled" } };
122
+ } catch (error) {
123
+ if (signal?.aborted) return { outcome: { kind: "abort" } };
124
+ if (deadline.expired()) return { outcome: { kind: "timeout" } };
125
+ return { outcome: { kind: "error", message: error instanceof Error ? error.message : String(error) } };
126
+ }
127
+ };
59
128
 
60
129
  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;
68
-
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) };
130
+ if (question.kind === "simple-form") {
131
+ const value: AskUserFormValue = {};
132
+ let usedDefault = false;
133
+ const fields = question.fields ?? [];
134
+ for (let fieldIndex = 0; fieldIndex < fields.length; fieldIndex += 1) {
135
+ const field = fields[fieldIndex]!;
136
+ let invalid: string | undefined;
137
+ for (;;) {
138
+ onUpdate?.(`Waiting for the user to answer question ${question.index + 1}/${request.questions.length}, field ${fieldIndex + 1}/${fields.length}…`);
139
+ const response = await ask(buildFieldPrompt(request, question, field, fieldIndex, invalid), fieldPlaceholder(field));
140
+ if (response.outcome) return response.outcome;
141
+ const raw = response.raw!;
142
+ if (raw.trim() === "") {
143
+ if (field.hasDefault && field.default !== undefined) {
144
+ value[field.name] = cloneDefault(field.default);
145
+ usedDefault = true;
146
+ break;
147
+ }
148
+ if (!field.required) break;
149
+ invalid = "This field is required and cannot be skipped.";
150
+ continue;
151
+ }
152
+ const parsed = parseFormField(field, raw);
153
+ if (!parsed.ok) {
154
+ invalid = parsed.error;
155
+ continue;
156
+ }
157
+ if (field.required && ((typeof parsed.value === "string" && parsed.value.trim() === "") || (Array.isArray(parsed.value) && parsed.value.length === 0))) {
158
+ invalid = "This field is required and cannot be empty.";
159
+ continue;
160
+ }
161
+ value[field.name] = parsed.value;
162
+ break;
163
+ }
74
164
  }
165
+ answers.push({ index: question.index, id: question.id, title: question.title, kind: question.kind, selections: [], usedDefault, value });
166
+ continue;
167
+ }
75
168
 
76
- if (raw === undefined) {
77
- if (deadline.expired()) return { kind: "timeout" };
78
- if (signal?.aborted) return { kind: "abort" };
79
- return { kind: "cancelled" };
169
+ let invalid: string | undefined;
170
+ for (;;) {
171
+ onUpdate?.(`Waiting for the user to answer question ${question.index + 1}/${request.questions.length}…`);
172
+ const response = await ask(buildPrompt(request, question, invalid), placeholderFor(question));
173
+ if (response.outcome) return response.outcome;
174
+ const raw = response.raw!;
175
+ if (question.kind === "bool") {
176
+ if (raw.trim() === "") {
177
+ const fallback = draftFromDefault(question);
178
+ if (fallback) {
179
+ answers.push(toAnswer(question, fallback));
180
+ break;
181
+ }
182
+ invalid = "This boolean is required; enter true or false.";
183
+ continue;
184
+ }
185
+ const lower = raw.trim().toLowerCase();
186
+ if (lower !== "true" && lower !== "false") {
187
+ invalid = "Enter exactly true or false.";
188
+ continue;
189
+ }
190
+ answers.push(toAnswer(question, { selections: [], freeText: "", usedDefault: false, value: lower === "true", valueSet: true }));
191
+ break;
80
192
  }
81
193
 
82
194
  const parsed = parseNativeAnswer(question, raw);
@@ -90,19 +202,10 @@ export function createNativeRunner(ui: NativeDialogUI): NativeDialogRunner {
90
202
  answers.push(toAnswer(question, fallback));
91
203
  break;
92
204
  }
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.";
205
+ invalid = question.kind === "input" ? "This question is required and cannot be empty." : "This question is required; enter an option number or free text.";
97
206
  continue;
98
207
  }
99
- answers.push(
100
- toAnswer(question, {
101
- selections: parsed.value.selections,
102
- freeText: parsed.value.freeText ?? "",
103
- usedDefault: false,
104
- }),
105
- );
208
+ answers.push(toAnswer(question, { selections: parsed.value.selections, freeText: parsed.value.freeText ?? "", usedDefault: false, valueSet: false }));
106
209
  break;
107
210
  }
108
211
  }