@fadhelhaidar/pi-ask-user 1.0.4 → 1.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/dist/index.js CHANGED
@@ -1,7 +1,14 @@
1
+ import {
2
+ ASK_USER_PROMPT_EVENT2,
3
+ ASK_USER_BLOCKED_EVENT2,
4
+ ASK_USER_ANSWER_EVENT2,
5
+ ASK_USER_ANSWER_RESULT_EVENT2
6
+ } from "./chunks/index-pen3t92h.js";
1
7
  import {
2
8
  validateGuidanceFields,
3
9
  COLLAPSE_KEY_OFF,
4
10
  resolveCollapseKey,
11
+ resolveNotesKey,
5
12
  formatKeySpecForDisplay,
6
13
  loadConfig,
7
14
  ROW_INTENT_META,
@@ -9,20 +16,199 @@ import {
9
16
  I18N_NAMESPACE,
10
17
  t,
11
18
  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";
19
+ NO_INPUT_PLACEHOLDER,
20
+ formatAnswerScalar,
21
+ stripFenceMarkers
22
+ } from "./chunks/index-n7wxhdmh.js";
18
23
 
19
24
  // ask-user-question.ts
20
25
  import { isKeyRelease, isKeyRepeat, matchesKey } from "@earendil-works/pi-tui";
21
26
 
27
+ // tool/external-answer.ts
28
+ var reject = (code, reason) => ({ ok: false, code, reason });
29
+ var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
30
+ function noteOf(v) {
31
+ const trimmed = v?.trim();
32
+ return trimmed ? trimmed : undefined;
33
+ }
34
+ function resolveExternalAnswer(questions, payload) {
35
+ const { answers, globalNote, cancel } = payload;
36
+ if (cancel !== undefined && typeof cancel !== "boolean")
37
+ return reject("invalid_payload", "cancel must be a boolean");
38
+ if (globalNote !== undefined && typeof globalNote !== "string") {
39
+ return reject("invalid_payload", "globalNote must be a string");
40
+ }
41
+ const note = noteOf(globalNote);
42
+ const withNote = note ? { globalNote: note } : {};
43
+ if (cancel === true) {
44
+ if (answers !== undefined && !(Array.isArray(answers) && answers.length === 0)) {
45
+ return reject("invalid_payload", "cancel takes no answers");
46
+ }
47
+ return { ok: true, result: { answers: [], cancelled: true, ...withNote } };
48
+ }
49
+ if (!Array.isArray(answers))
50
+ return reject("invalid_payload", "answers must be an array");
51
+ const byQuestion = new Map;
52
+ for (const entry of answers) {
53
+ if (!isRecord(entry))
54
+ return reject("invalid_payload", "each answer must be an object");
55
+ const index = entry.questionIndex;
56
+ if (!Number.isInteger(index))
57
+ return reject("invalid_payload", "questionIndex must be an integer");
58
+ const i = index;
59
+ if (i < 0 || i >= questions.length) {
60
+ return reject("invalid_answer", `questionIndex ${i} out of range (0..${questions.length - 1})`);
61
+ }
62
+ if (byQuestion.has(i))
63
+ return reject("invalid_answer", `question ${i} answered twice`);
64
+ byQuestion.set(i, entry);
65
+ }
66
+ const resolved = [];
67
+ for (let i = 0;i < questions.length; i++) {
68
+ const entry = byQuestion.get(i);
69
+ if (!entry)
70
+ return reject("invalid_answer", `no answer for question ${i} ("${questions[i].header}")`);
71
+ const answer = resolveEntry(questions[i], i, entry);
72
+ if (!answer.ok)
73
+ return answer;
74
+ resolved.push(answer.answer);
75
+ }
76
+ return { ok: true, result: { answers: resolved, cancelled: false, ...withNote } };
77
+ }
78
+ function resolveEntry(q, i, entry) {
79
+ const { optionIndexes, text, notes } = entry;
80
+ if (optionIndexes !== undefined && (!Array.isArray(optionIndexes) || !optionIndexes.every(Number.isInteger))) {
81
+ return {
82
+ ok: false,
83
+ code: "invalid_payload",
84
+ reason: `question ${i}: optionIndexes must be an array of integers`
85
+ };
86
+ }
87
+ if (text !== undefined && typeof text !== "string") {
88
+ return { ok: false, code: "invalid_payload", reason: `question ${i}: text must be a string` };
89
+ }
90
+ if (notes !== undefined && typeof notes !== "string") {
91
+ return { ok: false, code: "invalid_payload", reason: `question ${i}: notes must be a string` };
92
+ }
93
+ const invalid = (reason) => ({
94
+ ok: false,
95
+ code: "invalid_answer",
96
+ reason: `question ${i}: ${reason}`
97
+ });
98
+ const picked = optionIndexes ?? [];
99
+ const out = picked.find((n) => n < 0 || n >= q.options.length);
100
+ if (out !== undefined)
101
+ return invalid(`option index ${out} out of range (0..${q.options.length - 1})`);
102
+ if (new Set(picked).size !== picked.length)
103
+ return invalid("option index repeated");
104
+ if (text !== undefined && text.trim().length === 0)
105
+ return invalid("text is blank");
106
+ if (picked.length === 0 && text === undefined)
107
+ return invalid("needs optionIndexes or text");
108
+ const base = { questionIndex: i, question: q.question };
109
+ const note = noteOf(notes);
110
+ const withNote = note ? { notes: note } : {};
111
+ if (q.multiSelect) {
112
+ const selected = q.options.filter((_o, n) => picked.includes(n)).map((o) => o.label);
113
+ return {
114
+ ok: true,
115
+ answer: { ...base, kind: "multi", answer: text ?? null, selected, ...withNote }
116
+ };
117
+ }
118
+ if (picked.length > 0 && text !== undefined)
119
+ return invalid("single-select takes optionIndexes or text, not both");
120
+ if (picked.length > 1)
121
+ return invalid(`single-select takes one option, got ${picked.length}`);
122
+ if (text !== undefined)
123
+ return { ok: true, answer: { ...base, kind: "custom", answer: text, ...withNote } };
124
+ const option = q.options[picked[0]];
125
+ const withPreview = option.preview && option.preview.length > 0 ? { preview: option.preview } : {};
126
+ return { ok: true, answer: { ...base, kind: "option", answer: option.label, ...withPreview, ...withNote } };
127
+ }
128
+
129
+ // answer-channel.ts
130
+ class OpenQuestionnaire {
131
+ toolCallId;
132
+ questions;
133
+ host;
134
+ result;
135
+ deliver;
136
+ resolveEarly;
137
+ answeredEarly = new Promise((resolve) => {
138
+ this.resolveEarly = resolve;
139
+ });
140
+ constructor(toolCallId, questions, host) {
141
+ this.toolCallId = toolCallId;
142
+ this.questions = questions;
143
+ this.host = host;
144
+ }
145
+ get settled() {
146
+ return this.result;
147
+ }
148
+ attach(done) {
149
+ this.deliver = done;
150
+ }
151
+ settle(result) {
152
+ if (this.result)
153
+ return false;
154
+ this.result = result;
155
+ this.resolveEarly(result);
156
+ this.deliver?.(result);
157
+ return true;
158
+ }
159
+ }
160
+ var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
161
+ function registerAnswerChannel(pi) {
162
+ const open = new Map;
163
+ pi.events.on(ASK_USER_ANSWER_EVENT2, (data) => {
164
+ const payload = isRecord2(data) ? data : {};
165
+ const requestId = typeof payload.requestId === "string" ? payload.requestId : undefined;
166
+ const toolCallId = typeof payload.toolCallId === "string" ? payload.toolCallId : undefined;
167
+ const reply = (code, reason) => {
168
+ const verdict = {
169
+ ok: code === undefined,
170
+ ...code ? { code, reason } : {},
171
+ ...requestId !== undefined ? { requestId } : {},
172
+ ...toolCallId !== undefined ? { toolCallId } : {}
173
+ };
174
+ pi.events.emit(ASK_USER_ANSWER_RESULT_EVENT2, verdict);
175
+ };
176
+ if (!isRecord2(data))
177
+ return reply("invalid_payload", "payload must be an object");
178
+ if (payload.requestId !== undefined && requestId === undefined) {
179
+ return reply("invalid_payload", "requestId must be a string");
180
+ }
181
+ if (toolCallId === undefined)
182
+ return reply("invalid_payload", "toolCallId must be a string");
183
+ const target = open.get(toolCallId);
184
+ if (!target || target.settled)
185
+ return reply("not_found", `no open questionnaire for tool call ${toolCallId}`);
186
+ if (target.host === "rpc")
187
+ return reply("unsupported_host", "this host shows the questions in its own dialogs");
188
+ const outcome = resolveExternalAnswer(target.questions, payload);
189
+ if (!outcome.ok)
190
+ return reply(outcome.code, outcome.reason);
191
+ if (!target.settle(outcome.result))
192
+ return reply("not_found", `tool call ${toolCallId} is already answered`);
193
+ reply();
194
+ });
195
+ return {
196
+ open(toolCallId, questions, host) {
197
+ const questionnaire = new OpenQuestionnaire(toolCallId, questions, host);
198
+ open.set(toolCallId, questionnaire);
199
+ return questionnaire;
200
+ },
201
+ close(questionnaire) {
202
+ if (open.get(questionnaire.toolCallId) === questionnaire)
203
+ open.delete(questionnaire.toolCallId);
204
+ }
205
+ };
206
+ }
207
+
22
208
  // 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.';
209
+ var MULTI_SELECT_INSTRUCTIONS = 'Enter the numbers of all that apply, comma-separated (e.g. "1,3"). Add "; custom answer" to include your own text, or type only custom text.';
24
210
  var CUSTOM_ANSWER_TITLE = "Type your answer:";
25
- var MULTI_SELECT_PLACEHOLDER = "1,3";
211
+ var MULTI_SELECT_PLACEHOLDER = "1,3; custom answer";
26
212
  var MAX_PREVIEW_CHARS = 600;
27
213
  function hasDialogUI(ui) {
28
214
  const u = ui;
@@ -62,9 +248,12 @@ async function askSingleSelect(ui, q, questionIndex, header) {
62
248
  const chosen = await ui.select(`${header}${q.question}${buildPreviewBlock(q)}`, options);
63
249
  if (chosen == null)
64
250
  return;
65
- const idx = parseIndex(chosen, options.length);
66
- if (idx == null)
251
+ const reply = chosen.trim();
252
+ if (reply.length === 0)
67
253
  return;
254
+ const idx = resolveSingleSelectReply(chosen, reply, options, q);
255
+ if (idx === undefined)
256
+ return { questionIndex, question: q.question, kind: "custom", answer: reply };
68
257
  if (idx < q.options.length) {
69
258
  const o = q.options[idx];
70
259
  return {
@@ -77,11 +266,23 @@ async function askSingleSelect(ui, q, questionIndex, header) {
77
266
  }
78
267
  const typed = await ui.input(`${header}${q.question}
79
268
 
269
+ ${q.options.map(formatOptionLine).join(`
270
+ `)}
271
+
80
272
  ${t("rpc.custom_answer_title", CUSTOM_ANSWER_TITLE)}`, "");
81
273
  if (typed == null)
82
274
  return;
83
275
  return { questionIndex, question: q.question, kind: "custom", answer: typed };
84
276
  }
277
+ function resolveSingleSelectReply(chosen, reply, lines, q) {
278
+ const line = lines.indexOf(chosen);
279
+ if (line >= 0)
280
+ return line;
281
+ if (/^\d+\.?$/.test(reply))
282
+ return parseIndex(reply, lines.length) ?? undefined;
283
+ const label = q.options.findIndex((o) => o.label === reply);
284
+ return label >= 0 ? label : undefined;
285
+ }
85
286
  async function askMultiSelect(ui, q, questionIndex, header) {
86
287
  const list = q.options.map(formatOptionLine).join(`
87
288
  `);
@@ -96,18 +297,99 @@ ${t("rpc.multi_instructions", MULTI_SELECT_INSTRUCTIONS)}`, MULTI_SELECT_PLACEHO
96
297
  if (trimmed.length === 0) {
97
298
  return { questionIndex, question: q.question, kind: "multi", answer: null, selected: [] };
98
299
  }
99
- const tokens = trimmed.split(/[,\s]+/).filter((tok) => tok.length > 0);
300
+ const [selectionPart = "", ...customParts] = trimmed.split(";");
301
+ const selection = selectionPart.trim();
302
+ const custom = customParts.join(";").trim();
303
+ const tokens = selection.split(/[,\s]+/).filter((tok) => tok.length > 0);
100
304
  const indices = tokens.map((tok) => /^\d+\.?$/.test(tok) ? parseIndex(tok, q.options.length) : null);
101
- if (indices.every((i) => i != null)) {
305
+ if (selection.length === 0 || indices.every((i) => i != null)) {
102
306
  const selected = [];
103
307
  for (const i of indices) {
308
+ if (i == null)
309
+ continue;
104
310
  const label = q.options[i].label;
105
311
  if (!selected.includes(label))
106
312
  selected.push(label);
107
313
  }
108
- return { questionIndex, question: q.question, kind: "multi", answer: null, selected };
314
+ return {
315
+ questionIndex,
316
+ question: q.question,
317
+ kind: "multi",
318
+ answer: custom.length > 0 ? custom : null,
319
+ selected
320
+ };
321
+ }
322
+ return { questionIndex, question: q.question, kind: "multi", answer: trimmed, selected: [] };
323
+ }
324
+
325
+ // presentation.ts
326
+ var INLINE_ROW_RATIO = 0.7;
327
+ var INLINE_MIN_ROWS = 12;
328
+ var fullRowBudget = (terminalRows) => terminalRows;
329
+ var HOST_ALLOCATED_ROWS = Symbol.for("rpiv-ask-user-question:host-allocated-rows");
330
+ function hostAllocatesRows(terminal) {
331
+ return typeof terminal === "object" && terminal !== null && terminal[HOST_ALLOCATED_ROWS] === true;
332
+ }
333
+ function rowBudgetForHost(terminal, budget) {
334
+ return hostAllocatesRows(terminal) ? fullRowBudget : budget;
335
+ }
336
+ var YIELD_TO_OVERLAY = Object.freeze({
337
+ answers: [],
338
+ cancelled: true
339
+ });
340
+ function mustYieldInlineSlot(tui) {
341
+ if (hostAllocatesRows(tui.terminal))
342
+ return false;
343
+ const probe = overlayProbe(tui);
344
+ if (probe)
345
+ return probe() !== undefined;
346
+ return typeof tui.hasOverlay === "function" && tui.hasOverlay();
347
+ }
348
+ function overlayProbe(tui) {
349
+ const { getTopmostVisibleOverlay } = tui;
350
+ return typeof getTopmostVisibleOverlay === "function" ? () => getTopmostVisibleOverlay.call(tui) : undefined;
351
+ }
352
+ function topOverlayComponent(tui) {
353
+ let top;
354
+ try {
355
+ top = overlayProbe(tui)?.()?.component;
356
+ } catch {
357
+ return;
109
358
  }
110
- return { questionIndex, question: q.question, kind: "custom", answer: trimmed };
359
+ return typeof top === "object" && top !== null ? top : undefined;
360
+ }
361
+ function focusOverlayComponent(tui, component) {
362
+ const { setFocus } = tui;
363
+ if (typeof setFocus === "function")
364
+ setFocus.call(tui, component);
365
+ }
366
+ var inlineRowBudget = (terminalRows) => Math.min(terminalRows, Math.max(INLINE_MIN_ROWS, Math.floor(terminalRows * INLINE_ROW_RATIO)));
367
+ var inlineSlotHeld = false;
368
+ function claimInlineSlot() {
369
+ if (inlineSlotHeld)
370
+ return;
371
+ inlineSlotHeld = true;
372
+ let released = false;
373
+ return () => {
374
+ if (released)
375
+ return;
376
+ released = true;
377
+ inlineSlotHeld = false;
378
+ };
379
+ }
380
+ var OVERLAY_PRESENTATION = Object.freeze({
381
+ overlay: true,
382
+ rowBudget: fullRowBudget,
383
+ hideOnCollapse: true
384
+ });
385
+ var STACKED_OVERLAY_PRESENTATION = Object.freeze({
386
+ overlay: true,
387
+ rowBudget: fullRowBudget,
388
+ hideOnCollapse: false
389
+ });
390
+ function resolvePresentation() {
391
+ const release = claimInlineSlot();
392
+ return release ? { overlay: false, rowBudget: inlineRowBudget, release } : OVERLAY_PRESENTATION;
111
393
  }
112
394
 
113
395
  // tool/normalize-params.ts
@@ -133,6 +415,39 @@ function normalizeQuestionParams(params) {
133
415
  }))
134
416
  };
135
417
  }
418
+ function prepareQuestionArguments(args) {
419
+ if (!isJsonRecord(args))
420
+ return args;
421
+ const questions = decodeArray(args.questions);
422
+ if (!questions)
423
+ return args;
424
+ const decoded = questions.map((q) => {
425
+ const question = decodeObject(q);
426
+ if (!question)
427
+ return q;
428
+ const options = decodeArray(question.options);
429
+ return options ? { ...question, options: options.map((o) => decodeObject(o) ?? o) } : question;
430
+ });
431
+ return { ...args, questions: decoded };
432
+ }
433
+ function isJsonRecord(value) {
434
+ return typeof value === "object" && value !== null && !Array.isArray(value);
435
+ }
436
+ function decodeArray(value) {
437
+ const parsed = typeof value === "string" ? parseJson(value) : value;
438
+ return Array.isArray(parsed) ? parsed : undefined;
439
+ }
440
+ function decodeObject(value) {
441
+ const parsed = typeof value === "string" ? parseJson(value) : value;
442
+ return isJsonRecord(parsed) ? parsed : undefined;
443
+ }
444
+ function parseJson(text) {
445
+ try {
446
+ return JSON.parse(text);
447
+ } catch {
448
+ return;
449
+ }
450
+ }
136
451
 
137
452
  // tool/response-envelope.ts
138
453
  var DECLINE_MESSAGE = "User declined to answer questions";
@@ -147,18 +462,27 @@ function buildQuestionnaireResponse(result, params) {
147
462
  });
148
463
  }
149
464
  const segments = [];
465
+ const unanswered = [];
150
466
  for (let i = 0;i < params.questions.length; i++) {
151
467
  const a = result.answers.find((x) => x.questionIndex === i);
152
468
  if (a)
153
469
  segments.push(buildAnswerSegment(a));
470
+ else
471
+ unanswered.push(i);
154
472
  }
155
- if (result.globalNote && result.globalNote.length > 0) {
156
- segments.push(`global note: ${result.globalNote}.`);
157
- }
158
- if (segments.length === 0) {
473
+ const hasGlobalNote = result.globalNote !== undefined && result.globalNote.length > 0;
474
+ if (segments.length === 0 && !hasGlobalNote) {
159
475
  return buildToolResult(DECLINE_MESSAGE, { answers: result.answers, cancelled: true });
160
476
  }
161
- return buildToolResult(`${ENVELOPE_PREFIX} ${segments.join(" ")} ${ENVELOPE_SUFFIX}`, result);
477
+ if (unanswered.length > 0)
478
+ segments.push(buildUnansweredSegment(unanswered, params));
479
+ if (hasGlobalNote)
480
+ segments.push(`global note: ${result.globalNote}.`);
481
+ const details = unanswered.length > 0 ? { ...result, unanswered } : result;
482
+ return buildToolResult(`${ENVELOPE_PREFIX} ${segments.join(" ")} ${ENVELOPE_SUFFIX}`, details);
483
+ }
484
+ function buildUnansweredSegment(indexes, params) {
485
+ return `unanswered: ${indexes.map((i) => `"${params.questions[i]?.question}"`).join(", ")}.`;
162
486
  }
163
487
  function buildAnswerSegment(a) {
164
488
  const parts = [`"${a.question}"="${formatAnswerScalar(a, "envelope")}"`];
@@ -221,6 +545,12 @@ var QuestionsSchema = Type.Array(QuestionSchema, {
221
545
  var QuestionParamsSchema = Type.Object({
222
546
  questions: QuestionsSchema
223
547
  });
548
+ function isQuestionnaireResult(value) {
549
+ if (!value || typeof value !== "object")
550
+ return false;
551
+ const v = value;
552
+ return Array.isArray(v.answers) && typeof v.cancelled === "boolean";
553
+ }
224
554
 
225
555
  // tool/validate-questionnaire.ts
226
556
  var ERROR_NO_QUESTIONS = "Error: At least one question is required";
@@ -266,9 +596,99 @@ function validateQuestionnaire(typed) {
266
596
  return { ok: true };
267
597
  }
268
598
 
599
+ // view/transcript.ts
600
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
601
+ var INDENT = " ";
602
+ var COLUMN_GAP = " ";
603
+ var RECOMMENDED_SUFFIX = /\s*\(Recommended\)$/;
604
+
605
+ class WidthLines {
606
+ layout;
607
+ constructor(layout) {
608
+ this.layout = layout;
609
+ }
610
+ render(width) {
611
+ return this.layout(width);
612
+ }
613
+ invalidate() {}
614
+ }
615
+ function renderAskCall(args, theme) {
616
+ const headers = (args?.questions ?? []).map((q) => q?.header).filter(Boolean);
617
+ const title = theme.fg("toolTitle", theme.bold("Ask"));
618
+ const text = headers.length > 0 ? `${title} ${theme.fg("accent", headers.join(" · "))}` : title;
619
+ return new WidthLines((width) => [truncateToWidth(text, width, "…")]);
620
+ }
621
+ function renderAskResult(result, args, theme, options) {
622
+ const details = isQuestionnaireResult(result.details) ? result.details : undefined;
623
+ if (options.isError || details?.error) {
624
+ return new WidthLines(() => [theme.fg("error", `✗ ${firstTextLine(result)}`)]);
625
+ }
626
+ if (!details)
627
+ return new WidthLines(() => [theme.fg("success", "✓")]);
628
+ if (details.cancelled) {
629
+ return new WidthLines(() => [theme.fg("dim", `✗ ${t("transcript.no_answer", "no answer")}`)]);
630
+ }
631
+ const questions = args?.questions ?? [];
632
+ return new WidthLines((width) => questions.length > 1 ? tableLines(details, args, theme, options.expanded, width) : singleLines(details, questions[0]?.question, theme, options.expanded, width));
633
+ }
634
+ function singleLines(details, questionText, theme, expanded, width) {
635
+ const answer = details.answers[0];
636
+ const lines = hanging(`${theme.fg("success", "✓")} `, answerText(answer, theme), width);
637
+ lines.push(...detailLines(answer, expanded ? questionText : undefined, INDENT, theme, width));
638
+ return [...lines, ...globalNoteLines(details, theme, width)];
639
+ }
640
+ function tableLines(details, args, theme, expanded, width) {
641
+ const headerWidth = Math.max(...args.questions.map((q) => visibleWidth(q.header)));
642
+ const answerIndent = " ".repeat(INDENT.length + headerWidth + COLUMN_GAP.length);
643
+ const lines = args.questions.flatMap((q, i) => {
644
+ const answer = details.answers.find((a) => a.questionIndex === i);
645
+ const header = q.header + " ".repeat(headerWidth - visibleWidth(q.header));
646
+ const row = hanging(`${INDENT}${theme.fg("muted", header)}${COLUMN_GAP}`, answerText(answer, theme), width);
647
+ return [...row, ...detailLines(answer, expanded ? q.question : undefined, answerIndent, theme, width)];
648
+ });
649
+ return [...lines, ...globalNoteLines(details, theme, width)];
650
+ }
651
+ function detailLines(answer, questionText, indent, theme, width) {
652
+ const lines = [];
653
+ if (questionText)
654
+ lines.push(...hanging(indent, theme.fg("dim", questionText), width));
655
+ if (questionText && answer?.preview) {
656
+ for (const line of stripFenceMarkers(answer.preview.split(`
657
+ `))) {
658
+ lines.push(truncateToWidth(`${indent}${theme.fg("dim", `│ ${line}`)}`, width, "…"));
659
+ }
660
+ }
661
+ if (answer?.notes)
662
+ lines.push(...hanging(indent, theme.fg("dim", `✎ ${answer.notes}`), width));
663
+ return lines;
664
+ }
665
+ function globalNoteLines(details, theme, width) {
666
+ return details.globalNote ? hanging(INDENT, theme.fg("dim", `✎ ${details.globalNote}`), width) : [];
667
+ }
668
+ function answerText(answer, theme) {
669
+ if (!answer)
670
+ return theme.fg("dim", t("transcript.not_answered", "not answered"));
671
+ const typed = answer.answer ? `"${answer.answer}"` : undefined;
672
+ const picks = answer.kind === "option" ? [label(answer.answer ?? "")] : answer.kind === "custom" ? [typed] : [...(answer.selected ?? []).map(label), typed];
673
+ return theme.fg("toolOutput", picks.filter(Boolean).join(", ") || NO_INPUT_PLACEHOLDER);
674
+ }
675
+ function label(text) {
676
+ return text.replace(RECOMMENDED_SUFFIX, "");
677
+ }
678
+ function hanging(prefix, text, width) {
679
+ const indent = visibleWidth(prefix);
680
+ return wrapTextWithAnsi(text, Math.max(1, width - indent)).map((line, i) => (i === 0 ? prefix : " ".repeat(indent)) + line);
681
+ }
682
+ function firstTextLine(result) {
683
+ const text = result.content.find((c) => c.type === "text")?.text ?? "";
684
+ return text.split(`
685
+ `)[0] ?? "";
686
+ }
687
+
269
688
  // ask-user-question.ts
270
- function emitAskUserPromptEvent(pi, params) {
689
+ function emitAskUserPromptEvent(pi, params, toolCallId) {
271
690
  const payload = {
691
+ toolCallId,
272
692
  questions: params.questions.map((q) => ({
273
693
  question: q.question,
274
694
  header: q.header,
@@ -282,20 +702,20 @@ function emitAskUserPromptEvent(pi, params) {
282
702
  };
283
703
  pi.events.emit(ASK_USER_PROMPT_EVENT2, payload);
284
704
  }
285
- function emitAskUserBlockedEvent(pi, active) {
286
- const payload = { active };
705
+ function emitAskUserBlockedEvent(pi, active, toolCallId) {
706
+ const payload = { active, toolCallId };
287
707
  pi.events.emit(ASK_USER_BLOCKED_EVENT2, payload);
288
708
  }
289
709
  function rejectWithoutUi() {
290
710
  return buildToolResult(ERROR_NO_UI, { answers: [], cancelled: true, error: "no_ui" });
291
711
  }
292
- async function runRpcPath(pi, ui, typed) {
293
- emitAskUserBlockedEvent(pi, true);
712
+ async function runRpcPath(pi, ui, typed, toolCallId) {
713
+ emitAskUserBlockedEvent(pi, true, toolCallId);
294
714
  try {
295
715
  emitTerminalAttention();
296
716
  return buildQuestionnaireResponse(await runRpcQuestionnaire(ui, typed), typed);
297
717
  } finally {
298
- emitAskUserBlockedEvent(pi, false);
718
+ emitAskUserBlockedEvent(pi, false, toolCallId);
299
719
  }
300
720
  }
301
721
  var ASK_USER_QUESTION_TOOL_NAME = "ask_user_question";
@@ -314,7 +734,7 @@ var PREWARM_DELAY_MS = 2000;
314
734
  async function loadQuestionnaireSession() {
315
735
  let mod;
316
736
  try {
317
- mod = await import("./chunks/questionnaire-session-j2wmt696.js");
737
+ mod = await import("./chunks/questionnaire-session-m053x4ka.js");
318
738
  } catch (e) {
319
739
  const cause = e instanceof Error ? e.message : String(e);
320
740
  return { ok: false, error: "session_load_failed", message: `${ERROR_SESSION_LOAD_FAILED} (cause: ${cause})` };
@@ -352,14 +772,35 @@ function registerCollapseKeyListener(ctx, collapseKey, sessionRef, overlayHandle
352
772
  });
353
773
  }
354
774
  function makeSessionFactory(config) {
355
- const { ctx, typed, itemsByTab, collapseKey, canReopenWhileHidden, sessionRef, Session } = config;
775
+ const {
776
+ ctx,
777
+ typed,
778
+ itemsByTab,
779
+ collapseKey,
780
+ notesKey,
781
+ canReopenWhileHidden,
782
+ presentation,
783
+ open,
784
+ sessionRef,
785
+ overlayHandleRef,
786
+ Session
787
+ } = config;
356
788
  return (tui, theme, keybindings, done) => {
789
+ if (!presentation.overlay && mustYieldInlineSlot(tui)) {
790
+ done(YIELD_TO_OVERLAY);
791
+ return INERT_COMPONENT;
792
+ }
793
+ if (open.settled) {
794
+ if (!presentation.overlay)
795
+ done(open.settled);
796
+ return INERT_COMPONENT;
797
+ }
357
798
  const session = new Session({
358
799
  tui,
359
800
  theme,
360
801
  params: typed,
361
802
  itemsByTab,
362
- done,
803
+ done: (result) => open.settle(result),
363
804
  keybindings,
364
805
  editInput: async (value) => {
365
806
  try {
@@ -380,12 +821,29 @@ function makeSessionFactory(config) {
380
821
  }
381
822
  },
382
823
  collapseKey,
383
- canReopenWhileHidden
824
+ notesKey,
825
+ canReopenWhileHidden,
826
+ rowBudget: rowBudgetForHost(tui.terminal, presentation.rowBudget)
384
827
  });
385
828
  sessionRef.current = session;
829
+ open.attach((result) => {
830
+ if (presentation.overlay) {
831
+ overlayHandleRef.current?.hide();
832
+ return;
833
+ }
834
+ done(result);
835
+ const top = topOverlayComponent(tui);
836
+ if (top !== undefined && top !== session.component)
837
+ focusOverlayComponent(tui, top);
838
+ });
386
839
  return session.component;
387
840
  };
388
841
  }
842
+ var INERT_COMPONENT = {
843
+ render: () => [],
844
+ invalidate: () => {},
845
+ handleInput: () => {}
846
+ };
389
847
  async function resolveUndefinedResult(ctx, typed) {
390
848
  if (hasDialogUI(ctx.ui)) {
391
849
  return buildQuestionnaireResponse(await runRpcQuestionnaire(ctx.ui, typed), typed);
@@ -437,6 +895,7 @@ Use the optional \`preview\` field on options when presenting concrete artifacts
437
895
  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
896
  function registerAskUserQuestionTool(pi) {
439
897
  const guidance = validateGuidanceFields(loadConfig().guidance);
898
+ const answers = registerAnswerChannel(pi);
440
899
  pi.registerTool({
441
900
  name: ASK_USER_QUESTION_TOOL_NAME,
442
901
  label: "Ask User Question",
@@ -444,7 +903,11 @@ function registerAskUserQuestionTool(pi) {
444
903
  promptSnippet: guidance.promptSnippet ?? DEFAULT_PROMPT_SNIPPET,
445
904
  promptGuidelines: guidance.promptGuidelines ?? DEFAULT_PROMPT_GUIDELINES,
446
905
  parameters: QuestionParamsSchema,
447
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
906
+ prepareArguments: prepareQuestionArguments,
907
+ exposure: "model-only",
908
+ renderCall: (args, theme) => renderAskCall(args, theme),
909
+ renderResult: (result, { expanded }, theme, context) => renderAskResult(result, context?.args, theme, { expanded, isError: context?.isError ?? false }),
910
+ async execute(toolCallId, params, _signal, _onUpdate, ctx) {
448
911
  const typed = normalizeQuestionParams(params);
449
912
  if (!ctx.hasUI)
450
913
  return rejectWithoutUi();
@@ -456,57 +919,88 @@ function registerAskUserQuestionTool(pi) {
456
919
  error: validation.error
457
920
  });
458
921
  }
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);
922
+ const viaRpc = ctx.mode === "rpc" && hasDialogUI(ctx.ui);
923
+ const open = answers.open(toolCallId, typed.questions, viaRpc ? "rpc" : "tui");
475
924
  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);
925
+ emitAskUserPromptEvent(pi, typed, toolCallId);
926
+ if (viaRpc)
927
+ return await runRpcPath(pi, ctx.ui, typed, toolCallId);
928
+ return await runTuiPath(pi, ctx, typed, open, toolCallId);
502
929
  } finally {
503
- removeOverlayInputListener?.();
504
- emitAskUserBlockedEvent(pi, false);
930
+ answers.close(open);
505
931
  }
506
932
  }
507
933
  });
508
934
  prewarmSessionGraph();
509
935
  }
936
+ async function runTuiPath(pi, ctx, typed, open, toolCallId) {
937
+ const itemsByTab = typed.questions.map((q) => buildItemsForQuestion(q));
938
+ let presentation;
939
+ let removeOverlayInputListener;
940
+ emitAskUserBlockedEvent(pi, true, toolCallId);
941
+ try {
942
+ const sessionLoad = await loadQuestionnaireSession();
943
+ if (open.settled)
944
+ return buildQuestionnaireResponse(open.settled, typed);
945
+ if (!sessionLoad.ok) {
946
+ return buildToolResult(sessionLoad.message, { answers: [], cancelled: true, error: sessionLoad.error });
947
+ }
948
+ const { QuestionnaireSession } = sessionLoad.module;
949
+ const collapseKey = resolveCollapseKey(loadConfig());
950
+ const notesKey = resolveNotesKey(loadConfig());
951
+ const sessionRef = { current: null };
952
+ const overlayHandleRef = { current: undefined };
953
+ presentation = resolvePresentation();
954
+ removeOverlayInputListener = presentation.hideOnCollapse ? registerCollapseKeyListener(ctx, collapseKey, sessionRef, overlayHandleRef) : undefined;
955
+ const canReopenWhileHidden = removeOverlayInputListener !== undefined;
956
+ const mount = (p) => Promise.race([
957
+ ctx.ui.custom(makeSessionFactory({
958
+ ctx,
959
+ typed,
960
+ itemsByTab,
961
+ collapseKey,
962
+ notesKey,
963
+ canReopenWhileHidden,
964
+ presentation: p,
965
+ open,
966
+ sessionRef,
967
+ overlayHandleRef,
968
+ Session: QuestionnaireSession
969
+ }), p.overlay ? {
970
+ overlay: true,
971
+ overlayOptions: {
972
+ anchor: "bottom-center",
973
+ width: "100%",
974
+ maxHeight: "100%",
975
+ margin: { left: 0, right: 0, bottom: 0 }
976
+ },
977
+ onHandle: (handle) => {
978
+ overlayHandleRef.current = handle;
979
+ sessionRef.current?.setOverlayHandle(handle);
980
+ if (open.settled)
981
+ handle.hide();
982
+ }
983
+ } : { overlay: false }),
984
+ open.answeredEarly
985
+ ]);
986
+ emitTerminalAttention();
987
+ let result = await mount(presentation);
988
+ if (result === YIELD_TO_OVERLAY) {
989
+ presentation.release?.();
990
+ presentation = STACKED_OVERLAY_PRESENTATION;
991
+ result = await mount(presentation);
992
+ }
993
+ if (result === undefined) {
994
+ open.host = "rpc";
995
+ return await resolveUndefinedResult(ctx, typed);
996
+ }
997
+ return buildQuestionnaireResponse(result, typed);
998
+ } finally {
999
+ presentation?.release?.();
1000
+ removeOverlayInputListener?.();
1001
+ emitAskUserBlockedEvent(pi, false, toolCallId);
1002
+ }
1003
+ }
510
1004
 
511
1005
  // reconcile.ts
512
1006
  function reconcileAskUserQuestionTool(pi, ctx) {
@@ -532,6 +1026,8 @@ function pi_ask_user_default(pi) {
532
1026
  registerAskUserQuestionReconciler(pi);
533
1027
  }
534
1028
  export {
1029
+ ASK_USER_ANSWER_EVENT2 as ASK_USER_ANSWER_EVENT,
1030
+ ASK_USER_ANSWER_RESULT_EVENT2 as ASK_USER_ANSWER_RESULT_EVENT,
535
1031
  ASK_USER_BLOCKED_EVENT2 as ASK_USER_BLOCKED_EVENT,
536
1032
  ASK_USER_PROMPT_EVENT2 as ASK_USER_PROMPT_EVENT,
537
1033
  pi_ask_user_default as default