@agnishc/edb-ask-user 0.8.2 → 0.10.4
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 +4 -0
- package/package.json +1 -1
- package/src/component.ts +382 -133
- package/src/index.ts +111 -18
- package/src/render.ts +58 -16
- package/src/schemas.ts +38 -1
- package/src/types.ts +17 -0
- package/src/utils.ts +100 -0
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
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
|
|
17
|
-
const
|
|
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
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
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) =>
|
|
134
|
+
return questions.every((q, i) => isQuestionAnswered(q, i));
|
|
73
135
|
}
|
|
74
136
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
switchToTab(i);
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
147
|
+
if (selectedRows[tabIndex] >= scrollOffsets[tabIndex] + visible) {
|
|
148
|
+
scrollOffsets[tabIndex] = selectedRows[tabIndex] - visible + 1;
|
|
85
149
|
}
|
|
86
|
-
|
|
150
|
+
scrollOffsets[tabIndex] = Math.max(0, Math.min(scrollOffsets[tabIndex], Math.max(0, total - visible)));
|
|
87
151
|
}
|
|
88
152
|
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
216
|
+
submitCursor = 0;
|
|
104
217
|
const q = questions[tabIndex];
|
|
105
|
-
if (q
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
// ──
|
|
151
|
-
if (
|
|
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 (
|
|
164
|
-
const
|
|
308
|
+
if (needsSubmitTab && currentTab === questions.length) {
|
|
309
|
+
const itemCount = questions.length + 1; // question rows + Submit row
|
|
165
310
|
if (matchesKey(data, Key.up)) {
|
|
166
|
-
|
|
311
|
+
submitCursor = Math.max(0, submitCursor - 1);
|
|
167
312
|
refresh();
|
|
168
313
|
return;
|
|
169
314
|
}
|
|
170
315
|
if (matchesKey(data, Key.down)) {
|
|
171
|
-
|
|
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 (
|
|
177
|
-
switchToTab(
|
|
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
|
|
192
|
-
if (q
|
|
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
|
-
|
|
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
|
-
|
|
347
|
+
selectedRows[tabIndex] = Math.min(opts.length - 1, (selectedRows[tabIndex] ?? 0) + 1);
|
|
348
|
+
clampScroll(tabIndex);
|
|
201
349
|
refresh();
|
|
202
350
|
return;
|
|
203
351
|
}
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
211
|
-
editor.setText(
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
const
|
|
233
|
-
const
|
|
234
|
-
|
|
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 (
|
|
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 =
|
|
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
|
-
|
|
433
|
+
inner.push("");
|
|
258
434
|
}
|
|
259
435
|
|
|
260
|
-
// ── Content
|
|
436
|
+
// ── Content ──────────────────────────────────────────────────────────
|
|
261
437
|
const q = currentQuestion();
|
|
262
|
-
const opts = currentOptions();
|
|
263
438
|
|
|
264
|
-
if (
|
|
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
|
-
|
|
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 =
|
|
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
|
|
276
|
-
|
|
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
|
-
|
|
460
|
+
inner.push("");
|
|
283
461
|
}
|
|
284
|
-
const submitSel =
|
|
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((
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
304
|
-
for (const line of editor.render(
|
|
305
|
-
|
|
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
|
-
|
|
309
|
-
|
|
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
|
-
//
|
|
312
|
-
for (let 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
|
|
315
|
-
const prefix =
|
|
510
|
+
const sel = i === curRow;
|
|
511
|
+
const prefix = sel ? theme.fg("accent", "> ") : " ";
|
|
316
512
|
if (opt.isOther) {
|
|
317
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
(
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
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
|
-
|
|
543
|
+
// Normal option list
|
|
544
|
+
for (let i = start; i < end; i++) {
|
|
335
545
|
const opt = opts[i]!;
|
|
336
|
-
const
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
(
|
|
344
|
-
|
|
345
|
-
: theme.fg("text", `${i + 1}. ${
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
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
|
-
|
|
365
|
-
?
|
|
366
|
-
:
|
|
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
|
-
|
|
373
|
-
|
|
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
|
|
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
|
|
9
|
-
* - choice
|
|
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
|
|
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
|
|
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
|
|
30
|
-
"questionnaires. Use this instead of embedding questions in your
|
|
31
|
-
"to avoid an extra model round-trip and give the user a clear,
|
|
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,
|
|
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
|
-
{
|
|
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
|
|
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: [
|
|
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
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
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:
|
|
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,
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
|
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
|
+
}
|