@black942026/pi-ask-user 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,11 @@
1
1
  # @black942026/pi-ask-user
2
2
 
3
- `ask_user` — a route-resolved "ask the user" interaction for Pi.
3
+ A Pi extension for asking structured questions through interactive terminal
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
+
7
+ Install the package to add the **`ask_user`** tool to Pi. Other extensions can
8
+ reuse the same interaction through the TypeScript API.
4
9
 
5
10
  Two entry points share one strict questionnaire contract and one route resolution:
6
11
 
@@ -12,8 +17,8 @@ is created:
12
17
 
13
18
  | Route | Runs when | The user sees |
14
19
  | --- | --- | --- |
15
- | `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 |
16
- | `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; form fields are sequential dialogs |
17
22
 
18
23
  Only these two routes exist, and the route belongs to the host: it is configured
19
24
  or probed when the host is created, never per request.
@@ -22,16 +27,14 @@ or probed when the host is created, never per request.
22
27
 
23
28
  ```bash
24
29
  pi install npm:@black942026/pi-ask-user
25
- # or, from a local checkout
26
- pi install /absolute/path/to/pi-ask-user
27
- # or, during development
28
- pi --extension ./index.ts
29
30
  ```
30
31
 
31
- Requires Node.js ≥ 22.19.0.
32
+ Start a new Pi session, or run `/reload` in an existing session, to load the
33
+ extension. The `ask_user` tool is registered automatically.
32
34
 
33
- Peer dependencies: `@earendil-works/pi-coding-agent`, `@earendil-works/pi-tui`,
34
- `typebox`.
35
+ Requires Node.js ≥ 22.19.0 and Pi ≥ 0.87.0. Pi supplies the host dependencies
36
+ `@earendil-works/pi-coding-agent`, `@earendil-works/pi-tui`, and `typebox`;
37
+ no separate dependency installation is needed when using `pi install`.
35
38
 
36
39
  ## Tool parameters
37
40
 
@@ -42,9 +45,16 @@ The registered tool name is exactly **`ask_user`**.
42
45
  | `questions` | `Question[]` | 1–5 questions (required) |
43
46
  | `questions[].title` | `string` | heading (required) |
44
47
  | `questions[].prompt` | `string?` | extra hint under the heading |
45
- | `questions[].kind` | `"single" \| "multi" \| "input"?` | inferred from `options` when omitted: present ⇒ `single`, absent ⇒ `input`; `multi` is explicit |
46
- | `questions[].options` | `Option[]?` | 1–5 × `{ label, description?, preview? }`; required for `single`/`multi`, ignored for `input` |
47
- | `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 |
48
58
  | `questions[].id` | `string?` | non-empty; generated as `q1`, `q2`, … when omitted |
49
59
  | `header` | `string?` | heading above the questionnaire |
50
60
  | `displayMode` | `"overlay" \| "inline"?` | custom route only; default `overlay`; a user-configured preference overrides it |
@@ -52,20 +62,50 @@ The registered tool name is exactly **`ask_user`**.
52
62
 
53
63
  These fields are the whole input contract, for the tool and for direct calls
54
64
  alike. Unknown fields, wrong types, empty ids, and over-long values are rejected
55
- as `invalid_request`; a `single`/`multi` question without options is an error. An
56
- explicit `kind: "input"` ignores `options` — if present they are still validated,
57
- then dropped. Duplicate option labels are de-duplicated and reported in
58
- `warnings`. Limits: 1–5 questions, ≤5 options each.
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`.
59
76
 
60
77
  ```json
61
78
  {
79
+ "header": "Release",
62
80
  "questions": [
63
81
  {
64
- "title": "Which deploy target?",
65
- "kind": "single",
66
- "options": [
67
- { "label": "staging", "description": "Internal smoke test" },
68
- { "label": "production", "description": "Customer-facing" }
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" }
69
109
  ]
70
110
  }
71
111
  ]
@@ -74,13 +114,19 @@ then dropped. Duplicate option labels are de-duplicated and reported in
74
114
 
75
115
  ## User config file
76
116
 
77
- There are **no environment variables**. User preferences live in a documented
78
- JSON file:
117
+ There are **no AskUser-specific environment-variable preferences**. User
118
+ preferences live in a JSON file alongside Pi's `settings.json`:
79
119
 
80
120
  ```
81
- ~/.pi/ask-user/config.json
121
+ ~/.pi/agent/ask-user.json
82
122
  ```
83
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
+
84
130
  No file — or an empty/whitespace-only file — means the built-in defaults. The
85
131
  knobs are:
86
132
 
@@ -111,8 +157,8 @@ against the questionnaire's own controls. A malformed spec (`alt+banana`), a
111
157
  reserved key (`escape`/`esc` — which must keep cancelling — plus
112
158
  `enter`/`tab`/arrows/`space`/`pageUp`/…), a bare printable key that would swallow
113
159
  typing (`o`), and conflicting chords (`ctrl+c`, `ctrl+d`, `ctrl+u`, `ctrl+p`,
114
- `ctrl+n`, `ctrl+enter`) are each rejected with a specific reason. A key is never
115
- silently substituted.
160
+ `ctrl+n`, `ctrl+enter`, `alt+q`) are each rejected with a specific reason. A key
161
+ is never silently substituted.
116
162
 
117
163
  ### Effective precedence and read timing
118
164
 
@@ -275,6 +321,45 @@ an optional sink — `createAskUser(ctx, { events: { waitStarted, waitEnded } })
275
321
  or `createAskUser(host, { events })` — or accept that no runtime events are
276
322
  emitted. `AskUserEventSink` is exported for this.
277
323
 
324
+ ## Cancel countdown shortcut
325
+
326
+ In the **custom terminal UI** (overlay or inline), press **`Alt+Q`** to cancel
327
+ this questionnaire's countdown without dismissing it or changing your answers.
328
+ The header shows **`No timeout`**; you can keep answering, submit normally, or
329
+ press `Esc` to dismiss. Caller aborts still work. This is a one-way change for
330
+ the current questionnaire only; the next call starts with its configured timeout.
331
+ An already-expired deadline cannot be cancelled.
332
+
333
+ The key is shown in the bottom hints and reserved against `overlayToggleKey`
334
+ conflicts. Native input dialogs do not support this shortcut. If an overlay is
335
+ hidden, restore it first with its show/hide key before pressing `Alt+Q`.
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
+
278
363
  ## Overlay show/hide key
279
364
 
280
365
  On the **custom overlay route only** (never inline, never native), the overlay
@@ -314,8 +399,26 @@ interface AskUserResult {
314
399
  warnings?: string[];
315
400
  error?: { code: AskUserErrorCode; message: string };
316
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
+ }
317
413
  ```
318
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
+
319
422
  `aborted` covers both a user dismissal and a caller abort, distinguished by
320
423
  `cancelledReason`. Errors use `empty_answer`, `invalid_request`,
321
424
  `custom_ui_failed`, `native_ui_failed`, `invalid_config`, `unsupported_mode`,
@@ -325,42 +428,59 @@ for a refused explicit config, names the requested route.
325
428
  ## Key constraints
326
429
 
327
430
  - **The deadline is shared and owned by the core.** Per questionnaire it is
328
- strictly `timeoutPerQuestionMs × questionCount` (default 60 s each), clamped to
431
+ initially `timeoutPerQuestionMs × questionCount` (default 60 s each), clamped to
329
432
  `MAX_SAFE_TIMEOUT_MS` (2³¹−1); there is no absolute override. The deadline and
330
433
  the caller's cancellation are raced independently of the renderer, so a renderer
331
434
  that never resolves or ignores its `AbortSignal` still yields
332
- `timeout`/`aborted`; late results are discarded. Cancellation is checked before
333
- any route branch, so an already-aborted call never renders a UI.
435
+ `timeout`/`aborted`, unless the user explicitly cancels the timeout with `Alt+Q`
436
+ in the custom UI (caller aborts remain effective); late results are discarded.
437
+ Cancellation is checked before any route branch, so an already-aborted call
438
+ never renders a UI.
334
439
  - **A missing route is an error, not a downgrade.** JSON/print, or an explicit
335
440
  `mode` that cannot run, is refused with `unsupported_mode` and never replaced by
336
441
  another route.
337
- - **Configuration is a file, never the environment.** No `process.env` knob
338
- exists; `~/.pi/ask-user/config.json` is read once per host/session and
339
- strictly validated. An invalid config is `invalid_config`, never a fallback.
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.
340
446
  - **Events never leak content by default.** `herdr:blocked` bounds a real wait
341
447
  exactly once on each side; outcome events are one per UI attempt and carry only
342
448
  correlation id, route, and status.
343
449
  - **The overlay toggle is overlay-only.** Inline and native register no raw
344
450
  input listener; hiding never pauses the deadline.
345
- - **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
346
452
  `editor`, which accepts no timeout/signal). The number and an optional note
347
453
  share one box: `2`, `2 | note`, or free text; a number list may be
348
454
  comma/space/`,`/`、` separated (`1,3`). Out-of-range or malformed numbers
349
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.
350
460
  - **Tool calls run sequentially** (`executionMode: "sequential"`).
351
- - **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
352
462
  the key hints survive first, down to `MIN_USABLE_ROWS` (3); 0 rows renders
353
- nothing. `render(width)` never exceeds the requested width or `terminal.rows`.
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`.
354
465
 
355
466
  ## Development
356
467
 
357
468
  ```bash
358
469
  npm install
359
470
  npm run typecheck # tsc --noEmit
360
- npm test # node --test test/*.test.ts (Node ≥ 22.6 runs TS directly)
471
+ npm test # node --test test/*.test.ts
361
472
  npm run check # typecheck + tests
473
+
474
+ # Load a local checkout for development
475
+ pi --extension ./index.ts
476
+ # Or install it as a local package
477
+ pi install /absolute/path/to/pi-ask-user
362
478
  ```
363
479
 
364
- `@earendil-works/pi-tui` is a runtime dependency of the custom UI, and `typebox`
365
- is a runtime dependency of the parameter schema (`src/schema.ts`). The routing,
366
- parsing, and deadline layers are pure, non-TUI, and free of both.
480
+ `@earendil-works/pi-tui` and `typebox` are host-provided peer dependencies used
481
+ by the custom UI and parameter schema (`src/schema.ts`). They are also declared
482
+ as development dependencies for local checks. The routing, parsing, and deadline
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@black942026/pi-ask-user",
3
- "version": "0.1.0",
4
- "description": "AskUser - a route-forced ask-user interaction component/tool for Pi extensions (custom TUI or native dialogs)",
3
+ "version": "0.1.2",
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",
7
7
  "repository": {
@@ -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
  }
@@ -195,6 +197,7 @@ export function createPiCustomRenderer(ui: ExtensionUIContext, options: PiCustom
195
197
  component = new AskUserComponent({
196
198
  request,
197
199
  deadline,
200
+ cancelTimeout: input.cancelTimeout,
198
201
  theme: theme as unknown as AskUserTheme,
199
202
  tui,
200
203
  done,
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,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) 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 (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 — 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,17 +23,19 @@
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";
37
+ /** Cancel the current questionnaire's countdown without dismissing the UI. */
38
+ export const CANCEL_TIMEOUT_KEY = "alt+q";
35
39
  /** Built-in default custom-UI presentation. */
36
40
  export const DEFAULT_DISPLAY_MODE: AskUserDisplayMode = "overlay";
37
41
 
@@ -129,7 +133,7 @@ const RESERVED_BASES = new Set([
129
133
  ]);
130
134
  /** Modifier chords that conflict with a questionnaire control. */
131
135
  const RESERVED_SIGNATURES = new Set(
132
- ["ctrl+c", "ctrl+d", "ctrl+u", "ctrl+p", "ctrl+n", "ctrl+enter"].map((id) => {
136
+ ["ctrl+c", "ctrl+d", "ctrl+u", "ctrl+p", "ctrl+n", "ctrl+enter", CANCEL_TIMEOUT_KEY].map((id) => {
133
137
  const parts = id.split("+");
134
138
  const base = parts.pop()!;
135
139
  return [...parts.sort(), base].join("+");