@black942026/pi-ask-user 0.1.2 → 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 +50 -11
- package/package.json +1 -1
- package/src/adapters/pi.ts +2 -1
- package/src/answers.ts +1 -0
- package/src/index.ts +2 -1
- package/src/parse.ts +10 -0
- package/src/schema.ts +13 -1
- package/src/types.ts +11 -1
- package/src/ui/custom.ts +60 -65
- package/src/ui/native.ts +78 -9
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @black942026/pi-ask-user
|
|
2
2
|
|
|
3
3
|
A Pi extension for asking structured questions through interactive terminal
|
|
4
|
-
or native
|
|
4
|
+
or native dialogs. Supports single-choice, multiple-choice, free-text,
|
|
5
5
|
boolean, and flat typed form answers, with configurable timeouts and cancellation.
|
|
6
6
|
|
|
7
7
|
Install the package to add the **`ask_user`** tool to Pi. Other extensions can
|
|
@@ -18,7 +18,7 @@ is created:
|
|
|
18
18
|
| Route | Runs when | The user sees |
|
|
19
19
|
| --- | --- | --- |
|
|
20
20
|
| `custom` | the host is a real Pi TUI with a bound renderer | a tabbed terminal dialog: options, boolean controls, forms, text input, and Markdown previews |
|
|
21
|
-
| `native` | `ctx.hasUI` with a callable `ctx.ui.input` (TUI, RPC/ACP) | native dialogs, one logical question at a time; form fields are sequential dialogs |
|
|
21
|
+
| `native` | `ctx.hasUI` with a callable `ctx.ui.input` (TUI, RPC/ACP) | native dialogs, one logical question at a time; with `allowFreeText: false`, single/bool use `ctx.ui.select`; multi/text keep input boxes; form fields are sequential dialogs |
|
|
22
22
|
|
|
23
23
|
Only these two routes exist, and the route belongs to the host: it is configured
|
|
24
24
|
or probed when the host is created, never per request.
|
|
@@ -57,6 +57,7 @@ The registered tool name is exactly **`ask_user`**.
|
|
|
57
57
|
| `fields[].default` | `(boolean \| string \| string[])?` | boolean, selected label, selected-label array, or text according to field kind |
|
|
58
58
|
| `questions[].id` | `string?` | non-empty; generated as `q1`, `q2`, … when omitted |
|
|
59
59
|
| `header` | `string?` | heading above the questionnaire |
|
|
60
|
+
| `allowFreeText` | `boolean?` | default `true`; `false` removes supplemental/free-text answers on single/multi questions; native single/bool (including form fields) use selectors while multi/text keep input boxes |
|
|
60
61
|
| `displayMode` | `"overlay" \| "inline"?` | custom route only; default `overlay`; a user-configured preference overrides it |
|
|
61
62
|
| `timeoutPerQuestionMs` | `number?` | base timeout per question; default `60000`; a user-configured preference overrides it |
|
|
62
63
|
|
|
@@ -67,6 +68,37 @@ error. An explicit legacy `kind: "input"` keeps its existing behavior: supplied
|
|
|
67
68
|
options are validated, then dropped. Duplicate option labels are de-duplicated
|
|
68
69
|
and reported in `warnings`.
|
|
69
70
|
|
|
71
|
+
### Disable supplemental text
|
|
72
|
+
|
|
73
|
+
Set top-level `allowFreeText: false` to hide the custom UI's bottom extra input
|
|
74
|
+
on single/multi questions and require option selections. Regular `input`
|
|
75
|
+
questions and form text fields remain editable. Choice defaults must match an
|
|
76
|
+
option label when supplemental text is disabled.
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"allowFreeText": false,
|
|
81
|
+
"questions": [
|
|
82
|
+
{ "title": "Environment", "kind": "single", "options": [{ "label": "dev" }, { "label": "prod" }] }
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
On the native route with this flag:
|
|
88
|
+
|
|
89
|
+
- Single-choice questions/fields use `ctx.ui.select`, returning option labels.
|
|
90
|
+
- Boolean questions/fields use a True/False selector. We deliberately avoid
|
|
91
|
+
`ctx.ui.confirm`: Pi returns `false` both for a negative answer and dismissal,
|
|
92
|
+
so a selector is necessary to preserve cancellation semantics.
|
|
93
|
+
- Multi-choice questions/fields **still use a text box**, accepting numbers such
|
|
94
|
+
as `1,3`; standalone multi questions reject prose and `| note`.
|
|
95
|
+
- Text questions/fields keep their text boxes.
|
|
96
|
+
- Selectors provide explicit default/skip actions where applicable. A host
|
|
97
|
+
without `ui.select` refuses requests that need it rather than falling back
|
|
98
|
+
to text input. The shared deadline and abort signal apply to every dialog.
|
|
99
|
+
|
|
100
|
+
Omitting the flag (or setting `true`) preserves the native input-box behavior.
|
|
101
|
+
|
|
70
102
|
Forms are flat. Field names cannot contain dots, brackets, spaces, or path-like
|
|
71
103
|
syntax, and `__proto__`, `constructor`, and `prototype` are rejected. Names must
|
|
72
104
|
be unique. Defaults are checked against the field kind and, for choice fields,
|
|
@@ -338,16 +370,20 @@ hidden, restore it first with its show/hide key before pressing `Alt+Q`.
|
|
|
338
370
|
|
|
339
371
|
Legacy `single`/`multi`/`input` questions retain their controls: `Tab` and
|
|
340
372
|
`Shift+Tab` switch question/review tabs, arrows move within choices, `Space`
|
|
341
|
-
selects, and `Enter` confirms.
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
373
|
+
selects, and `Enter` confirms. With `allowFreeText: false`, multi-choice uses `Space` to
|
|
374
|
+
toggle selections and `Enter` to continue (there is no extra text row).
|
|
375
|
+
A standalone `bool` shows **one** control: `[✓] True` or `[✗] False`.
|
|
376
|
+
`Space` toggles, up/left sets true, down/right sets false, and `Enter` confirms
|
|
377
|
+
and continues. An unset boolean shows `[ ] True (unset)`; Enter explicitly
|
|
378
|
+
accepts true. Defaults are displayed immediately, and confirming a false
|
|
379
|
+
default keeps it false. `false` is an answered value, never an empty answer.
|
|
345
380
|
|
|
346
381
|
A `simple-form` is one question tab with all fields in one scrollable view:
|
|
347
382
|
|
|
348
383
|
- `Tab` / `Shift+Tab` cycles fields and the Continue/Submit button.
|
|
349
384
|
- `Ctrl+Left` / `Ctrl+Right` switches questionnaire tabs from any question.
|
|
350
|
-
- Boolean fields use
|
|
385
|
+
- Boolean fields use the same single toggle control (`Space` toggles, arrows set,
|
|
386
|
+
`Enter` confirms and moves on); single-choice fields use
|
|
351
387
|
arrows plus `Space`/`Enter`; multi-choice fields use `Space` to toggle and
|
|
352
388
|
`Enter` to move on; text fields use `Enter` to move on.
|
|
353
389
|
- `Ctrl+Enter` validates and submits the questionnaire. Required missing/empty
|
|
@@ -448,7 +484,7 @@ for a refused explicit config, names the requested route.
|
|
|
448
484
|
correlation id, route, and status.
|
|
449
485
|
- **The overlay toggle is overlay-only.** Inline and native register no raw
|
|
450
486
|
input listener; hiding never pauses the deadline.
|
|
451
|
-
- **The native route
|
|
487
|
+
- **The native route defaults to single-line `input`**, one logical question at a time (not
|
|
452
488
|
`editor`, which accepts no timeout/signal). The number and an optional note
|
|
453
489
|
share one box: `2`, `2 | note`, or free text; a number list may be
|
|
454
490
|
comma/space/`,`/`、` separated (`1,3`). Out-of-range or malformed numbers
|
|
@@ -456,11 +492,14 @@ for a refused explicit config, names the requested route.
|
|
|
456
492
|
A form opens sequential per-field dialogs under the same questionnaire
|
|
457
493
|
deadline: boolean fields accept `true`/`false`, form choices accept only
|
|
458
494
|
option numbers and return labels, and optional empty fields are omitted (or
|
|
459
|
-
use their field default).
|
|
495
|
+
use their field default). With `allowFreeText: false`, single/bool use native
|
|
496
|
+
selectors (including form fields); multi stays a numbers-only text box, and
|
|
497
|
+
text fields stay editable. Cancel/abort/timeout at any field terminates the form.
|
|
460
498
|
- **Tool calls run sequentially** (`executionMode: "sequential"`).
|
|
461
|
-
- **Short terminals degrade in a defined order:** the pinned legacy free-input row and
|
|
499
|
+
- **Short terminals degrade in a defined order:** when enabled, the pinned legacy free-input row and
|
|
462
500
|
the key hints survive first, down to `MIN_USABLE_ROWS` (3); 0 rows renders
|
|
463
|
-
nothing.
|
|
501
|
+
nothing. Bool, forms, and choices without supplemental text reserve a visible
|
|
502
|
+
row for the focused control and auto-scroll it.
|
|
464
503
|
`render(width)` never exceeds the requested width or `terminal.rows`.
|
|
465
504
|
|
|
466
505
|
## Development
|
package/package.json
CHANGED
package/src/adapters/pi.ts
CHANGED
|
@@ -59,7 +59,8 @@ export interface PiHostOptions {
|
|
|
59
59
|
*
|
|
60
60
|
* - `customUI` — only in `tui` mode with a callable `ctx.ui.custom()`.
|
|
61
61
|
* - `nativeDialogs` — only when `ctx.hasUI` and `ctx.ui.input` is callable,
|
|
62
|
-
* i.e. native
|
|
62
|
+
* i.e. native dialogs shown one question at a time. Requests disabling extra
|
|
63
|
+
* text additionally require `ui.select` for single/bool controls. RPC reports
|
|
63
64
|
* `hasUI: true`, so a nonresponsive RPC client is *not* pre-judged: the
|
|
64
65
|
* attempt is made and the shared deadline turns silence into an actionable
|
|
65
66
|
* `timeout`.
|
package/src/answers.ts
CHANGED
|
@@ -162,6 +162,7 @@ export function validateAnswer(question: NormalizedQuestion, candidate: unknown)
|
|
|
162
162
|
if (answer.selections.length > 0) return "input answers cannot contain selections";
|
|
163
163
|
return (answer.freeText ?? "").trim() === "" ? "input answer is empty" : undefined;
|
|
164
164
|
}
|
|
165
|
+
if (question.allowFreeText === false && answer.freeText !== undefined) return "freeText is disabled for this question";
|
|
165
166
|
if (new Set(answer.selections).size !== answer.selections.length) return "selections contain duplicate labels";
|
|
166
167
|
if (!answer.selections.every((label) => question.options.some((option) => option.label === label))) return "selection is not an option label";
|
|
167
168
|
if (question.kind === "single" && answer.selections.length > 1) return "single answer contains multiple selections";
|
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,
|
|
@@ -109,6 +109,7 @@ const PROMPT_GUIDELINES = [
|
|
|
109
109
|
"If the tool reports the UI could not run, ask the user the question yourself in ordinary text instead.",
|
|
110
110
|
"Never invent an answer; on aborted or timeout, report it plainly and do not re-ask.",
|
|
111
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.",
|
|
112
113
|
];
|
|
113
114
|
|
|
114
115
|
/**
|
package/src/parse.ts
CHANGED
|
@@ -66,12 +66,14 @@ export function parseNativeAnswer(question: NormalizedQuestion, raw: string): Pa
|
|
|
66
66
|
|
|
67
67
|
// No number in the box: the (optional) right side is the free input.
|
|
68
68
|
if (leftTrim === "") {
|
|
69
|
+
if (question.allowFreeText === false) return { ok: false, error: "Free text is disabled; enter option numbers only." };
|
|
69
70
|
return { ok: true, value: { selections: [], freeText: comment, empty: comment === "" } };
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
const isStrictList = DIGIT_SEPARATOR_ONLY.test(leftTrim);
|
|
73
74
|
const isListAttempt = isStrictList || NUMBER_LIST_ATTEMPT.test(leftTrim);
|
|
74
75
|
if (!isListAttempt) {
|
|
76
|
+
if (question.allowFreeText === false) return { ok: false, error: "Free text is disabled; enter option numbers only." };
|
|
75
77
|
// Free text, taken verbatim including any `|`.
|
|
76
78
|
return { ok: true, value: { selections: [], freeText: trimmed, empty: false } };
|
|
77
79
|
}
|
|
@@ -96,6 +98,9 @@ export function parseNativeAnswer(question: NormalizedQuestion, raw: string): Pa
|
|
|
96
98
|
return { ok: false, error: "This is a single-select question; enter only one number." };
|
|
97
99
|
}
|
|
98
100
|
|
|
101
|
+
if (question.allowFreeText === false && pipeIndex >= 0) {
|
|
102
|
+
return { ok: false, error: "Notes are disabled; enter option numbers only." };
|
|
103
|
+
}
|
|
99
104
|
const ordered = question.kind === "single" ? unique : [...unique].sort((a, b) => a - b);
|
|
100
105
|
const value: ParsedAnswer = {
|
|
101
106
|
selections: question.kind === "single" ? [ordered[0]!] : ordered,
|
|
@@ -107,6 +112,11 @@ export function parseNativeAnswer(question: NormalizedQuestion, raw: string): Pa
|
|
|
107
112
|
|
|
108
113
|
/** Render the per-question input instruction used by the native route. */
|
|
109
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
|
+
}
|
|
110
120
|
switch (question.kind) {
|
|
111
121
|
case "single":
|
|
112
122
|
return question.options.length > 0
|
package/src/schema.ts
CHANGED
|
@@ -30,7 +30,7 @@ const FIELD_KINDS = ["single", "multi", "input", "bool"] as const;
|
|
|
30
30
|
const QUESTION_FIELDS = ["id", "title", "prompt", "kind", "options", "default", "fields"] as const;
|
|
31
31
|
const FORM_FIELD_FIELDS = ["name", "label", "kind", "required", "options", "default"] as const;
|
|
32
32
|
const OPTION_FIELDS = ["label", "description", "preview"] as const;
|
|
33
|
-
const REQUEST_FIELDS = ["questions", "header", "displayMode", "timeoutPerQuestionMs"] as const;
|
|
33
|
+
const REQUEST_FIELDS = ["questions", "header", "displayMode", "timeoutPerQuestionMs", "allowFreeText"] as const;
|
|
34
34
|
const SAFE_FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
35
35
|
const UNSAFE_OBJECT_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
36
36
|
|
|
@@ -77,6 +77,7 @@ const QuestionSchema = Type.Object({
|
|
|
77
77
|
export const AskUserParams = Type.Object({
|
|
78
78
|
questions: Type.Array(QuestionSchema, { minItems: 1, maxItems: MAX_QUESTIONS }),
|
|
79
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." })),
|
|
80
81
|
displayMode: Type.Optional(StringEnum(["overlay", "inline"] as const)),
|
|
81
82
|
timeoutPerQuestionMs: Type.Optional(Type.Number({ minimum: 0 })),
|
|
82
83
|
}, { additionalProperties: false });
|
|
@@ -312,6 +313,10 @@ export function normalizeAskUserRequest(raw: unknown): { request: NormalizedRequ
|
|
|
312
313
|
if (rawQuestions.length === 0) throw new AskUserValidationError("At least 1 question is required.");
|
|
313
314
|
if (rawQuestions.length > MAX_QUESTIONS) throw new AskUserValidationError(`At most ${MAX_QUESTIONS} questions; received ${rawQuestions.length}.`);
|
|
314
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;
|
|
315
320
|
const warnings: string[] = [];
|
|
316
321
|
const usedIds = new Set<string>();
|
|
317
322
|
const questions: NormalizedQuestion[] = [];
|
|
@@ -338,12 +343,19 @@ export function normalizeAskUserRequest(raw: unknown): { request: NormalizedRequ
|
|
|
338
343
|
};
|
|
339
344
|
if (question.prompt !== undefined) normalized.prompt = question.prompt;
|
|
340
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
|
+
}
|
|
341
352
|
questions.push(normalized);
|
|
342
353
|
});
|
|
343
354
|
|
|
344
355
|
const timeoutPerQuestionMs = readTimeoutPerQuestionMs(raw.timeoutPerQuestionMs);
|
|
345
356
|
const request: NormalizedRequest = {
|
|
346
357
|
questions,
|
|
358
|
+
allowFreeText,
|
|
347
359
|
displayMode: readDisplayMode(raw.displayMode),
|
|
348
360
|
timeoutPerQuestionMs,
|
|
349
361
|
totalTimeoutMs: timeoutPerQuestionMs * questions.length,
|
package/src/types.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* answered through one of two routes:
|
|
6
6
|
*
|
|
7
7
|
* - `custom` — a custom terminal UI (only a real Pi TUI)
|
|
8
|
-
* - `native` — native
|
|
8
|
+
* - `native` — native dialogs (`ctx.hasUI` + callable input), one logical
|
|
9
9
|
* question at a time (a form uses sequential per-field dialogs)
|
|
10
10
|
*
|
|
11
11
|
* The route is resolved **once, when the host adapter is created**, from
|
|
@@ -141,6 +141,12 @@ export interface AskUserRequest {
|
|
|
141
141
|
questions: AskUserQuestion[];
|
|
142
142
|
/** Optional heading shown above the questionnaire. */
|
|
143
143
|
header?: string;
|
|
144
|
+
/**
|
|
145
|
+
* Allow supplemental/free-text answers on single/multi questions. Defaults to true.
|
|
146
|
+
* False also uses native selectors for single/bool (including form fields);
|
|
147
|
+
* native multi/text continue using input boxes.
|
|
148
|
+
*/
|
|
149
|
+
allowFreeText?: boolean;
|
|
144
150
|
/** Custom-UI presentation. Defaults to `overlay`. */
|
|
145
151
|
displayMode?: AskUserDisplayMode;
|
|
146
152
|
/**
|
|
@@ -403,6 +409,8 @@ export interface NormalizedQuestion {
|
|
|
403
409
|
prompt?: string;
|
|
404
410
|
kind: QuestionKind;
|
|
405
411
|
options: AskUserOption[];
|
|
412
|
+
/** False disables free text for single/multi; absent preserves legacy behavior. */
|
|
413
|
+
allowFreeText?: boolean;
|
|
406
414
|
default?: string | boolean;
|
|
407
415
|
hasDefault: boolean;
|
|
408
416
|
/** Present for `simple-form`; omitted by older already-normalized callers. */
|
|
@@ -421,6 +429,8 @@ export interface NormalizedFormField {
|
|
|
421
429
|
|
|
422
430
|
export interface NormalizedRequest {
|
|
423
431
|
header?: string;
|
|
432
|
+
/** Absent in older normalized callers means true. */
|
|
433
|
+
allowFreeText?: boolean;
|
|
424
434
|
questions: NormalizedQuestion[];
|
|
425
435
|
displayMode: AskUserDisplayMode;
|
|
426
436
|
timeoutPerQuestionMs: number;
|
package/src/ui/custom.ts
CHANGED
|
@@ -190,7 +190,9 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
190
190
|
this.mdTheme = buildMarkdownTheme(options.theme);
|
|
191
191
|
this.overlayToggleKey =
|
|
192
192
|
options.request.displayMode === "overlay" ? options.overlayToggleKey : undefined;
|
|
193
|
-
this.drafts = options.request.questions.map(() =>
|
|
193
|
+
this.drafts = options.request.questions.map((question) =>
|
|
194
|
+
question.kind === "bool" ? draftFromDefault(question) ?? emptyDraft() : emptyDraft(),
|
|
195
|
+
);
|
|
194
196
|
this.cursors = options.request.questions.map(() => 0);
|
|
195
197
|
this.inputs = options.request.questions.map(
|
|
196
198
|
(question) => new Input({
|
|
@@ -226,7 +228,6 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
226
228
|
selections: [], freeText: "", value: { ...values }, valueSet: true, usedDefault: usedDefaults.size > 0,
|
|
227
229
|
};
|
|
228
230
|
const optionCursors = fields.map((field) => {
|
|
229
|
-
if (field.kind === "bool" && field.default === false) return 1;
|
|
230
231
|
if (field.kind === "single" && typeof field.default === "string") {
|
|
231
232
|
return Math.max(0, field.options.findIndex((option) => option.label === field.default));
|
|
232
233
|
}
|
|
@@ -322,10 +323,15 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
322
323
|
return this.request.questions[this.tab]!;
|
|
323
324
|
}
|
|
324
325
|
|
|
326
|
+
private hasInputBar(question: NormalizedQuestion): boolean {
|
|
327
|
+
return question.kind === "input" ||
|
|
328
|
+
((question.kind === "single" || question.kind === "multi") && question.allowFreeText !== false && this.request.allowFreeText !== false);
|
|
329
|
+
}
|
|
330
|
+
|
|
325
331
|
private rowCount(question: NormalizedQuestion): number {
|
|
326
|
-
if (question.kind === "bool") return
|
|
332
|
+
if (question.kind === "bool") return 1;
|
|
327
333
|
if (question.kind === "simple-form") return (question.fields?.length ?? 0) + 1;
|
|
328
|
-
return question.options.length + 1;
|
|
334
|
+
return question.options.length + (this.hasInputBar(question) ? 1 : 0);
|
|
329
335
|
}
|
|
330
336
|
|
|
331
337
|
private rowIndex(): number {
|
|
@@ -336,7 +342,7 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
336
342
|
|
|
337
343
|
private isInputRow(): boolean {
|
|
338
344
|
if (this.isReview) return false;
|
|
339
|
-
if (this.
|
|
345
|
+
if (!this.hasInputBar(this.currentQuestion())) return false;
|
|
340
346
|
return this.rowIndex() >= this.currentQuestion().options.length;
|
|
341
347
|
}
|
|
342
348
|
|
|
@@ -374,7 +380,7 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
374
380
|
private syncDraftFromInput(index: number): void {
|
|
375
381
|
const draft = this.drafts[index];
|
|
376
382
|
const input = this.inputs[index];
|
|
377
|
-
if (!draft || !input) return;
|
|
383
|
+
if (!draft || !input || !this.hasInputBar(this.request.questions[index]!)) return;
|
|
378
384
|
draft.freeText = input.getValue();
|
|
379
385
|
}
|
|
380
386
|
|
|
@@ -540,18 +546,14 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
540
546
|
return;
|
|
541
547
|
}
|
|
542
548
|
if (field.kind === "bool") {
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
this.setFormValue(field, cursor === 0, fieldIndex);
|
|
549
|
-
this.advanceFormField();
|
|
550
|
-
return;
|
|
551
|
-
} else return;
|
|
552
|
-
form.optionCursors[fieldIndex] = cursor;
|
|
549
|
+
const current = form.present.has(field.name) ? form.values[field.name] : undefined;
|
|
550
|
+
const next = this.boolValueForKey(data, typeof current === "boolean" ? current : undefined);
|
|
551
|
+
if (next === undefined) return;
|
|
552
|
+
// Confirming an existing default preserves its provenance.
|
|
553
|
+
if (!matchesKey(data, Key.enter) || current === undefined) this.setFormValue(field, next, fieldIndex);
|
|
553
554
|
this.syncFormDraft(this.tab);
|
|
554
|
-
this.
|
|
555
|
+
if (matchesKey(data, Key.enter)) this.advanceFormField();
|
|
556
|
+
else this.refresh();
|
|
555
557
|
return;
|
|
556
558
|
}
|
|
557
559
|
if (matchesKey(data, Key.up) || matchesKey(data, Key.left)) cursor = clamp(cursor - 1, 0, field.options.length - 1);
|
|
@@ -575,6 +577,15 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
575
577
|
this.refresh();
|
|
576
578
|
}
|
|
577
579
|
|
|
580
|
+
/** One boolean control: arrows set, Space toggles, Enter confirms (unset -> true). */
|
|
581
|
+
private boolValueForKey(data: string, current: boolean | undefined): boolean | undefined {
|
|
582
|
+
if (matchesKey(data, Key.up) || matchesKey(data, Key.left) || matchesKey(data, "ctrl+p")) return true;
|
|
583
|
+
if (matchesKey(data, Key.down) || matchesKey(data, Key.right) || matchesKey(data, "ctrl+n")) return false;
|
|
584
|
+
if (matchesKey(data, Key.space)) return current === undefined ? true : !current;
|
|
585
|
+
if (matchesKey(data, Key.enter)) return current ?? true;
|
|
586
|
+
return undefined;
|
|
587
|
+
}
|
|
588
|
+
|
|
578
589
|
// -- key handling --------------------------------------------------------
|
|
579
590
|
|
|
580
591
|
handleInput(data: string): void {
|
|
@@ -642,28 +653,19 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
642
653
|
|
|
643
654
|
const question = this.currentQuestion();
|
|
644
655
|
if (question.kind === "bool") {
|
|
645
|
-
if (matchesKey(data, Key.up) || matchesKey(data, Key.left) || matchesKey(data, "ctrl+p")) {
|
|
646
|
-
this.cursors[this.tab] = 0;
|
|
647
|
-
this.refresh();
|
|
648
|
-
return;
|
|
649
|
-
}
|
|
650
|
-
if (matchesKey(data, Key.down) || matchesKey(data, Key.right) || matchesKey(data, "ctrl+n")) {
|
|
651
|
-
this.cursors[this.tab] = 1;
|
|
652
|
-
this.refresh();
|
|
653
|
-
return;
|
|
654
|
-
}
|
|
655
656
|
if (matchesKey(data, "s")) {
|
|
656
657
|
this.skipWithDefault();
|
|
657
658
|
return;
|
|
658
659
|
}
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
this.
|
|
664
|
-
if (matchesKey(data, Key.enter)) this.advance();
|
|
665
|
-
else this.refresh();
|
|
660
|
+
const draft = this.drafts[this.tab]!;
|
|
661
|
+
const next = this.boolValueForKey(data, draft.valueSet && typeof draft.value === "boolean" ? draft.value : undefined);
|
|
662
|
+
if (next === undefined) return;
|
|
663
|
+
if (!matchesKey(data, Key.enter) || !draft.valueSet) {
|
|
664
|
+
this.drafts[this.tab] = { selections: [], freeText: "", usedDefault: false, value: next, valueSet: true };
|
|
666
665
|
}
|
|
666
|
+
this.notice = undefined;
|
|
667
|
+
if (matchesKey(data, Key.enter)) this.advance();
|
|
668
|
+
else this.refresh();
|
|
667
669
|
return;
|
|
668
670
|
}
|
|
669
671
|
if (question.kind === "input") {
|
|
@@ -721,7 +723,7 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
721
723
|
}
|
|
722
724
|
|
|
723
725
|
const printable = printableText(data);
|
|
724
|
-
if (printable !== undefined) {
|
|
726
|
+
if (printable !== undefined && this.hasInputBar(question)) {
|
|
725
727
|
this.moveRowToInput();
|
|
726
728
|
this.forwardToInput(data);
|
|
727
729
|
this.refresh();
|
|
@@ -731,7 +733,8 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
731
733
|
private onOptionEnter(): void {
|
|
732
734
|
const question = this.currentQuestion();
|
|
733
735
|
if (question.kind === "multi") {
|
|
734
|
-
this.toggleRow();
|
|
736
|
+
if (this.hasInputBar(question)) this.toggleRow();
|
|
737
|
+
else this.commitAndAdvance();
|
|
735
738
|
return;
|
|
736
739
|
}
|
|
737
740
|
// Sync any typed note before advancing, so selecting an option never drops
|
|
@@ -782,7 +785,9 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
782
785
|
this.syncDraftFromInput(this.tab);
|
|
783
786
|
const draft = this.drafts[this.tab]!;
|
|
784
787
|
if (draftIsEmpty(draft) && !question.hasDefault) {
|
|
785
|
-
this.notice =
|
|
788
|
+
this.notice = this.hasInputBar(question)
|
|
789
|
+
? "This question is required: select an option or type text."
|
|
790
|
+
: "This question is required: select an option.";
|
|
786
791
|
this.refresh();
|
|
787
792
|
return;
|
|
788
793
|
}
|
|
@@ -944,22 +949,19 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
944
949
|
return lines;
|
|
945
950
|
}
|
|
946
951
|
|
|
952
|
+
private renderBoolControl(value: boolean | undefined): string {
|
|
953
|
+
if (value === undefined) return this.theme.fg("muted", "[ ] True (unset)");
|
|
954
|
+
return this.theme.fg(value ? "success" : "error", value ? "[✓] True" : "[✗] False");
|
|
955
|
+
}
|
|
956
|
+
|
|
947
957
|
private renderBoolOptions(width: number): { lines: string[]; focusLine: number } {
|
|
948
958
|
const draft = this.drafts[this.tab]!;
|
|
949
|
-
const
|
|
950
|
-
const
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
const lines = choices.map((choice, index) => {
|
|
956
|
-
const cursor = index === row ? this.theme.fg("accent", "▸") : " ";
|
|
957
|
-
const chosen = selected === choice.value;
|
|
958
|
-
const control = chosen ? this.theme.fg(choice.value ? "success" : "error", `[${choice.mark}]`) : this.theme.fg("muted", `[ ] ${choice.mark}`);
|
|
959
|
-
const label = index === row ? this.theme.bold(this.theme.fg("accent", choice.label)) : choice.label;
|
|
960
|
-
return truncateToWidth(` ${cursor} ${control} ${label}`, width);
|
|
961
|
-
});
|
|
962
|
-
return { lines, focusLine: row };
|
|
959
|
+
const value = draft.valueSet && typeof draft.value === "boolean" ? draft.value : undefined;
|
|
960
|
+
const defaultMark = draft.usedDefault ? this.theme.fg("muted", " (default)") : "";
|
|
961
|
+
return {
|
|
962
|
+
lines: [truncateToWidth(` ${this.theme.fg("accent", "▸")} ${this.renderBoolControl(value)}${defaultMark}`, width)],
|
|
963
|
+
focusLine: 0,
|
|
964
|
+
};
|
|
963
965
|
}
|
|
964
966
|
|
|
965
967
|
/** Options only (no input row): the input stays pinned outside the body. */
|
|
@@ -1011,15 +1013,8 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
1011
1013
|
}
|
|
1012
1014
|
if (field.kind === "bool") {
|
|
1013
1015
|
const value = form.present.has(field.name) ? form.values[field.name] : undefined;
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
if (cursor === choiceIndex) focusLine = lines.length;
|
|
1017
|
-
const at = cursor === choiceIndex ? this.theme.fg("accent", "▸") : " ";
|
|
1018
|
-
const selected = value === choice;
|
|
1019
|
-
const mark = choice ? "✓" : "✗";
|
|
1020
|
-
const box = selected ? this.theme.fg(choice ? "success" : "error", `[${mark}]`) : this.theme.fg("muted", `[ ] ${mark}`);
|
|
1021
|
-
lines.push(truncateToWidth(` ${at} ${box} ${choice ? "True" : "False"}`, width));
|
|
1022
|
-
}
|
|
1016
|
+
focusLine = lines.length;
|
|
1017
|
+
lines.push(truncateToWidth(` ${this.renderBoolControl(typeof value === "boolean" ? value : undefined)}`, width));
|
|
1023
1018
|
return;
|
|
1024
1019
|
}
|
|
1025
1020
|
|
|
@@ -1116,7 +1111,7 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
1116
1111
|
|
|
1117
1112
|
/** The free-input row, rendered as a fixed line that never scrolls away. */
|
|
1118
1113
|
private renderInputBar(width: number): string[] {
|
|
1119
|
-
if (this.
|
|
1114
|
+
if (!this.hasInputBar(this.currentQuestion())) return [];
|
|
1120
1115
|
const input = this.inputs[this.tab]!;
|
|
1121
1116
|
input.focused = this.focused && this.isInputRow();
|
|
1122
1117
|
return input
|
|
@@ -1192,18 +1187,18 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
1192
1187
|
const form = this.forms[this.tab]!;
|
|
1193
1188
|
const field = question.fields?.[form.active];
|
|
1194
1189
|
if (field?.kind === "multi") hints.push("Space toggle", "Enter next");
|
|
1195
|
-
else if (field?.kind === "bool") hints.push("
|
|
1190
|
+
else if (field?.kind === "bool") hints.push("Space toggle", "←/→ set", "Enter next");
|
|
1196
1191
|
else if (field) hints.push("Enter next");
|
|
1197
1192
|
else hints.push("Enter continue");
|
|
1198
1193
|
if (this.viewport.maxScroll > 0) hints.push("PgUp/PgDn scroll");
|
|
1199
1194
|
return withControls(hints);
|
|
1200
1195
|
}
|
|
1201
1196
|
if (question.kind === "bool") {
|
|
1202
|
-
const hints = ["
|
|
1197
|
+
const hints = ["Space toggle", "↑/↓ set", "Enter confirm", "Tab switch"];
|
|
1203
1198
|
if (question.hasDefault) hints.push("s use default");
|
|
1204
1199
|
return withControls(hints);
|
|
1205
1200
|
}
|
|
1206
|
-
const hints = ["Tab switch", "↑/↓ move", `Enter ${this.isInputRow() ? "confirm" : "select"}`];
|
|
1201
|
+
const hints = ["Tab switch", "↑/↓ move", `Enter ${this.isInputRow() || (question.kind === "multi" && !this.hasInputBar(question)) ? "confirm" : "select"}`];
|
|
1207
1202
|
if (question.kind === "multi") hints.push("Space multi-select");
|
|
1208
1203
|
if (question.hasDefault) hints.push("s use default");
|
|
1209
1204
|
if (this.viewport.maxScroll > 0) hints.push("PgUp/PgDn preview");
|
|
@@ -1259,7 +1254,7 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
1259
1254
|
const hintBlock = [this.hintLine(contentWidth)];
|
|
1260
1255
|
|
|
1261
1256
|
let remaining = contentRows;
|
|
1262
|
-
const typedQuestion = !this.isReview &&
|
|
1257
|
+
const typedQuestion = !this.isReview && !this.hasInputBar(this.currentQuestion());
|
|
1263
1258
|
const reserveBody = typedQuestion && contentRows > 0 ? 1 : 0;
|
|
1264
1259
|
const take = (block: string[]): string[] => {
|
|
1265
1260
|
if (remaining <= reserveBody || block.length === 0) return [];
|
package/src/ui/native.ts
CHANGED
|
@@ -11,12 +11,21 @@ import type {
|
|
|
11
11
|
NormalizedRequest,
|
|
12
12
|
} from "../types.ts";
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
export interface NativeDialogOptions {
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
timeout?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Native select is required for typed controls when allowFreeText is false. */
|
|
15
20
|
export interface NativeDialogUI {
|
|
16
|
-
input(title: string, placeholder?: string, opts?:
|
|
21
|
+
input(title: string, placeholder?: string, opts?: NativeDialogOptions): Promise<string | undefined>;
|
|
22
|
+
select?(title: string, options: string[], opts?: NativeDialogOptions): Promise<string | undefined>;
|
|
17
23
|
}
|
|
18
24
|
|
|
19
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
|
+
}
|
|
20
29
|
switch (question.kind) {
|
|
21
30
|
case "single": return 'e.g. 2 or "2 | note" or type free text';
|
|
22
31
|
case "multi": return 'e.g. 1,3 or "1,3 | note" or type free text';
|
|
@@ -26,7 +35,7 @@ function placeholderFor(question: NormalizedQuestion): string {
|
|
|
26
35
|
}
|
|
27
36
|
}
|
|
28
37
|
|
|
29
|
-
function buildPrompt(request: NormalizedRequest, question: NormalizedQuestion, invalid: string | undefined): string {
|
|
38
|
+
function buildPrompt(request: NormalizedRequest, question: NormalizedQuestion, invalid: string | undefined, typed = false): string {
|
|
30
39
|
const lines: string[] = [];
|
|
31
40
|
if (request.header?.trim()) lines.push(request.header.trim());
|
|
32
41
|
lines.push(`[Question ${question.index + 1}/${request.questions.length}] ${question.title}`);
|
|
@@ -35,8 +44,8 @@ function buildPrompt(request: NormalizedRequest, question: NormalizedQuestion, i
|
|
|
35
44
|
const description = option.description?.trim();
|
|
36
45
|
lines.push(` ${index + 1}. ${option.label}${description ? ` — ${description}` : ""}`);
|
|
37
46
|
});
|
|
38
|
-
if (question.hasDefault && question.default !== undefined) lines.push(`Default: ${String(question.default)} (empty box uses it)`);
|
|
39
|
-
lines.push(nativeInputHint(question));
|
|
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));
|
|
40
49
|
const body = lines.join("\n");
|
|
41
50
|
return invalid ? `⚠ Invalid input: ${invalid}\n\n${body}` : body;
|
|
42
51
|
}
|
|
@@ -56,6 +65,7 @@ function buildFieldPrompt(
|
|
|
56
65
|
field: NormalizedFormField,
|
|
57
66
|
fieldIndex: number,
|
|
58
67
|
invalid: string | undefined,
|
|
68
|
+
typed = false,
|
|
59
69
|
): string {
|
|
60
70
|
const lines: string[] = [];
|
|
61
71
|
if (request.header?.trim()) lines.push(request.header.trim());
|
|
@@ -66,7 +76,8 @@ function buildFieldPrompt(
|
|
|
66
76
|
const description = option.description?.trim();
|
|
67
77
|
lines.push(` ${index + 1}. ${option.label}${description ? ` — ${description}` : ""}`);
|
|
68
78
|
});
|
|
69
|
-
if (field.hasDefault) lines.push(`Default: ${Array.isArray(field.default) ? field.default.join(", ") : String(field.default)} (empty box uses it)`);
|
|
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");
|
|
70
81
|
if (field.kind === "bool") lines.push("Enter exactly true or false (case-insensitive).");
|
|
71
82
|
else if (field.kind === "single") lines.push("Enter exactly one option number; arbitrary text is not accepted in a form.");
|
|
72
83
|
else if (field.kind === "multi") lines.push("Enter option numbers separated by commas/spaces; arbitrary text is not accepted in a form.");
|
|
@@ -108,13 +119,21 @@ export function createNativeRunner(ui: NativeDialogUI): NativeDialogRunner {
|
|
|
108
119
|
return {
|
|
109
120
|
async run({ request, deadline, signal, onUpdate }: AskUIInput): Promise<AskUIOutcome> {
|
|
110
121
|
const answers: AskUserAnswer[] = [];
|
|
111
|
-
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 }> => {
|
|
112
131
|
if (signal?.aborted) return { outcome: { kind: "abort" } };
|
|
113
132
|
if (deadline.expired()) return { outcome: { kind: "timeout" } };
|
|
114
133
|
const opts: { signal?: AbortSignal; timeout?: number } = { timeout: deadline.remainingMs() };
|
|
115
134
|
if (signal) opts.signal = signal;
|
|
116
135
|
try {
|
|
117
|
-
const raw = await
|
|
136
|
+
const raw = await invoke(opts);
|
|
118
137
|
if (signal?.aborted) return { outcome: { kind: "abort" } };
|
|
119
138
|
if (deadline.expired()) return { outcome: { kind: "timeout" } };
|
|
120
139
|
if (raw !== undefined) return { raw };
|
|
@@ -126,6 +145,26 @@ export function createNativeRunner(ui: NativeDialogUI): NativeDialogRunner {
|
|
|
126
145
|
}
|
|
127
146
|
};
|
|
128
147
|
|
|
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
|
+
};
|
|
167
|
+
|
|
129
168
|
for (const question of request.questions) {
|
|
130
169
|
if (question.kind === "simple-form") {
|
|
131
170
|
const value: AskUserFormValue = {};
|
|
@@ -133,6 +172,14 @@ export function createNativeRunner(ui: NativeDialogUI): NativeDialogRunner {
|
|
|
133
172
|
const fields = question.fields ?? [];
|
|
134
173
|
for (let fieldIndex = 0; fieldIndex < fields.length; fieldIndex += 1) {
|
|
135
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
|
+
}
|
|
136
183
|
let invalid: string | undefined;
|
|
137
184
|
for (;;) {
|
|
138
185
|
onUpdate?.(`Waiting for the user to answer question ${question.index + 1}/${request.questions.length}, field ${fieldIndex + 1}/${fields.length}…`);
|
|
@@ -166,6 +213,26 @@ export function createNativeRunner(ui: NativeDialogUI): NativeDialogRunner {
|
|
|
166
213
|
continue;
|
|
167
214
|
}
|
|
168
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
|
+
}
|
|
235
|
+
|
|
169
236
|
let invalid: string | undefined;
|
|
170
237
|
for (;;) {
|
|
171
238
|
onUpdate?.(`Waiting for the user to answer question ${question.index + 1}/${request.questions.length}…`);
|
|
@@ -202,7 +269,9 @@ export function createNativeRunner(ui: NativeDialogUI): NativeDialogRunner {
|
|
|
202
269
|
answers.push(toAnswer(question, fallback));
|
|
203
270
|
break;
|
|
204
271
|
}
|
|
205
|
-
invalid = question.kind === "input" ? "This question is required and cannot be empty."
|
|
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.";
|
|
206
275
|
continue;
|
|
207
276
|
}
|
|
208
277
|
answers.push(toAnswer(question, { selections: parsed.value.selections, freeText: parsed.value.freeText ?? "", usedDefault: false, valueSet: false }));
|