@black942026/pi-ask-user 0.0.0-stage → 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 black
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,366 @@
1
- # Temporary Holding Version
1
+ # @black942026/pi-ask-user
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `ask_user` — a route-resolved "ask the user" interaction for Pi.
4
+
5
+ Two entry points share one strict questionnaire contract and one route resolution:
6
+
7
+ - the registered model-facing tool **`ask_user`**;
8
+ - the host-bound TypeScript API **`createAskUser(ctx)`**, for other extensions.
9
+
10
+ A questionnaire is answered through one of two routes, resolved once when the host
11
+ is created:
12
+
13
+ | Route | Runs when | The user sees |
14
+ | --- | --- | --- |
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) |
17
+
18
+ Only these two routes exist, and the route belongs to the host: it is configured
19
+ or probed when the host is created, never per request.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ 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
+
31
+ Requires Node.js ≥ 22.19.0.
32
+
33
+ Peer dependencies: `@earendil-works/pi-coding-agent`, `@earendil-works/pi-tui`,
34
+ `typebox`.
35
+
36
+ ## Tool parameters
37
+
38
+ The registered tool name is exactly **`ask_user`**.
39
+
40
+ | Field | Type | Notes |
41
+ | --- | --- | --- |
42
+ | `questions` | `Question[]` | 1–5 questions (required) |
43
+ | `questions[].title` | `string` | heading (required) |
44
+ | `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[].id` | `string?` | non-empty; generated as `q1`, `q2`, … when omitted |
49
+ | `header` | `string?` | heading above the questionnaire |
50
+ | `displayMode` | `"overlay" \| "inline"?` | custom route only; default `overlay`; a user-configured preference overrides it |
51
+ | `timeoutPerQuestionMs` | `number?` | base timeout per question; default `60000`; a user-configured preference overrides it |
52
+
53
+ These fields are the whole input contract, for the tool and for direct calls
54
+ 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.
59
+
60
+ ```json
61
+ {
62
+ "questions": [
63
+ {
64
+ "title": "Which deploy target?",
65
+ "kind": "single",
66
+ "options": [
67
+ { "label": "staging", "description": "Internal smoke test" },
68
+ { "label": "production", "description": "Customer-facing" }
69
+ ]
70
+ }
71
+ ]
72
+ }
73
+ ```
74
+
75
+ ## User config file
76
+
77
+ There are **no environment variables**. User preferences live in a documented
78
+ JSON file:
79
+
80
+ ```
81
+ ~/.pi/ask-user/config.json
82
+ ```
83
+
84
+ No file — or an empty/whitespace-only file — means the built-in defaults. The
85
+ knobs are:
86
+
87
+ | Key | Type | Default | Meaning |
88
+ | --- | --- | --- | --- |
89
+ | `mode` | `"custom" \| "native"` | omitted ⇒ probe | force the UI route |
90
+ | `displayMode` | `"overlay" \| "inline"` | `overlay` | custom-UI presentation |
91
+ | `overlayToggleKey` | KeyId chord, or `null`/`"off"`/`"none"`/`"disabled"` | `alt+o` | overlay show/hide key; the disable values turn it off |
92
+ | `timeoutPerQuestionMs` | non-negative number | `60000` | base timeout per question |
93
+
94
+ ```json
95
+ {
96
+ "mode": "custom",
97
+ "displayMode": "overlay",
98
+ "overlayToggleKey": "alt+o",
99
+ "timeoutPerQuestionMs": 60000
100
+ }
101
+ ```
102
+
103
+ Validation is strict and actionable. Invalid JSON, an unknown key, a wrong type,
104
+ or an out-of-range value is reported as `invalid_config` naming the file and the
105
+ offending field — never ignored, coerced, or fallen back from. A bad config
106
+ makes the host unusable: every `ask_user` call is refused with the actionable
107
+ reason, and the model is told to ask in ordinary text instead.
108
+
109
+ `overlayToggleKey` is validated against the supported **KeyId** grammar and
110
+ against the questionnaire's own controls. A malformed spec (`alt+banana`), a
111
+ reserved key (`escape`/`esc` — which must keep cancelling — plus
112
+ `enter`/`tab`/arrows/`space`/`pageUp`/…), a bare printable key that would swallow
113
+ 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.
116
+
117
+ ### Effective precedence and read timing
118
+
119
+ Per knob:
120
+
121
+ ```
122
+ explicit programmatic option > user config file > built-in default
123
+ ```
124
+
125
+ and, for the two request-overridable knobs only:
126
+
127
+ ```
128
+ user displayMode / timeoutPerQuestionMs > the model's request value > built-in default
129
+ ```
130
+
131
+ So a user who configures `displayMode` or `timeoutPerQuestionMs` always wins over
132
+ whatever the model sends; when the user configured nothing, the model's value
133
+ applies; when neither is set, the built-in default applies.
134
+
135
+ Timing: the config file is read **once per host/session creation**, never per
136
+ request. For the registered tool the host is built in `session_start`, so the
137
+ route and preferences are fixed for the session; a reload
138
+ (`session_shutdown` + `session_start`) re-reads. `createPiHost` / `createAskUser`
139
+ read once at construction. Route resolution and preference resolution happen
140
+ once, together, and are then immutable.
141
+
142
+ Programmatic options are accepted by all three entry points:
143
+ `registerAskUser(pi, { mode, displayMode, overlayToggleKey, timeoutPerQuestionMs, configFile })`,
144
+ `createPiHost(ctx, { ... })`, and `createAskUser(ctx, { ... })`. `configFile`
145
+ may point at another path, or be `false` to skip the file (used by embedding and
146
+ by this repo's tests). `overlayToggleKey: null` disables the toggle explicitly.
147
+
148
+ ## Routing and capabilities
149
+
150
+ The route is resolved once, at host creation, from exactly two inputs:
151
+
152
+ 1. an explicit `mode` (`custom` | `native`) — used verbatim when its
153
+ implementation is bound; otherwise refused with `unsupported_mode`, and the
154
+ other bound mode is **not** substituted;
155
+ 2. otherwise an initialisation-time **probe**: `custom` when both can run (custom
156
+ preferred), else the single mode that can run.
157
+
158
+ With neither available there is no route and the call is refused with
159
+ `unsupported_mode`; an invalid configured value is refused with `invalid_config`.
160
+ A render failure never silently switches `custom` → `native`: it is reported as
161
+ `custom_ui_failed`/`native_ui_failed`.
162
+
163
+ `askUserSupport(host)` reports whether — and how — a host can prompt, without
164
+ prompting. It is a discriminated union on `status`:
165
+
166
+ | `status` | Meaning |
167
+ | --- | --- |
168
+ | `available` | can prompt; `route` is `custom`/`native`, `source` is `configured`/`probed`, `available` lists every mode that can run |
169
+ | `configured_unavailable` | the configured mode cannot run; `configured` names it; no fallback |
170
+ | `no_available_ui` | neither a custom TUI nor native dialogs can run |
171
+ | `invalid_config` | the configured value is not a known route |
172
+
173
+ With `createPiHost(ctx, { mode })`, `customUI` is bound only in `tui` mode with a
174
+ callable `ctx.ui.custom()`, and `nativeDialogs` only when `ctx.hasUI` and
175
+ `ctx.ui.input` is callable (TUI, RPC). RPC reports `hasUI: true`, so a
176
+ nonresponsive client is not pre-judged: the deadline turns silence into a
177
+ `timeout`. JSON/print have no dialog-capable UI and are refused with
178
+ `unsupported_mode`.
179
+
180
+ In an extension, `registerAskUser(pi, { mode })` captures the mode at registration
181
+ and builds the host once per session from the context delivered to
182
+ `pi.on("session_start", ...)`, releasing it on `session_shutdown`. A call before
183
+ the session has started fails with `not_initialized`.
184
+
185
+ ## Direct API
186
+
187
+ For another extension that wants to ask without going through the model, bind the
188
+ interaction to its host and check availability first:
189
+
190
+ ```ts
191
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
192
+ import { createAskUser } from "@black942026/pi-ask-user";
193
+
194
+ async function askWhereToDeploy(ctx: ExtensionContext) {
195
+ const ask = createAskUser(ctx); // or createAskUser(host), or { mode }
196
+ if (!ask.isAvailable) {
197
+ // ask.notAvailableReason explains why, before any UI is shown
198
+ throw new Error(ask.notAvailableReason ?? "no interactive UI available");
199
+ }
200
+
201
+ const result = await ask({
202
+ questions: [{ title: "Deploy where?", kind: "single", options: [{ label: "staging" }, { label: "prod" }] }],
203
+ });
204
+ // result.status === "answered" | "aborted" | "timeout" | "error"
205
+ return result;
206
+ }
207
+ ```
208
+
209
+ `createAskUser` returns a callable object:
210
+
211
+ - `ask(request)` → `Promise<AskUserResult>`; it never throws for a bad request or
212
+ an unavailable host, and it never forwards anything to the model — a direct call
213
+ belongs to the caller, with no plain-text fallback.
214
+ - `ask.isAvailable` → `true` when the bound host has a ready interactive route
215
+ (its structural/configured capability, not a promise that a render will succeed).
216
+ - `ask.notAvailableReason` → the reason string when it has none, `undefined` when
217
+ it has one.
218
+ - `ask.host` → the bound `AskUserHost`.
219
+
220
+ It accepts a Pi `ExtensionContext` (with optional options) and probes once,
221
+ reading the user config file once at the same time, or a pre-built
222
+ `AskUserHost` to reuse one. Availability reflects that host's structural +
223
+ configured state. Pass `events` to observe the interactive wait; a direct call
224
+ otherwise emits no runtime events (there is no fabricated bus).
225
+
226
+ ## Tool result and model fallback
227
+
228
+ The tool returns its result to the model as tool output. Its text depends on the
229
+ outcome, and it always tells the model **not to invent an answer**:
230
+
231
+ - `answered` — the answers.
232
+ - `aborted` / `timeout` — no answer; reported plainly, with no instruction to
233
+ re-ask in text (the UI may already have been shown, so a text re-ask would
234
+ prompt the user twice).
235
+ - Interactive UI failed — `unsupported_mode`, `invalid_config`, `not_initialized`,
236
+ `custom_ui_failed`, `native_ui_failed`: the UI could not complete the request, so
237
+ the text advises the model to **ask the user the question itself in ordinary
238
+ text**, including the question and options.
239
+ - Invalid questionnaire — `invalid_request`: a request error, **not** a UI failure;
240
+ the model is told to fix the call. Other errors (`empty_answer`, `internal`) are
241
+ reported plainly with no retry instruction.
242
+
243
+ The ordinary-text advice is tool output only. It is not a route, not an answer,
244
+ and no questionnaire is handed back as if the user had answered it.
245
+
246
+ ## Runtime events
247
+
248
+ Pi already emits generic `ui_prompt_start` / `ui_prompt_end` around a blocking
249
+ extension UI, but they carry no result semantics. The tool adds result-bearing
250
+ events on `pi.events` (default payloads never include the question, the answers,
251
+ or free text):
252
+
253
+ | Event | When | Payload (default) |
254
+ | --- | --- | --- |
255
+ | `herdr:blocked` | entering the interactive wait | `{ active: true, label: "Waiting for user response", callId? }` |
256
+ | `herdr:blocked` | the wait terminated, once, for every outcome | `{ active: false, callId? }` |
257
+ | `ask:answered` | the user answered | `{ callId?, route, status: "answered" }` |
258
+ | `ask:aborted` | dismissed or caller-aborted | `{ callId?, route, status: "aborted", cancelledReason: "user" \| "abort" }` |
259
+ | `ask:timeout` | the shared deadline expired | `{ callId?, route, status: "timeout" }` |
260
+ | `ask:error` | the renderer failed / the UI could not finish | `{ callId?, route?, status: "error", errorCode }` |
261
+
262
+ Guarantees:
263
+
264
+ - `herdr:blocked` `{ active: true }` is emitted **only when the tool actually
265
+ enters the interactive wait**, and is always followed by exactly one matching
266
+ `{ active: false }`, including abort, timeout, and error.
267
+ - Invalid (`invalid_request`) and unavailable/refused requests emit **nothing**:
268
+ no UI is attempted, so neither a blocked pair nor an outcome event is sent.
269
+ - Each outcome event is emitted **exactly once per UI attempt** and carries the
270
+ correlation id (the tool call id, when available) plus route/status.
271
+ - The full question, answers, and free text are never broadcast by default.
272
+
273
+ A direct `createAskUser` caller has no Pi event bus. Instead of faking one, pass
274
+ an optional sink — `createAskUser(ctx, { events: { waitStarted, waitEnded } })`,
275
+ or `createAskUser(host, { events })` — or accept that no runtime events are
276
+ emitted. `AskUserEventSink` is exported for this.
277
+
278
+ ## Overlay show/hide key
279
+
280
+ On the **custom overlay route only** (never inline, never native), the overlay
281
+ can be hidden and restored with the same key:
282
+
283
+ - default `alt+o`; configurable via `overlayToggleKey` and disableable with
284
+ `null` / `"off"` / `"none"` / `"disabled"`; reserved/conflicting keys are
285
+ rejected by config validation (see above) so `Esc` always keeps cancelling;
286
+ - implemented with `ctx.ui.custom(..., { onHandle })` →
287
+ `OverlayHandle.setHidden()`, plus a raw `ctx.ui.onTerminalInput` listener so the
288
+ **same** key restores the overlay even while hidden (a hidden overlay receives
289
+ no component input);
290
+ - the toggle key is consumed; Kitty press/repeat/release events are handled so a
291
+ single physical press cannot hide and immediately re-show it;
292
+ - the first hide shows a one-time discoverable restore notice
293
+ (`ask_user hidden — press <key> to reopen`);
294
+ - cleanup is idempotent and **does not depend on `ui.custom()` resolving**. The
295
+ core settles a timeout/abort independently, so the renderer listens to the
296
+ interaction signal and removes the raw listener, drops the handle, and aborts
297
+ the component immediately; the same cleanup also runs from `finally`. A
298
+ renderer that ignores its signal and never resolves therefore cannot keep
299
+ capturing keys or leave UI side effects after termination;
300
+ - hiding neither resolves the interaction nor pauses its deadline — aborts and
301
+ timeouts still complete while hidden — and `Esc` still cancels;
302
+ - inline display and the native route register no listener at all.
303
+
304
+ ## Results
305
+
306
+ ```ts
307
+ type AskUserStatus = "answered" | "aborted" | "timeout" | "error";
308
+
309
+ interface AskUserResult {
310
+ status: AskUserStatus;
311
+ route?: "custom" | "native"; // absent when no route ran or none exists
312
+ answers: AskUserAnswer[]; // selections (labels), optional freeText, usedDefault
313
+ cancelledReason?: "user" | "abort";
314
+ warnings?: string[];
315
+ error?: { code: AskUserErrorCode; message: string };
316
+ }
317
+ ```
318
+
319
+ `aborted` covers both a user dismissal and a caller abort, distinguished by
320
+ `cancelledReason`. Errors use `empty_answer`, `invalid_request`,
321
+ `custom_ui_failed`, `native_ui_failed`, `invalid_config`, `unsupported_mode`,
322
+ `not_initialized`, or `internal`. `route` is present only when a route ran — or,
323
+ for a refused explicit config, names the requested route.
324
+
325
+ ## Key constraints
326
+
327
+ - **The deadline is shared and owned by the core.** Per questionnaire it is
328
+ strictly `timeoutPerQuestionMs × questionCount` (default 60 s each), clamped to
329
+ `MAX_SAFE_TIMEOUT_MS` (2³¹−1); there is no absolute override. The deadline and
330
+ the caller's cancellation are raced independently of the renderer, so a renderer
331
+ 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.
334
+ - **A missing route is an error, not a downgrade.** JSON/print, or an explicit
335
+ `mode` that cannot run, is refused with `unsupported_mode` and never replaced by
336
+ 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.
340
+ - **Events never leak content by default.** `herdr:blocked` bounds a real wait
341
+ exactly once on each side; outcome events are one per UI attempt and carry only
342
+ correlation id, route, and status.
343
+ - **The overlay toggle is overlay-only.** Inline and native register no raw
344
+ input listener; hiding never pauses the deadline.
345
+ - **The native route uses single-line `input`**, one question at a time (not
346
+ `editor`, which accepts no timeout/signal). The number and an optional note
347
+ share one box: `2`, `2 | note`, or free text; a number list may be
348
+ comma/space/`,`/`、` separated (`1,3`). Out-of-range or malformed numbers
349
+ re-prompt, and an empty box is rejected unless the question has a `default`.
350
+ - **Tool calls run sequentially** (`executionMode: "sequential"`).
351
+ - **Short terminals degrade in a defined order:** the pinned free-input row and
352
+ 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`.
354
+
355
+ ## Development
356
+
357
+ ```bash
358
+ npm install
359
+ npm run typecheck # tsc --noEmit
360
+ npm test # node --test test/*.test.ts (Node ≥ 22.6 runs TS directly)
361
+ npm run check # typecheck + tests
362
+ ```
363
+
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.
package/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./src/index.ts";
2
+ export { default } from "./src/index.ts";
package/package.json CHANGED
@@ -1,6 +1,64 @@
1
1
  {
2
2
  "name": "@black942026/pi-ask-user",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "AskUser - a route-forced ask-user interaction component/tool for Pi extensions (custom TUI or native dialogs)",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/black-94/pi-ask-user.git"
10
+ },
11
+ "homepage": "https://github.com/black-94/pi-ask-user#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/black-94/pi-ask-user/issues"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "engines": {
19
+ "node": ">=22.19.0"
20
+ },
21
+ "keywords": [
22
+ "pi-package",
23
+ "pi-extension",
24
+ "pi",
25
+ "pi-coding-agent",
26
+ "extension",
27
+ "ask_user",
28
+ "AskUser",
29
+ "interactive"
30
+ ],
31
+ "pi": {
32
+ "extensions": [
33
+ "./index.ts"
34
+ ]
35
+ },
36
+ "exports": {
37
+ ".": "./index.ts",
38
+ "./types": "./src/types.ts"
39
+ },
40
+ "files": [
41
+ "index.ts",
42
+ "src",
43
+ "README.md",
44
+ "LICENSE"
45
+ ],
46
+ "scripts": {
47
+ "test": "node --test test/*.test.ts",
48
+ "typecheck": "tsc --noEmit",
49
+ "check": "npm run typecheck && npm run test",
50
+ "prepublishOnly": "npm run check"
51
+ },
52
+ "peerDependencies": {
53
+ "@earendil-works/pi-coding-agent": ">=0.87.0",
54
+ "@earendil-works/pi-tui": ">=0.87.0",
55
+ "typebox": "*"
56
+ },
57
+ "devDependencies": {
58
+ "@earendil-works/pi-coding-agent": "0.87.1",
59
+ "@earendil-works/pi-tui": "0.87.1",
60
+ "@types/node": "22.10.2",
61
+ "typebox": "1.3.27",
62
+ "typescript": "5.9.3"
63
+ }
64
+ }