@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 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, and free-text
5
- answers, with configurable timeouts and cancellation.
4
+ or native 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, a pinned free-input row, and a Markdown preview |
21
- | `native` | `ctx.hasUI` with a callable `ctx.ui.input` (TUI, RPC/ACP) | native input dialogs, one question at a time (invalid input re-prompts) |
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; 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.
@@ -45,30 +45,99 @@ 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`; `multi` is explicit |
49
- | `questions[].options` | `Option[]?` | 1–5 × `{ label, description?, preview? }`; required for `single`/`multi`, ignored for `input` |
50
- | `questions[].default` | `string?` | makes the question skippable |
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 |
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 |
53
61
  | `displayMode` | `"overlay" \| "inline"?` | custom route only; default `overlay`; a user-configured preference overrides it |
54
62
  | `timeoutPerQuestionMs` | `number?` | base timeout per question; default `60000`; a user-configured preference overrides it |
55
63
 
56
64
  These fields are the whole input contract, for the tool and for direct calls
57
65
  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 error. An
59
- explicit `kind: "input"` ignores `options` — if present they are still validated,
60
- then dropped. Duplicate option labels are de-duplicated and reported in
61
- `warnings`. Limits: 1–5 questions, ≤5 options each.
66
+ as `invalid_request`; a `single`/`multi` question or field without options is an
67
+ error. An explicit legacy `kind: "input"` keeps its existing behavior: supplied
68
+ options are validated, then dropped. Duplicate option labels are de-duplicated
69
+ and reported in `warnings`.
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
+
102
+ Forms are flat. Field names cannot contain dots, brackets, spaces, or path-like
103
+ syntax, and `__proto__`, `constructor`, and `prototype` are rejected. Names must
104
+ be unique. Defaults are checked against the field kind and, for choice fields,
105
+ against the option labels. A top-level form `options` or `default`, a nested
106
+ form/schema, and `options` on a boolean are errors. Limits are `MAX_QUESTIONS=5`,
107
+ `MAX_OPTIONS=5`, and `MAX_FORM_FIELDS=10`.
62
108
 
63
109
  ```json
64
110
  {
111
+ "header": "Release",
65
112
  "questions": [
66
113
  {
67
- "title": "Which deploy target?",
68
- "kind": "single",
69
- "options": [
70
- { "label": "staging", "description": "Internal smoke test" },
71
- { "label": "production", "description": "Customer-facing" }
114
+ "title": "Proceed with deployment?",
115
+ "kind": "bool",
116
+ "default": false
117
+ },
118
+ {
119
+ "title": "Deployment settings",
120
+ "kind": "simple-form",
121
+ "fields": [
122
+ { "name": "confirmed", "label": "Confirm", "kind": "bool", "required": true },
123
+ {
124
+ "name": "target",
125
+ "label": "Target",
126
+ "kind": "single",
127
+ "required": true,
128
+ "options": [
129
+ { "label": "staging", "description": "Internal smoke test" },
130
+ { "label": "production", "description": "Customer-facing" }
131
+ ]
132
+ },
133
+ {
134
+ "name": "checks",
135
+ "label": "Checks",
136
+ "kind": "multi",
137
+ "options": [{ "label": "unit" }, { "label": "integration" }],
138
+ "default": ["unit"]
139
+ },
140
+ { "name": "note", "label": "Release note", "kind": "input" }
72
141
  ]
73
142
  }
74
143
  ]
@@ -77,13 +146,19 @@ then dropped. Duplicate option labels are de-duplicated and reported in
77
146
 
78
147
  ## User config file
79
148
 
80
- There are **no environment variables**. User preferences live in a documented
81
- JSON file:
149
+ There are **no AskUser-specific environment-variable preferences**. User
150
+ preferences live in a JSON file alongside Pi's `settings.json`:
82
151
 
83
152
  ```
84
- ~/.pi/ask-user/config.json
153
+ ~/.pi/agent/ask-user.json
85
154
  ```
86
155
 
156
+ The directory is resolved by Pi's `getAgentDir()`. If you set Pi's
157
+ `PI_CODING_AGENT_DIR`, the file is `<PI_CODING_AGENT_DIR>/ask-user.json` instead.
158
+ The old `~/.pi/ask-user/config.json` location is no longer read; move an existing
159
+ file to the new location (without overwriting a config already there), then
160
+ restart Pi or run `/reload`.
161
+
87
162
  No file — or an empty/whitespace-only file — means the built-in defaults. The
88
163
  knobs are:
89
164
 
@@ -291,6 +366,36 @@ The key is shown in the bottom hints and reserved against `overlayToggleKey`
291
366
  conflicts. Native input dialogs do not support this shortcut. If an overlay is
292
367
  hidden, restore it first with its show/hide key before pressing `Alt+Q`.
293
368
 
369
+ ## Questionnaire keyboard controls
370
+
371
+ Legacy `single`/`multi`/`input` questions retain their controls: `Tab` and
372
+ `Shift+Tab` switch question/review tabs, arrows move within choices, `Space`
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.
380
+
381
+ A `simple-form` is one question tab with all fields in one scrollable view:
382
+
383
+ - `Tab` / `Shift+Tab` cycles fields and the Continue/Submit button.
384
+ - `Ctrl+Left` / `Ctrl+Right` switches questionnaire tabs from any question.
385
+ - Boolean fields use the same single toggle control (`Space` toggles, arrows set,
386
+ `Enter` confirms and moves on); single-choice fields use
387
+ arrows plus `Space`/`Enter`; multi-choice fields use `Space` to toggle and
388
+ `Enter` to move on; text fields use `Enter` to move on.
389
+ - `Ctrl+Enter` validates and submits the questionnaire. Required missing/empty
390
+ fields focus the first invalid field. `PageUp` / `PageDown` scrolls long form
391
+ or preview content.
392
+ - `Esc`, `Alt+Q`, and overlay show/hide keep their existing behavior.
393
+
394
+ The active form field's input or selected option is automatically scrolled into view. Rendering is bounded
395
+ by both terminal width and rows, including narrow/short terminals. Focus is
396
+ propagated to Pi's `Input`, so the hardware cursor marker remains available to
397
+ Chinese/Japanese/Korean IMEs.
398
+
294
399
  ## Overlay show/hide key
295
400
 
296
401
  On the **custom overlay route only** (never inline, never native), the overlay
@@ -330,8 +435,26 @@ interface AskUserResult {
330
435
  warnings?: string[];
331
436
  error?: { code: AskUserErrorCode; message: string };
332
437
  }
438
+
439
+ interface AskUserAnswer {
440
+ index: number;
441
+ id: string;
442
+ title: string;
443
+ kind: "single" | "multi" | "input" | "bool" | "simple-form";
444
+ selections: string[]; // unchanged for legacy kinds
445
+ freeText?: string; // unchanged for legacy kinds
446
+ usedDefault: boolean;
447
+ value?: boolean | Record<string, boolean | string | string[]>;
448
+ }
333
449
  ```
334
450
 
451
+ `bool` answers use `value: boolean`, including meaningful `value: false`.
452
+ `simple-form` answers use a flat object keyed by field `name`. Choice values are
453
+ option labels, multi-choice values are label arrays, boolean values stay
454
+ booleans, and entered text is trimmed on both routes. Optional fields left
455
+ unset are omitted. An explicit field default is included and makes `usedDefault` true; required empty
456
+ text/multi fields are still invalid.
457
+
335
458
  `aborted` covers both a user dismissal and a caller abort, distinguished by
336
459
  `cancelledReason`. Errors use `empty_answer`, `invalid_request`,
337
460
  `custom_ui_failed`, `native_ui_failed`, `invalid_config`, `unsupported_mode`,
@@ -352,23 +475,32 @@ for a refused explicit config, names the requested route.
352
475
  - **A missing route is an error, not a downgrade.** JSON/print, or an explicit
353
476
  `mode` that cannot run, is refused with `unsupported_mode` and never replaced by
354
477
  another route.
355
- - **Configuration is a file, never the environment.** No `process.env` knob
356
- exists; `~/.pi/ask-user/config.json` is read once per host/session and
357
- strictly validated. An invalid config is `invalid_config`, never a fallback.
478
+ - **Preferences are configured in a file, not environment variables.**
479
+ `<Pi agent directory>/ask-user.json` (normally `~/.pi/agent/ask-user.json`) is
480
+ read once per host/session and strictly validated. Only the directory follows
481
+ Pi's `PI_CODING_AGENT_DIR`. An invalid config is `invalid_config`, never a fallback.
358
482
  - **Events never leak content by default.** `herdr:blocked` bounds a real wait
359
483
  exactly once on each side; outcome events are one per UI attempt and carry only
360
484
  correlation id, route, and status.
361
485
  - **The overlay toggle is overlay-only.** Inline and native register no raw
362
486
  input listener; hiding never pauses the deadline.
363
- - **The native route uses single-line `input`**, one question at a time (not
487
+ - **The native route defaults to single-line `input`**, one logical question at a time (not
364
488
  `editor`, which accepts no timeout/signal). The number and an optional note
365
489
  share one box: `2`, `2 | note`, or free text; a number list may be
366
490
  comma/space/`,`/`、` separated (`1,3`). Out-of-range or malformed numbers
367
491
  re-prompt, and an empty box is rejected unless the question has a `default`.
492
+ A form opens sequential per-field dialogs under the same questionnaire
493
+ deadline: boolean fields accept `true`/`false`, form choices accept only
494
+ option numbers and return labels, and optional empty fields are omitted (or
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.
368
498
  - **Tool calls run sequentially** (`executionMode: "sequential"`).
369
- - **Short terminals degrade in a defined order:** the pinned free-input row and
499
+ - **Short terminals degrade in a defined order:** when enabled, the pinned legacy free-input row and
370
500
  the key hints survive first, down to `MIN_USABLE_ROWS` (3); 0 rows renders
371
- nothing. `render(width)` never exceeds the requested width or `terminal.rows`.
501
+ nothing. Bool, forms, and choices without supplemental text reserve a visible
502
+ row for the focused control and auto-scroll it.
503
+ `render(width)` never exceeds the requested width or `terminal.rows`.
372
504
 
373
505
  ## Development
374
506
 
@@ -388,3 +520,6 @@ pi install /absolute/path/to/pi-ask-user
388
520
  by the custom UI and parameter schema (`src/schema.ts`). They are also declared
389
521
  as development dependencies for local checks. The routing, parsing, and deadline
390
522
  layers are pure, non-TUI, and free of both.
523
+
524
+ For maintainer release instructions, see
525
+ [CONTRIBUTING.md](https://github.com/black-94/pi-ask-user/blob/main/CONTRIBUTING.md#release).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@black942026/pi-ask-user",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Structured user questions for Pi, with interactive terminal dialogs, native input dialogs, and a reusable TypeScript API.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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/config.json`. There are no environment variables.
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
  }
@@ -57,7 +59,8 @@ export interface PiHostOptions {
57
59
  *
58
60
  * - `customUI` — only in `tui` mode with a callable `ctx.ui.custom()`.
59
61
  * - `nativeDialogs` — only when `ctx.hasUI` and `ctx.ui.input` is callable,
60
- * i.e. native input dialogs shown one question at a time. RPC reports
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
61
64
  * `hasUI: true`, so a nonresponsive RPC client is *not* pre-judged: the
62
65
  * attempt is made and the shared deadline turns silence into an actionable
63
66
  * `timeout`.
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 carries neither a selection nor any free text. */
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!.trim());
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 into an answer. Assumes the draft is non-empty. */
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,126 @@ 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) still unanswered and without a default. */
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
- const draft = drafts[index] ?? emptyDraft();
67
+ let draft = drafts[index] ?? emptyDraft();
72
68
  if (draftIsEmpty(draft)) {
73
69
  const fallback = draftFromDefault(question);
74
- if (fallback) {
75
- answers.push(toAnswer(question, fallback));
76
- } else {
77
- unanswered.push(index);
78
- }
70
+ if (fallback) draft = fallback;
71
+ }
72
+ if (draftIsEmpty(draft)) {
73
+ unanswered.push(index);
79
74
  return;
80
75
  }
81
- answers.push(toAnswer(question, draft));
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 (question.allowFreeText === false && answer.freeText !== undefined) return "freeText is disabled for this question";
166
+ if (new Set(answer.selections).size !== answer.selections.length) return "selections contain duplicate labels";
167
+ if (!answer.selections.every((label) => question.options.some((option) => option.label === label))) return "selection is not an option label";
168
+ if (question.kind === "single" && answer.selections.length > 1) return "single answer contains multiple selections";
169
+ return answer.selections.length === 0 && (answer.freeText ?? "").trim() === "" ? "answer is empty" : undefined;
170
+ }
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 — never environment variables. It lives in
5
- * a per-user directory and is read **once per host/session creation**, never per
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/config.json
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. There are deliberately no environment-variable knobs.
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 = join(homedir(), ".pi", "ask-user");
30
- /** The documented user config file. */
31
- export const USER_CONFIG_PATH = join(USER_CONFIG_DIR, "config.json");
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
- const answers = outcome.answers ?? [];
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
- const empty = answers.find(
288
- (answer) => answer.selections.length === 0 && (answer.freeText ?? "").trim() === "",
289
- );
290
- if (empty) {
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: { code: "empty_answer", message: `The answer to question "${empty.title}" is empty.` },
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 };