@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/README.md +160 -25
- package/package.json +1 -1
- package/src/adapters/pi.ts +5 -2
- package/src/answers.ts +119 -33
- package/src/config.ts +11 -9
- package/src/core.ts +23 -6
- package/src/index.ts +19 -3
- package/src/parse.ts +17 -0
- package/src/schema.ts +201 -90
- package/src/types.ts +113 -13
- package/src/ui/custom.ts +395 -16
- package/src/ui/native.ts +232 -60
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 {
|
|
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
|
+
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
|
-
|
|
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
|
-
|
|
18
|
-
case "
|
|
19
|
-
|
|
20
|
-
case "
|
|
21
|
-
|
|
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
|
|
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
|
|
33
|
-
lines.push(`[Question ${question.index + 1}/${
|
|
34
|
-
if (question.prompt
|
|
35
|
-
|
|
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 (
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
lines.push(
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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.
|
|
95
|
-
|
|
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
|
}
|