@fadhelhaidar/pi-ask-user 0.0.0-stage → 1.0.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.
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Public event contract for @juicesharp/rpiv-ask-user-question.
3
+ *
4
+ * STABILITY POLICY — applies to every event in the `rpiv:*` namespace.
5
+ *
6
+ * 1. Channel names are immutable. Once shipped, never rename.
7
+ * 2. Payload changes are append-only. Listeners MUST tolerate unknown
8
+ * fields. New fields ship as optional (`?:`).
9
+ * 3. Breaking changes (rename, retype, remove a field; change emission
10
+ * semantics) require a NEW channel, e.g. `rpiv:ask-user:prompt.v2`,
11
+ * with dual-emit during a deprecation window.
12
+ * 4. No `version` field inside payloads. Version via channel name only.
13
+ * 5. Payloads must be JSON-safe: primitives, arrays, plain objects.
14
+ * No Set/Map/Date/class instances — payloads must survive JSON
15
+ * serialization when listeners forward them across process or
16
+ * network boundaries.
17
+ *
18
+ * Naming: `rpiv:<package-or-tool>:<phase>`, lowercase, hyphen-separated.
19
+ * Aligns with Pi's `"my-extension:status"` example and UniPi's `unipi:*`.
20
+ */
21
+ export declare const ASK_USER_PROMPT_EVENT: "rpiv:ask-user:prompt";
22
+ export interface AskUserPromptEventPayload {
23
+ questions: ReadonlyArray<AskUserPromptQuestion>;
24
+ }
25
+ /**
26
+ * Emitted while the questionnaire is awaiting user input (TUI `ui.custom` and
27
+ * RPC dialog walker). Cleared with `{ active: false }` in `finally` so listeners
28
+ * can distinguish blocked-on-human from working.
29
+ */
30
+ export declare const ASK_USER_BLOCKED_EVENT: "rpiv:ask-user:blocked";
31
+ export interface AskUserBlockedEventPayload {
32
+ /** True while input is awaited; false when the wait ends (answer, cancel, or error). */
33
+ active: boolean;
34
+ }
35
+ export interface AskUserPromptQuestion {
36
+ /**
37
+ * The full question text as the agent authored it, with line terminators
38
+ * normalized at tool entry (`\r\n` → `\n`, lone `\r` removed — #192). The
39
+ * same normalization applies to `header` and every option field below.
40
+ */
41
+ question: string;
42
+ /** The short chip/tag shown next to the question. */
43
+ header: string;
44
+ /** True iff the user may pick multiple options. Normalized from optional. */
45
+ multiSelect: boolean;
46
+ options: ReadonlyArray<AskUserPromptOption>;
47
+ }
48
+ export interface AskUserPromptOption {
49
+ label: string;
50
+ description: string;
51
+ /** True iff the option carries rich preview content (content not shipped). */
52
+ hasPreview: boolean;
53
+ }
package/dist/events.js ADDED
@@ -0,0 +1,8 @@
1
+ import {
2
+ ASK_USER_PROMPT_EVENT2,
3
+ ASK_USER_BLOCKED_EVENT2
4
+ } from "./chunks/index-bx2tbz93.js";
5
+ export {
6
+ ASK_USER_BLOCKED_EVENT2 as ASK_USER_BLOCKED_EVENT,
7
+ ASK_USER_PROMPT_EVENT2 as ASK_USER_PROMPT_EVENT
8
+ };
@@ -0,0 +1,12 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ export {
4
+ ASK_USER_BLOCKED_EVENT,
5
+ ASK_USER_PROMPT_EVENT,
6
+ type AskUserBlockedEventPayload,
7
+ type AskUserPromptEventPayload,
8
+ type AskUserPromptOption,
9
+ type AskUserPromptQuestion,
10
+ } from "./events.js";
11
+
12
+ export default function (pi: ExtensionAPI): void;
package/dist/index.js ADDED
@@ -0,0 +1,538 @@
1
+ import {
2
+ validateGuidanceFields,
3
+ COLLAPSE_KEY_OFF,
4
+ resolveCollapseKey,
5
+ formatKeySpecForDisplay,
6
+ loadConfig,
7
+ ROW_INTENT_META,
8
+ sentinelsToAppend,
9
+ I18N_NAMESPACE,
10
+ t,
11
+ displayLabel,
12
+ formatAnswerScalar
13
+ } from "./chunks/index-x7fb685n.js";
14
+ import {
15
+ ASK_USER_PROMPT_EVENT2,
16
+ ASK_USER_BLOCKED_EVENT2
17
+ } from "./chunks/index-bx2tbz93.js";
18
+
19
+ // ask-user-question.ts
20
+ import { isKeyRelease, isKeyRepeat, matchesKey } from "@earendil-works/pi-tui";
21
+
22
+ // rpc-fallback.ts
23
+ var MULTI_SELECT_INSTRUCTIONS = 'Enter the numbers of all that apply, comma-separated (e.g. "1,3"), or type a custom answer as plain text.';
24
+ var CUSTOM_ANSWER_TITLE = "Type your answer:";
25
+ var MULTI_SELECT_PLACEHOLDER = "1,3";
26
+ var MAX_PREVIEW_CHARS = 600;
27
+ function hasDialogUI(ui) {
28
+ const u = ui;
29
+ return typeof u?.select === "function" && typeof u?.input === "function";
30
+ }
31
+ function formatOptionLine(option, index) {
32
+ return `${index + 1}. ${option.label} — ${option.description}`;
33
+ }
34
+ function parseIndex(token, count) {
35
+ const i = Number.parseInt(token, 10) - 1;
36
+ return i >= 0 && i < count ? i : null;
37
+ }
38
+ function buildPreviewBlock(question) {
39
+ const blocks = question.options.flatMap((o, i) => o.preview && o.preview.length > 0 ? [`--- ${i + 1}. ${o.label} preview ---
40
+ ${o.preview.slice(0, MAX_PREVIEW_CHARS)}`] : []);
41
+ return blocks.length > 0 ? `
42
+
43
+ ${blocks.join(`
44
+
45
+ `)}` : "";
46
+ }
47
+ async function runRpcQuestionnaire(ui, params) {
48
+ const answers = [];
49
+ for (let qi = 0;qi < params.questions.length; qi++) {
50
+ const q = params.questions[qi];
51
+ const header = q.header ? `[${q.header}] ` : "";
52
+ const answer = q.multiSelect ? await askMultiSelect(ui, q, qi, header) : await askSingleSelect(ui, q, qi, header);
53
+ if (answer === undefined)
54
+ return { answers, cancelled: true };
55
+ answers.push(answer);
56
+ }
57
+ return { answers, cancelled: false };
58
+ }
59
+ async function askSingleSelect(ui, q, questionIndex, header) {
60
+ const options = q.options.map(formatOptionLine);
61
+ options.push(`${q.options.length + 1}. ${displayLabel("other")}`);
62
+ const chosen = await ui.select(`${header}${q.question}${buildPreviewBlock(q)}`, options);
63
+ if (chosen == null)
64
+ return;
65
+ const idx = parseIndex(chosen, options.length);
66
+ if (idx == null)
67
+ return;
68
+ if (idx < q.options.length) {
69
+ const o = q.options[idx];
70
+ return {
71
+ questionIndex,
72
+ question: q.question,
73
+ kind: "option",
74
+ answer: o.label,
75
+ preview: o.preview && o.preview.length > 0 ? o.preview : undefined
76
+ };
77
+ }
78
+ const typed = await ui.input(`${header}${q.question}
79
+
80
+ ${t("rpc.custom_answer_title", CUSTOM_ANSWER_TITLE)}`, "");
81
+ if (typed == null)
82
+ return;
83
+ return { questionIndex, question: q.question, kind: "custom", answer: typed };
84
+ }
85
+ async function askMultiSelect(ui, q, questionIndex, header) {
86
+ const list = q.options.map(formatOptionLine).join(`
87
+ `);
88
+ const value = await ui.input(`${header}${q.question}
89
+
90
+ ${list}
91
+
92
+ ${t("rpc.multi_instructions", MULTI_SELECT_INSTRUCTIONS)}`, MULTI_SELECT_PLACEHOLDER);
93
+ if (value == null)
94
+ return;
95
+ const trimmed = value.trim();
96
+ if (trimmed.length === 0) {
97
+ return { questionIndex, question: q.question, kind: "multi", answer: null, selected: [] };
98
+ }
99
+ const tokens = trimmed.split(/[,\s]+/).filter((tok) => tok.length > 0);
100
+ const indices = tokens.map((tok) => /^\d+\.?$/.test(tok) ? parseIndex(tok, q.options.length) : null);
101
+ if (indices.every((i) => i != null)) {
102
+ const selected = [];
103
+ for (const i of indices) {
104
+ const label = q.options[i].label;
105
+ if (!selected.includes(label))
106
+ selected.push(label);
107
+ }
108
+ return { questionIndex, question: q.question, kind: "multi", answer: null, selected };
109
+ }
110
+ return { questionIndex, question: q.question, kind: "custom", answer: trimmed };
111
+ }
112
+
113
+ // tool/normalize-params.ts
114
+ function normalizeLineTerminators(text) {
115
+ return text.replace(/\r\n/g, `
116
+ `).replace(/\r/g, "");
117
+ }
118
+ function normalizeStringFields(obj, keys) {
119
+ const out = { ...obj };
120
+ for (const key of keys) {
121
+ const value = obj[key];
122
+ if (typeof value === "string")
123
+ out[key] = normalizeLineTerminators(value);
124
+ }
125
+ return out;
126
+ }
127
+ function normalizeQuestionParams(params) {
128
+ return {
129
+ ...params,
130
+ questions: params.questions.map((q) => ({
131
+ ...normalizeStringFields(q, ["question", "header"]),
132
+ options: q.options.map((o) => normalizeStringFields(o, ["label", "description", "preview"]))
133
+ }))
134
+ };
135
+ }
136
+
137
+ // tool/response-envelope.ts
138
+ var DECLINE_MESSAGE = "User declined to answer questions";
139
+ var ENVELOPE_PREFIX = "User has answered your questions:";
140
+ var ENVELOPE_SUFFIX = "You can now continue with the user's answers in mind.";
141
+ function buildQuestionnaireResponse(result, params) {
142
+ if (!result || result.cancelled) {
143
+ return buildToolResult(DECLINE_MESSAGE, {
144
+ answers: result?.answers ?? [],
145
+ cancelled: true,
146
+ ...result?.globalNote && result.globalNote.length > 0 ? { globalNote: result.globalNote } : {}
147
+ });
148
+ }
149
+ const segments = [];
150
+ for (let i = 0;i < params.questions.length; i++) {
151
+ const a = result.answers.find((x) => x.questionIndex === i);
152
+ if (a)
153
+ segments.push(buildAnswerSegment(a));
154
+ }
155
+ if (result.globalNote && result.globalNote.length > 0) {
156
+ segments.push(`global note: ${result.globalNote}.`);
157
+ }
158
+ if (segments.length === 0) {
159
+ return buildToolResult(DECLINE_MESSAGE, { answers: result.answers, cancelled: true });
160
+ }
161
+ return buildToolResult(`${ENVELOPE_PREFIX} ${segments.join(" ")} ${ENVELOPE_SUFFIX}`, result);
162
+ }
163
+ function buildAnswerSegment(a) {
164
+ const parts = [`"${a.question}"="${formatAnswerScalar(a, "envelope")}"`];
165
+ if (a.preview && a.preview.length > 0)
166
+ parts.push(`selected preview: ${a.preview}`);
167
+ if (a.notes && a.notes.length > 0)
168
+ parts.push(`user notes: ${a.notes}`);
169
+ return `${parts.join(". ")}.`;
170
+ }
171
+ function buildToolResult(text, details) {
172
+ return {
173
+ content: [{ type: "text", text }],
174
+ details
175
+ };
176
+ }
177
+
178
+ // tool/types.ts
179
+ import { Type } from "typebox";
180
+ var MAX_QUESTIONS = 4;
181
+ var MIN_OPTIONS = 2;
182
+ var MAX_OPTIONS = 4;
183
+ var MAX_HEADER_LENGTH = 16;
184
+ var MAX_LABEL_LENGTH = 60;
185
+ var RESERVED_LABELS = ["Other", ROW_INTENT_META.other.label, ROW_INTENT_META.next.label];
186
+ var OptionSchema = Type.Object({
187
+ label: Type.String({
188
+ maxLength: MAX_LABEL_LENGTH,
189
+ description: `MAX ${MAX_LABEL_LENGTH} CHARACTERS — hard limit, requests over the limit are rejected. The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice.`
190
+ }),
191
+ description: Type.String({
192
+ description: "Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications."
193
+ }),
194
+ preview: Type.Optional(Type.String({
195
+ description: "Optional preview content rendered when this option is focused. Use for mockups, code snippets, or visual comparisons that help users compare options. See the tool description for the expected content format."
196
+ }))
197
+ });
198
+ var QuestionSchema = Type.Object({
199
+ question: Type.String({
200
+ description: 'The complete question to ask the user. Should be clear, specific, and end with a question mark. Example: "Which library should we use for date formatting?" If multiSelect is true, phrase it accordingly, e.g. "Which features do you want to enable?"'
201
+ }),
202
+ header: Type.String({
203
+ maxLength: MAX_HEADER_LENGTH,
204
+ description: `MAX ${MAX_HEADER_LENGTH} CHARACTERS — hard limit, requests over the limit are rejected. Very short chip/tag shown next to the question. Examples: "Auth method", "Library", "Approach".`
205
+ }),
206
+ options: Type.Array(OptionSchema, {
207
+ minItems: MIN_OPTIONS,
208
+ maxItems: MAX_OPTIONS,
209
+ description: "The available choices for this question. Must have 2-4 options. Each option should be a distinct, mutually exclusive choice (unless multiSelect is enabled). The 'Type something.' row is appended automatically — do NOT author it."
210
+ }),
211
+ multiSelect: Type.Optional(Type.Boolean({
212
+ default: false,
213
+ description: "Set to true to allow the user to select multiple options instead of just one. Use when choices are not mutually exclusive."
214
+ }))
215
+ });
216
+ var QuestionsSchema = Type.Array(QuestionSchema, {
217
+ minItems: 1,
218
+ maxItems: MAX_QUESTIONS,
219
+ description: "Questions to ask the user (1-4 questions)"
220
+ });
221
+ var QuestionParamsSchema = Type.Object({
222
+ questions: QuestionsSchema
223
+ });
224
+
225
+ // tool/validate-questionnaire.ts
226
+ var ERROR_NO_QUESTIONS = "Error: At least one question is required";
227
+ var ERROR_TOO_MANY_QUESTIONS = `Error: At most ${MAX_QUESTIONS} questions are allowed per invocation`;
228
+ var ERROR_DUPLICATE_QUESTION = "Error: Question text must be unique within an invocation";
229
+ var ERROR_TOO_FEW_OPTIONS = `Error: Each question requires at least ${MIN_OPTIONS} options`;
230
+ var ERROR_RESERVED_LABEL = `Error: Option label is reserved (${RESERVED_LABELS.join(", ")})`;
231
+ var ERROR_DUPLICATE_OPTION_LABEL = "Error: Option labels must be unique within a question";
232
+ var RESERVED_LABEL_SET = new Set(RESERVED_LABELS);
233
+ function validateQuestionnaire(typed) {
234
+ if (typed.questions.length === 0) {
235
+ return { ok: false, error: "no_questions", message: ERROR_NO_QUESTIONS };
236
+ }
237
+ if (typed.questions.length > MAX_QUESTIONS) {
238
+ return { ok: false, error: "too_many_questions", message: ERROR_TOO_MANY_QUESTIONS };
239
+ }
240
+ const seenQuestions = new Set;
241
+ for (const q of typed.questions) {
242
+ if (seenQuestions.has(q.question)) {
243
+ return { ok: false, error: "duplicate_question", message: ERROR_DUPLICATE_QUESTION };
244
+ }
245
+ seenQuestions.add(q.question);
246
+ }
247
+ for (const q of typed.questions) {
248
+ if (q.options.length < MIN_OPTIONS) {
249
+ return { ok: false, error: "empty_options", message: ERROR_TOO_FEW_OPTIONS };
250
+ }
251
+ const seenLabels = new Set;
252
+ for (const o of q.options) {
253
+ if (RESERVED_LABEL_SET.has(o.label)) {
254
+ return { ok: false, error: "reserved_label", message: ERROR_RESERVED_LABEL };
255
+ }
256
+ if (seenLabels.has(o.label)) {
257
+ return {
258
+ ok: false,
259
+ error: "duplicate_option_label",
260
+ message: ERROR_DUPLICATE_OPTION_LABEL
261
+ };
262
+ }
263
+ seenLabels.add(o.label);
264
+ }
265
+ }
266
+ return { ok: true };
267
+ }
268
+
269
+ // ask-user-question.ts
270
+ function emitAskUserPromptEvent(pi, params) {
271
+ const payload = {
272
+ questions: params.questions.map((q) => ({
273
+ question: q.question,
274
+ header: q.header,
275
+ multiSelect: q.multiSelect ?? false,
276
+ options: q.options.map((o) => ({
277
+ label: o.label,
278
+ description: o.description,
279
+ hasPreview: typeof o.preview === "string" && o.preview.length > 0
280
+ }))
281
+ }))
282
+ };
283
+ pi.events.emit(ASK_USER_PROMPT_EVENT2, payload);
284
+ }
285
+ function emitAskUserBlockedEvent(pi, active) {
286
+ const payload = { active };
287
+ pi.events.emit(ASK_USER_BLOCKED_EVENT2, payload);
288
+ }
289
+ function rejectWithoutUi() {
290
+ return buildToolResult(ERROR_NO_UI, { answers: [], cancelled: true, error: "no_ui" });
291
+ }
292
+ async function runRpcPath(pi, ui, typed) {
293
+ emitAskUserBlockedEvent(pi, true);
294
+ try {
295
+ emitTerminalAttention();
296
+ return buildQuestionnaireResponse(await runRpcQuestionnaire(ui, typed), typed);
297
+ } finally {
298
+ emitAskUserBlockedEvent(pi, false);
299
+ }
300
+ }
301
+ var ASK_USER_QUESTION_TOOL_NAME = "ask_user_question";
302
+ var ERROR_NO_UI = "Error: UI not available (running in non-interactive mode)";
303
+ var ERROR_NO_CUSTOM_UI = "Error: this client cannot render the questionnaire (custom UI is unavailable, e.g. RPC/ACP hosts such as Zed or Paseo). The user never saw the questions — do NOT treat this as a decline. Ask the questions as plain chat text instead, without using this tool.";
304
+ var ERROR_SESSION_LOAD_FAILED = "Error: the questionnaire UI failed to load — the host's installed dependencies were likely replaced or removed on disk while Pi was running (e.g. a package-manager install touched the store). The user never saw the questions — do NOT treat this as a decline. Ask the questions as plain chat text instead, and tell the user that restoring this tool requires repairing the install if needed and restarting Pi.";
305
+ var ERROR_STALE_MODULE_CACHE = "Error: the questionnaire UI cannot load — the host's module cache went stale after an earlier failed load (typically dependencies replaced on disk mid-session). This is unrecoverable within the current Pi process. The user never saw the questions — do NOT treat this as a decline. Ask the questions as plain chat text instead, and tell the user to restart Pi to restore this tool.";
306
+ var BEL = "\x07";
307
+ function emitTerminalAttention() {
308
+ try {
309
+ if (process.stdout.isTTY)
310
+ process.stdout.write(BEL);
311
+ } catch {}
312
+ }
313
+ var PREWARM_DELAY_MS = 2000;
314
+ async function loadQuestionnaireSession() {
315
+ let mod;
316
+ try {
317
+ mod = await import("./chunks/questionnaire-session-gdq5zqtt.js");
318
+ } catch (e) {
319
+ const cause = e instanceof Error ? e.message : String(e);
320
+ return { ok: false, error: "session_load_failed", message: `${ERROR_SESSION_LOAD_FAILED} (cause: ${cause})` };
321
+ }
322
+ if (typeof mod.QuestionnaireSession !== "function") {
323
+ const keys = JSON.stringify(Object.keys(mod));
324
+ return {
325
+ ok: false,
326
+ error: "stale_module_cache",
327
+ message: `${ERROR_STALE_MODULE_CACHE} (resolved namespace keys: ${keys})`
328
+ };
329
+ }
330
+ return { ok: true, module: mod };
331
+ }
332
+ function registerCollapseKeyListener(ctx, collapseKey, sessionRef, overlayHandleRef) {
333
+ if (collapseKey === COLLAPSE_KEY_OFF || typeof ctx.ui.onTerminalInput !== "function")
334
+ return;
335
+ let hasAnnouncedHide = false;
336
+ return ctx.ui.onTerminalInput((data) => {
337
+ const handle = overlayHandleRef.current;
338
+ if (!handle)
339
+ return;
340
+ if (!handle.isHidden() && !handle.isFocused())
341
+ return;
342
+ if (!matchesKey(data, collapseKey))
343
+ return;
344
+ if (isKeyRelease(data) || isKeyRepeat(data))
345
+ return { consume: true };
346
+ sessionRef.current?.toggleCollapsedExternal();
347
+ if (handle.isHidden() && !hasAnnouncedHide) {
348
+ hasAnnouncedHide = true;
349
+ ctx.ui.notify?.(`ask_user_question hidden — press ${formatKeySpecForDisplay(collapseKey)} to reopen`, "info");
350
+ }
351
+ return { consume: true };
352
+ });
353
+ }
354
+ function makeSessionFactory(config) {
355
+ const { ctx, typed, itemsByTab, collapseKey, canReopenWhileHidden, sessionRef, Session } = config;
356
+ return (tui, theme, keybindings, done) => {
357
+ const session = new Session({
358
+ tui,
359
+ theme,
360
+ params: typed,
361
+ itemsByTab,
362
+ done,
363
+ keybindings,
364
+ editInput: async (value) => {
365
+ try {
366
+ const [{ SettingsManager }, { editWithExternalEditor }] = await Promise.all([
367
+ import("@earendil-works/pi-coding-agent"),
368
+ import("./chunks/external-editor-5wd19nyg.js")
369
+ ]);
370
+ const editorCommand = SettingsManager.create(ctx.cwd, undefined, {
371
+ projectTrusted: ctx.isProjectTrusted()
372
+ }).getExternalEditorCommand();
373
+ if (!editorCommand)
374
+ throw new Error("No external editor command is configured");
375
+ return await editWithExternalEditor(tui, editorCommand, value);
376
+ } catch (error) {
377
+ const message = error instanceof Error ? error.message : String(error);
378
+ ctx.ui.notify(`${t("editor.failed", "External editor failed")}: ${message}`, "error");
379
+ return;
380
+ }
381
+ },
382
+ collapseKey,
383
+ canReopenWhileHidden
384
+ });
385
+ sessionRef.current = session;
386
+ return session.component;
387
+ };
388
+ }
389
+ async function resolveUndefinedResult(ctx, typed) {
390
+ if (hasDialogUI(ctx.ui)) {
391
+ return buildQuestionnaireResponse(await runRpcQuestionnaire(ctx.ui, typed), typed);
392
+ }
393
+ return buildToolResult(ERROR_NO_CUSTOM_UI, { answers: [], cancelled: true, error: "no_custom_ui" });
394
+ }
395
+ function prewarmSessionGraph() {
396
+ const timer = setTimeout(() => void loadQuestionnaireSession().catch(() => {
397
+ return;
398
+ }), PREWARM_DELAY_MS);
399
+ timer.unref?.();
400
+ }
401
+ function buildItemsForQuestion(question) {
402
+ const items = question.options.map((o) => ({
403
+ kind: "option",
404
+ label: o.label,
405
+ description: o.description
406
+ }));
407
+ for (const kind of sentinelsToAppend(question)) {
408
+ items.push({ kind, label: displayLabel(kind) });
409
+ }
410
+ return items;
411
+ }
412
+ var DEFAULT_PROMPT_SNIPPET = `Ask the user up to ${MAX_QUESTIONS} structured questions (${MIN_OPTIONS}-${MAX_OPTIONS} options each) when requirements are ambiguous`;
413
+ var DEFAULT_PROMPT_GUIDELINES = [
414
+ `Use ask_user_question whenever the user's request is underspecified and you cannot proceed without concrete decisions — you can ask up to ${MAX_QUESTIONS} questions per invocation.`,
415
+ `Each question MUST have ${MIN_OPTIONS}-${MAX_OPTIONS} options. Every option requires a concise label (1-5 words) and a description explaining what the choice means or its trade-offs. The user can additionally type a custom answer via the automatically appended "Type something." row on every question, or press Esc to abandon the questionnaire. Do NOT author "Other" or "Type something." labels yourself — reserved labels are rejected at runtime.`,
416
+ `Set multiSelect: true when multiple answers are valid. Provide an options[].preview markdown string when an option benefits from richer side-by-side context (mockups, code snippets, diagrams, configs) — single-select only. The "Type something." row is appended to every question; in preview mode it expands to the full pane width while typing so the custom answer is not cramped into the narrow options column. If you recommend a specific option, make that the first option and append "(Recommended)" to its label.`,
417
+ "Do not stack multiple ask_user_question calls back-to-back — group all clarifying questions into one invocation."
418
+ ];
419
+ var DEFAULT_TOOL_DESCRIPTION = `Ask the user one or more structured questions during execution. Use when you need to:
420
+ 1. Gather user preferences or requirements
421
+ 2. Clarify ambiguous instructions
422
+ 3. Get decisions on implementation choices as you work
423
+ 4. Offer choices to the user about what direction to take
424
+
425
+ Usage notes:
426
+ - Users can type a custom answer via the automatically appended "Type something." row on every question or press Esc to abandon the questionnaire. Do NOT author "Other" or "Type something." labels yourself — reserved labels are rejected at runtime.
427
+ - Use multiSelect: true when multiple answers are valid. The "Type something." row is available on every question, including when options carry a \`preview\`; in preview mode it expands to the full pane width while typing so the custom answer is not cramped into the narrow options column.
428
+ - If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label.
429
+
430
+ Preview feature:
431
+ Use the optional \`preview\` field on options when presenting concrete artifacts that users need to visually compare:
432
+ - ASCII mockups of UI layouts or components
433
+ - Code snippets showing different implementations
434
+ - Diagram variations
435
+ - Configuration examples
436
+
437
+ Preview content is rendered as markdown in a monospace box. Multi-line text with newlines is supported. When any option has a preview, the UI switches to a side-by-side layout with a vertical option list on the left and preview on the right. Do not use previews for simple preference questions where labels and descriptions suffice. Note: previews are only supported for single-select questions (not multiSelect).`;
438
+ function registerAskUserQuestionTool(pi) {
439
+ const guidance = validateGuidanceFields(loadConfig().guidance);
440
+ pi.registerTool({
441
+ name: ASK_USER_QUESTION_TOOL_NAME,
442
+ label: "Ask User Question",
443
+ description: guidance.description ?? DEFAULT_TOOL_DESCRIPTION,
444
+ promptSnippet: guidance.promptSnippet ?? DEFAULT_PROMPT_SNIPPET,
445
+ promptGuidelines: guidance.promptGuidelines ?? DEFAULT_PROMPT_GUIDELINES,
446
+ parameters: QuestionParamsSchema,
447
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
448
+ const typed = normalizeQuestionParams(params);
449
+ if (!ctx.hasUI)
450
+ return rejectWithoutUi();
451
+ const validation = validateQuestionnaire(typed);
452
+ if (!validation.ok) {
453
+ return buildToolResult(validation.message, {
454
+ answers: [],
455
+ cancelled: true,
456
+ error: validation.error
457
+ });
458
+ }
459
+ emitAskUserPromptEvent(pi, typed);
460
+ if (ctx.mode === "rpc" && hasDialogUI(ctx.ui)) {
461
+ return runRpcPath(pi, ctx.ui, typed);
462
+ }
463
+ const itemsByTab = typed.questions.map((q) => buildItemsForQuestion(q));
464
+ const sessionLoad = await loadQuestionnaireSession();
465
+ if (!sessionLoad.ok) {
466
+ return buildToolResult(sessionLoad.message, { answers: [], cancelled: true, error: sessionLoad.error });
467
+ }
468
+ const { QuestionnaireSession } = sessionLoad.module;
469
+ const collapseKey = resolveCollapseKey(loadConfig());
470
+ const sessionRef = { current: null };
471
+ const overlayHandleRef = { current: undefined };
472
+ const removeOverlayInputListener = registerCollapseKeyListener(ctx, collapseKey, sessionRef, overlayHandleRef);
473
+ const canReopenWhileHidden = removeOverlayInputListener !== undefined;
474
+ emitAskUserBlockedEvent(pi, true);
475
+ try {
476
+ emitTerminalAttention();
477
+ const result = await ctx.ui.custom(makeSessionFactory({
478
+ ctx,
479
+ typed,
480
+ itemsByTab,
481
+ collapseKey,
482
+ canReopenWhileHidden,
483
+ sessionRef,
484
+ Session: QuestionnaireSession
485
+ }), {
486
+ overlay: true,
487
+ overlayOptions: {
488
+ anchor: "bottom-center",
489
+ width: "100%",
490
+ maxHeight: "100%",
491
+ margin: { left: 0, right: 0, bottom: 0 }
492
+ },
493
+ onHandle: (handle) => {
494
+ overlayHandleRef.current = handle;
495
+ sessionRef.current?.setOverlayHandle(handle);
496
+ }
497
+ });
498
+ if (result === undefined) {
499
+ return resolveUndefinedResult(ctx, typed);
500
+ }
501
+ return buildQuestionnaireResponse(result, typed);
502
+ } finally {
503
+ removeOverlayInputListener?.();
504
+ emitAskUserBlockedEvent(pi, false);
505
+ }
506
+ }
507
+ });
508
+ prewarmSessionGraph();
509
+ }
510
+
511
+ // reconcile.ts
512
+ function reconcileAskUserQuestionTool(pi, ctx) {
513
+ const active = pi.getActiveTools();
514
+ const hasTool = active.includes(ASK_USER_QUESTION_TOOL_NAME);
515
+ if (!ctx.hasUI && hasTool) {
516
+ pi.setActiveTools(active.filter((n) => n !== ASK_USER_QUESTION_TOOL_NAME));
517
+ } else if (ctx.hasUI && !hasTool) {
518
+ pi.setActiveTools([...active, ASK_USER_QUESTION_TOOL_NAME]);
519
+ }
520
+ }
521
+ function registerAskUserQuestionReconciler(pi) {
522
+ pi.on("before_agent_start", (_event, ctx) => reconcileAskUserQuestionTool(pi, ctx));
523
+ }
524
+
525
+ // index.ts
526
+ try {
527
+ const sdk = await import("@juicesharp/rpiv-i18n/loader");
528
+ sdk.registerLocalesFromDir(I18N_NAMESPACE, import.meta.url, { label: "rpiv-ask-user-question" });
529
+ } catch {}
530
+ function pi_ask_user_default(pi) {
531
+ registerAskUserQuestionTool(pi);
532
+ registerAskUserQuestionReconciler(pi);
533
+ }
534
+ export {
535
+ ASK_USER_BLOCKED_EVENT2 as ASK_USER_BLOCKED_EVENT,
536
+ ASK_USER_PROMPT_EVENT2 as ASK_USER_PROMPT_EVENT,
537
+ pi_ask_user_default as default
538
+ };
@@ -0,0 +1,31 @@
1
+ {
2
+ "_meta.notes": "Auto-translated draft. Native-speaker review welcome via PR. TUI symbols (↑/↓, ⚠) and key names (Enter, Esc, Tab, Space, n, Ctrl+G, Ctrl+U) are universal CLI conventions and stay untranslated.",
3
+
4
+ "sentinel.other": "Schreibe etwas.",
5
+ "sentinel.next": "Weiter",
6
+
7
+ "submit.label": "Antworten senden",
8
+ "submit.cancel": "Abbrechen",
9
+
10
+ "hint.enter": "Enter zum Auswählen",
11
+ "hint.navigate": "↑/↓ zur Navigation",
12
+ "hint.newline": "Umschalt+Enter für neue Zeile",
13
+ "hint.clear": "Ctrl+U zum Leeren",
14
+ "hint.toggle": "Leertaste zum Umschalten",
15
+ "hint.notes": "n für Notizen",
16
+ "hint.tab": "Tab wechselt die Frage",
17
+ "hint.cancel": "Esc zum Abbrechen",
18
+
19
+ "review.heading": "Antworten überprüfen",
20
+ "review.ready": "Bereit, deine Antworten zu senden?",
21
+ "review.incomplete": "⚠ Beantworte die verbleibenden Fragen vor dem Senden:",
22
+
23
+ "preview.no_preview": "Keine Vorschau verfügbar",
24
+ "preview.notes_affordance": "Notizen: drücke n, um Notizen hinzuzufügen",
25
+ "notes.header": "Notizen:",
26
+
27
+ "editor.failed": "Externer Editor fehlgeschlagen",
28
+
29
+ "rpc.multi_instructions": "Gib die Nummern aller zutreffenden Optionen durch Komma getrennt ein (z. B. \"1,3\") oder tippe eine eigene Antwort als Text.",
30
+ "rpc.custom_answer_title": "Gib deine Antwort ein:"
31
+ }
@@ -0,0 +1,29 @@
1
+ {
2
+ "sentinel.other": "Type something.",
3
+ "sentinel.next": "Next",
4
+ "submit.label": "Submit answers",
5
+ "submit.cancel": "Cancel",
6
+ "hint.enter": "Enter to select",
7
+ "hint.navigate": "↑/↓ to navigate",
8
+ "hint.newline": "Shift+Enter for newline",
9
+ "hint.clear": "Ctrl+U to clear",
10
+ "hint.toggle": "Enter to toggle",
11
+ "hint.notes": "n to add notes",
12
+ "hint.tab": "Tab to switch questions",
13
+ "hint.collapse": "{key} to collapse",
14
+ "hint.expand_line": "{key} to expand · Esc to cancel",
15
+ "hint.cancel": "Esc to cancel",
16
+ "review.heading": "Review your answers",
17
+ "review.ready": "Ready to submit your answers?",
18
+ "review.incomplete": "⚠ Answer remaining questions before submitting:",
19
+ "review.global_hint": "n to add a note",
20
+ "review.note_label": "Note",
21
+ "preview.no_preview": "No preview available",
22
+ "preview.notes_affordance": "Notes: press n to add notes",
23
+ "notes.header": "Notes:",
24
+ "notes.global_header": "Global note:",
25
+ "editor.failed": "External editor failed",
26
+ "rpc.multi_instructions": "Enter the numbers of all that apply, comma-separated (e.g. \"1,3\"), or type a custom answer as plain text.",
27
+ "rpc.custom_answer_title": "Type your answer:",
28
+ "hint.enter_toggle": "Enter to toggle"
29
+ }