@agnishc/edb-ask-user 0.8.2 → 0.10.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.10.3] - 2026-05-15
4
+
3
5
  ## [0.8.2] - 2026-05-11
4
6
 
5
7
  ## [0.8.1] - 2026-05-11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agnishc/edb-ask-user",
3
- "version": "0.8.2",
3
+ "version": "0.10.3",
4
4
  "description": "Pi extension: ask_user tool for structured questions — text, choice, and multi-step wizard",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/component.ts CHANGED
@@ -1,5 +1,42 @@
1
- import { Editor, type EditorTheme, Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
1
+ import { Editor, type EditorTheme, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
2
  import type { Answer, AskQuestion, AskResult, RenderOption } from "./types";
3
+ import { wrapText } from "./utils";
4
+
5
+ // ── Constants ──────────────────────────────────────────────────────────────────
6
+
7
+ const DEFAULT_MAX_VISIBLE_OPTIONS = 10;
8
+ const OVERLAY_PADDING_X = 2;
9
+
10
+ // ── Overlay frame helpers ──────────────────────────────────────────────────────
11
+
12
+ function padToWidth(text: string, width: number): string {
13
+ const truncated = truncateToWidth(text, width, "");
14
+ return `${truncated}${" ".repeat(Math.max(0, width - visibleWidth(truncated)))}`;
15
+ }
16
+
17
+ function framePopup(lines: string[], width: number, theme: any, title = ""): string[] {
18
+ if (width < 8) return lines.map((l) => truncateToWidth(l, width, ""));
19
+
20
+ const border = (t: string) => theme.fg("borderAccent", t);
21
+ const innerWidth = Math.max(1, width - 2 - OVERLAY_PADDING_X * 2);
22
+
23
+ const topBorder = (): string => {
24
+ if (!title) return `${border("┏")}${border("━".repeat(width - 2))}${border("┓")}`;
25
+ const safe = truncateToWidth(title, Math.max(1, width - 6), "…");
26
+ const titleStr = ` ${safe} `;
27
+ const fillW = Math.max(0, width - 2 - visibleWidth(titleStr));
28
+ return `${border("┏")}${theme.fg("accent", titleStr)}${border("━".repeat(fillW))}${border("┓")}`;
29
+ };
30
+
31
+ const framed: string[] = [topBorder()];
32
+ for (const line of lines) {
33
+ framed.push(
34
+ `${border("┃")}${" ".repeat(OVERLAY_PADDING_X)}${padToWidth(line, innerWidth)}${" ".repeat(OVERLAY_PADDING_X)}${border("┃")}`,
35
+ );
36
+ }
37
+ framed.push(`${border("┗")}${border("━".repeat(width - 2))}${border("┛")}`);
38
+ return framed.map((l) => truncateToWidth(l, width, ""));
39
+ }
3
40
 
4
41
  // ── Component factory ──────────────────────────────────────────────────────────
5
42
 
@@ -12,18 +49,32 @@ export function createAskUserComponent(
12
49
  theme: any,
13
50
  done: (result: AskResult) => void,
14
51
  questions: AskQuestion[],
52
+ opts?: { header?: string; useOverlay?: boolean },
15
53
  ) {
16
- const isMulti = questions.length > 1;
17
- const totalTabs = questions.length + 1; // questions + Submit tab
54
+ const header = opts?.header;
55
+ const useOverlay = opts?.useOverlay ?? false;
56
+
57
+ // A Submit tab is needed when there are multiple questions or any multiple-select question.
58
+ const needsSubmitTab = questions.length > 1 || questions.some((q) => q.multiple);
59
+ const totalTabs = questions.length + (needsSubmitTab ? 1 : 0);
18
60
 
19
61
  // ── Shared state ──────────────────────────────────────────────────────────
20
62
  let currentTab = 0;
21
- let optionIndex = 0;
63
+ let submitCursor = 0; // cursor row on the Submit tab
22
64
  let inputMode = false;
23
65
  let inputQuestionId: string | null = null;
24
66
  let cachedLines: string[] | undefined;
25
67
 
68
+ /** Committed answers (single-select + text questions). */
26
69
  const answers = new Map<string, Answer>();
70
+ /** Selected option values per choice-question tab (multi-select). */
71
+ const multiSelections: Set<string>[] = questions.map(() => new Set());
72
+ /** Free-text entered via the isOther editor, per tab. */
73
+ const customTexts: string[] = questions.map(() => "");
74
+ /** Cursor row (absolute option index) per choice-question tab. */
75
+ const selectedRows: number[] = questions.map(() => 0);
76
+ /** Scroll offset (absolute option index of first visible row) per tab. */
77
+ const scrollOffsets: number[] = questions.map(() => 0);
27
78
 
28
79
  // ── Inline editor ─────────────────────────────────────────────────────────
29
80
  const editorTheme: EditorTheme = {
@@ -53,40 +104,87 @@ export function createAskUserComponent(
53
104
  return questions[currentTab];
54
105
  }
55
106
 
56
- function currentOptions(): RenderOption[] {
57
- const q = currentQuestion();
58
- if (!q || q.type !== "choice") return [];
59
- const rawOpts = q.options ?? [];
60
- const opts: RenderOption[] = rawOpts.map((o) => ({
61
- ...o,
62
- isOther: o.isOther === true ? true : undefined,
63
- }));
64
- // If no option is already marked as free-text (isOther), auto-append "Type something."
107
+ /** Builds the full options list for a question, auto-appending an isOther option if needed. */
108
+ function optionsFor(q: AskQuestion): RenderOption[] {
109
+ const raw = q.options ?? [];
110
+ const opts: RenderOption[] = raw.map((o) => ({ ...o }));
65
111
  if (!opts.some((o) => o.isOther)) {
66
- opts.push({ value: "__other__", label: "Type something.", isOther: true });
112
+ opts.push({ value: "__other__", label: q.customLabel ?? "Type something.", isOther: true });
67
113
  }
68
114
  return opts;
69
115
  }
70
116
 
117
+ function currentOptions(): RenderOption[] {
118
+ const q = currentQuestion();
119
+ return q?.type === "choice" ? optionsFor(q) : [];
120
+ }
121
+
122
+ function maxVisibleFor(q: AskQuestion): number {
123
+ return Math.max(1, q.maxVisibleOptions ?? DEFAULT_MAX_VISIBLE_OPTIONS);
124
+ }
125
+
126
+ function isQuestionAnswered(q: AskQuestion, i: number): boolean {
127
+ if (q.type === "choice" && q.multiple) {
128
+ return multiSelections[i].size > 0 || customTexts[i].trim().length > 0;
129
+ }
130
+ return answers.has(q.id);
131
+ }
132
+
71
133
  function allAnswered(): boolean {
72
- return questions.every((q) => answers.has(q.id));
134
+ return questions.every((q, i) => isQuestionAnswered(q, i));
73
135
  }
74
136
 
75
- function advanceAfterAnswer() {
76
- if (!isMulti) {
77
- submitAll(false);
78
- return;
137
+ /** Clamp selectedRows and scrollOffsets for a choice tab. */
138
+ function clampScroll(tabIndex: number): void {
139
+ const q = questions[tabIndex];
140
+ if (!q || q.type !== "choice") return;
141
+ const total = optionsFor(q).length;
142
+ const visible = maxVisibleFor(q);
143
+ selectedRows[tabIndex] = Math.max(0, Math.min(selectedRows[tabIndex] ?? 0, total - 1));
144
+ if (selectedRows[tabIndex] < scrollOffsets[tabIndex]) {
145
+ scrollOffsets[tabIndex] = selectedRows[tabIndex];
79
146
  }
80
- for (let i = currentTab + 1; i < questions.length; i++) {
81
- if (!answers.has(questions[i]!.id)) {
82
- switchToTab(i);
83
- return;
84
- }
147
+ if (selectedRows[tabIndex] >= scrollOffsets[tabIndex] + visible) {
148
+ scrollOffsets[tabIndex] = selectedRows[tabIndex] - visible + 1;
85
149
  }
86
- switchToTab(questions.length);
150
+ scrollOffsets[tabIndex] = Math.max(0, Math.min(scrollOffsets[tabIndex], Math.max(0, total - visible)));
87
151
  }
88
152
 
89
- function saveAnswer(questionId: string, value: string, label: string, wasCustom: boolean, optIndex?: number) {
153
+ /** Rebuild the Answer entry for a multiple-select question from its selections + custom text. */
154
+ function updateMultiAnswer(tabIndex: number): void {
155
+ const q = questions[tabIndex];
156
+ if (!q) return;
157
+ const opts = optionsFor(q);
158
+ const selected = Array.from(multiSelections[tabIndex]);
159
+ const custom = customTexts[tabIndex].trim();
160
+ const allValues = custom ? [...selected, custom] : selected;
161
+
162
+ if (allValues.length === 0) {
163
+ answers.delete(q.id);
164
+ return;
165
+ }
166
+
167
+ const labels = allValues.map((v) => opts.find((o) => o.value === v)?.label ?? v);
168
+ const indices = selected
169
+ .map((v) => {
170
+ const idx = opts.findIndex((o) => o.value === v && !o.isOther);
171
+ return idx >= 0 ? idx + 1 : undefined;
172
+ })
173
+ .filter((x): x is number => x !== undefined);
174
+
175
+ answers.set(q.id, {
176
+ id: q.id,
177
+ value: allValues[0]!,
178
+ values: allValues,
179
+ label: labels[0]!,
180
+ labels,
181
+ type: "choice",
182
+ wasCustom: Boolean(custom),
183
+ optionIndices: indices.length ? indices : undefined,
184
+ });
185
+ }
186
+
187
+ function saveAnswer(questionId: string, value: string, label: string, wasCustom: boolean, optIndex?: number): void {
90
188
  const q = questions.find((x) => x.id === questionId);
91
189
  answers.set(questionId, {
92
190
  id: questionId,
@@ -98,11 +196,26 @@ export function createAskUserComponent(
98
196
  });
99
197
  }
100
198
 
101
- function switchToTab(tabIndex: number) {
199
+ /** After answering a single-select or text question, advance to the next unanswered tab. */
200
+ function advanceAfterAnswer(): void {
201
+ if (!needsSubmitTab) {
202
+ submitAll(false);
203
+ return;
204
+ }
205
+ for (let i = currentTab + 1; i < questions.length; i++) {
206
+ if (!isQuestionAnswered(questions[i]!, i)) {
207
+ switchToTab(i);
208
+ return;
209
+ }
210
+ }
211
+ switchToTab(questions.length); // go to Submit tab
212
+ }
213
+
214
+ function switchToTab(tabIndex: number): void {
102
215
  currentTab = tabIndex;
103
- optionIndex = 0;
216
+ submitCursor = 0;
104
217
  const q = questions[tabIndex];
105
- if (q && q.type === "text") {
218
+ if (q?.type === "text") {
106
219
  inputMode = true;
107
220
  inputQuestionId = q.id;
108
221
  const existing = answers.get(q.id);
@@ -112,17 +225,42 @@ export function createAskUserComponent(
112
225
  inputQuestionId = null;
113
226
  editor.setText("");
114
227
  }
228
+ clampScroll(tabIndex);
115
229
  refresh();
116
230
  }
117
231
 
232
+ // ── Editor callbacks ──────────────────────────────────────────────────────
233
+
118
234
  editor.onSubmit = (value) => {
119
235
  if (!inputQuestionId) return;
236
+ const q = questions.find((x) => x.id === inputQuestionId);
237
+ if (!q) return;
120
238
  const trimmed = value.trim() || "(no response)";
121
- saveAnswer(inputQuestionId, trimmed, trimmed, true);
122
- inputMode = false;
123
- inputQuestionId = null;
124
- editor.setText("");
125
- advanceAfterAnswer();
239
+ const tabIndex = questions.indexOf(q);
240
+
241
+ if (q.type === "choice") {
242
+ if (q.multiple) {
243
+ customTexts[tabIndex] = trimmed;
244
+ updateMultiAnswer(tabIndex);
245
+ inputMode = false;
246
+ inputQuestionId = null;
247
+ editor.setText("");
248
+ refresh();
249
+ } else {
250
+ saveAnswer(q.id, trimmed, trimmed, true);
251
+ inputMode = false;
252
+ inputQuestionId = null;
253
+ editor.setText("");
254
+ advanceAfterAnswer();
255
+ }
256
+ } else {
257
+ // text question
258
+ saveAnswer(q.id, trimmed, trimmed, true);
259
+ inputMode = false;
260
+ inputQuestionId = null;
261
+ editor.setText("");
262
+ advanceAfterAnswer();
263
+ }
126
264
  };
127
265
 
128
266
  // ── Input handler ─────────────────────────────────────────────────────────
@@ -130,11 +268,12 @@ export function createAskUserComponent(
130
268
  function handleInput(data: string) {
131
269
  // ── Editor is active ───────────────────────────────────────────────
132
270
  if (inputMode) {
133
- if (matchesKey(data, Key.escape)) {
271
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
134
272
  const q = currentQuestion();
135
273
  if (q?.type === "text") {
136
274
  submitAll(true);
137
275
  } else {
276
+ // Cancel the inline editor; go back to choice list
138
277
  inputMode = false;
139
278
  inputQuestionId = null;
140
279
  editor.setText("");
@@ -147,8 +286,14 @@ export function createAskUserComponent(
147
286
  return;
148
287
  }
149
288
 
150
- // ── Tab bar navigation (multi-question only) ───────────────────────
151
- if (isMulti) {
289
+ // ── Global cancel ──────────────────────────────────────────────────
290
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
291
+ submitAll(true);
292
+ return;
293
+ }
294
+
295
+ // ── Tab bar navigation ─────────────────────────────────────────────
296
+ if (needsSubmitTab) {
152
297
  if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
153
298
  switchToTab((currentTab + 1) % totalTabs);
154
299
  return;
@@ -160,65 +305,90 @@ export function createAskUserComponent(
160
305
  }
161
306
 
162
307
  // ── Submit tab ─────────────────────────────────────────────────────
163
- if (isMulti && currentTab === questions.length) {
164
- const submitItems = questions.length + 1;
308
+ if (needsSubmitTab && currentTab === questions.length) {
309
+ const itemCount = questions.length + 1; // question rows + Submit row
165
310
  if (matchesKey(data, Key.up)) {
166
- optionIndex = Math.max(0, optionIndex - 1);
311
+ submitCursor = Math.max(0, submitCursor - 1);
167
312
  refresh();
168
313
  return;
169
314
  }
170
315
  if (matchesKey(data, Key.down)) {
171
- optionIndex = Math.min(submitItems - 1, optionIndex + 1);
316
+ submitCursor = Math.min(itemCount - 1, submitCursor + 1);
172
317
  refresh();
173
318
  return;
174
319
  }
175
320
  if (matchesKey(data, Key.enter)) {
176
- if (optionIndex < questions.length) {
177
- switchToTab(optionIndex);
321
+ if (submitCursor < questions.length) {
322
+ switchToTab(submitCursor);
178
323
  } else if (allAnswered()) {
179
324
  submitAll(false);
180
325
  }
181
326
  return;
182
327
  }
183
- if (matchesKey(data, Key.escape)) {
184
- submitAll(true);
185
- }
186
328
  return;
187
329
  }
188
330
 
189
331
  const q = currentQuestion();
332
+ if (!q) return;
333
+ const tabIndex = currentTab;
190
334
 
191
- // ── Choice question: option navigation ─────────────────────────────
192
- if (q?.type === "choice") {
335
+ // ── Choice question ────────────────────────────────────────────────
336
+ if (q.type === "choice") {
193
337
  const opts = currentOptions();
338
+ const maxVisible = maxVisibleFor(q);
339
+
194
340
  if (matchesKey(data, Key.up)) {
195
- optionIndex = Math.max(0, optionIndex - 1);
341
+ selectedRows[tabIndex] = Math.max(0, (selectedRows[tabIndex] ?? 0) - 1);
342
+ clampScroll(tabIndex);
196
343
  refresh();
197
344
  return;
198
345
  }
199
346
  if (matchesKey(data, Key.down)) {
200
- optionIndex = Math.min(opts.length - 1, optionIndex + 1);
347
+ selectedRows[tabIndex] = Math.min(opts.length - 1, (selectedRows[tabIndex] ?? 0) + 1);
348
+ clampScroll(tabIndex);
201
349
  refresh();
202
350
  return;
203
351
  }
204
- if (matchesKey(data, Key.enter)) {
205
- const opt = opts[optionIndex];
352
+ // Page up / down
353
+ if (matchesKey(data, Key.pageUp) || data === "-") {
354
+ selectedRows[tabIndex] = Math.max(0, (selectedRows[tabIndex] ?? 0) - maxVisible);
355
+ clampScroll(tabIndex);
356
+ refresh();
357
+ return;
358
+ }
359
+ if (matchesKey(data, Key.pageDown) || data === "=") {
360
+ selectedRows[tabIndex] = Math.min(opts.length - 1, (selectedRows[tabIndex] ?? 0) + maxVisible);
361
+ clampScroll(tabIndex);
362
+ refresh();
363
+ return;
364
+ }
365
+
366
+ const isSpaceToggle = data === " " && q.multiple;
367
+ if (matchesKey(data, Key.enter) || isSpaceToggle) {
368
+ const opt = opts[selectedRows[tabIndex] ?? 0];
206
369
  if (!opt) return;
370
+
207
371
  if (opt.isOther) {
208
372
  inputMode = true;
209
373
  inputQuestionId = q.id;
210
- const existing = answers.get(q.id);
211
- editor.setText(existing?.wasCustom ? existing.label : "");
374
+ const custom = customTexts[tabIndex];
375
+ editor.setText(custom || "");
376
+ refresh();
377
+ } else if (q.multiple) {
378
+ // Toggle multi-select
379
+ if (multiSelections[tabIndex].has(opt.value)) {
380
+ multiSelections[tabIndex].delete(opt.value);
381
+ } else {
382
+ multiSelections[tabIndex].add(opt.value);
383
+ }
384
+ updateMultiAnswer(tabIndex);
212
385
  refresh();
213
386
  } else {
214
- saveAnswer(q.id, opt.value, opt.label, false, optionIndex + 1);
387
+ // Single-select: save and advance
388
+ const optIdx = selectedRows[tabIndex] ?? 0;
389
+ saveAnswer(q.id, opt.value, opt.label, false, optIdx + 1);
215
390
  advanceAfterAnswer();
216
391
  }
217
- return;
218
- }
219
- if (matchesKey(data, Key.escape)) {
220
- submitAll(true);
221
- return;
222
392
  }
223
393
  }
224
394
  }
@@ -228,19 +398,25 @@ export function createAskUserComponent(
228
398
  function render(width: number): string[] {
229
399
  if (cachedLines) return cachedLines;
230
400
 
231
- const lines: string[] = [];
232
- const add = (s: string) => lines.push(truncateToWidth(s, width));
233
- const hr = () => add(theme.fg("accent", "─".repeat(width)));
234
- hr();
401
+ // When using overlay, content is rendered at innerWidth and then framed.
402
+ const innerWidth = useOverlay ? Math.max(1, width - 2 - OVERLAY_PADDING_X * 2) : width;
403
+ const inner: string[] = [];
404
+ const add = (s: string) => inner.push(truncateToWidth(s, innerWidth));
235
405
 
236
406
  const canSubmit = allAnswered();
237
407
 
408
+ // Header (inline mode only — overlay title is shown in the frame border)
409
+ if (!useOverlay && header) {
410
+ add(theme.bold(theme.fg("accent", ` ${header}`)));
411
+ inner.push("");
412
+ }
413
+
238
414
  // ── Tab bar ─────────────────────────────────────────────────────────
239
- if (isMulti) {
415
+ if (needsSubmitTab) {
240
416
  const parts: string[] = [" "];
241
417
  for (let i = 0; i < questions.length; i++) {
242
418
  const isActive = i === currentTab;
243
- const isAnswered = answers.has(questions[i]!.id);
419
+ const isAnswered = isQuestionAnswered(questions[i]!, i);
244
420
  const bullet = isAnswered ? "■" : "□";
245
421
  const color: "success" | "muted" = isAnswered ? "success" : "muted";
246
422
  const lbl = ` ${bullet} ${questions[i]!.label} `;
@@ -254,34 +430,36 @@ export function createAskUserComponent(
254
430
  : theme.fg(canSubmit ? "success" : "dim", submitLabel),
255
431
  );
256
432
  add(parts.join(""));
257
- lines.push("");
433
+ inner.push("");
258
434
  }
259
435
 
260
- // ── Content ─────────────────────────────────────────────────────────
436
+ // ── Content ──────────────────────────────────────────────────────────
261
437
  const q = currentQuestion();
262
- const opts = currentOptions();
263
438
 
264
- if (isMulti && currentTab === questions.length) {
265
- // Submit tab
439
+ if (needsSubmitTab && currentTab === questions.length) {
440
+ // ── Submit / Review tab ──────────────────────────────────────────
266
441
  add(theme.fg("accent", theme.bold(" Review your answers")));
267
- lines.push("");
442
+ inner.push("");
268
443
  for (let i = 0; i < questions.length; i++) {
269
444
  const question = questions[i]!;
270
445
  const ans = answers.get(question.id);
271
- const sel = optionIndex === i;
446
+ const sel = submitCursor === i;
272
447
  const prefix = sel ? theme.fg("accent", "> ") : " ";
273
448
  add(prefix + (sel ? theme.fg("accent", question.prompt) : theme.fg("text", question.prompt)));
274
449
  if (ans) {
275
- const pre = ans.wasCustom ? theme.fg("muted", "(wrote) ") : theme.fg("dim", `${ans.optionIndex}. `);
276
- add(` ${theme.fg("success", "✓ ")}${pre}${theme.fg(sel ? "accent" : "muted", ans.label)}`);
450
+ const answerText = ans.labels && ans.labels.length > 1 ? ans.labels.join(", ") : ans.label;
451
+ const pre = ans.wasCustom
452
+ ? theme.fg("muted", "(wrote) ")
453
+ : theme.fg("dim", ans.optionIndex ? `${ans.optionIndex}. ` : "");
454
+ add(` ${theme.fg("success", "✓ ")}${pre}${theme.fg(sel ? "accent" : "muted", answerText)}`);
277
455
  } else {
278
456
  add(
279
457
  ` ${theme.fg("warning", "✗ unanswered")}${sel ? theme.fg("dim", " — press Enter to answer") : ""}`,
280
458
  );
281
459
  }
282
- lines.push("");
460
+ inner.push("");
283
461
  }
284
- const submitSel = optionIndex === questions.length;
462
+ const submitSel = submitCursor === questions.length;
285
463
  const submitPrefix = submitSel ? theme.fg("accent", "> ") : " ";
286
464
  if (canSubmit) {
287
465
  add(
@@ -290,90 +468,161 @@ export function createAskUserComponent(
290
468
  );
291
469
  } else {
292
470
  const missing = questions
293
- .filter((x) => !answers.has(x.id))
471
+ .filter((_q, i) => !isQuestionAnswered(questions[i]!, i))
294
472
  .map((x) => x.label)
295
473
  .join(", ");
296
474
  add(` ${theme.fg("dim", "✓ Submit All")} ${theme.fg("warning", `(unanswered: ${missing})`)}`);
297
475
  }
298
- lines.push("");
299
- add(theme.fg("dim", " ↑↓ navigate • Enter to edit answer or submit • Tab/←→ switch tab • Esc cancel"));
476
+ inner.push("");
477
+ add(theme.fg("dim", " ↑↓ navigate • Enter to edit answer or submit • Tab/←→ switch tab • Esc/Ctrl+C cancel"));
300
478
  } else if (q?.type === "text") {
301
- add(theme.fg("text", ` ${q.prompt}`));
479
+ // ── Text question ────────────────────────────────────────────────
480
+ for (const line of wrapText(q.prompt, innerWidth - 1, 3)) {
481
+ add(theme.fg("text", ` ${line}`));
482
+ }
302
483
  if (q.placeholder) add(theme.fg("dim", ` ${q.placeholder}`));
303
- lines.push("");
304
- for (const line of editor.render(width - 2)) add(` ${line}`);
305
- lines.push("");
306
- add(theme.fg("dim", " Enter to submit • Esc to cancel"));
484
+ inner.push("");
485
+ for (const line of editor.render(innerWidth - 2)) add(` ${line}`);
486
+ inner.push("");
487
+ add(theme.fg("dim", " Enter to submit • Esc/Ctrl+C to cancel"));
307
488
  } else if (q?.type === "choice") {
308
- add(theme.fg("text", ` ${q.prompt}`));
309
- lines.push("");
489
+ // ── Choice question ──────────────────────────────────────────────
490
+ for (const line of wrapText(q.prompt, innerWidth - 1, 3)) {
491
+ add(theme.fg("text", ` ${line}`));
492
+ }
493
+ if (q.multiple) {
494
+ add(theme.fg("dim", " Space/Enter toggles • Tab to advance when done"));
495
+ }
496
+ inner.push("");
497
+
498
+ const tabIndex = currentTab;
499
+ const opts = optionsFor(q);
500
+ const maxVisible = maxVisibleFor(q);
501
+ const start = scrollOffsets[tabIndex] ?? 0;
502
+ const end = Math.min(opts.length, start + maxVisible);
503
+ const scrollable = opts.length > maxVisible;
504
+ const curRow = selectedRows[tabIndex] ?? 0;
505
+
310
506
  if (inputMode) {
311
- // Inline "other" editor inside option list
312
- for (let i = 0; i < opts.length; i++) {
507
+ // Render options list with the inline editor on the isOther row
508
+ for (let i = start; i < end; i++) {
313
509
  const opt = opts[i]!;
314
- const selected = i === optionIndex;
315
- const prefix = selected ? theme.fg("accent", "> ") : " ";
510
+ const sel = i === curRow;
511
+ const prefix = sel ? theme.fg("accent", "> ") : " ";
316
512
  if (opt.isOther) {
317
- add(prefix + theme.fg("accent", `${i + 1}. ${opt.label} ✎`));
513
+ const filled = customTexts[tabIndex].trim();
514
+ const rowLabel = filled ? `${opt.label}: ${filled}` : opt.label;
515
+ if (q.multiple) {
516
+ const chk = filled ? theme.fg("success", "[x] ") : theme.fg("muted", "[ ] ");
517
+ add(`${prefix}${chk}${theme.fg("accent", `${i + 1}. ${rowLabel} ✎`)}`);
518
+ } else {
519
+ add(`${prefix}${theme.fg("accent", `${i + 1}. ${rowLabel} ✎`)}`);
520
+ }
521
+ const hint = q.customPlaceholder ?? "Type your answer, then press Enter.";
522
+ add(theme.fg("muted", ` ${hint}`));
523
+ for (const line of editor.render(innerWidth - 4)) add(` ${line}`);
318
524
  } else {
319
- add(
320
- prefix +
321
- (selected
322
- ? theme.fg("accent", `${i + 1}. ${opt.label}`)
323
- : theme.fg("text", `${i + 1}. ${opt.label}`)),
324
- );
525
+ if (q.multiple) {
526
+ const chk = multiSelections[tabIndex].has(opt.value)
527
+ ? theme.fg("success", "[x] ")
528
+ : theme.fg("muted", "[ ] ");
529
+ add(
530
+ `${prefix}${chk}${sel ? theme.fg("accent", `${i + 1}. ${opt.label}`) : theme.fg("text", `${i + 1}. ${opt.label}`)}`,
531
+ );
532
+ } else {
533
+ add(
534
+ `${prefix}${sel ? theme.fg("accent", `${i + 1}. ${opt.label}`) : theme.fg("text", `${i + 1}. ${opt.label}`)}`,
535
+ );
536
+ }
325
537
  if (opt.description) add(` ${theme.fg("muted", opt.description)}`);
326
538
  }
327
539
  }
328
- lines.push("");
329
- add(theme.fg("muted", " Your answer:"));
330
- for (const line of editor.render(width - 2)) add(` ${line}`);
331
- lines.push("");
540
+ inner.push("");
332
541
  add(theme.fg("dim", " Enter to submit • Esc to go back"));
333
542
  } else {
334
- for (let i = 0; i < opts.length; i++) {
543
+ // Normal option list
544
+ for (let i = start; i < end; i++) {
335
545
  const opt = opts[i]!;
336
- const selected = i === optionIndex;
337
- const isAnswered =
338
- !opt.isOther && answers.get(q.id)?.value === opt.value && !answers.get(q.id)?.wasCustom;
339
- const prefix = selected ? theme.fg("accent", "> ") : " ";
340
- const checkmark = isAnswered ? theme.fg("success", " ✓") : "";
341
- add(
342
- prefix +
343
- (selected
344
- ? theme.fg("accent", `${i + 1}. ${opt.label}`)
345
- : theme.fg("text", `${i + 1}. ${opt.label}`)) +
346
- checkmark,
347
- );
348
- if (opt.description) add(` ${theme.fg("muted", opt.description)}`);
546
+ const sel = i === curRow;
547
+ const prefix = sel ? theme.fg("accent", "> ") : " ";
548
+
549
+ if (opt.isOther) {
550
+ const filled = customTexts[tabIndex].trim();
551
+ const rowLabel = filled ? `${opt.label}: ${filled}` : opt.label;
552
+ if (q.multiple) {
553
+ const chk = filled ? theme.fg("success", "[x] ") : theme.fg("muted", "[ ] ");
554
+ add(
555
+ `${prefix}${chk}${sel ? theme.fg("accent", `${i + 1}. ${rowLabel}`) : theme.fg(filled ? "success" : "text", `${i + 1}. ${rowLabel}`)}`,
556
+ );
557
+ } else {
558
+ const chk = answers.get(q.id)?.wasCustom ? theme.fg("success", " ✓") : "";
559
+ add(
560
+ `${prefix}${sel ? theme.fg("accent", `${i + 1}. ${opt.label}`) : theme.fg("text", `${i + 1}. ${opt.label}`)}${chk}`,
561
+ );
562
+ }
563
+ } else {
564
+ const isChecked = q.multiple
565
+ ? multiSelections[tabIndex].has(opt.value)
566
+ : answers.get(q.id)?.value === opt.value && !answers.get(q.id)?.wasCustom;
567
+
568
+ if (q.multiple) {
569
+ const chk = isChecked ? theme.fg("success", "[x] ") : theme.fg("muted", "[ ] ");
570
+ add(
571
+ `${prefix}${chk}${sel ? theme.fg("accent", `${i + 1}. ${opt.label}`) : theme.fg(isChecked ? "success" : "text", `${i + 1}. ${opt.label}`)}`,
572
+ );
573
+ } else {
574
+ const chk = isChecked ? theme.fg("success", " ✓") : "";
575
+ add(
576
+ `${prefix}${sel ? theme.fg("accent", `${i + 1}. ${opt.label}`) : theme.fg("text", `${i + 1}. ${opt.label}`)}${chk}`,
577
+ );
578
+ }
579
+ if (opt.description && !q.multiple) add(` ${theme.fg("muted", opt.description)}`);
580
+ }
349
581
  }
350
- const existing = answers.get(q.id);
351
- if (existing?.wasCustom) {
352
- lines.push("");
353
- add(
354
- theme.fg("dim", " Current: ") +
355
- theme.fg("success", "✓ ") +
356
- theme.fg("muted", "(wrote) ") +
357
- theme.fg("accent", existing.label),
358
- );
582
+
583
+ // Scroll indicator
584
+ if (scrollable) {
585
+ add(theme.fg("dim", ` (${start + 1}–${end} of ${opts.length}) ↑↓ scroll, -/= page`));
359
586
  }
360
- lines.push("");
587
+
588
+ // For single-select: show current custom answer below the list
589
+ if (!q.multiple) {
590
+ const existing = answers.get(q.id);
591
+ if (existing?.wasCustom) {
592
+ inner.push("");
593
+ add(
594
+ theme.fg("dim", " Current: ") +
595
+ theme.fg("success", "✓ ") +
596
+ theme.fg("muted", "(wrote) ") +
597
+ theme.fg("accent", existing.label),
598
+ );
599
+ }
600
+ }
601
+
602
+ inner.push("");
603
+ const hintSuffix = needsSubmitTab ? " • Tab/←→ switch tab" : "";
361
604
  add(
362
605
  theme.fg(
363
606
  "dim",
364
- isMulti
365
- ? " ↑↓ navigate • Enter selectTab/←→ switch tab • Esc cancel"
366
- : " ↑↓ navigate • Enter select • Esc cancel",
607
+ q.multiple
608
+ ? `↑↓ navigate • Space/Enter toggle${hintSuffix} • Esc/Ctrl+C cancel`
609
+ : `↑↓ navigate • Enter select${hintSuffix} • Esc/Ctrl+C cancel`,
367
610
  ),
368
611
  );
369
612
  }
370
613
  }
371
614
 
372
- lines.push("");
373
- hr();
615
+ inner.push("");
616
+
617
+ // ── Wrap in frame or add horizontal rules ─────────────────────────
618
+ if (useOverlay) {
619
+ cachedLines = framePopup(inner, width, theme, header ?? "");
620
+ } else {
621
+ const hr = theme.fg("accent", "─".repeat(width));
622
+ cachedLines = [hr, ...inner, hr];
623
+ }
374
624
 
375
- cachedLines = lines;
376
- return lines;
625
+ return cachedLines;
377
626
  }
378
627
 
379
628
  // ── Bootstrap: activate first tab ─────────────────────────────────────────
package/src/index.ts CHANGED
@@ -5,18 +5,25 @@
5
5
  * questions directly in the terminal UI — without an extra model round-trip.
6
6
  *
7
7
  * Supports three question modes (freely mixed in one call):
8
- * - text : free-form text input via inline editor
9
- * - choice : pick from a numbered option list (+ always-available free-text)
8
+ * - text : free-form text input via inline editor
9
+ * - choice : pick from a numbered option list (single or multiple-select)
10
10
  *
11
- * Single question → focused UI, no tab bar, immediate return
12
- * Multiple questions → tab-based wizard with a Submit tab
11
+ * Single question → focused UI, no tab bar, immediate return on answer
12
+ * Multiple questions / any multiple-select → tab-based wizard with a Submit tab
13
13
  */
14
14
 
15
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
+ import {
16
+ DEFAULT_MAX_BYTES,
17
+ DEFAULT_MAX_LINES,
18
+ type ExtensionAPI,
19
+ formatSize,
20
+ truncateHead,
21
+ } from "@earendil-works/pi-coding-agent";
16
22
  import { createAskUserComponent } from "./component";
17
23
  import { renderCall, renderResult } from "./render";
18
24
  import { AskUserParams } from "./schemas";
19
25
  import type { AskQuestion, AskResult } from "./types";
26
+ import { acquireModalLock, isModalActive, sleep, writeTempJson } from "./utils";
20
27
 
21
28
  // ── Extension ──────────────────────────────────────────────────────────────────
22
29
 
@@ -26,9 +33,10 @@ export default function askUserExtension(pi: ExtensionAPI): void {
26
33
  label: "Ask User",
27
34
  description:
28
35
  "Ask the user one or more structured questions directly in the terminal UI. " +
29
- "Supports free-text input, multiple-choice option lists, and multi-step " +
30
- "questionnaires. Use this instead of embedding questions in your response text " +
31
- "to avoid an extra model round-trip and give the user a clear, interactive prompt.",
36
+ "Supports free-text input, multiple-choice option lists (single or multi-select), " +
37
+ "and multi-step questionnaires. Use this instead of embedding questions in your " +
38
+ "response text to avoid an extra model round-trip and give the user a clear, " +
39
+ "interactive prompt.",
32
40
  promptSnippet: "Ask the user a question or questionnaire and get structured answers",
33
41
  promptGuidelines: [
34
42
  "Use ask_user whenever you need information, a preference, or confirmation from the user before proceeding.",
@@ -36,19 +44,26 @@ export default function askUserExtension(pi: ExtensionAPI): void {
36
44
  "For a single free-form question set type to 'text'. " +
37
45
  "For a multiple-choice question set type to 'choice' and provide options. " +
38
46
  "Pass several questions together for a multi-step flow.",
47
+ "Set multiple: true on a choice question to allow the user to select several options at once (checkbox style).",
39
48
  "A free-text option is always available for choice questions. " +
40
49
  "By default, a 'Type something.' option is auto-appended. " +
41
50
  "If you want to provide your own free-text option (e.g. 'Other', 'Custom'), " +
42
- "mark it with isOther: true — this replaces the default and avoids redundancy.",
51
+ "mark it with isOther: true — this replaces the default and avoids redundancy. " +
52
+ "Use customLabel / customPlaceholder to rename or hint that auto-appended option.",
53
+ "Use the header field to give the overall prompt a title (e.g. 'Deployment settings').",
54
+ "Set overlay: true for prominent confirmations or when terminal context should stay visible.",
43
55
  ],
44
56
  parameters: AskUserParams,
45
57
 
46
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
58
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
47
59
  // ── Guard: interactive mode only ──────────────────────────────────
48
60
  if (!ctx.hasUI) {
49
61
  return {
50
62
  content: [
51
- { type: "text", text: "Error: ask_user requires an interactive terminal (UI not available)." },
63
+ {
64
+ type: "text",
65
+ text: "Error: ask_user requires an interactive terminal (UI not available).",
66
+ },
52
67
  ],
53
68
  details: { questions: [], answers: [], cancelled: true } satisfies AskResult,
54
69
  };
@@ -61,26 +76,71 @@ export default function askUserExtension(pi: ExtensionAPI): void {
61
76
  };
62
77
  }
63
78
 
64
- // Validate: choice questions must have at least one option
79
+ // Validate choice questions have options
65
80
  for (const q of params.questions) {
66
81
  if (q.type === "choice" && (!q.options || q.options.length === 0)) {
67
82
  return {
68
- content: [{ type: "text", text: `Error: Question "${q.id}" is type 'choice' but has no options.` }],
83
+ content: [
84
+ {
85
+ type: "text",
86
+ text: `Error: Question "${q.id}" is type 'choice' but has no options.`,
87
+ },
88
+ ],
69
89
  details: { questions: [], answers: [], cancelled: true } satisfies AskResult,
70
90
  };
71
91
  }
72
92
  }
73
93
 
94
+ // ── Queue behind any active modal ─────────────────────────────────
95
+ while (isModalActive()) {
96
+ if (signal?.aborted) {
97
+ return {
98
+ content: [{ type: "text", text: "Error: Tool call aborted while waiting for active modal." }],
99
+ details: { questions: [], answers: [], cancelled: true } satisfies AskResult,
100
+ };
101
+ }
102
+ await sleep(100);
103
+ }
104
+
74
105
  // Normalise questions (fill in derived defaults)
75
106
  const questions: AskQuestion[] = params.questions.map((q, i) => ({
76
107
  ...q,
77
108
  label: q.label || `Q${i + 1}`,
78
109
  }));
79
110
 
80
- // ── Build & show the custom TUI ───────────────────────────────────
81
- const result = await ctx.ui.custom<AskResult>((tui, theme, _kb, done) =>
82
- createAskUserComponent(tui, theme, done, questions),
83
- );
111
+ const useOverlay = params.overlay ?? false;
112
+ const overlayOpts = useOverlay
113
+ ? {
114
+ overlay: true,
115
+ overlayOptions: {
116
+ anchor: "center" as const,
117
+ maxHeight: "80%" as `${number}%`,
118
+ width: 96,
119
+ },
120
+ }
121
+ : undefined;
122
+
123
+ // ── Acquire modal lock + save hardware cursor ─────────────────────
124
+ const releaseModalLock = acquireModalLock();
125
+ let restoreHardwareCursor: (() => void) | undefined;
126
+
127
+ let result: AskResult;
128
+ try {
129
+ result = await ctx.ui.custom<AskResult>((tui, theme, _kb, done) => {
130
+ const prev = (tui as any).getShowHardwareCursor?.();
131
+ if (prev !== undefined) {
132
+ (tui as any).setShowHardwareCursor?.(true);
133
+ restoreHardwareCursor = () => (tui as any).setShowHardwareCursor?.(prev);
134
+ }
135
+ return createAskUserComponent(tui, theme, done, questions, {
136
+ header: params.header,
137
+ useOverlay,
138
+ });
139
+ }, overlayOpts);
140
+ } finally {
141
+ restoreHardwareCursor?.();
142
+ releaseModalLock();
143
+ }
84
144
 
85
145
  // ── Build response for LLM ────────────────────────────────────────
86
146
  if (result.cancelled) {
@@ -93,12 +153,45 @@ export default function askUserExtension(pi: ExtensionAPI): void {
93
153
  const lines = result.answers.map((a) => {
94
154
  const q = questions.find((x) => x.id === a.id);
95
155
  const label = q?.label ?? a.id;
156
+ if (a.labels && a.labels.length > 1) {
157
+ const parts = a.labels.map((lbl, i) => {
158
+ const idx = a.optionIndices?.[i];
159
+ return idx ? `${idx}. ${lbl}` : lbl;
160
+ });
161
+ return `${label} (${a.id}): user selected: ${parts.join(", ")}`;
162
+ }
96
163
  if (a.wasCustom) return `${label} (${a.id}): user wrote: ${a.label}`;
97
164
  return `${label} (${a.id}): user selected: ${a.optionIndex}. ${a.label}`;
98
165
  });
99
166
 
167
+ const text = lines.join("\n");
168
+
169
+ // ── Truncate large responses ──────────────────────────────────────
170
+ const full = JSON.stringify(result, null, 2);
171
+ const truncation = truncateHead(full, { maxBytes: DEFAULT_MAX_BYTES, maxLines: DEFAULT_MAX_LINES });
172
+
173
+ if (!truncation.truncated) {
174
+ return {
175
+ content: [{ type: "text", text }],
176
+ details: result satisfies AskResult,
177
+ };
178
+ }
179
+
180
+ const artifact = await writeTempJson(full);
181
+ const omittedLines = Math.max(0, truncation.totalLines - truncation.outputLines);
182
+ const omittedBytes = Math.max(0, truncation.totalBytes - truncation.outputBytes);
183
+ const artifactNote = artifact.path
184
+ ? ` Full output saved to: ${artifact.path}`
185
+ : artifact.error
186
+ ? ` Full output preservation failed: ${artifact.error}`
187
+ : "";
188
+ const truncationNotice =
189
+ `[Output truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines ` +
190
+ `(${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}). ` +
191
+ `${omittedLines} lines (${formatSize(omittedBytes)}) omitted.${artifactNote}]`;
192
+
100
193
  return {
101
- content: [{ type: "text", text: lines.join("\n") }],
194
+ content: [{ type: "text", text: `${text}\n\n${truncationNotice}` }],
102
195
  details: result satisfies AskResult,
103
196
  };
104
197
  },
package/src/render.ts CHANGED
@@ -7,9 +7,10 @@ export function renderCall(args: any, theme: any): any {
7
7
  const qs = (args.questions as AskQuestion[]) ?? [];
8
8
  const count = qs.length;
9
9
  let text = theme.fg("toolTitle", theme.bold("ask_user "));
10
+ if (args.header) text += theme.fg("accent", `${args.header} `) + theme.fg("dim", "— ");
10
11
  if (count === 1) {
11
12
  const q = qs[0];
12
- const typeTag = theme.fg("muted", `[${q?.type ?? "?"}] `);
13
+ const typeTag = theme.fg("muted", `[${q?.type ?? "?"}${q?.multiple ? "/multi" : ""}] `);
13
14
  text += typeTag + theme.fg("muted", q?.prompt ?? "");
14
15
  } else {
15
16
  text += theme.fg("muted", `${count} questions`);
@@ -19,7 +20,7 @@ export function renderCall(args: any, theme: any): any {
19
20
  return new Text(text, 0, 0);
20
21
  }
21
22
 
22
- export function renderResult(result: any, _options: any, theme: any): any {
23
+ export function renderResult(result: any, options: any, theme: any): any {
23
24
  const details = result.details as AskResult | undefined;
24
25
  if (!details) {
25
26
  const first = result.content[0];
@@ -29,20 +30,61 @@ export function renderResult(result: any, _options: any, theme: any): any {
29
30
  return new Text(theme.fg("warning", "Cancelled"), 0, 0);
30
31
  }
31
32
 
32
- const lines = details.answers.map((a) => {
33
- const q = details.questions.find((x) => x.id === a.id);
34
- const label = q?.label ?? a.id;
35
- if (a.wasCustom) {
36
- return (
37
- theme.fg("success", "✓ ") +
38
- theme.fg("accent", label) +
39
- theme.fg("dim", ": ") +
40
- theme.fg("muted", "(wrote) ") +
41
- a.label
42
- );
33
+ if (!options?.expanded) {
34
+ // ── Compact mode ──────────────────────────────────────────────────
35
+ const lines = details.answers.map((a) => {
36
+ const q = details.questions.find((x) => x.id === a.id);
37
+ const label = q?.label ?? a.id;
38
+ if (a.labels && a.labels.length > 1) {
39
+ return (
40
+ theme.fg("success", " ") +
41
+ theme.fg("accent", label) +
42
+ theme.fg("dim", ": ") +
43
+ theme.fg("text", a.labels.join(", "))
44
+ );
45
+ }
46
+ if (a.wasCustom) {
47
+ return (
48
+ theme.fg("success", "✓ ") +
49
+ theme.fg("accent", label) +
50
+ theme.fg("dim", ": ") +
51
+ theme.fg("muted", "(wrote) ") +
52
+ a.label
53
+ );
54
+ }
55
+ const num = a.optionIndex ? `${a.optionIndex}. ` : "";
56
+ return theme.fg("success", "✓ ") + theme.fg("accent", label) + theme.fg("dim", ": ") + num + a.label;
57
+ });
58
+ const expandHint = theme.fg("dim", " · ctrl+o to expand");
59
+ return new Text(lines.join("\n") + expandHint, 0, 0);
60
+ }
61
+
62
+ // ── Expanded mode ──────────────────────────────────────────────────────
63
+ const lines: string[] = [];
64
+ for (let i = 0; i < details.questions.length; i++) {
65
+ const q = details.questions[i]!;
66
+ const ans = details.answers.find((a) => a.id === q.id);
67
+ const isLast = i === details.questions.length - 1;
68
+ const branch = theme.fg("muted", isLast ? " └─ " : " ├─ ");
69
+ const stem = theme.fg("muted", isLast ? " " : " │ ");
70
+
71
+ lines.push(`${branch}${theme.fg("accent", theme.bold(q.label ?? q.id))}`);
72
+ lines.push(`${stem}${theme.fg("muted", "Q: ")}${theme.fg("text", q.prompt)}`);
73
+
74
+ if (!ans) {
75
+ lines.push(`${stem} ${theme.fg("warning", "✗ unanswered")}`);
76
+ } else if (ans.labels && ans.labels.length > 0) {
77
+ for (const lbl of ans.labels) {
78
+ const pre = ans.wasCustom ? theme.fg("muted", "(wrote) ") : "";
79
+ lines.push(`${stem} ${theme.fg("success", "✓")} ${pre}${theme.fg("text", lbl)}`);
80
+ }
81
+ } else {
82
+ const pre = ans.wasCustom ? theme.fg("muted", "(wrote) ") : "";
83
+ lines.push(`${stem} ${theme.fg("success", "✓")} ${pre}${theme.fg("text", ans.label)}`);
43
84
  }
44
- const num = a.optionIndex ? `${a.optionIndex}. ` : "";
45
- return theme.fg("success", "✓ ") + theme.fg("accent", label) + theme.fg("dim", ": ") + num + a.label;
46
- });
85
+
86
+ if (!isLast) lines.push(theme.fg("muted", ""));
87
+ }
88
+
47
89
  return new Text(lines.join("\n"), 0, 0);
48
90
  }
package/src/schemas.ts CHANGED
@@ -21,7 +21,7 @@ export const OptionSchema = Type.Object({
21
21
  "Mark this option as a free-text option. When selected, opens an inline editor " +
22
22
  "instead of returning the option value. Use this when you want to provide your own " +
23
23
  "label for the free-text option (e.g. 'Other', 'Custom'). " +
24
- "If no option is marked isOther, a default 'Type something.' option is auto-appended. " +
24
+ "If no option is marked isOther, a default free-text option is auto-appended. " +
25
25
  "Only one option should be marked isOther per question.",
26
26
  }),
27
27
  ),
@@ -57,13 +57,50 @@ export const QuestionSchema = Type.Object({
57
57
  "(e.g. 'Enter your API key…'). Purely informational.",
58
58
  }),
59
59
  ),
60
+ multiple: Type.Optional(
61
+ Type.Boolean({
62
+ description:
63
+ "Allow the user to select multiple options (checkbox style). " +
64
+ "Only applies to choice questions. Default: false.",
65
+ }),
66
+ ),
67
+ customLabel: Type.Optional(
68
+ Type.String({
69
+ description:
70
+ "Label for the free-text option row when no option is marked isOther. " + "Defaults to 'Type something.'",
71
+ }),
72
+ ),
73
+ customPlaceholder: Type.Optional(
74
+ Type.String({
75
+ description: "Placeholder shown inside the inline editor for the free-text option in choice questions.",
76
+ }),
77
+ ),
78
+ maxVisibleOptions: Type.Optional(
79
+ Type.Number({
80
+ description: "Maximum number of option rows visible before scrolling kicks in. Default: 10.",
81
+ }),
82
+ ),
60
83
  });
61
84
 
62
85
  export const AskUserParams = Type.Object({
86
+ header: Type.Optional(
87
+ Type.String({
88
+ description:
89
+ "Optional title shown at the top of the prompt (e.g. 'Deployment settings'). " +
90
+ "Summarises the overall interaction.",
91
+ }),
92
+ ),
63
93
  questions: Type.Array(QuestionSchema, {
64
94
  description:
65
95
  "One or more questions to ask the user. " +
66
96
  "Single-item arrays show a focused UI. " +
67
97
  "Multi-item arrays show a tabbed wizard with a Submit step.",
68
98
  }),
99
+ overlay: Type.Optional(
100
+ Type.Boolean({
101
+ description:
102
+ "Render the prompt as a framed popup overlay centred in the terminal instead of inline. " +
103
+ "Use for prominent confirmations or when screen context should remain visible. Default: false.",
104
+ }),
105
+ ),
69
106
  });
package/src/types.ts CHANGED
@@ -16,15 +16,32 @@ export interface AskQuestion {
16
16
  options?: QuestionOption[];
17
17
  placeholder?: string;
18
18
  label?: string;
19
+ /** Allow the user to select multiple options (checkbox style). Only applies to choice questions. Default: false. */
20
+ multiple?: boolean;
21
+ /** Label for the auto-appended free-text option when no option is marked isOther. Defaults to "Type something." */
22
+ customLabel?: string;
23
+ /** Placeholder shown inside the inline editor for the free-text option in choice questions. */
24
+ customPlaceholder?: string;
25
+ /** Maximum number of option rows visible before scrolling kicks in. Default: 10. */
26
+ maxVisibleOptions?: number;
19
27
  }
20
28
 
21
29
  export interface Answer {
22
30
  id: string;
31
+ /** Primary selected value (first value for multiple-select). */
23
32
  value: string;
33
+ /** All selected values — populated for multiple: true questions. */
34
+ values?: string[];
35
+ /** Primary display label. */
24
36
  label: string;
37
+ /** All selected labels — populated for multiple: true questions. */
38
+ labels?: string[];
25
39
  type: "text" | "choice";
26
40
  wasCustom: boolean;
41
+ /** 1-based option index (single-select). */
27
42
  optionIndex?: number;
43
+ /** 1-based option indices (multiple-select). */
44
+ optionIndices?: number[];
28
45
  }
29
46
 
30
47
  export interface AskResult {
package/src/utils.ts ADDED
@@ -0,0 +1,100 @@
1
+ import { mkdtemp, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { visibleWidth } from "@earendil-works/pi-tui";
5
+
6
+ // ── Text wrapping ──────────────────────────────────────────────────────────────
7
+
8
+ /**
9
+ * Reflows plain text into lines of at most `width` visible characters.
10
+ * The last rendered line gets an ellipsis if the full text didn't fit.
11
+ */
12
+ export function wrapText(text: string, width: number, maxLines = 4): string[] {
13
+ const words = text.trim().split(/\s+/).filter(Boolean);
14
+ if (!words.length) return [""];
15
+
16
+ const lines: string[] = [];
17
+ let current = "";
18
+
19
+ for (const word of words) {
20
+ const wordW = visibleWidth(word);
21
+ const currentW = visibleWidth(current);
22
+ if (!current) {
23
+ current = wordW > width ? word.slice(0, width) : word;
24
+ } else if (currentW + 1 + wordW <= width) {
25
+ current = `${current} ${word}`;
26
+ } else {
27
+ lines.push(current);
28
+ if (lines.length >= maxLines) {
29
+ current = "";
30
+ break;
31
+ }
32
+ current = wordW > width ? word.slice(0, width) : word;
33
+ }
34
+ }
35
+
36
+ if (current && lines.length < maxLines) lines.push(current);
37
+
38
+ // Append ellipsis on last line if the text was clipped
39
+ const fullText = words.join(" ");
40
+ const rendered = lines.join(" ");
41
+ if (rendered.length < fullText.length && lines.length > 0) {
42
+ const last = lines[lines.length - 1]!;
43
+ lines[lines.length - 1] = visibleWidth(last) < width ? `${last}…` : `${last.slice(0, Math.max(0, width - 1))}…`;
44
+ }
45
+
46
+ return lines.length > 0 ? lines : [""];
47
+ }
48
+
49
+ // ── Modal lock ─────────────────────────────────────────────────────────────────
50
+
51
+ const MODAL_LOCK_SYMBOL = Symbol.for("edb.ask-user.modal-lock");
52
+
53
+ interface ModalLock {
54
+ depth: number;
55
+ }
56
+
57
+ function getModalLock(): ModalLock {
58
+ const host = globalThis as unknown as Record<PropertyKey, unknown>;
59
+ let lock = host[MODAL_LOCK_SYMBOL] as ModalLock | undefined;
60
+ if (!lock) {
61
+ lock = { depth: 0 };
62
+ host[MODAL_LOCK_SYMBOL] = lock;
63
+ }
64
+ return lock;
65
+ }
66
+
67
+ /** Returns true while another ask_user prompt is already open. */
68
+ export function isModalActive(): boolean {
69
+ return getModalLock().depth > 0;
70
+ }
71
+
72
+ /** Marks a modal as open. Returns a release callback. */
73
+ export function acquireModalLock(): () => void {
74
+ const lock = getModalLock();
75
+ lock.depth += 1;
76
+ let released = false;
77
+ return () => {
78
+ if (released) return;
79
+ released = true;
80
+ lock.depth = Math.max(0, lock.depth - 1);
81
+ };
82
+ }
83
+
84
+ export function sleep(ms: number): Promise<void> {
85
+ return new Promise((resolve) => setTimeout(resolve, ms));
86
+ }
87
+
88
+ // ── Temp file output ───────────────────────────────────────────────────────────
89
+
90
+ /** Writes content to a unique temp file. Returns the path or an error string. */
91
+ export async function writeTempJson(content: string): Promise<{ path?: string; error?: string }> {
92
+ try {
93
+ const dir = await mkdtemp(join(tmpdir(), "pi-ask-user-"));
94
+ const filePath = join(dir, "result.json");
95
+ await writeFile(filePath, content, { encoding: "utf-8", mode: 0o600 });
96
+ return { path: filePath };
97
+ } catch (e) {
98
+ return { error: e instanceof Error ? e.message : String(e) };
99
+ }
100
+ }