@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/README.md +160 -40
- package/package.json +2 -2
- package/src/adapters/pi.ts +4 -1
- package/src/answers.ts +118 -33
- package/src/config.ts +14 -10
- package/src/core.ts +49 -12
- package/src/deadline.ts +22 -6
- package/src/index.ts +17 -2
- package/src/parse.ts +7 -0
- package/src/schema.ts +188 -89
- package/src/types.ts +107 -15
- package/src/ui/custom.ts +423 -18
- package/src/ui/native.ts +162 -59
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 {
|
|
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
|
-
|
|
18
|
-
case "
|
|
19
|
-
|
|
20
|
-
case "
|
|
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
|
|
33
|
-
lines.push(`[Question ${question.index + 1}/${
|
|
34
|
-
if (question.prompt
|
|
35
|
-
|
|
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 (
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
lines.push(
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
}
|