@black942026/pi-ask-user 0.1.1 → 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 +121 -25
- package/package.json +1 -1
- package/src/adapters/pi.ts +3 -1
- package/src/answers.ts +118 -33
- package/src/config.ts +11 -9
- package/src/core.ts +23 -6
- package/src/index.ts +17 -2
- package/src/parse.ts +7 -0
- package/src/schema.ts +188 -89
- package/src/types.ts +103 -13
- package/src/ui/custom.ts +393 -9
- package/src/ui/native.ts +162 -59
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @black942026/pi-ask-user
|
|
2
2
|
|
|
3
3
|
A Pi extension for asking structured questions through interactive terminal
|
|
4
|
-
or native input dialogs. Supports single-choice, multiple-choice,
|
|
5
|
-
answers, with configurable timeouts and cancellation.
|
|
4
|
+
or native input dialogs. Supports single-choice, multiple-choice, free-text,
|
|
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
|
|
8
8
|
reuse the same interaction through the TypeScript API.
|
|
@@ -17,8 +17,8 @@ is created:
|
|
|
17
17
|
|
|
18
18
|
| Route | Runs when | The user sees |
|
|
19
19
|
| --- | --- | --- |
|
|
20
|
-
| `custom` | the host is a real Pi TUI with a bound renderer | a tabbed terminal dialog: options,
|
|
21
|
-
| `native` | `ctx.hasUI` with a callable `ctx.ui.input` (TUI, RPC/ACP) | native
|
|
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 |
|
|
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.
|
|
@@ -45,9 +45,16 @@ The registered tool name is exactly **`ask_user`**.
|
|
|
45
45
|
| `questions` | `Question[]` | 1–5 questions (required) |
|
|
46
46
|
| `questions[].title` | `string` | heading (required) |
|
|
47
47
|
| `questions[].prompt` | `string?` | extra hint under the heading |
|
|
48
|
-
| `questions[].kind` | `"single" \| "multi" \| "input"?` | inferred from `options` when omitted: present ⇒ `single`, absent ⇒ `input`;
|
|
49
|
-
| `questions[].options` | `Option[]?` | 1–5 × `{ label, description?, preview? }`; required for `single`/`multi`,
|
|
50
|
-
| `questions[].default` | `string?` |
|
|
48
|
+
| `questions[].kind` | `"single" \| "multi" \| "input" \| "bool" \| "simple-form"?` | inferred from `options` when omitted: present ⇒ `single`, absent ⇒ `input`; other kinds are explicit |
|
|
49
|
+
| `questions[].options` | `Option[]?` | 1–5 × `{ label, description?, preview? }`; required for `single`/`multi`, validated then dropped for legacy `input`, rejected for `bool`/`simple-form` |
|
|
50
|
+
| `questions[].default` | `(string \| boolean)?` | string for legacy kinds, boolean for `bool`; `simple-form` uses field defaults |
|
|
51
|
+
| `questions[].fields` | `FormField[]?` | required only for `simple-form`; 1–10 flat fields, never nested |
|
|
52
|
+
| `fields[].name` | `string` | unique literal output key, 1–64 ASCII letters/digits/`_`/`-`, starting with a letter or `_` |
|
|
53
|
+
| `fields[].label` | `string` | field label (required) |
|
|
54
|
+
| `fields[].kind` | `"bool" \| "single" \| "multi" \| "input"` | exact non-nesting field kind |
|
|
55
|
+
| `fields[].required` | `boolean?` | default `false`; `false` is a valid answered boolean |
|
|
56
|
+
| `fields[].options` | `Option[]?` | required for field `single`/`multi`, rejected for field `bool`/`input` |
|
|
57
|
+
| `fields[].default` | `(boolean \| string \| string[])?` | boolean, selected label, selected-label array, or text according to field kind |
|
|
51
58
|
| `questions[].id` | `string?` | non-empty; generated as `q1`, `q2`, … when omitted |
|
|
52
59
|
| `header` | `string?` | heading above the questionnaire |
|
|
53
60
|
| `displayMode` | `"overlay" \| "inline"?` | custom route only; default `overlay`; a user-configured preference overrides it |
|
|
@@ -55,20 +62,50 @@ The registered tool name is exactly **`ask_user`**.
|
|
|
55
62
|
|
|
56
63
|
These fields are the whole input contract, for the tool and for direct calls
|
|
57
64
|
alike. Unknown fields, wrong types, empty ids, and over-long values are rejected
|
|
58
|
-
as `invalid_request`; a `single`/`multi` question without options is an
|
|
59
|
-
explicit `kind: "input"`
|
|
60
|
-
then dropped. Duplicate option labels are de-duplicated
|
|
61
|
-
`warnings`.
|
|
65
|
+
as `invalid_request`; a `single`/`multi` question or field without options is an
|
|
66
|
+
error. An explicit legacy `kind: "input"` keeps its existing behavior: supplied
|
|
67
|
+
options are validated, then dropped. Duplicate option labels are de-duplicated
|
|
68
|
+
and reported in `warnings`.
|
|
69
|
+
|
|
70
|
+
Forms are flat. Field names cannot contain dots, brackets, spaces, or path-like
|
|
71
|
+
syntax, and `__proto__`, `constructor`, and `prototype` are rejected. Names must
|
|
72
|
+
be unique. Defaults are checked against the field kind and, for choice fields,
|
|
73
|
+
against the option labels. A top-level form `options` or `default`, a nested
|
|
74
|
+
form/schema, and `options` on a boolean are errors. Limits are `MAX_QUESTIONS=5`,
|
|
75
|
+
`MAX_OPTIONS=5`, and `MAX_FORM_FIELDS=10`.
|
|
62
76
|
|
|
63
77
|
```json
|
|
64
78
|
{
|
|
79
|
+
"header": "Release",
|
|
65
80
|
"questions": [
|
|
66
81
|
{
|
|
67
|
-
"title": "
|
|
68
|
-
"kind": "
|
|
69
|
-
"
|
|
70
|
-
|
|
71
|
-
|
|
82
|
+
"title": "Proceed with deployment?",
|
|
83
|
+
"kind": "bool",
|
|
84
|
+
"default": false
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"title": "Deployment settings",
|
|
88
|
+
"kind": "simple-form",
|
|
89
|
+
"fields": [
|
|
90
|
+
{ "name": "confirmed", "label": "Confirm", "kind": "bool", "required": true },
|
|
91
|
+
{
|
|
92
|
+
"name": "target",
|
|
93
|
+
"label": "Target",
|
|
94
|
+
"kind": "single",
|
|
95
|
+
"required": true,
|
|
96
|
+
"options": [
|
|
97
|
+
{ "label": "staging", "description": "Internal smoke test" },
|
|
98
|
+
{ "label": "production", "description": "Customer-facing" }
|
|
99
|
+
]
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"name": "checks",
|
|
103
|
+
"label": "Checks",
|
|
104
|
+
"kind": "multi",
|
|
105
|
+
"options": [{ "label": "unit" }, { "label": "integration" }],
|
|
106
|
+
"default": ["unit"]
|
|
107
|
+
},
|
|
108
|
+
{ "name": "note", "label": "Release note", "kind": "input" }
|
|
72
109
|
]
|
|
73
110
|
}
|
|
74
111
|
]
|
|
@@ -77,13 +114,19 @@ then dropped. Duplicate option labels are de-duplicated and reported in
|
|
|
77
114
|
|
|
78
115
|
## User config file
|
|
79
116
|
|
|
80
|
-
There are **no environment
|
|
81
|
-
JSON file
|
|
117
|
+
There are **no AskUser-specific environment-variable preferences**. User
|
|
118
|
+
preferences live in a JSON file alongside Pi's `settings.json`:
|
|
82
119
|
|
|
83
120
|
```
|
|
84
|
-
~/.pi/ask-user
|
|
121
|
+
~/.pi/agent/ask-user.json
|
|
85
122
|
```
|
|
86
123
|
|
|
124
|
+
The directory is resolved by Pi's `getAgentDir()`. If you set Pi's
|
|
125
|
+
`PI_CODING_AGENT_DIR`, the file is `<PI_CODING_AGENT_DIR>/ask-user.json` instead.
|
|
126
|
+
The old `~/.pi/ask-user/config.json` location is no longer read; move an existing
|
|
127
|
+
file to the new location (without overwriting a config already there), then
|
|
128
|
+
restart Pi or run `/reload`.
|
|
129
|
+
|
|
87
130
|
No file — or an empty/whitespace-only file — means the built-in defaults. The
|
|
88
131
|
knobs are:
|
|
89
132
|
|
|
@@ -291,6 +334,32 @@ The key is shown in the bottom hints and reserved against `overlayToggleKey`
|
|
|
291
334
|
conflicts. Native input dialogs do not support this shortcut. If an overlay is
|
|
292
335
|
hidden, restore it first with its show/hide key before pressing `Alt+Q`.
|
|
293
336
|
|
|
337
|
+
## Questionnaire keyboard controls
|
|
338
|
+
|
|
339
|
+
Legacy `single`/`multi`/`input` questions retain their controls: `Tab` and
|
|
340
|
+
`Shift+Tab` switch question/review tabs, arrows move within choices, `Space`
|
|
341
|
+
selects, and `Enter` confirms. A standalone `bool` shows explicit `✓ True` and
|
|
342
|
+
`✗ False` controls; arrows choose, `Space` sets the value, and `Enter` sets and
|
|
343
|
+
continues. A required boolean without a default begins unset, so the user must
|
|
344
|
+
choose; `false` is an answered value.
|
|
345
|
+
|
|
346
|
+
A `simple-form` is one question tab with all fields in one scrollable view:
|
|
347
|
+
|
|
348
|
+
- `Tab` / `Shift+Tab` cycles fields and the Continue/Submit button.
|
|
349
|
+
- `Ctrl+Left` / `Ctrl+Right` switches questionnaire tabs from any question.
|
|
350
|
+
- Boolean fields use arrows plus `Space`/`Enter`; single-choice fields use
|
|
351
|
+
arrows plus `Space`/`Enter`; multi-choice fields use `Space` to toggle and
|
|
352
|
+
`Enter` to move on; text fields use `Enter` to move on.
|
|
353
|
+
- `Ctrl+Enter` validates and submits the questionnaire. Required missing/empty
|
|
354
|
+
fields focus the first invalid field. `PageUp` / `PageDown` scrolls long form
|
|
355
|
+
or preview content.
|
|
356
|
+
- `Esc`, `Alt+Q`, and overlay show/hide keep their existing behavior.
|
|
357
|
+
|
|
358
|
+
The active form field's input or selected option is automatically scrolled into view. Rendering is bounded
|
|
359
|
+
by both terminal width and rows, including narrow/short terminals. Focus is
|
|
360
|
+
propagated to Pi's `Input`, so the hardware cursor marker remains available to
|
|
361
|
+
Chinese/Japanese/Korean IMEs.
|
|
362
|
+
|
|
294
363
|
## Overlay show/hide key
|
|
295
364
|
|
|
296
365
|
On the **custom overlay route only** (never inline, never native), the overlay
|
|
@@ -330,8 +399,26 @@ interface AskUserResult {
|
|
|
330
399
|
warnings?: string[];
|
|
331
400
|
error?: { code: AskUserErrorCode; message: string };
|
|
332
401
|
}
|
|
402
|
+
|
|
403
|
+
interface AskUserAnswer {
|
|
404
|
+
index: number;
|
|
405
|
+
id: string;
|
|
406
|
+
title: string;
|
|
407
|
+
kind: "single" | "multi" | "input" | "bool" | "simple-form";
|
|
408
|
+
selections: string[]; // unchanged for legacy kinds
|
|
409
|
+
freeText?: string; // unchanged for legacy kinds
|
|
410
|
+
usedDefault: boolean;
|
|
411
|
+
value?: boolean | Record<string, boolean | string | string[]>;
|
|
412
|
+
}
|
|
333
413
|
```
|
|
334
414
|
|
|
415
|
+
`bool` answers use `value: boolean`, including meaningful `value: false`.
|
|
416
|
+
`simple-form` answers use a flat object keyed by field `name`. Choice values are
|
|
417
|
+
option labels, multi-choice values are label arrays, boolean values stay
|
|
418
|
+
booleans, and entered text is trimmed on both routes. Optional fields left
|
|
419
|
+
unset are omitted. An explicit field default is included and makes `usedDefault` true; required empty
|
|
420
|
+
text/multi fields are still invalid.
|
|
421
|
+
|
|
335
422
|
`aborted` covers both a user dismissal and a caller abort, distinguished by
|
|
336
423
|
`cancelledReason`. Errors use `empty_answer`, `invalid_request`,
|
|
337
424
|
`custom_ui_failed`, `native_ui_failed`, `invalid_config`, `unsupported_mode`,
|
|
@@ -352,23 +439,29 @@ for a refused explicit config, names the requested route.
|
|
|
352
439
|
- **A missing route is an error, not a downgrade.** JSON/print, or an explicit
|
|
353
440
|
`mode` that cannot run, is refused with `unsupported_mode` and never replaced by
|
|
354
441
|
another route.
|
|
355
|
-
- **
|
|
356
|
-
|
|
357
|
-
|
|
442
|
+
- **Preferences are configured in a file, not environment variables.**
|
|
443
|
+
`<Pi agent directory>/ask-user.json` (normally `~/.pi/agent/ask-user.json`) is
|
|
444
|
+
read once per host/session and strictly validated. Only the directory follows
|
|
445
|
+
Pi's `PI_CODING_AGENT_DIR`. An invalid config is `invalid_config`, never a fallback.
|
|
358
446
|
- **Events never leak content by default.** `herdr:blocked` bounds a real wait
|
|
359
447
|
exactly once on each side; outcome events are one per UI attempt and carry only
|
|
360
448
|
correlation id, route, and status.
|
|
361
449
|
- **The overlay toggle is overlay-only.** Inline and native register no raw
|
|
362
450
|
input listener; hiding never pauses the deadline.
|
|
363
|
-
- **The native route uses single-line `input`**, one question at a time (not
|
|
451
|
+
- **The native route uses single-line `input`**, one logical question at a time (not
|
|
364
452
|
`editor`, which accepts no timeout/signal). The number and an optional note
|
|
365
453
|
share one box: `2`, `2 | note`, or free text; a number list may be
|
|
366
454
|
comma/space/`,`/`、` separated (`1,3`). Out-of-range or malformed numbers
|
|
367
455
|
re-prompt, and an empty box is rejected unless the question has a `default`.
|
|
456
|
+
A form opens sequential per-field dialogs under the same questionnaire
|
|
457
|
+
deadline: boolean fields accept `true`/`false`, form choices accept only
|
|
458
|
+
option numbers and return labels, and optional empty fields are omitted (or
|
|
459
|
+
use their field default). Cancel/abort/timeout at any field terminates the form.
|
|
368
460
|
- **Tool calls run sequentially** (`executionMode: "sequential"`).
|
|
369
|
-
- **Short terminals degrade in a defined order:** the pinned free-input row and
|
|
461
|
+
- **Short terminals degrade in a defined order:** the pinned legacy free-input row and
|
|
370
462
|
the key hints survive first, down to `MIN_USABLE_ROWS` (3); 0 rows renders
|
|
371
|
-
nothing.
|
|
463
|
+
nothing. Forms reserve a visible row for the focused field and auto-scroll it.
|
|
464
|
+
`render(width)` never exceeds the requested width or `terminal.rows`.
|
|
372
465
|
|
|
373
466
|
## Development
|
|
374
467
|
|
|
@@ -388,3 +481,6 @@ pi install /absolute/path/to/pi-ask-user
|
|
|
388
481
|
by the custom UI and parameter schema (`src/schema.ts`). They are also declared
|
|
389
482
|
as development dependencies for local checks. The routing, parsing, and deadline
|
|
390
483
|
layers are pure, non-TUI, and free of both.
|
|
484
|
+
|
|
485
|
+
For maintainer release instructions, see
|
|
486
|
+
[CONTRIBUTING.md](https://github.com/black-94/pi-ask-user/blob/main/CONTRIBUTING.md#release).
|
package/package.json
CHANGED
package/src/adapters/pi.ts
CHANGED
|
@@ -35,7 +35,9 @@ export interface PiHostOptions {
|
|
|
35
35
|
timeoutPerQuestionMs?: number;
|
|
36
36
|
/**
|
|
37
37
|
* Path to the user config file, or `false` to skip reading it. Defaults to
|
|
38
|
-
* `~/.pi/ask-user
|
|
38
|
+
* `<Pi agent directory>/ask-user.json` (normally `~/.pi/agent/ask-user.json`).
|
|
39
|
+
* The directory follows Pi's `PI_CODING_AGENT_DIR`; there are no AskUser-specific
|
|
40
|
+
* environment-variable preferences.
|
|
39
41
|
*/
|
|
40
42
|
configFile?: string | false;
|
|
41
43
|
}
|
package/src/answers.ts
CHANGED
|
@@ -1,46 +1,42 @@
|
|
|
1
|
-
import type { AskUserAnswer, NormalizedQuestion } from "./types.ts";
|
|
1
|
+
import type { AskUserAnswer, AskUserFormValue, NormalizedFormField, NormalizedQuestion } from "./types.ts";
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* A draft answer as manipulated by a UI before being finalized. Selections are
|
|
5
|
-
* option indices; free text is kept verbatim while editing.
|
|
6
|
-
*/
|
|
3
|
+
/** A UI draft. `valueSet` makes boolean `false` distinct from unanswered. */
|
|
7
4
|
export interface DraftAnswer {
|
|
8
5
|
selections: number[];
|
|
9
6
|
freeText: string;
|
|
10
7
|
usedDefault: boolean;
|
|
8
|
+
value?: boolean | AskUserFormValue;
|
|
9
|
+
/** Required to distinguish typed false/empty forms; optional for legacy drafts. */
|
|
10
|
+
valueSet?: boolean;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export function emptyDraft(): DraftAnswer {
|
|
14
|
-
return { selections: [], freeText: "", usedDefault: false };
|
|
14
|
+
return { selections: [], freeText: "", usedDefault: false, valueSet: false };
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
/** A draft is empty when it
|
|
17
|
+
/** A draft is empty only when it contains no legacy or typed value. */
|
|
18
18
|
export function draftIsEmpty(draft: DraftAnswer): boolean {
|
|
19
|
-
return draft.selections.length === 0 && draft.freeText.trim() === "";
|
|
19
|
+
return !draft.valueSet && draft.selections.length === 0 && draft.freeText.trim() === "";
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
/**
|
|
23
|
-
* Materialize the question default as a draft, or `undefined` when the question
|
|
24
|
-
* has no default. A default that matches an option label selects that option;
|
|
25
|
-
* otherwise it is treated as free text (which is what `input` questions want).
|
|
26
|
-
*/
|
|
22
|
+
/** Materialize a standalone question default. Forms use per-field defaults. */
|
|
27
23
|
export function draftFromDefault(question: NormalizedQuestion): DraftAnswer | undefined {
|
|
28
24
|
if (!question.hasDefault || question.default === undefined) return undefined;
|
|
25
|
+
if (question.kind === "bool") {
|
|
26
|
+
return { selections: [], freeText: "", value: question.default as boolean, valueSet: true, usedDefault: true };
|
|
27
|
+
}
|
|
29
28
|
if (question.kind !== "input") {
|
|
30
|
-
const idx = question.options.findIndex((option) => option.label === question.default
|
|
31
|
-
if (idx >= 0) {
|
|
32
|
-
return { selections: [idx], freeText: "", usedDefault: true };
|
|
33
|
-
}
|
|
29
|
+
const idx = question.options.findIndex((option) => option.label === String(question.default).trim());
|
|
30
|
+
if (idx >= 0) return { selections: [idx], freeText: "", valueSet: false, usedDefault: true };
|
|
34
31
|
}
|
|
35
|
-
return { selections: [], freeText: question.default, usedDefault: true };
|
|
32
|
+
return { selections: [], freeText: String(question.default), valueSet: false, usedDefault: true };
|
|
36
33
|
}
|
|
37
34
|
|
|
38
|
-
/** Finalize a draft
|
|
35
|
+
/** Finalize a UI draft. Validation is performed separately before acceptance. */
|
|
39
36
|
export function toAnswer(question: NormalizedQuestion, draft: DraftAnswer): AskUserAnswer {
|
|
40
37
|
const selections = draft.selections
|
|
41
38
|
.filter((idx) => idx >= 0 && idx < question.options.length)
|
|
42
39
|
.map((idx) => question.options[idx]!.label);
|
|
43
|
-
const freeText = draft.freeText.trim();
|
|
44
40
|
const answer: AskUserAnswer = {
|
|
45
41
|
index: question.index,
|
|
46
42
|
id: question.id,
|
|
@@ -49,36 +45,125 @@ export function toAnswer(question: NormalizedQuestion, draft: DraftAnswer): AskU
|
|
|
49
45
|
selections,
|
|
50
46
|
usedDefault: draft.usedDefault,
|
|
51
47
|
};
|
|
48
|
+
const freeText = draft.freeText.trim();
|
|
52
49
|
if (freeText !== "") answer.freeText = freeText;
|
|
50
|
+
if (draft.valueSet && draft.value !== undefined) {
|
|
51
|
+
answer.value = typeof draft.value === "object" ? { ...draft.value } : draft.value;
|
|
52
|
+
}
|
|
53
53
|
return answer;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
export interface FinalizeResult {
|
|
57
57
|
answers: AskUserAnswer[];
|
|
58
|
-
/** Indices (0-based question indices)
|
|
58
|
+
/** Indices (0-based question indices) that fail the normalized contract. */
|
|
59
59
|
unanswered: number[];
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
/**
|
|
63
|
-
* Apply defaults to empty drafts and finalize the questionnaire. Questions that
|
|
64
|
-
* are still empty and have no default are reported in `unanswered`; they are
|
|
65
|
-
* omitted from `answers`.
|
|
66
|
-
*/
|
|
62
|
+
/** Apply defaults and finalize; required form fields and `false` are handled explicitly. */
|
|
67
63
|
export function finalizeAnswers(questions: NormalizedQuestion[], drafts: DraftAnswer[]): FinalizeResult {
|
|
68
64
|
const answers: AskUserAnswer[] = [];
|
|
69
65
|
const unanswered: number[] = [];
|
|
70
66
|
questions.forEach((question, index) => {
|
|
71
|
-
|
|
67
|
+
let draft = drafts[index] ?? emptyDraft();
|
|
72
68
|
if (draftIsEmpty(draft)) {
|
|
73
69
|
const fallback = draftFromDefault(question);
|
|
74
|
-
if (fallback)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
70
|
+
if (fallback) draft = fallback;
|
|
71
|
+
}
|
|
72
|
+
if (draftIsEmpty(draft)) {
|
|
73
|
+
unanswered.push(index);
|
|
79
74
|
return;
|
|
80
75
|
}
|
|
81
|
-
|
|
76
|
+
const answer = toAnswer(question, draft);
|
|
77
|
+
if (validateAnswer(question, answer) !== undefined) {
|
|
78
|
+
unanswered.push(index);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
answers.push(answer);
|
|
82
82
|
});
|
|
83
83
|
return { answers, unanswered };
|
|
84
84
|
}
|
|
85
|
+
|
|
86
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
87
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function hasOwn(record: Record<string, unknown>, key: string): boolean {
|
|
91
|
+
return Object.prototype.hasOwnProperty.call(record, key);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function validateFieldValue(field: NormalizedFormField, value: unknown): string | undefined {
|
|
95
|
+
switch (field.kind) {
|
|
96
|
+
case "bool":
|
|
97
|
+
return typeof value === "boolean" ? undefined : "must be a boolean";
|
|
98
|
+
case "input":
|
|
99
|
+
if (typeof value !== "string") return "must be a string";
|
|
100
|
+
if (field.required && value.trim() === "") return "must not be empty";
|
|
101
|
+
return undefined;
|
|
102
|
+
case "single":
|
|
103
|
+
if (typeof value !== "string") return "must be one option label";
|
|
104
|
+
return field.options.some((option) => option.label === value) ? undefined : "is not an option label";
|
|
105
|
+
case "multi": {
|
|
106
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) return "must be an array of option labels";
|
|
107
|
+
if (field.required && value.length === 0) return "must contain at least one option";
|
|
108
|
+
if (new Set(value).size !== value.length) return "contains duplicate labels";
|
|
109
|
+
return value.every((entry) => field.options.some((option) => option.label === entry))
|
|
110
|
+
? undefined
|
|
111
|
+
: "contains a label that is not an option";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Validate a renderer outcome against its exact normalized question. This is
|
|
118
|
+
* the core trust boundary: malformed ids/kinds/options/typed values are never
|
|
119
|
+
* silently accepted as user input.
|
|
120
|
+
*/
|
|
121
|
+
export function validateAnswer(question: NormalizedQuestion, candidate: unknown): string | undefined {
|
|
122
|
+
if (!isRecord(candidate)) return "answer must be an object";
|
|
123
|
+
const allowedAnswerKeys = ["index", "id", "title", "kind", "selections", "freeText", "usedDefault", "value"];
|
|
124
|
+
const extra = Object.keys(candidate).find((key) => !allowedAnswerKeys.includes(key));
|
|
125
|
+
if (extra) return `answer has unknown field "${extra}"`;
|
|
126
|
+
const answer = candidate as unknown as AskUserAnswer;
|
|
127
|
+
if (answer.index !== question.index || answer.id !== question.id || answer.title !== question.title || answer.kind !== question.kind) {
|
|
128
|
+
return "answer identity does not match the question";
|
|
129
|
+
}
|
|
130
|
+
if (!Array.isArray(answer.selections) || answer.selections.some((entry) => typeof entry !== "string")) return "selections must be strings";
|
|
131
|
+
if (typeof answer.usedDefault !== "boolean") return "usedDefault must be a boolean";
|
|
132
|
+
if (answer.freeText !== undefined && typeof answer.freeText !== "string") return "freeText must be a string";
|
|
133
|
+
|
|
134
|
+
if (question.kind === "bool") {
|
|
135
|
+
if (answer.selections.length > 0 || answer.freeText !== undefined) return "bool answers cannot contain selections or freeText";
|
|
136
|
+
return typeof answer.value === "boolean" ? undefined : "bool answer value must be a boolean";
|
|
137
|
+
}
|
|
138
|
+
if (question.kind === "simple-form") {
|
|
139
|
+
if (answer.selections.length > 0 || answer.freeText !== undefined) return "simple-form answers cannot contain selections or freeText";
|
|
140
|
+
if (!isRecord(answer.value)) return "simple-form answer value must be a flat object";
|
|
141
|
+
const prototype = Object.getPrototypeOf(answer.value);
|
|
142
|
+
if (prototype !== Object.prototype && prototype !== null) return "simple-form answer value has an unsafe prototype";
|
|
143
|
+
const fields = question.fields ?? [];
|
|
144
|
+
const allowed = new Map(fields.map((field) => [field.name, field]));
|
|
145
|
+
for (const key of Object.keys(answer.value)) {
|
|
146
|
+
const field = allowed.get(key);
|
|
147
|
+
if (!field) return `simple-form answer has unknown field "${key}"`;
|
|
148
|
+
const invalid = validateFieldValue(field, answer.value[key]);
|
|
149
|
+
if (invalid) return `simple-form field "${key}" ${invalid}`;
|
|
150
|
+
}
|
|
151
|
+
for (const field of fields) {
|
|
152
|
+
if (!field.required) continue;
|
|
153
|
+
if (!hasOwn(answer.value, field.name)) return `simple-form required field "${field.name}" is missing`;
|
|
154
|
+
const invalid = validateFieldValue(field, answer.value[field.name]);
|
|
155
|
+
if (invalid) return `simple-form required field "${field.name}" ${invalid}`;
|
|
156
|
+
}
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (answer.value !== undefined) return `${question.kind} answers cannot contain a typed value`;
|
|
161
|
+
if (question.kind === "input") {
|
|
162
|
+
if (answer.selections.length > 0) return "input answers cannot contain selections";
|
|
163
|
+
return (answer.freeText ?? "").trim() === "" ? "input answer is empty" : undefined;
|
|
164
|
+
}
|
|
165
|
+
if (new Set(answer.selections).size !== answer.selections.length) return "selections contain duplicate labels";
|
|
166
|
+
if (!answer.selections.every((label) => question.options.some((option) => option.label === label))) return "selection is not an option label";
|
|
167
|
+
if (question.kind === "single" && answer.selections.length > 1) return "single answer contains multiple selections";
|
|
168
|
+
return answer.selections.length === 0 && (answer.freeText ?? "").trim() === "" ? "answer is empty" : undefined;
|
|
169
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* User configuration for AskUser.
|
|
3
3
|
*
|
|
4
|
-
* Configuration is a plain JSON file —
|
|
5
|
-
*
|
|
6
|
-
* request:
|
|
4
|
+
* Configuration is a plain JSON file — no AskUser-specific environment knobs.
|
|
5
|
+
* It lives alongside Pi's settings.json and is read **once per host/session
|
|
6
|
+
* creation**, never per request:
|
|
7
7
|
*
|
|
8
|
-
* ~/.pi/ask-user
|
|
8
|
+
* ~/.pi/agent/ask-user.json
|
|
9
|
+
*
|
|
10
|
+
* The directory follows Pi's getAgentDir(), including PI_CODING_AGENT_DIR.
|
|
9
11
|
*
|
|
10
12
|
* No file (or an empty file) means the built-in defaults. An existing file is
|
|
11
13
|
* validated strictly: malformed JSON, unknown keys, and bad values are reported
|
|
12
14
|
* as actionable `invalid_config` errors instead of being silently ignored or
|
|
13
|
-
* coerced.
|
|
15
|
+
* coerced. Preferences are configured only through the file or explicit options.
|
|
14
16
|
*
|
|
15
17
|
* Effective precedence for every knob is:
|
|
16
18
|
*
|
|
@@ -21,14 +23,14 @@
|
|
|
21
23
|
* reports what the user actually configured, leaving unset knobs `undefined`.
|
|
22
24
|
*/
|
|
23
25
|
import { readFileSync } from "node:fs";
|
|
24
|
-
import { homedir } from "node:os";
|
|
25
26
|
import { join } from "node:path";
|
|
27
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
26
28
|
import type { AskUserDisplayMode, AskUserMode, AskUserUiPreferences } from "./types.ts";
|
|
27
29
|
|
|
28
30
|
/** Directory holding the user config file. */
|
|
29
|
-
export const USER_CONFIG_DIR =
|
|
30
|
-
/** The documented user config file. */
|
|
31
|
-
export const USER_CONFIG_PATH = join(USER_CONFIG_DIR, "
|
|
31
|
+
export const USER_CONFIG_DIR = getAgentDir();
|
|
32
|
+
/** The documented user config file, alongside Pi's settings.json. */
|
|
33
|
+
export const USER_CONFIG_PATH = join(USER_CONFIG_DIR, "ask-user.json");
|
|
32
34
|
|
|
33
35
|
/** Built-in default key that toggles the overlay between hidden and visible. */
|
|
34
36
|
export const DEFAULT_OVERLAY_TOGGLE_KEY = "alt+o";
|
package/src/core.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { combineSignals, createDeadline, safeTimeoutMs } from "./deadline.ts";
|
|
2
|
+
import { validateAnswer } from "./answers.ts";
|
|
2
3
|
import { AskUserValidationError, normalizeAskUserRequest } from "./schema.ts";
|
|
3
4
|
import type {
|
|
4
5
|
AskUIInput,
|
|
@@ -272,7 +273,15 @@ function mapOutcome(
|
|
|
272
273
|
): AskUserResult {
|
|
273
274
|
switch (outcome.kind) {
|
|
274
275
|
case "submitted": {
|
|
275
|
-
|
|
276
|
+
if (!Array.isArray(outcome.answers)) {
|
|
277
|
+
return {
|
|
278
|
+
status: "error",
|
|
279
|
+
route,
|
|
280
|
+
answers: [],
|
|
281
|
+
error: { code: "internal", message: `${route} UI returned a malformed answers payload.` },
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
const answers = outcome.answers;
|
|
276
285
|
if (answers.length !== request.questions.length) {
|
|
277
286
|
return {
|
|
278
287
|
status: "error",
|
|
@@ -284,15 +293,23 @@ function mapOutcome(
|
|
|
284
293
|
},
|
|
285
294
|
};
|
|
286
295
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
296
|
+
for (let index = 0; index < answers.length; index += 1) {
|
|
297
|
+
const answer = answers[index]!;
|
|
298
|
+
let invalid: string | undefined;
|
|
299
|
+
try {
|
|
300
|
+
invalid = validateAnswer(request.questions[index]!, answer);
|
|
301
|
+
} catch (error) {
|
|
302
|
+
invalid = `answer validation failed: ${messageOf(error)}`;
|
|
303
|
+
}
|
|
304
|
+
if (!invalid) continue;
|
|
291
305
|
return {
|
|
292
306
|
status: "error",
|
|
293
307
|
route,
|
|
294
308
|
answers,
|
|
295
|
-
error: {
|
|
309
|
+
error: {
|
|
310
|
+
code: invalid.includes("empty") || invalid.includes("missing") ? "empty_answer" : "internal",
|
|
311
|
+
message: `The ${route} UI returned an invalid answer for question "${request.questions[index]!.title}": ${invalid}.`,
|
|
312
|
+
},
|
|
296
313
|
};
|
|
297
314
|
}
|
|
298
315
|
return { status: "answered", route, answers };
|
package/src/index.ts
CHANGED
|
@@ -38,6 +38,7 @@ export {
|
|
|
38
38
|
DEFAULT_TIMEOUT_PER_QUESTION_MS,
|
|
39
39
|
LIMITS,
|
|
40
40
|
MAX_OPTIONS,
|
|
41
|
+
MAX_FORM_FIELDS,
|
|
41
42
|
MAX_QUESTIONS,
|
|
42
43
|
normalizeAskUserRequest,
|
|
43
44
|
} from "./schema.ts";
|
|
@@ -77,6 +78,7 @@ export {
|
|
|
77
78
|
emptyDraft,
|
|
78
79
|
finalizeAnswers,
|
|
79
80
|
toAnswer,
|
|
81
|
+
validateAnswer,
|
|
80
82
|
type DraftAnswer,
|
|
81
83
|
type FinalizeResult,
|
|
82
84
|
} from "./answers.ts";
|
|
@@ -98,7 +100,7 @@ export interface AskUserToolDetails {
|
|
|
98
100
|
progress?: string;
|
|
99
101
|
}
|
|
100
102
|
|
|
101
|
-
const DESCRIPTION = "Ask the user
|
|
103
|
+
const DESCRIPTION = "Ask the user structured single-choice, multi-choice, text, boolean, or flat form questions through the host's interactive UI.";
|
|
102
104
|
|
|
103
105
|
const PROMPT_SNIPPET = "Ask the user structured questions when you would otherwise guess.";
|
|
104
106
|
|
|
@@ -106,6 +108,7 @@ const PROMPT_GUIDELINES = [
|
|
|
106
108
|
"Use ask_user when a choice is high-impact or ambiguous and you cannot infer the answer.",
|
|
107
109
|
"If the tool reports the UI could not run, ask the user the question yourself in ordinary text instead.",
|
|
108
110
|
"Never invent an answer; on aborted or timeout, report it plainly and do not re-ask.",
|
|
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.",
|
|
109
112
|
];
|
|
110
113
|
|
|
111
114
|
/**
|
|
@@ -136,6 +139,12 @@ function describeRequestAsText(request: NormalizedRequest): string {
|
|
|
136
139
|
const description = option.description?.trim();
|
|
137
140
|
lines.push(` ${optionIndex + 1}) ${option.label}${description ? ` — ${description}` : ""}`);
|
|
138
141
|
});
|
|
142
|
+
if (question.kind === "bool") lines.push(" answer: true or false");
|
|
143
|
+
for (const field of question.fields ?? []) {
|
|
144
|
+
lines.push(` - ${field.label} [${field.name}: ${field.kind}]${field.required ? " required" : " optional"}`);
|
|
145
|
+
field.options.forEach((option, optionIndex) => lines.push(` ${optionIndex + 1}) ${option.label}`));
|
|
146
|
+
if (field.hasDefault) lines.push(` default: ${JSON.stringify(field.default)}`);
|
|
147
|
+
}
|
|
139
148
|
if (question.hasDefault && question.default !== undefined) lines.push(` default: ${question.default}`);
|
|
140
149
|
});
|
|
141
150
|
return lines.join("\n");
|
|
@@ -151,6 +160,10 @@ function buildModelContent(result: AskUserResult, request: NormalizedRequest): s
|
|
|
151
160
|
switch (result.status) {
|
|
152
161
|
case "answered": {
|
|
153
162
|
const lines = result.answers.map((answer) => {
|
|
163
|
+
if (answer.value !== undefined) {
|
|
164
|
+
const suffix = answer.usedDefault ? " (default used)" : "";
|
|
165
|
+
return `- ${answer.title}: ${JSON.stringify(answer.value)}${suffix}`;
|
|
166
|
+
}
|
|
154
167
|
const parts: string[] = [];
|
|
155
168
|
if (answer.selections.length > 0) parts.push(answer.selections.join(", "));
|
|
156
169
|
if (answer.freeText) parts.push(answer.freeText);
|
|
@@ -211,7 +224,9 @@ export interface RegisterAskUserOptions {
|
|
|
211
224
|
timeoutPerQuestionMs?: number;
|
|
212
225
|
/**
|
|
213
226
|
* Path to the user config file, or `false` to skip reading it. Defaults to
|
|
214
|
-
* `~/.pi/ask-user
|
|
227
|
+
* `<Pi agent directory>/ask-user.json` (normally `~/.pi/agent/ask-user.json`).
|
|
228
|
+
* The directory follows Pi's `PI_CODING_AGENT_DIR`; there are no AskUser-specific
|
|
229
|
+
* environment-variable preferences.
|
|
215
230
|
*/
|
|
216
231
|
configFile?: string | false;
|
|
217
232
|
}
|
package/src/parse.ts
CHANGED
|
@@ -54,6 +54,9 @@ export function parseNativeAnswer(question: NormalizedQuestion, raw: string): Pa
|
|
|
54
54
|
if (question.kind === "input") {
|
|
55
55
|
return { ok: true, value: { selections: [], freeText: trimmed, empty: false } };
|
|
56
56
|
}
|
|
57
|
+
if (question.kind === "bool" || question.kind === "simple-form") {
|
|
58
|
+
return { ok: false, error: `${question.kind} uses typed native input and cannot be parsed as a legacy answer.` };
|
|
59
|
+
}
|
|
57
60
|
|
|
58
61
|
const pipeIndex = raw.indexOf("|");
|
|
59
62
|
const left = pipeIndex >= 0 ? raw.slice(0, pipeIndex) : raw;
|
|
@@ -115,5 +118,9 @@ export function nativeInputHint(question: NormalizedQuestion): string {
|
|
|
115
118
|
: "Free input. Reply with text to answer.";
|
|
116
119
|
case "input":
|
|
117
120
|
return "Free input. Reply with text to answer.";
|
|
121
|
+
case "bool":
|
|
122
|
+
return "Boolean. Reply with true or false.";
|
|
123
|
+
case "simple-form":
|
|
124
|
+
return "Form fields are asked one at a time.";
|
|
118
125
|
}
|
|
119
126
|
}
|