@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/src/core.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { combineSignals, createDeadline, safeTimeoutMs } from "./deadline.ts";
2
+ import { validateAnswer } from "./answers.ts";
2
3
  import { AskUserValidationError, normalizeAskUserRequest } from "./schema.ts";
3
4
  import type {
4
5
  AskUIInput,
@@ -92,9 +93,17 @@ export async function askUserNormalized(
92
93
  const effectiveRequest = applyPreferences(request, host.preferences);
93
94
  const deadline = createDeadline(effectiveRequest.totalTimeoutMs, now);
94
95
  const deadlineController = new AbortController();
96
+ const timeoutCancelled = new AbortController();
95
97
  const linked = combineSignals(signal, deadlineController.signal);
96
98
 
97
99
  const input: AskUIInput = { request: effectiveRequest, deadline, signal: linked.signal };
100
+ if (route === "custom") {
101
+ input.cancelTimeout = () => {
102
+ if (!deadline.cancel()) return false;
103
+ timeoutCancelled.abort();
104
+ return true;
105
+ };
106
+ }
98
107
  if (onUpdate) input.onUpdate = onUpdate;
99
108
 
100
109
  // Wait events are emitted exactly around a real UI attempt: `waitStarted`
@@ -138,12 +147,14 @@ export async function askUserNormalized(
138
147
  }
139
148
 
140
149
  // 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.
150
+ // cannot hang the call unless the user explicitly disables the timeout.
151
+ // The deadline and caller's cancellation are raced independently against
152
+ // the renderer; a late renderer result is ignored.
143
153
  let race: DeadlineRace;
144
154
  try {
145
- race = await raceWithDeadline(renderPromise, deadline.remainingMs(), signal, () =>
146
- deadlineController.abort(),
155
+ race = await raceWithDeadline(
156
+ renderPromise, deadline.remainingMs(), signal,
157
+ () => deadlineController.abort(), timeoutCancelled.signal,
147
158
  );
148
159
  } finally {
149
160
  // Always release the listeners installed on the caller's signal.
@@ -178,7 +189,8 @@ type DeadlineRace =
178
189
  *
179
190
  * This is what makes the timeout guarantee independent of renderer behaviour: a
180
191
  * renderer that never resolves (or that ignores its signal) still yields
181
- * `timeout`. Late results are discarded, and the rejection path is always
192
+ * `timeout`, unless the user explicitly cancels the timeout. Late results are
193
+ * discarded, and the rejection path is always
182
194
  * handled so a late failure cannot become an unhandled rejection.
183
195
  */
184
196
  function raceWithDeadline(
@@ -186,15 +198,23 @@ function raceWithDeadline(
186
198
  remainingMs: number,
187
199
  callerSignal: AbortSignal | undefined,
188
200
  onDeadline: () => void,
201
+ timeoutCancelled: AbortSignal,
189
202
  ): Promise<DeadlineRace> {
190
203
  let timer: ReturnType<typeof setTimeout> | undefined;
191
204
  let abortHandler: (() => void) | undefined;
205
+ const clearDeadlineTimer = (): void => {
206
+ if (timer !== undefined) clearTimeout(timer);
207
+ timer = undefined;
208
+ };
192
209
  const racers: Array<Promise<DeadlineRace>> = [
193
210
  render.then(
194
211
  (outcome): DeadlineRace => ({ kind: "outcome", outcome }),
195
212
  (error): DeadlineRace => ({ kind: "outcome", outcome: { kind: "error", message: messageOf(error) } }),
196
213
  ),
197
214
  new Promise<DeadlineRace>((resolve) => {
215
+ // Cancellation may happen synchronously during renderer construction.
216
+ if (timeoutCancelled.aborted) return;
217
+ timeoutCancelled.addEventListener("abort", clearDeadlineTimer, { once: true });
198
218
  // `safeTimeoutMs` prevents a >32-bit delay from firing immediately.
199
219
  timer = setTimeout(() => {
200
220
  onDeadline();
@@ -215,7 +235,8 @@ function raceWithDeadline(
215
235
  );
216
236
  }
217
237
  return Promise.race(racers).finally(() => {
218
- if (timer) clearTimeout(timer);
238
+ clearDeadlineTimer();
239
+ timeoutCancelled.removeEventListener("abort", clearDeadlineTimer);
219
240
  // Remove the listener when the render settled first, so repeated calls do
220
241
  // not accumulate listeners on a long-lived caller signal.
221
242
  if (abortHandler && callerSignal) callerSignal.removeEventListener("abort", abortHandler);
@@ -252,7 +273,15 @@ function mapOutcome(
252
273
  ): AskUserResult {
253
274
  switch (outcome.kind) {
254
275
  case "submitted": {
255
- const answers = outcome.answers ?? [];
276
+ if (!Array.isArray(outcome.answers)) {
277
+ return {
278
+ status: "error",
279
+ route,
280
+ answers: [],
281
+ error: { code: "internal", message: `${route} UI returned a malformed answers payload.` },
282
+ };
283
+ }
284
+ const answers = outcome.answers;
256
285
  if (answers.length !== request.questions.length) {
257
286
  return {
258
287
  status: "error",
@@ -264,15 +293,23 @@ function mapOutcome(
264
293
  },
265
294
  };
266
295
  }
267
- const empty = answers.find(
268
- (answer) => answer.selections.length === 0 && (answer.freeText ?? "").trim() === "",
269
- );
270
- if (empty) {
296
+ for (let index = 0; index < answers.length; index += 1) {
297
+ const answer = answers[index]!;
298
+ let invalid: string | undefined;
299
+ try {
300
+ invalid = validateAnswer(request.questions[index]!, answer);
301
+ } catch (error) {
302
+ invalid = `answer validation failed: ${messageOf(error)}`;
303
+ }
304
+ if (!invalid) continue;
271
305
  return {
272
306
  status: "error",
273
307
  route,
274
308
  answers,
275
- error: { code: "empty_answer", message: `The answer to question "${empty.title}" is empty.` },
309
+ error: {
310
+ code: invalid.includes("empty") || invalid.includes("missing") ? "empty_answer" : "internal",
311
+ message: `The ${route} UI returned an invalid answer for question "${request.questions[index]!.title}": ${invalid}.`,
312
+ },
276
313
  };
277
314
  }
278
315
  return { status: "answered", route, answers };
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/index.ts CHANGED
@@ -38,6 +38,7 @@ export {
38
38
  DEFAULT_TIMEOUT_PER_QUESTION_MS,
39
39
  LIMITS,
40
40
  MAX_OPTIONS,
41
+ MAX_FORM_FIELDS,
41
42
  MAX_QUESTIONS,
42
43
  normalizeAskUserRequest,
43
44
  } from "./schema.ts";
@@ -77,6 +78,7 @@ export {
77
78
  emptyDraft,
78
79
  finalizeAnswers,
79
80
  toAnswer,
81
+ validateAnswer,
80
82
  type DraftAnswer,
81
83
  type FinalizeResult,
82
84
  } from "./answers.ts";
@@ -98,7 +100,7 @@ export interface AskUserToolDetails {
98
100
  progress?: string;
99
101
  }
100
102
 
101
- const DESCRIPTION = "Ask the user one or more structured questions through the host's interactive UI.";
103
+ const DESCRIPTION = "Ask the user structured single-choice, multi-choice, text, boolean, or flat form questions through the host's interactive UI.";
102
104
 
103
105
  const PROMPT_SNIPPET = "Ask the user structured questions when you would otherwise guess.";
104
106
 
@@ -106,6 +108,7 @@ const PROMPT_GUIDELINES = [
106
108
  "Use ask_user when a choice is high-impact or ambiguous and you cannot infer the answer.",
107
109
  "If the tool reports the UI could not run, ask the user the question yourself in ordinary text instead.",
108
110
  "Never invent an answer; on aborted or timeout, report it plainly and do not re-ask.",
111
+ "Use kind=bool for a real boolean. Use kind=simple-form with flat uniquely named fields when several typed values belong in one logical question; forms cannot nest.",
109
112
  ];
110
113
 
111
114
  /**
@@ -136,6 +139,12 @@ function describeRequestAsText(request: NormalizedRequest): string {
136
139
  const description = option.description?.trim();
137
140
  lines.push(` ${optionIndex + 1}) ${option.label}${description ? ` — ${description}` : ""}`);
138
141
  });
142
+ if (question.kind === "bool") lines.push(" answer: true or false");
143
+ for (const field of question.fields ?? []) {
144
+ lines.push(` - ${field.label} [${field.name}: ${field.kind}]${field.required ? " required" : " optional"}`);
145
+ field.options.forEach((option, optionIndex) => lines.push(` ${optionIndex + 1}) ${option.label}`));
146
+ if (field.hasDefault) lines.push(` default: ${JSON.stringify(field.default)}`);
147
+ }
139
148
  if (question.hasDefault && question.default !== undefined) lines.push(` default: ${question.default}`);
140
149
  });
141
150
  return lines.join("\n");
@@ -151,6 +160,10 @@ function buildModelContent(result: AskUserResult, request: NormalizedRequest): s
151
160
  switch (result.status) {
152
161
  case "answered": {
153
162
  const lines = result.answers.map((answer) => {
163
+ if (answer.value !== undefined) {
164
+ const suffix = answer.usedDefault ? " (default used)" : "";
165
+ return `- ${answer.title}: ${JSON.stringify(answer.value)}${suffix}`;
166
+ }
154
167
  const parts: string[] = [];
155
168
  if (answer.selections.length > 0) parts.push(answer.selections.join(", "));
156
169
  if (answer.freeText) parts.push(answer.freeText);
@@ -211,7 +224,9 @@ export interface RegisterAskUserOptions {
211
224
  timeoutPerQuestionMs?: number;
212
225
  /**
213
226
  * Path to the user config file, or `false` to skip reading it. Defaults to
214
- * `~/.pi/ask-user/config.json`. There are no environment variables.
227
+ * `<Pi agent directory>/ask-user.json` (normally `~/.pi/agent/ask-user.json`).
228
+ * The directory follows Pi's `PI_CODING_AGENT_DIR`; there are no AskUser-specific
229
+ * environment-variable preferences.
215
230
  */
216
231
  configFile?: string | false;
217
232
  }
package/src/parse.ts CHANGED
@@ -54,6 +54,9 @@ export function parseNativeAnswer(question: NormalizedQuestion, raw: string): Pa
54
54
  if (question.kind === "input") {
55
55
  return { ok: true, value: { selections: [], freeText: trimmed, empty: false } };
56
56
  }
57
+ if (question.kind === "bool" || question.kind === "simple-form") {
58
+ return { ok: false, error: `${question.kind} uses typed native input and cannot be parsed as a legacy answer.` };
59
+ }
57
60
 
58
61
  const pipeIndex = raw.indexOf("|");
59
62
  const left = pipeIndex >= 0 ? raw.slice(0, pipeIndex) : raw;
@@ -115,5 +118,9 @@ export function nativeInputHint(question: NormalizedQuestion): string {
115
118
  : "Free input. Reply with text to answer.";
116
119
  case "input":
117
120
  return "Free input. Reply with text to answer.";
121
+ case "bool":
122
+ return "Boolean. Reply with true or false.";
123
+ case "simple-form":
124
+ return "Form fields are asked one at a time.";
118
125
  }
119
126
  }