@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 +21 -0
- package/README.md +365 -2
- package/index.ts +2 -0
- package/package.json +62 -4
- package/src/adapters/pi.ts +238 -0
- package/src/answers.ts +84 -0
- package/src/ask.ts +103 -0
- package/src/config.ts +390 -0
- package/src/core.ts +302 -0
- package/src/deadline.ts +78 -0
- package/src/events.ts +96 -0
- package/src/index.ts +358 -0
- package/src/mode.ts +18 -0
- package/src/parse.ts +119 -0
- package/src/route.ts +144 -0
- package/src/schema.ts +255 -0
- package/src/types.ts +343 -0
- package/src/ui/custom.ts +887 -0
- package/src/ui/native.ts +112 -0
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
|
-
#
|
|
1
|
+
# @black942026/pi-ask-user
|
|
2
2
|
|
|
3
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@black942026/pi-ask-user",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|