@black942026/pi-ask-user 0.1.0 → 0.1.1

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, and free-text
5
+ 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
 
@@ -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
 
@@ -111,8 +114,8 @@ against the questionnaire's own controls. A malformed spec (`alt+banana`), a
111
114
  reserved key (`escape`/`esc` — which must keep cancelling — plus
112
115
  `enter`/`tab`/arrows/`space`/`pageUp`/…), a bare printable key that would swallow
113
116
  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.
117
+ `ctrl+n`, `ctrl+enter`, `alt+q`) are each rejected with a specific reason. A key
118
+ is never silently substituted.
116
119
 
117
120
  ### Effective precedence and read timing
118
121
 
@@ -275,6 +278,19 @@ an optional sink — `createAskUser(ctx, { events: { waitStarted, waitEnded } })
275
278
  or `createAskUser(host, { events })` — or accept that no runtime events are
276
279
  emitted. `AskUserEventSink` is exported for this.
277
280
 
281
+ ## Cancel countdown shortcut
282
+
283
+ In the **custom terminal UI** (overlay or inline), press **`Alt+Q`** to cancel
284
+ this questionnaire's countdown without dismissing it or changing your answers.
285
+ The header shows **`No timeout`**; you can keep answering, submit normally, or
286
+ press `Esc` to dismiss. Caller aborts still work. This is a one-way change for
287
+ the current questionnaire only; the next call starts with its configured timeout.
288
+ An already-expired deadline cannot be cancelled.
289
+
290
+ The key is shown in the bottom hints and reserved against `overlayToggleKey`
291
+ conflicts. Native input dialogs do not support this shortcut. If an overlay is
292
+ hidden, restore it first with its show/hide key before pressing `Alt+Q`.
293
+
278
294
  ## Overlay show/hide key
279
295
 
280
296
  On the **custom overlay route only** (never inline, never native), the overlay
@@ -325,12 +341,14 @@ for a refused explicit config, names the requested route.
325
341
  ## Key constraints
326
342
 
327
343
  - **The deadline is shared and owned by the core.** Per questionnaire it is
328
- strictly `timeoutPerQuestionMs × questionCount` (default 60 s each), clamped to
344
+ initially `timeoutPerQuestionMs × questionCount` (default 60 s each), clamped to
329
345
  `MAX_SAFE_TIMEOUT_MS` (2³¹−1); there is no absolute override. The deadline and
330
346
  the caller's cancellation are raced independently of the renderer, so a renderer
331
347
  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.
348
+ `timeout`/`aborted`, unless the user explicitly cancels the timeout with `Alt+Q`
349
+ in the custom UI (caller aborts remain effective); late results are discarded.
350
+ Cancellation is checked before any route branch, so an already-aborted call
351
+ never renders a UI.
334
352
  - **A missing route is an error, not a downgrade.** JSON/print, or an explicit
335
353
  `mode` that cannot run, is refused with `unsupported_mode` and never replaced by
336
354
  another route.
@@ -357,10 +375,16 @@ for a refused explicit config, names the requested route.
357
375
  ```bash
358
376
  npm install
359
377
  npm run typecheck # tsc --noEmit
360
- npm test # node --test test/*.test.ts (Node ≥ 22.6 runs TS directly)
378
+ npm test # node --test test/*.test.ts
361
379
  npm run check # typecheck + tests
380
+
381
+ # Load a local checkout for development
382
+ pi --extension ./index.ts
383
+ # Or install it as a local package
384
+ pi install /absolute/path/to/pi-ask-user
362
385
  ```
363
386
 
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.
387
+ `@earendil-works/pi-tui` and `typebox` are host-provided peer dependencies used
388
+ by the custom UI and parameter schema (`src/schema.ts`). They are also declared
389
+ as development dependencies for local checks. The routing, parsing, and deadline
390
+ layers are pure, non-TUI, and free of both.
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.1",
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": {
@@ -195,6 +195,7 @@ export function createPiCustomRenderer(ui: ExtensionUIContext, options: PiCustom
195
195
  component = new AskUserComponent({
196
196
  request,
197
197
  deadline,
198
+ cancelTimeout: input.cancelTimeout,
198
199
  theme: theme as unknown as AskUserTheme,
199
200
  tui,
200
201
  done,
package/src/config.ts CHANGED
@@ -32,6 +32,8 @@ export const USER_CONFIG_PATH = join(USER_CONFIG_DIR, "config.json");
32
32
 
33
33
  /** Built-in default key that toggles the overlay between hidden and visible. */
34
34
  export const DEFAULT_OVERLAY_TOGGLE_KEY = "alt+o";
35
+ /** Cancel the current questionnaire's countdown without dismissing the UI. */
36
+ export const CANCEL_TIMEOUT_KEY = "alt+q";
35
37
  /** Built-in default custom-UI presentation. */
36
38
  export const DEFAULT_DISPLAY_MODE: AskUserDisplayMode = "overlay";
37
39
 
@@ -129,7 +131,7 @@ const RESERVED_BASES = new Set([
129
131
  ]);
130
132
  /** Modifier chords that conflict with a questionnaire control. */
131
133
  const RESERVED_SIGNATURES = new Set(
132
- ["ctrl+c", "ctrl+d", "ctrl+u", "ctrl+p", "ctrl+n", "ctrl+enter"].map((id) => {
134
+ ["ctrl+c", "ctrl+d", "ctrl+u", "ctrl+p", "ctrl+n", "ctrl+enter", CANCEL_TIMEOUT_KEY].map((id) => {
133
135
  const parts = id.split("+");
134
136
  const base = parts.pop()!;
135
137
  return [...parts.sort(), base].join("+");
package/src/core.ts CHANGED
@@ -92,9 +92,17 @@ export async function askUserNormalized(
92
92
  const effectiveRequest = applyPreferences(request, host.preferences);
93
93
  const deadline = createDeadline(effectiveRequest.totalTimeoutMs, now);
94
94
  const deadlineController = new AbortController();
95
+ const timeoutCancelled = new AbortController();
95
96
  const linked = combineSignals(signal, deadlineController.signal);
96
97
 
97
98
  const input: AskUIInput = { request: effectiveRequest, deadline, signal: linked.signal };
99
+ if (route === "custom") {
100
+ input.cancelTimeout = () => {
101
+ if (!deadline.cancel()) return false;
102
+ timeoutCancelled.abort();
103
+ return true;
104
+ };
105
+ }
98
106
  if (onUpdate) input.onUpdate = onUpdate;
99
107
 
100
108
  // Wait events are emitted exactly around a real UI attempt: `waitStarted`
@@ -138,12 +146,14 @@ export async function askUserNormalized(
138
146
  }
139
147
 
140
148
  // The core owns the guarantee: even a renderer that ignores its AbortSignal
141
- // cannot hang the call. The deadline and the caller's cancellation are raced
142
- // independently against the renderer; a late renderer result is ignored.
149
+ // cannot hang the call unless the user explicitly disables the timeout.
150
+ // The deadline and caller's cancellation are raced independently against
151
+ // the renderer; a late renderer result is ignored.
143
152
  let race: DeadlineRace;
144
153
  try {
145
- race = await raceWithDeadline(renderPromise, deadline.remainingMs(), signal, () =>
146
- deadlineController.abort(),
154
+ race = await raceWithDeadline(
155
+ renderPromise, deadline.remainingMs(), signal,
156
+ () => deadlineController.abort(), timeoutCancelled.signal,
147
157
  );
148
158
  } finally {
149
159
  // Always release the listeners installed on the caller's signal.
@@ -178,7 +188,8 @@ type DeadlineRace =
178
188
  *
179
189
  * This is what makes the timeout guarantee independent of renderer behaviour: a
180
190
  * renderer that never resolves (or that ignores its signal) still yields
181
- * `timeout`. Late results are discarded, and the rejection path is always
191
+ * `timeout`, unless the user explicitly cancels the timeout. Late results are
192
+ * discarded, and the rejection path is always
182
193
  * handled so a late failure cannot become an unhandled rejection.
183
194
  */
184
195
  function raceWithDeadline(
@@ -186,15 +197,23 @@ function raceWithDeadline(
186
197
  remainingMs: number,
187
198
  callerSignal: AbortSignal | undefined,
188
199
  onDeadline: () => void,
200
+ timeoutCancelled: AbortSignal,
189
201
  ): Promise<DeadlineRace> {
190
202
  let timer: ReturnType<typeof setTimeout> | undefined;
191
203
  let abortHandler: (() => void) | undefined;
204
+ const clearDeadlineTimer = (): void => {
205
+ if (timer !== undefined) clearTimeout(timer);
206
+ timer = undefined;
207
+ };
192
208
  const racers: Array<Promise<DeadlineRace>> = [
193
209
  render.then(
194
210
  (outcome): DeadlineRace => ({ kind: "outcome", outcome }),
195
211
  (error): DeadlineRace => ({ kind: "outcome", outcome: { kind: "error", message: messageOf(error) } }),
196
212
  ),
197
213
  new Promise<DeadlineRace>((resolve) => {
214
+ // Cancellation may happen synchronously during renderer construction.
215
+ if (timeoutCancelled.aborted) return;
216
+ timeoutCancelled.addEventListener("abort", clearDeadlineTimer, { once: true });
198
217
  // `safeTimeoutMs` prevents a >32-bit delay from firing immediately.
199
218
  timer = setTimeout(() => {
200
219
  onDeadline();
@@ -215,7 +234,8 @@ function raceWithDeadline(
215
234
  );
216
235
  }
217
236
  return Promise.race(racers).finally(() => {
218
- if (timer) clearTimeout(timer);
237
+ clearDeadlineTimer();
238
+ timeoutCancelled.removeEventListener("abort", clearDeadlineTimer);
219
239
  // Remove the listener when the render settled first, so repeated calls do
220
240
  // not accumulate listeners on a long-lived caller signal.
221
241
  if (abortHandler && callerSignal) callerSignal.removeEventListener("abort", abortHandler);
package/src/deadline.ts CHANGED
@@ -13,27 +13,43 @@ export function safeTimeoutMs(ms: number): number {
13
13
  return Math.min(Math.floor(ms), MAX_SAFE_TIMEOUT_MS);
14
14
  }
15
15
 
16
+ /** A shared deadline that the user can disable for the current questionnaire. */
17
+ export interface CancellableDeadline extends Deadline {
18
+ /** Disable expiry. Returns false if the deadline has already expired. */
19
+ cancel(): boolean;
20
+ }
21
+
16
22
  /**
17
23
  * Create a single overall deadline for a questionnaire.
18
24
  *
19
- * The deadline is always `timeoutMs` (which callers compute as
25
+ * The initial deadline is `totalMs` (which callers compute as
20
26
  * `timeoutPerQuestionMs × questionCount`). It is clamped to
21
27
  * {@link MAX_SAFE_TIMEOUT_MS} so `setTimeout` never overflows. Every interaction
22
28
  * for the questionnaire shares this one deadline; native dialogs receive only
23
- * the remaining time. A deadline never degrades to another route.
29
+ * the remaining time. `cancel()` disables expiry but does not clear any timers;
30
+ * the core and custom UI clear their timers separately. A deadline never
31
+ * degrades to another route.
24
32
  */
25
- export function createDeadline(totalMs: number, now: () => number = Date.now): Deadline {
33
+ export function createDeadline(totalMs: number, now: () => number = Date.now): CancellableDeadline {
26
34
  const startedAt = now();
27
35
  const safeTotal = safeTimeoutMs(totalMs);
28
36
  const expiresAt = safeTotal === 0 ? startedAt : startedAt + safeTotal;
37
+ let cancelled = false;
29
38
  return {
30
39
  startedAt,
31
- expiresAt,
40
+ get expiresAt(): number {
41
+ return cancelled ? Infinity : expiresAt;
42
+ },
32
43
  remainingMs(): number {
33
- return Math.max(0, expiresAt - now());
44
+ return cancelled ? Infinity : Math.max(0, expiresAt - now());
34
45
  },
35
46
  expired(): boolean {
36
- return now() >= expiresAt;
47
+ return !cancelled && now() >= expiresAt;
48
+ },
49
+ cancel(): boolean {
50
+ if (!cancelled && now() >= expiresAt) return false;
51
+ cancelled = true;
52
+ return true;
37
53
  },
38
54
  };
39
55
  }
package/src/types.ts CHANGED
@@ -70,8 +70,8 @@ export interface AskUserRequest {
70
70
  /**
71
71
  * Base timeout per question in milliseconds. Defaults to 60000.
72
72
  *
73
- * The questionnaire's single deadline is always
74
- * `timeoutPerQuestionMs × questionCount`; there is no absolute override.
73
+ * The questionnaire's initial shared deadline is
74
+ * `timeoutPerQuestionMs × questionCount`; the user can cancel it in the custom UI.
75
75
  */
76
76
  timeoutPerQuestionMs?: number;
77
77
  }
@@ -220,6 +220,8 @@ export type AskUserSupport =
220
220
  export interface AskUIInput {
221
221
  request: NormalizedRequest;
222
222
  deadline: Deadline;
223
+ /** Custom UI only: disable this questionnaire's timeout; false if already expired. */
224
+ cancelTimeout?: () => boolean;
223
225
  signal?: AbortSignal;
224
226
  /** Report progress such as "Waiting for user input...". */
225
227
  onUpdate?: (text: string) => void;
package/src/ui/custom.ts CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  Input,
3
3
  Key,
4
4
  Markdown,
5
+ isKeyRelease,
5
6
  matchesKey,
6
7
  truncateToWidth,
7
8
  visibleWidth,
@@ -10,6 +11,7 @@ import {
10
11
  import type { Component, Focusable, MarkdownTheme } from "@earendil-works/pi-tui";
11
12
  import { draftFromDefault, draftIsEmpty, emptyDraft, finalizeAnswers, type DraftAnswer } from "../answers.ts";
12
13
  import type { AskUserAnswer, Deadline, NormalizedQuestion, NormalizedRequest } from "../types.ts";
14
+ import { CANCEL_TIMEOUT_KEY } from "../config.ts";
13
15
 
14
16
  /** Structural theme surface the component needs (a real Pi Theme satisfies it). */
15
17
  export interface AskUserTheme {
@@ -35,6 +37,8 @@ export interface CustomUIResult {
35
37
  export interface AskUserComponentOptions {
36
38
  request: NormalizedRequest;
37
39
  deadline: Deadline;
40
+ /** Disable the core deadline as well as this component's local timer. */
41
+ cancelTimeout?: () => boolean;
38
42
  theme: AskUserTheme;
39
43
  tui: AskUserTUI;
40
44
  done: (result: CustomUIResult) => void;
@@ -132,6 +136,8 @@ function printableText(data: string): string | undefined {
132
136
  export class AskUserComponent implements Component, Focusable {
133
137
  private readonly request: NormalizedRequest;
134
138
  private readonly deadline: Deadline;
139
+ private readonly cancelTimeout: (() => boolean) | undefined;
140
+ private timeoutCancelled = false;
135
141
  private readonly theme: AskUserTheme;
136
142
  private readonly tui: AskUserTUI;
137
143
  private readonly done: (result: CustomUIResult) => void;
@@ -164,6 +170,7 @@ export class AskUserComponent implements Component, Focusable {
164
170
  constructor(options: AskUserComponentOptions) {
165
171
  this.request = options.request;
166
172
  this.deadline = options.deadline;
173
+ this.cancelTimeout = options.cancelTimeout;
167
174
  this.theme = options.theme;
168
175
  this.tui = options.tui;
169
176
  this.done = options.done;
@@ -369,6 +376,14 @@ export class AskUserComponent implements Component, Focusable {
369
376
  handleInput(data: string): void {
370
377
  if (this.finished) return;
371
378
 
379
+ if (matchesKey(data, CANCEL_TIMEOUT_KEY)) {
380
+ if (isKeyRelease(data) || this.timeoutCancelled || this.deadline.expired()) return;
381
+ if (this.cancelTimeout && !this.cancelTimeout()) return;
382
+ this.timeoutCancelled = true;
383
+ this.dispose();
384
+ this.refresh();
385
+ return;
386
+ }
372
387
  if (matchesKey(data, Key.escape)) {
373
388
  this.finish({ kind: "cancelled" });
374
389
  return;
@@ -574,6 +589,7 @@ export class AskUserComponent implements Component, Focusable {
574
589
  }
575
590
 
576
591
  private countdownLabel(): string {
592
+ if (this.timeoutCancelled) return "No timeout";
577
593
  const hours = Math.floor(this.remainingSeconds / 3600);
578
594
  const minutes = Math.floor((this.remainingSeconds % 3600) / 60);
579
595
  const seconds = String(this.remainingSeconds % 60).padStart(2, "0");
@@ -789,13 +805,20 @@ export class AskUserComponent implements Component, Focusable {
789
805
  return parts.join(" | ") + suffix;
790
806
  }
791
807
 
792
- private hintLine(): string {
793
- const toggleHint = this.overlayToggleKey ? `${this.overlayToggleKey} hide` : undefined;
808
+ private hintLine(width: number): string {
809
+ // Keep cancellation controls discoverable even when contextual hints grow.
810
+ const controls = ["Esc cancel"];
811
+ if (!this.timeoutCancelled) controls.push(`${CANCEL_TIMEOUT_KEY} stop timer`);
812
+ if (this.overlayToggleKey) controls.push(`${this.overlayToggleKey} hide`);
813
+ const controlText = controls.join(" · ");
814
+ const withControls = (hints: string[]): string => {
815
+ const contextWidth = width - visibleWidth(controlText) - 3;
816
+ const context = contextWidth > 0 ? truncateToWidth(hints.join(" · "), contextWidth) : "";
817
+ return this.theme.fg("dim", context ? `${context} · ${controlText}` : controlText);
818
+ };
794
819
  if (this.isReview) {
795
820
  const reviewHints = ["↓ focus submit", "Enter submit", "Tab switch question"];
796
- if (toggleHint) reviewHints.push(toggleHint);
797
- reviewHints.push("Esc cancel");
798
- return this.theme.fg("dim", reviewHints.join(" · "));
821
+ return withControls(reviewHints);
799
822
  }
800
823
  const question = this.currentQuestion();
801
824
  const hints = ["Tab switch", "↑/↓ move", `Enter ${this.isInputRow() ? "confirm" : "select"}`];
@@ -803,9 +826,7 @@ export class AskUserComponent implements Component, Focusable {
803
826
  if (question.hasDefault) hints.push("s use default");
804
827
  if (this.viewport.maxScroll > 0) hints.push("PgUp/PgDn preview");
805
828
  if (this.isInputRow() && question.options.length > 0) hints.push("↑ back to options");
806
- if (toggleHint) hints.push(toggleHint);
807
- hints.push("Esc cancel");
808
- return this.theme.fg("dim", hints.join(" · "));
829
+ return withControls(hints);
809
830
  }
810
831
 
811
832
  render(width: number): string[] {
@@ -853,7 +874,7 @@ export class AskUserComponent implements Component, Focusable {
853
874
  ? this.wrap(this.theme.fg("warning", `⚠ ${this.notice}`), contentWidth).slice(0, MAX_NOTICE_LINES)
854
875
  : [];
855
876
  const inputBlock = this.isReview ? this.renderSubmitBar(contentWidth) : this.renderInputBar(contentWidth);
856
- const hintBlock = [this.hintLine()];
877
+ const hintBlock = [this.hintLine(contentWidth)];
857
878
 
858
879
  let remaining = contentRows;
859
880
  const take = (block: string[]): string[] => {