@black942026/pi-ask-user 0.0.0-stage → 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/src/schema.ts ADDED
@@ -0,0 +1,255 @@
1
+ import { Type } from "typebox";
2
+ import type { AskUserOption, AskUserQuestion, NormalizedQuestion, NormalizedRequest, QuestionKind } from "./types.ts";
3
+
4
+ export const MAX_QUESTIONS = 5;
5
+ export const MAX_OPTIONS = 5;
6
+ export const DEFAULT_TIMEOUT_PER_QUESTION_MS = 60_000;
7
+
8
+ export const LIMITS = {
9
+ title: 200,
10
+ prompt: 500,
11
+ label: 80,
12
+ description: 240,
13
+ preview: 4000,
14
+ defaultValue: 500,
15
+ header: 200,
16
+ } as const;
17
+
18
+ const ID_MAX_LENGTH = 64;
19
+ const QUESTION_KINDS = ["single", "multi", "input"] as const;
20
+ const QUESTION_FIELDS = ["id", "title", "prompt", "kind", "options", "default"] as const;
21
+ const OPTION_FIELDS = ["label", "description", "preview"] as const;
22
+ const REQUEST_FIELDS = ["questions", "header", "displayMode", "timeoutPerQuestionMs"] as const;
23
+
24
+ /**
25
+ * Flat string enum. Emitted as `{ type: "string", enum: [...] }` because some
26
+ * provider proxies reject `anyOf`/`union` shapes.
27
+ */
28
+ function StringEnum<T extends readonly string[]>(values: T, options: Record<string, unknown> = {}) {
29
+ return Type.Unsafe<T[number]>({ type: "string", enum: [...values], ...options });
30
+ }
31
+
32
+ const OptionSchema = Type.Object({
33
+ label: Type.String({ minLength: 1, maxLength: LIMITS.label }),
34
+ description: Type.Optional(Type.String({ maxLength: LIMITS.description })),
35
+ preview: Type.Optional(Type.String({ maxLength: LIMITS.preview })),
36
+ });
37
+
38
+ const QuestionSchema = Type.Object({
39
+ id: Type.Optional(Type.String({ minLength: 1, maxLength: ID_MAX_LENGTH })),
40
+ title: Type.String({ minLength: 1, maxLength: LIMITS.title }),
41
+ prompt: Type.Optional(Type.String({ maxLength: LIMITS.prompt })),
42
+ kind: Type.Optional(StringEnum(QUESTION_KINDS)),
43
+ options: Type.Optional(Type.Array(OptionSchema, { maxItems: MAX_OPTIONS })),
44
+ default: Type.Optional(Type.String({ maxLength: LIMITS.defaultValue })),
45
+ });
46
+
47
+ /**
48
+ * Parameter schema registered for the `ask_user` tool, and the single input
49
+ * contract for direct `askUser` calls too. Only these fields and types are read;
50
+ * anything else is rejected as `invalid_request` rather than coerced.
51
+ *
52
+ * The UI route is host configuration, resolved by the host adapter — it is never
53
+ * part of a request.
54
+ */
55
+ export const AskUserParams = Type.Object({
56
+ questions: Type.Array(QuestionSchema, { minItems: 1, maxItems: MAX_QUESTIONS }),
57
+ header: Type.Optional(Type.String({ maxLength: LIMITS.header })),
58
+ displayMode: Type.Optional(StringEnum(["overlay", "inline"] as const)),
59
+ timeoutPerQuestionMs: Type.Optional(Type.Number({ minimum: 0 })),
60
+ });
61
+
62
+ export class AskUserValidationError extends Error {
63
+ readonly code = "invalid_request";
64
+
65
+ constructor(message: string) {
66
+ super(message);
67
+ this.name = "AskUserValidationError";
68
+ }
69
+ }
70
+
71
+ function isRecord(value: unknown): value is Record<string, unknown> {
72
+ return typeof value === "object" && value !== null && !Array.isArray(value);
73
+ }
74
+
75
+ /** Reject any field that is not part of the declared contract. */
76
+ function assertKnownKeys(record: Record<string, unknown>, allowed: readonly string[], where: string): void {
77
+ for (const key of Object.keys(record)) {
78
+ if (!allowed.includes(key)) throw new AskUserValidationError(`${where} has an unknown field "${key}".`);
79
+ }
80
+ }
81
+
82
+ /** A required, non-empty, length-bounded string; returns the trimmed value. */
83
+ function requireText(value: unknown, where: string, maxLength: number): string {
84
+ if (typeof value !== "string") throw new AskUserValidationError(`${where} must be a string.`);
85
+ const text = value.trim();
86
+ if (text === "") throw new AskUserValidationError(`${where} must not be empty.`);
87
+ if (text.length > maxLength) throw new AskUserValidationError(`${where} exceeds the ${maxLength}-character limit.`);
88
+ return text;
89
+ }
90
+
91
+ /** An optional, length-bounded string; absent stays absent. */
92
+ function optionalText(value: unknown, where: string, maxLength: number): string | undefined {
93
+ if (value === undefined) return undefined;
94
+ if (typeof value !== "string") throw new AskUserValidationError(`${where} must be a string.`);
95
+ if (value.length > maxLength) throw new AskUserValidationError(`${where} exceeds the ${maxLength}-character limit.`);
96
+ return value;
97
+ }
98
+
99
+ function optionalQuestionId(value: unknown, where: string): string | undefined {
100
+ if (value === undefined) return undefined;
101
+ if (typeof value !== "string") throw new AskUserValidationError(`${where} "id" must be a string.`);
102
+ const id = value.trim();
103
+ if (id === "") throw new AskUserValidationError(`${where} "id" must not be empty.`);
104
+ if (id.length > ID_MAX_LENGTH) throw new AskUserValidationError(`${where} "id" exceeds the ${ID_MAX_LENGTH}-character limit.`);
105
+ return id;
106
+ }
107
+
108
+ /** A canonical option object: `{ label, description?, preview? }`. */
109
+ function readOption(value: unknown, where: string, position: number): AskUserOption {
110
+ const at = `${where} option ${position + 1}`;
111
+ if (!isRecord(value)) throw new AskUserValidationError(`${at} must be an object with a "label".`);
112
+ assertKnownKeys(value, OPTION_FIELDS, at);
113
+ const label = requireText(value.label, `${at} label`, LIMITS.label);
114
+ const option: AskUserOption = { label };
115
+ const description = optionalText(value.description, `${at} description`, LIMITS.description);
116
+ if (description !== undefined) option.description = description;
117
+ const preview = optionalText(value.preview, `${at} preview`, LIMITS.preview);
118
+ if (preview !== undefined) option.preview = preview;
119
+ return option;
120
+ }
121
+
122
+ function readQuestion(value: unknown, index: number): { question: AskUserQuestion; warnings: string[] } {
123
+ const warnings: string[] = [];
124
+ const where = `Question ${index + 1}`;
125
+ if (!isRecord(value)) throw new AskUserValidationError(`${where} must be an object.`);
126
+ assertKnownKeys(value, QUESTION_FIELDS, where);
127
+
128
+ const title = requireText(value.title, `${where} title`, LIMITS.title);
129
+
130
+ let kind: QuestionKind | undefined;
131
+ if (value.kind !== undefined) {
132
+ if (typeof value.kind !== "string" || !QUESTION_KINDS.includes(value.kind as QuestionKind)) {
133
+ throw new AskUserValidationError(`${where} "kind" must be "single", "multi", or "input".`);
134
+ }
135
+ kind = value.kind as QuestionKind;
136
+ }
137
+
138
+ const rawOptions = value.options;
139
+ if (rawOptions !== undefined && !Array.isArray(rawOptions)) {
140
+ throw new AskUserValidationError(`${where} "options" must be an array.`);
141
+ }
142
+ const rawOptionList = rawOptions ?? [];
143
+ if (rawOptionList.length > MAX_OPTIONS) {
144
+ throw new AskUserValidationError(`${where} has more than ${MAX_OPTIONS} options.`);
145
+ }
146
+
147
+ const options: AskUserOption[] = [];
148
+ const seen = new Set<string>();
149
+ rawOptionList.forEach((rawOption, position) => {
150
+ const option = readOption(rawOption, where, position);
151
+ if (seen.has(option.label)) {
152
+ warnings.push(`${where} has a duplicate option "${option.label}"; de-duplicated.`);
153
+ return;
154
+ }
155
+ seen.add(option.label);
156
+ options.push(option);
157
+ });
158
+
159
+ // When `kind` is omitted it is inferred from `options`: present ⇒ `single`,
160
+ // absent ⇒ `input`. `multi` is only ever explicit.
161
+ if (kind === undefined) kind = options.length > 0 ? "single" : "input";
162
+ if (kind !== "input" && options.length === 0) {
163
+ throw new AskUserValidationError(`${where} is a "${kind}" question but has no options.`);
164
+ }
165
+
166
+ const question: AskUserQuestion = { title, kind };
167
+ const prompt = optionalText(value.prompt, `${where} prompt`, LIMITS.prompt);
168
+ if (prompt !== undefined) question.prompt = prompt;
169
+ if (kind === "input") {
170
+ delete question.options;
171
+ } else {
172
+ question.options = options;
173
+ }
174
+ const id = optionalQuestionId(value.id, where);
175
+ if (id) question.id = id;
176
+ const fallback = optionalText(value.default, `${where} default`, LIMITS.defaultValue);
177
+ if (fallback !== undefined) question.default = fallback;
178
+ return { question, warnings };
179
+ }
180
+
181
+ function readDisplayMode(value: unknown): "overlay" | "inline" {
182
+ if (value === undefined) return "overlay";
183
+ if (value !== "overlay" && value !== "inline") {
184
+ throw new AskUserValidationError('displayMode must be "overlay" or "inline".');
185
+ }
186
+ return value;
187
+ }
188
+
189
+ function readTimeoutPerQuestionMs(value: unknown): number {
190
+ if (value === undefined) return DEFAULT_TIMEOUT_PER_QUESTION_MS;
191
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
192
+ throw new AskUserValidationError("timeoutPerQuestionMs must be a non-negative finite number.");
193
+ }
194
+ return Math.floor(value);
195
+ }
196
+
197
+ /** Normalize and validate a request against the {@link AskUserParams} contract. */
198
+ export function normalizeAskUserRequest(raw: unknown): { request: NormalizedRequest; warnings: string[] } {
199
+ if (!isRecord(raw)) throw new AskUserValidationError("Missing the questions array.");
200
+ for (const key of Object.keys(raw)) {
201
+ if (!REQUEST_FIELDS.includes(key as (typeof REQUEST_FIELDS)[number])) {
202
+ throw new AskUserValidationError(`Unknown field "${key}".`);
203
+ }
204
+ }
205
+ const rawQuestions = raw.questions;
206
+ if (!Array.isArray(rawQuestions)) throw new AskUserValidationError("Missing the questions array.");
207
+ if (rawQuestions.length === 0) throw new AskUserValidationError("At least 1 question is required.");
208
+ if (rawQuestions.length > MAX_QUESTIONS) {
209
+ throw new AskUserValidationError(`At most ${MAX_QUESTIONS} questions; received ${rawQuestions.length}.`);
210
+ }
211
+
212
+ const warnings: string[] = [];
213
+ const usedIds = new Set<string>();
214
+ const questions: NormalizedQuestion[] = [];
215
+ rawQuestions.forEach((rawQuestion, index) => {
216
+ const { question, warnings: questionWarnings } = readQuestion(rawQuestion, index);
217
+ warnings.push(...questionWarnings);
218
+
219
+ let id = question.id ?? "";
220
+ if (id === "" || usedIds.has(id)) {
221
+ let candidate = `q${index + 1}`;
222
+ let suffix = 1;
223
+ while (usedIds.has(candidate)) {
224
+ candidate = `q${index + 1}_${suffix++}`;
225
+ }
226
+ id = candidate;
227
+ }
228
+ usedIds.add(id);
229
+
230
+ const normalized: NormalizedQuestion = {
231
+ id,
232
+ index,
233
+ title: question.title,
234
+ kind: question.kind,
235
+ options: question.options ?? [],
236
+ hasDefault: question.default !== undefined,
237
+ };
238
+ if (question.prompt !== undefined) normalized.prompt = question.prompt;
239
+ if (question.default !== undefined) normalized.default = question.default;
240
+ questions.push(normalized);
241
+ });
242
+
243
+ const header = optionalText(raw.header, "header", LIMITS.header);
244
+ const displayMode = readDisplayMode(raw.displayMode);
245
+ const timeoutPerQuestionMs = readTimeoutPerQuestionMs(raw.timeoutPerQuestionMs);
246
+
247
+ const request: NormalizedRequest = {
248
+ questions,
249
+ displayMode,
250
+ timeoutPerQuestionMs,
251
+ totalTimeoutMs: timeoutPerQuestionMs * questions.length,
252
+ };
253
+ if (header !== undefined) request.header = header;
254
+ return { request, warnings };
255
+ }
package/src/types.ts ADDED
@@ -0,0 +1,345 @@
1
+ /**
2
+ * Public types for AskUser.
3
+ *
4
+ * AskUser is a host-adaptive "ask the user" interaction primitive. A request is
5
+ * answered through one of two routes:
6
+ *
7
+ * - `custom` — a custom terminal UI (only a real Pi TUI)
8
+ * - `native` — native input dialogs (`ctx.hasUI` + callable input), one question
9
+ * at a time
10
+ *
11
+ * The route is resolved **once, when the host adapter is created**, from
12
+ * trustworthy inputs only:
13
+ *
14
+ * 1. an explicit configured `mode`, which wins whenever it is supplied, or
15
+ * 2. otherwise an initialisation-time probe of the bound implementations —
16
+ * `custom` when both can run, `native` when only that can run.
17
+ *
18
+ * If an explicitly configured route cannot run here, it is reported as
19
+ * unsupported; the probe result is never substituted for it. If nothing can
20
+ * run, there is no route at all. The model's tool parameters can never
21
+ * influence any of this.
22
+ *
23
+ * Capabilities remain bound to a concrete implementation object, so a route
24
+ * cannot be declared without something that actually performs it.
25
+ */
26
+
27
+ /** The kind of question. */
28
+ export type QuestionKind = "single" | "multi" | "input";
29
+
30
+ /** Custom-UI presentation: floating overlay or inline prompt area. */
31
+ export type AskUserDisplayMode = "overlay" | "inline";
32
+
33
+ /** One selectable option. `preview` is rendered only by the custom UI. */
34
+ export interface AskUserOption {
35
+ /** Short label shown in the list and returned as the answer value. */
36
+ label: string;
37
+ /** One-line explanation rendered next to the label. */
38
+ description?: string;
39
+ /** Optional Markdown preview. Custom UI only; never shown on the native route. */
40
+ preview?: string;
41
+ }
42
+
43
+ /** One question in a questionnaire. */
44
+ export interface AskUserQuestion {
45
+ /** Stable id. Generated (`q1`, `q2`, ...) when omitted. */
46
+ id?: string;
47
+ /** Question heading (required). */
48
+ title: string;
49
+ /** Optional extra hint or context rendered under the heading. */
50
+ prompt?: string;
51
+ /** `single` = choose one, `multi` = choose several, `input` = free text only. */
52
+ kind: QuestionKind;
53
+ /** 1..5 options. Required for `single`/`multi`; ignored for `input`. */
54
+ options?: AskUserOption[];
55
+ /**
56
+ * Default answer. When present the question may be skipped and the default
57
+ * is used; when absent the question must be answered.
58
+ */
59
+ default?: string;
60
+ }
61
+
62
+ /** A model-facing (or caller-facing) request. */
63
+ export interface AskUserRequest {
64
+ /** 1..5 questions. */
65
+ questions: AskUserQuestion[];
66
+ /** Optional heading shown above the questionnaire. */
67
+ header?: string;
68
+ /** Custom-UI presentation. Defaults to `overlay`. */
69
+ displayMode?: AskUserDisplayMode;
70
+ /**
71
+ * Base timeout per question in milliseconds. Defaults to 60000.
72
+ *
73
+ * The questionnaire's initial shared deadline is
74
+ * `timeoutPerQuestionMs × questionCount`; the user can cancel it in the custom UI.
75
+ */
76
+ timeoutPerQuestionMs?: number;
77
+ }
78
+
79
+ /** A single resolved answer. */
80
+ export interface AskUserAnswer {
81
+ index: number;
82
+ id: string;
83
+ title: string;
84
+ kind: QuestionKind;
85
+ /** Selected option labels. Empty for free-text-only answers. */
86
+ selections: string[];
87
+ /** Trailing free-text input, when provided. */
88
+ freeText?: string;
89
+ /** True when the answer came from the question default rather than user input. */
90
+ usedDefault: boolean;
91
+ }
92
+
93
+ /**
94
+ * - `answered` — the user answered through a custom or native UI.
95
+ * - `aborted` — the user dismissed the interaction (or the caller aborted).
96
+ * - `timeout` — the shared deadline expired before an answer was given.
97
+ * - `error` — the renderer failed; or the forced mode is invalid
98
+ * (`invalid_config`) or unsupported here (`unsupported_mode`).
99
+ * Never a fallback to another route.
100
+ */
101
+ export type AskUserStatus = "answered" | "aborted" | "timeout" | "error";
102
+ export type AskUserRoute = "custom" | "native";
103
+ /**
104
+ * The configured route a host is asked to use: the same two values as
105
+ * {@link AskUserRoute}. Configuration never falls back — an unsupported value is
106
+ * an error.
107
+ */
108
+ export type AskUserMode = AskUserRoute;
109
+ export type AskUserErrorCode =
110
+ | "invalid_request"
111
+ | "custom_ui_failed"
112
+ | "native_ui_failed"
113
+ | "empty_answer"
114
+ | "invalid_config"
115
+ | "unsupported_mode"
116
+ | "not_initialized"
117
+ | "internal";
118
+
119
+ /** An actionable failure: what went wrong and what to do about it. */
120
+ export interface AskUserError {
121
+ code: AskUserErrorCode;
122
+ message: string;
123
+ }
124
+
125
+ /** Result of {@link askUser}. */
126
+ export interface AskUserResult {
127
+ status: AskUserStatus;
128
+ /**
129
+ * Which route serviced the request. Absent when no route ran at all
130
+ * (`invalid_request`, `invalid_config`, or an environment with no usable UI).
131
+ * When an explicitly configured route was refused as unavailable, it names
132
+ * that requested route.
133
+ */
134
+ route?: AskUserRoute;
135
+ answers: AskUserAnswer[];
136
+ /** Populated when `status === "aborted"`. */
137
+ cancelledReason?: "user" | "abort";
138
+ /** Non-fatal normalization notes (e.g. dropped duplicate options). */
139
+ warnings?: string[];
140
+ error?: AskUserError;
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Host adapter contract
145
+ // ---------------------------------------------------------------------------
146
+
147
+ /**
148
+ * Structural capabilities of a host: `customUI` is true only when the adapter
149
+ * binds a callable custom renderer, `nativeDialogs` only when it binds a
150
+ * callable native runner. They describe what can really run here; they never
151
+ * invent a route.
152
+ */
153
+ export interface HostCapabilities {
154
+ customUI: boolean;
155
+ nativeDialogs: boolean;
156
+ }
157
+
158
+ /** The structural implementation surface probed to derive {@link HostCapabilities}. */
159
+ export interface HostImplementations {
160
+ customUI?: CustomUIRenderer;
161
+ nativeDialogs?: NativeDialogRunner;
162
+ }
163
+
164
+ /**
165
+ * The outcome of resolving a host's route, computed once at host creation.
166
+ *
167
+ * `available` is the probe result (the modes that can really run here), in
168
+ * priority order with `custom` first. `route` is present only when a request
169
+ * will actually be serviced — never a fabricated default.
170
+ */
171
+ export interface AskUserSupportAvailable {
172
+ status: "available";
173
+ /** The route a request will be serviced through. */
174
+ route: AskUserRoute;
175
+ /** `configured` when an explicit mode was supplied, else `probed`. */
176
+ source: "configured" | "probed";
177
+ capabilities: HostCapabilities;
178
+ available: AskUserRoute[];
179
+ }
180
+
181
+ /** An explicit mode was configured but cannot run here. No fallback is taken. */
182
+ export interface AskUserSupportConfiguredUnavailable {
183
+ status: "configured_unavailable";
184
+ /** The explicitly configured route, which cannot run in this environment. */
185
+ configured: AskUserRoute;
186
+ capabilities: HostCapabilities;
187
+ available: AskUserRoute[];
188
+ reason: string;
189
+ }
190
+
191
+ /** Nothing can run here: neither a custom TUI nor native dialogs. */
192
+ export interface AskUserSupportNoUI {
193
+ status: "no_available_ui";
194
+ capabilities: HostCapabilities;
195
+ available: [];
196
+ reason: string;
197
+ }
198
+
199
+ /** The configured value is not a known route. */
200
+ export interface AskUserSupportInvalidConfig {
201
+ status: "invalid_config";
202
+ capabilities: HostCapabilities;
203
+ /** What could run here, had the configuration been valid. */
204
+ available: AskUserRoute[];
205
+ reason: string;
206
+ }
207
+
208
+ /**
209
+ * A discriminated union describing whether — and how — a host can prompt the
210
+ * user right now, without actually prompting. Narrow on `status`: the `route`
211
+ * field exists only for `"available"`.
212
+ */
213
+ export type AskUserSupport =
214
+ | AskUserSupportAvailable
215
+ | AskUserSupportConfiguredUnavailable
216
+ | AskUserSupportNoUI
217
+ | AskUserSupportInvalidConfig;
218
+
219
+ /** Shared input passed to custom/native renderers. */
220
+ export interface AskUIInput {
221
+ request: NormalizedRequest;
222
+ deadline: Deadline;
223
+ /** Custom UI only: disable this questionnaire's timeout; false if already expired. */
224
+ cancelTimeout?: () => boolean;
225
+ signal?: AbortSignal;
226
+ /** Report progress such as "Waiting for user input...". */
227
+ onUpdate?: (text: string) => void;
228
+ }
229
+
230
+ /** Outcome of an interactive render attempt. */
231
+ export interface AskUIOutcome {
232
+ kind: "submitted" | "cancelled" | "timeout" | "abort" | "error";
233
+ answers?: AskUserAnswer[];
234
+ message?: string;
235
+ }
236
+
237
+ export interface CustomUIRenderer {
238
+ /** Render the full questionnaire. Must not silently degrade to another route. */
239
+ render(input: AskUIInput): Promise<AskUIOutcome>;
240
+ }
241
+
242
+ export interface NativeDialogRunner {
243
+ /** Walk the questionnaire one native dialog at a time. Must not degrade. */
244
+ run(input: AskUIInput): Promise<AskUIOutcome>;
245
+ }
246
+
247
+ /**
248
+ * A trusted host adapter. Each declared capability is bound to the object that
249
+ * implements it: `customUI` is present iff the host really renders custom UI,
250
+ * `nativeDialogs` is present iff the host really shows native dialogs. There is
251
+ * no way to declare a capability without the implementation.
252
+ */
253
+ export interface AskUserHost {
254
+ name: string;
255
+ /**
256
+ * Resolved once, when this host was created, from the explicit configuration
257
+ * and the bound implementations. The core honours it verbatim: it never
258
+ * re-selects a route per request.
259
+ */
260
+ support: AskUserSupport;
261
+ /**
262
+ * User-configured UI preferences, resolved once at host creation from the
263
+ * explicit options over the user config file. Absent means "nothing
264
+ * configured": the request's own `displayMode`/`timeoutPerQuestionMs` then
265
+ * applies over the built-in defaults.
266
+ */
267
+ preferences?: AskUserUiPreferences;
268
+ /** Bound wherever the custom route can really run. */
269
+ customUI?: CustomUIRenderer;
270
+ /** Bound wherever the native route can really run. */
271
+ nativeDialogs?: NativeDialogRunner;
272
+ }
273
+
274
+ /**
275
+ * Resolved user presentation preferences (explicit option > config file).
276
+ *
277
+ * Every field is optional so the core can distinguish "the user configured
278
+ * this" from "fall back to the request, then the built-in default":
279
+ *
280
+ * - `displayMode` set → wins over the request's `displayMode`;
281
+ * - `overlayToggleKey` set to `null` → the overlay toggle is disabled;
282
+ * absent → the built-in `alt+o` applies;
283
+ * - `timeoutPerQuestionMs` set → wins over the request's value.
284
+ */
285
+ export interface AskUserUiPreferences {
286
+ displayMode?: AskUserDisplayMode;
287
+ overlayToggleKey?: string | null;
288
+ timeoutPerQuestionMs?: number;
289
+ }
290
+
291
+ /**
292
+ * Observer of the interactive wait, used for runtime events. `waitStarted` is
293
+ * called only when a route is actually about to render; `waitEnded` is called
294
+ * exactly once afterwards for every outcome (answered/aborted/timeout/error).
295
+ * A request that never enters the UI (invalid, refused, already aborted) emits
296
+ * neither. Implementations must never throw.
297
+ */
298
+ export interface AskUserEventSink {
299
+ waitStarted(): void;
300
+ waitEnded(result: AskUserResult): void;
301
+ }
302
+
303
+ /** Options for {@link askUser}. */
304
+ export interface AskUserOptions {
305
+ host: AskUserHost;
306
+ signal?: AbortSignal;
307
+ onUpdate?: (text: string) => void;
308
+ /** Injectable clock, used by tests. Defaults to `Date.now`. */
309
+ now?: () => number;
310
+ /**
311
+ * Optional event observer for the interactive wait. The tool supplies a
312
+ * Pi event-bus-backed sink; a direct caller may supply its own (or none).
313
+ */
314
+ events?: AskUserEventSink;
315
+ }
316
+
317
+ // ---------------------------------------------------------------------------
318
+ // Normalized request / deadline
319
+ // ---------------------------------------------------------------------------
320
+
321
+ export interface NormalizedQuestion {
322
+ id: string;
323
+ index: number;
324
+ title: string;
325
+ prompt?: string;
326
+ kind: QuestionKind;
327
+ options: AskUserOption[];
328
+ default?: string;
329
+ hasDefault: boolean;
330
+ }
331
+
332
+ export interface NormalizedRequest {
333
+ header?: string;
334
+ questions: NormalizedQuestion[];
335
+ displayMode: AskUserDisplayMode;
336
+ timeoutPerQuestionMs: number;
337
+ totalTimeoutMs: number;
338
+ }
339
+
340
+ export interface Deadline {
341
+ readonly startedAt: number;
342
+ readonly expiresAt: number;
343
+ remainingMs(): number;
344
+ expired(): boolean;
345
+ }