@fitchmultz/pi-ask-question 0.6.3 → 0.6.5
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 +31 -3
- package/extensions/ask-question.ts +999 -706
- package/extensions/lib/question-observer.ts +58 -17
- package/package.json +10 -2
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
defineTool,
|
|
4
|
+
type ExtensionAPI,
|
|
5
|
+
type ExtensionContext,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import {
|
|
8
|
+
CURSOR_MARKER,
|
|
9
|
+
Editor,
|
|
10
|
+
Key,
|
|
11
|
+
matchesKey,
|
|
12
|
+
sliceByColumn,
|
|
13
|
+
Text,
|
|
14
|
+
truncateToWidth,
|
|
15
|
+
visibleWidth,
|
|
16
|
+
wrapTextWithAnsi,
|
|
17
|
+
type AutocompleteItem,
|
|
18
|
+
type Keybinding,
|
|
19
|
+
type OverlayHandle,
|
|
20
|
+
type TuiMouseEvent,
|
|
21
|
+
} from "@earendil-works/pi-tui";
|
|
4
22
|
import { Type } from "typebox";
|
|
5
23
|
import { createQuestionObserver, type QuestionObserver } from "./lib/question-observer.ts";
|
|
6
24
|
|
|
@@ -46,7 +64,8 @@ type QuestionTimeout = {
|
|
|
46
64
|
};
|
|
47
65
|
|
|
48
66
|
const QUESTION_TIMEOUT_MS = 5 * 60 * 1000;
|
|
49
|
-
const TIMEOUT_MESSAGE =
|
|
67
|
+
const TIMEOUT_MESSAGE =
|
|
68
|
+
"Mitch is currently AFK. Use your best judgement to choose the option Mitch would choose.";
|
|
50
69
|
const CUSTOM_OPTION = "Type a custom answer";
|
|
51
70
|
const DONE_OPTION = "Done selecting";
|
|
52
71
|
const GRILL_ME_STATE_TYPE = "ask-question.grill-me";
|
|
@@ -70,10 +89,22 @@ Pressure-test the user's request before doing meaningful work:
|
|
|
70
89
|
|
|
71
90
|
const QuestionSchema = Type.Object(
|
|
72
91
|
{
|
|
73
|
-
id: Type.Optional(
|
|
92
|
+
id: Type.Optional(
|
|
93
|
+
Type.String({
|
|
94
|
+
description: "Stable answer id. Defaults to question_<n>; duplicates are auto-suffixed.",
|
|
95
|
+
}),
|
|
96
|
+
),
|
|
74
97
|
question: Type.String({ minLength: 1, description: "Question to ask the user." }),
|
|
75
|
-
options: Type.Optional(
|
|
76
|
-
|
|
98
|
+
options: Type.Optional(
|
|
99
|
+
Type.Array(Type.String({ minLength: 1 }), {
|
|
100
|
+
description: "Choices, ordered from most recommended to least recommended.",
|
|
101
|
+
}),
|
|
102
|
+
),
|
|
103
|
+
multiSelect: Type.Optional(
|
|
104
|
+
Type.Boolean({
|
|
105
|
+
description: "Allow selecting more than one option for this question. Defaults to false.",
|
|
106
|
+
}),
|
|
107
|
+
),
|
|
77
108
|
},
|
|
78
109
|
{ additionalProperties: false },
|
|
79
110
|
);
|
|
@@ -81,9 +112,23 @@ const QuestionSchema = Type.Object(
|
|
|
81
112
|
const AskQuestionParams = Type.Object(
|
|
82
113
|
{
|
|
83
114
|
question: Type.Optional(Type.String({ minLength: 1, description: "Single question to ask." })),
|
|
84
|
-
options: Type.Optional(
|
|
85
|
-
|
|
86
|
-
|
|
115
|
+
options: Type.Optional(
|
|
116
|
+
Type.Array(Type.String({ minLength: 1 }), {
|
|
117
|
+
description: "Choices, ordered from most recommended to least recommended.",
|
|
118
|
+
}),
|
|
119
|
+
),
|
|
120
|
+
multiSelect: Type.Optional(
|
|
121
|
+
Type.Boolean({
|
|
122
|
+
description:
|
|
123
|
+
"Allow selecting more than one option for the single question. Defaults to false.",
|
|
124
|
+
}),
|
|
125
|
+
),
|
|
126
|
+
questions: Type.Optional(
|
|
127
|
+
Type.Array(QuestionSchema, {
|
|
128
|
+
description:
|
|
129
|
+
"Ask several questions in order. When provided, takes precedence over the single-question fields.",
|
|
130
|
+
}),
|
|
131
|
+
),
|
|
87
132
|
},
|
|
88
133
|
{ additionalProperties: false },
|
|
89
134
|
);
|
|
@@ -99,16 +144,27 @@ function uniqueOptionLabel(label: string, options: string[]): string {
|
|
|
99
144
|
return result;
|
|
100
145
|
}
|
|
101
146
|
|
|
102
|
-
function normalize(params: {
|
|
147
|
+
function normalize(params: {
|
|
148
|
+
question?: string;
|
|
149
|
+
options?: string[];
|
|
150
|
+
multiSelect?: boolean;
|
|
151
|
+
questions?: Question[];
|
|
152
|
+
}): NormalizedQuestion[] {
|
|
103
153
|
const raw = params.questions?.length
|
|
104
154
|
? params.questions
|
|
105
|
-
: [
|
|
155
|
+
: [
|
|
156
|
+
{
|
|
157
|
+
id: "question_1",
|
|
158
|
+
question: params.question ?? "",
|
|
159
|
+
options: params.options,
|
|
160
|
+
multiSelect: params.multiSelect,
|
|
161
|
+
},
|
|
162
|
+
];
|
|
106
163
|
|
|
107
164
|
const assigned = new Set<string>();
|
|
108
165
|
const result: NormalizedQuestion[] = [];
|
|
109
166
|
|
|
110
|
-
for (
|
|
111
|
-
const question = raw[index];
|
|
167
|
+
for (const [index, question] of raw.entries()) {
|
|
112
168
|
const text = clean(question.question);
|
|
113
169
|
if (!text) continue;
|
|
114
170
|
|
|
@@ -135,7 +191,9 @@ function normalize(params: { question?: string; options?: string[]; multiSelect?
|
|
|
135
191
|
}
|
|
136
192
|
|
|
137
193
|
function orderedAnswers(questions: NormalizedQuestion[], answers: Map<string, Answer>): Answer[] {
|
|
138
|
-
return questions
|
|
194
|
+
return questions
|
|
195
|
+
.map((question) => answers.get(question.id))
|
|
196
|
+
.filter((answer): answer is Answer => Boolean(answer));
|
|
139
197
|
}
|
|
140
198
|
|
|
141
199
|
function formatAnswer(answer?: Answer): string {
|
|
@@ -157,20 +215,38 @@ function wrapByColumn(text: string, width: number): string[] {
|
|
|
157
215
|
function exactText(text: string) {
|
|
158
216
|
return {
|
|
159
217
|
render(width: number) {
|
|
160
|
-
const wrapped = text
|
|
218
|
+
const wrapped = text
|
|
219
|
+
.split("\n")
|
|
220
|
+
.flatMap((line) => wrapByColumn(line, width))
|
|
221
|
+
.join("\n");
|
|
161
222
|
return new Text(wrapped, 0, 0).render(width);
|
|
162
223
|
},
|
|
163
224
|
invalidate() {},
|
|
164
225
|
};
|
|
165
226
|
}
|
|
166
227
|
|
|
167
|
-
function summarize(
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
228
|
+
function summarize(
|
|
229
|
+
questions: NormalizedQuestion[],
|
|
230
|
+
answers: Map<string, Answer>,
|
|
231
|
+
cancelled: boolean,
|
|
232
|
+
timedOut: boolean,
|
|
233
|
+
): string {
|
|
234
|
+
if (timedOut)
|
|
235
|
+
return [
|
|
236
|
+
`Timed out after 5 minutes. ${TIMEOUT_MESSAGE}`,
|
|
237
|
+
...(answers.size
|
|
238
|
+
? [
|
|
239
|
+
"",
|
|
240
|
+
"Answers already provided:",
|
|
241
|
+
...orderedAnswers(questions, answers).map(
|
|
242
|
+
(answer) => `- ${answer.id}: ${formatAnswer(answer)}`,
|
|
243
|
+
),
|
|
244
|
+
]
|
|
245
|
+
: []),
|
|
246
|
+
].join("\n");
|
|
172
247
|
if (cancelled) return "User cancelled the question.";
|
|
173
|
-
|
|
248
|
+
const [only] = questions;
|
|
249
|
+
if (questions.length === 1 && only) return `User answered: ${formatAnswer(answers.get(only.id))}`;
|
|
174
250
|
return [
|
|
175
251
|
"User answered:",
|
|
176
252
|
...questions.map((question) => `- ${question.id}: ${formatAnswer(answers.get(question.id))}`),
|
|
@@ -190,629 +266,806 @@ async function askWithKeyboard(
|
|
|
190
266
|
let disposeBridge = () => {};
|
|
191
267
|
let announceBridge = () => {};
|
|
192
268
|
let overlayHandle: OverlayHandle | undefined;
|
|
193
|
-
return ui
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
let option = 0;
|
|
217
|
-
let editing = false;
|
|
218
|
-
let cachedWidth: number | undefined;
|
|
219
|
-
let cachedHeight: number | undefined;
|
|
220
|
-
let cachedLines: string[] | undefined;
|
|
221
|
-
let optionScroll = 0;
|
|
222
|
-
let labelScroll = 0;
|
|
223
|
-
let pageRows = 0;
|
|
224
|
-
let maxLabelScroll = 0;
|
|
225
|
-
let questionScroll = 0;
|
|
226
|
-
let questionPageRows = 0;
|
|
227
|
-
let maxQuestionScroll = 0;
|
|
228
|
-
let reviewScroll: number | undefined;
|
|
229
|
-
let reviewPageRows = 0;
|
|
230
|
-
let maxReviewScroll = 0;
|
|
231
|
-
let readingContext = false;
|
|
232
|
-
let contextHintStart = 0;
|
|
233
|
-
let contextHintEnd = 0;
|
|
234
|
-
let contextHintWidth = 0;
|
|
235
|
-
|
|
236
|
-
const submitTab = questions.length;
|
|
237
|
-
const showTabs = questions.length > 1 || questions.some((question) => question.multiSelect);
|
|
238
|
-
const current = () => questions[tab];
|
|
239
|
-
const customOption = () => uniqueOptionLabel(CUSTOM_OPTION, current()?.options ?? []);
|
|
240
|
-
const choices = () => [...(current()?.options ?? []), customOption()];
|
|
241
|
-
const allAnswered = () => questions.every((question) => answers.has(question.id));
|
|
242
|
-
const refresh = () => {
|
|
243
|
-
cachedLines = undefined;
|
|
244
|
-
tui.requestRender();
|
|
245
|
-
};
|
|
246
|
-
const toggleContext = () => {
|
|
247
|
-
if (!overlayHandle) return;
|
|
248
|
-
readingContext = !readingContext;
|
|
249
|
-
if (readingContext) {
|
|
250
|
-
reservedRows = 0;
|
|
251
|
-
overlayHandle.unfocus({ target: null });
|
|
252
|
-
} else {
|
|
253
|
-
overlayHandle.focus();
|
|
254
|
-
}
|
|
255
|
-
refresh();
|
|
256
|
-
};
|
|
257
|
-
const unsubscribeInput = tui.addInputListener((data) => {
|
|
258
|
-
if (matchesKey(data, Key.ctrl("o")) && (readingContext || overlayHandle?.isFocused())) {
|
|
259
|
-
markInteraction();
|
|
260
|
-
toggleContext();
|
|
261
|
-
return { consume: true };
|
|
262
|
-
}
|
|
263
|
-
return undefined;
|
|
264
|
-
});
|
|
265
|
-
let finished = false;
|
|
266
|
-
let revision = 0;
|
|
267
|
-
let publishQueued = false;
|
|
268
|
-
let closed = false;
|
|
269
|
-
let announced = false;
|
|
270
|
-
const acceptedRequests = new Set<string>();
|
|
271
|
-
function waitingState() {
|
|
272
|
-
const question = current();
|
|
273
|
-
if (!announced || finished || signal?.aborted || editing || !question ||
|
|
274
|
-
(timeout.active && Date.now() >= timeout.deadline)) return undefined;
|
|
275
|
-
return {
|
|
276
|
-
status: "waiting" as const, ...observer.identity(), promptId, questionId: question.id, revision,
|
|
277
|
-
deadline: timeout.deadline, timeoutActive: timeout.active,
|
|
278
|
-
question: question.question, options: [...question.options], multiSelect: question.multiSelect,
|
|
279
|
-
};
|
|
280
|
-
}
|
|
281
|
-
const unwatch = observer.watch(waitingState);
|
|
282
|
-
function publishState() {
|
|
283
|
-
const waiting = waitingState();
|
|
284
|
-
if (!waiting) {
|
|
285
|
-
if (!closed) pi.events.emit("pi-ask-question:state", { status: "closed", promptId });
|
|
286
|
-
closed = true;
|
|
287
|
-
return;
|
|
288
|
-
}
|
|
289
|
-
closed = false;
|
|
290
|
-
pi.events.emit("pi-ask-question:state", waiting);
|
|
291
|
-
}
|
|
292
|
-
function markInteraction() {
|
|
293
|
-
if (!timeout.stop()) return;
|
|
294
|
-
refresh();
|
|
295
|
-
publishState();
|
|
296
|
-
}
|
|
297
|
-
function stateChanged() {
|
|
298
|
-
revision += 1;
|
|
299
|
-
if (publishQueued) return;
|
|
300
|
-
publishQueued = true;
|
|
301
|
-
queueMicrotask(() => {
|
|
302
|
-
publishQueued = false;
|
|
303
|
-
publishState();
|
|
304
|
-
});
|
|
305
|
-
}
|
|
306
|
-
const finish = (cancelled: boolean) => {
|
|
307
|
-
if (finished) return;
|
|
308
|
-
if (readingContext) overlayHandle?.focus();
|
|
309
|
-
disposeBridge();
|
|
310
|
-
done({ answers: orderedAnswers(questions, answers), cancelled });
|
|
311
|
-
};
|
|
312
|
-
const abort = () => finish(true);
|
|
313
|
-
const unsubscribeAnswer = pi.events.on("pi-ask-question:answer", (data) => {
|
|
314
|
-
if (data === null || typeof data !== "object") return;
|
|
315
|
-
const request = data as Record<string, unknown>;
|
|
316
|
-
if (typeof request.requestId !== "string" || !request.requestId.trim() || request.requestId.length > 128) return;
|
|
317
|
-
// Other live owners on the shared bus must not acknowledge this prompt.
|
|
318
|
-
if (request.promptId !== promptId) return;
|
|
319
|
-
const receipt = (status: "answered" | "stale" | "invalid") =>
|
|
320
|
-
pi.events.emit("pi-ask-question:result", { requestId: request.requestId, promptId, status });
|
|
321
|
-
const question = current();
|
|
322
|
-
if (finished || signal?.aborted || editing || !question || request.questionId !== question.id ||
|
|
323
|
-
request.revision !== revision || acceptedRequests.has(request.requestId) ||
|
|
324
|
-
!observer.matches(request, timeout.deadline) || (timeout.active && Date.now() >= timeout.deadline)) {
|
|
325
|
-
receipt("stale");
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
|
-
const answer = request.answer;
|
|
329
|
-
const validText = typeof answer === "string" && answer.length <= MAX_REMOTE_ANSWER_LENGTH && Boolean(clean(answer));
|
|
330
|
-
const validOptions = Array.isArray(answer) && question.multiSelect && answer.length > 0 &&
|
|
331
|
-
answer.length <= question.options.length && new Set(answer).size === answer.length &&
|
|
332
|
-
Array.from(answer).every((value) => typeof value === "string" && question.options.includes(value)) &&
|
|
333
|
-
answer.reduce((size, value) => size + value.length, 0) <= MAX_REMOTE_ANSWER_LENGTH;
|
|
334
|
-
// ponytail: bound deduplication to 1024 commits per prompt; raise with a higher-volume client.
|
|
335
|
-
if ((!validText && !validOptions) || acceptedRequests.size >= MAX_REMOTE_REQUESTS) {
|
|
336
|
-
receipt("invalid");
|
|
337
|
-
return;
|
|
338
|
-
}
|
|
339
|
-
const target = { promptId, questionId: question.id, revision, deadline: timeout.deadline };
|
|
340
|
-
acceptedRequests.add(request.requestId);
|
|
341
|
-
timeout.stop();
|
|
342
|
-
if (typeof answer === "string") {
|
|
343
|
-
if (question.multiSelect) {
|
|
344
|
-
saveMultiCustomAnswer(question, answer.trim());
|
|
345
|
-
moveForwardIfAnswered(question);
|
|
346
|
-
} else saveSingleAnswer(answer.trim(), true);
|
|
347
|
-
} else {
|
|
348
|
-
const selected = selectedSet(question);
|
|
349
|
-
selected.clear();
|
|
350
|
-
customAnswers.delete(question.id);
|
|
351
|
-
for (const choice of answer as string[]) selected.add(choice);
|
|
352
|
-
syncMultiAnswer(question);
|
|
353
|
-
moveForwardIfAnswered(question);
|
|
354
|
-
}
|
|
355
|
-
if (allAnswered()) finish(false);
|
|
356
|
-
observer.accept(target, request.requestId, answer as string | string[]);
|
|
357
|
-
receipt("answered");
|
|
358
|
-
});
|
|
359
|
-
const unsubscribeShutdown = pi.on("session_shutdown", abort);
|
|
360
|
-
disposeBridge = () => {
|
|
361
|
-
if (finished) return;
|
|
362
|
-
finished = true;
|
|
363
|
-
signal?.removeEventListener("abort", abort);
|
|
364
|
-
unwatch();
|
|
365
|
-
unsubscribeAnswer();
|
|
366
|
-
unsubscribeShutdown();
|
|
367
|
-
unsubscribeInput();
|
|
368
|
-
ui.setWidget(widgetKey, undefined);
|
|
369
|
-
publishState();
|
|
370
|
-
};
|
|
371
|
-
signal?.addEventListener("abort", abort, { once: true });
|
|
372
|
-
if (signal?.aborted) finish(true);
|
|
373
|
-
// Advertise only after the native host has installed this component.
|
|
374
|
-
announceBridge = () => {
|
|
375
|
-
announced = true;
|
|
376
|
-
if (signal?.aborted) abort();
|
|
377
|
-
else publishState();
|
|
378
|
-
};
|
|
379
|
-
|
|
380
|
-
function moveTab(next: number) {
|
|
381
|
-
tab = (next + questions.length + 1) % (questions.length + 1);
|
|
382
|
-
option = 0;
|
|
383
|
-
optionScroll = 0;
|
|
384
|
-
labelScroll = 0;
|
|
385
|
-
questionScroll = 0;
|
|
386
|
-
reviewScroll = undefined;
|
|
387
|
-
editing = false;
|
|
388
|
-
editor.setText("");
|
|
389
|
-
stateChanged();
|
|
390
|
-
refresh();
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
function selectedSet(question: NormalizedQuestion): Set<string> {
|
|
394
|
-
let selected = multiAnswers.get(question.id);
|
|
395
|
-
if (!selected) {
|
|
396
|
-
selected = new Set<string>();
|
|
397
|
-
multiAnswers.set(question.id, selected);
|
|
398
|
-
}
|
|
399
|
-
return selected;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
function syncMultiAnswer(question: NormalizedQuestion) {
|
|
403
|
-
stateChanged();
|
|
404
|
-
const selected = [...selectedSet(question)];
|
|
405
|
-
if (!selected.length) {
|
|
406
|
-
answers.delete(question.id);
|
|
407
|
-
return;
|
|
408
|
-
}
|
|
409
|
-
answers.set(question.id, {
|
|
410
|
-
id: question.id,
|
|
411
|
-
question: question.question,
|
|
412
|
-
answer: selected.join(", "),
|
|
413
|
-
selectedOptions: selected,
|
|
414
|
-
wasCustom: selected.some((answer) => !question.options.includes(answer)),
|
|
415
|
-
});
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
function toggleMultiChoice(question: NormalizedQuestion, choice: string) {
|
|
419
|
-
const selected = selectedSet(question);
|
|
420
|
-
if (selected.has(choice)) selected.delete(choice);
|
|
421
|
-
else selected.add(choice);
|
|
422
|
-
syncMultiAnswer(question);
|
|
423
|
-
labelScroll = 0;
|
|
424
|
-
refresh();
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
function startCustomEdit(question: NormalizedQuestion) {
|
|
428
|
-
const currentCustom = question.multiSelect
|
|
429
|
-
? customAnswers.get(question.id)
|
|
430
|
-
: answers.get(question.id)?.wasCustom ? answers.get(question.id)?.answer : undefined;
|
|
431
|
-
editor.setText(currentCustom ?? "");
|
|
432
|
-
editing = true;
|
|
433
|
-
labelScroll = 0;
|
|
434
|
-
optionScroll = 0;
|
|
435
|
-
questionScroll = 0;
|
|
436
|
-
stateChanged();
|
|
437
|
-
refresh();
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
function saveMultiCustomAnswer(question: NormalizedQuestion, answer: string) {
|
|
441
|
-
const selected = selectedSet(question);
|
|
442
|
-
const previous = customAnswers.get(question.id);
|
|
443
|
-
if (previous) selected.delete(previous);
|
|
444
|
-
customAnswers.set(question.id, answer);
|
|
445
|
-
selected.add(answer);
|
|
446
|
-
syncMultiAnswer(question);
|
|
447
|
-
refresh();
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
function moveForwardIfAnswered(question: NormalizedQuestion) {
|
|
451
|
-
if (answers.has(question.id)) moveTab(Math.min(tab + 1, submitTab));
|
|
452
|
-
else refresh();
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
function saveSingleAnswer(answer: string, wasCustom: boolean) {
|
|
456
|
-
const question = current();
|
|
457
|
-
if (!question) return;
|
|
458
|
-
answers.set(question.id, { id: question.id, question: question.question, answer, wasCustom });
|
|
459
|
-
stateChanged();
|
|
460
|
-
if (questions.length === 1) finish(false);
|
|
461
|
-
else moveTab(Math.min(tab + 1, submitTab));
|
|
462
|
-
}
|
|
269
|
+
return ui
|
|
270
|
+
.custom<{ answers: Answer[]; cancelled: boolean }>(
|
|
271
|
+
(tui, theme, keys, done) => {
|
|
272
|
+
let reservedRows = 0;
|
|
273
|
+
const widgetKey = `ask-question.${promptId}`;
|
|
274
|
+
ui.setWidget(widgetKey, () => ({
|
|
275
|
+
render: () => Array(reservedRows).fill(""),
|
|
276
|
+
invalidate() {},
|
|
277
|
+
}));
|
|
278
|
+
const answers = new Map<string, Answer>();
|
|
279
|
+
const multiAnswers = new Map<string, Set<string>>();
|
|
280
|
+
const customAnswers = new Map<string, string>();
|
|
281
|
+
const keyText = (binding: Keybinding) => keys.getKeys(binding).join("/");
|
|
282
|
+
const editor = new Editor(tui, {
|
|
283
|
+
borderColor: (text) => theme.fg("accent", text),
|
|
284
|
+
selectList: {
|
|
285
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
286
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
287
|
+
description: (text) => theme.fg("muted", text),
|
|
288
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
289
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
290
|
+
},
|
|
291
|
+
});
|
|
463
292
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
293
|
+
let tab = 0;
|
|
294
|
+
let option = 0;
|
|
295
|
+
let editing = false;
|
|
296
|
+
let cachedWidth: number | undefined;
|
|
297
|
+
let cachedHeight: number | undefined;
|
|
298
|
+
let cachedLines: string[] | undefined;
|
|
299
|
+
let optionScroll = 0;
|
|
300
|
+
let labelScroll = 0;
|
|
301
|
+
let pageRows = 0;
|
|
302
|
+
let maxLabelScroll = 0;
|
|
303
|
+
let questionScroll = 0;
|
|
304
|
+
let questionPageRows = 0;
|
|
305
|
+
let maxQuestionScroll = 0;
|
|
306
|
+
let reviewScroll: number | undefined;
|
|
307
|
+
let reviewPageRows = 0;
|
|
308
|
+
let maxReviewScroll = 0;
|
|
309
|
+
let readingContext = false;
|
|
310
|
+
let contextHintStart = 0;
|
|
311
|
+
let contextHintEnd = 0;
|
|
312
|
+
let contextHintWidth = 0;
|
|
313
|
+
|
|
314
|
+
const submitTab = questions.length;
|
|
315
|
+
const showTabs = questions.length > 1 || questions.some((question) => question.multiSelect);
|
|
316
|
+
const current = () => questions[tab];
|
|
317
|
+
const customOption = () => uniqueOptionLabel(CUSTOM_OPTION, current()?.options ?? []);
|
|
318
|
+
const choices = () => [...(current()?.options ?? []), customOption()];
|
|
319
|
+
const allAnswered = () => questions.every((question) => answers.has(question.id));
|
|
320
|
+
const refresh = () => {
|
|
321
|
+
cachedLines = undefined;
|
|
322
|
+
tui.requestRender();
|
|
323
|
+
};
|
|
324
|
+
const toggleContext = () => {
|
|
325
|
+
if (!overlayHandle) return;
|
|
326
|
+
readingContext = !readingContext;
|
|
327
|
+
if (readingContext) {
|
|
328
|
+
reservedRows = 0;
|
|
329
|
+
overlayHandle.unfocus({ target: null });
|
|
330
|
+
} else {
|
|
331
|
+
overlayHandle.focus();
|
|
332
|
+
}
|
|
484
333
|
refresh();
|
|
485
|
-
|
|
334
|
+
};
|
|
335
|
+
const unsubscribeInput = tui.addInputListener((data) => {
|
|
336
|
+
if (matchesKey(data, Key.ctrl("o")) && (readingContext || overlayHandle?.isFocused())) {
|
|
337
|
+
markInteraction();
|
|
338
|
+
toggleContext();
|
|
339
|
+
return { consume: true };
|
|
340
|
+
}
|
|
341
|
+
return undefined;
|
|
342
|
+
});
|
|
343
|
+
let finished = false;
|
|
344
|
+
let revision = 0;
|
|
345
|
+
let publishQueued = false;
|
|
346
|
+
let closed = false;
|
|
347
|
+
let announced = false;
|
|
348
|
+
const acceptedRequests = new Set<string>();
|
|
349
|
+
function waitingState() {
|
|
350
|
+
const question = current();
|
|
351
|
+
if (
|
|
352
|
+
!announced ||
|
|
353
|
+
finished ||
|
|
354
|
+
signal?.aborted ||
|
|
355
|
+
editing ||
|
|
356
|
+
!question ||
|
|
357
|
+
(timeout.active && Date.now() >= timeout.deadline)
|
|
358
|
+
)
|
|
359
|
+
return undefined;
|
|
360
|
+
return {
|
|
361
|
+
status: "waiting" as const,
|
|
362
|
+
...observer.identity(),
|
|
363
|
+
promptId,
|
|
364
|
+
questionId: question.id,
|
|
365
|
+
revision,
|
|
366
|
+
deadline: timeout.deadline,
|
|
367
|
+
timeoutActive: timeout.active,
|
|
368
|
+
question: question.question,
|
|
369
|
+
options: [...question.options],
|
|
370
|
+
multiSelect: question.multiSelect,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
const unwatch = observer.watch(waitingState);
|
|
374
|
+
function publishState() {
|
|
375
|
+
const waiting = waitingState();
|
|
376
|
+
if (!waiting) {
|
|
377
|
+
if (!closed) pi.events.emit("pi-ask-question:state", { status: "closed", promptId });
|
|
378
|
+
closed = true;
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
closed = false;
|
|
382
|
+
pi.events.emit("pi-ask-question:state", waiting);
|
|
486
383
|
}
|
|
487
|
-
|
|
488
|
-
|
|
384
|
+
function markInteraction() {
|
|
385
|
+
if (!timeout.stop()) return;
|
|
489
386
|
refresh();
|
|
490
|
-
|
|
387
|
+
publishState();
|
|
388
|
+
}
|
|
389
|
+
function stateChanged() {
|
|
390
|
+
revision += 1;
|
|
391
|
+
if (publishQueued) return;
|
|
392
|
+
publishQueued = true;
|
|
393
|
+
queueMicrotask(() => {
|
|
394
|
+
publishQueued = false;
|
|
395
|
+
publishState();
|
|
396
|
+
});
|
|
491
397
|
}
|
|
492
|
-
|
|
398
|
+
const finish = (cancelled: boolean) => {
|
|
399
|
+
if (finished) return;
|
|
400
|
+
if (readingContext) overlayHandle?.focus();
|
|
401
|
+
disposeBridge();
|
|
402
|
+
done({ answers: orderedAnswers(questions, answers), cancelled });
|
|
403
|
+
};
|
|
404
|
+
const abort = () => finish(true);
|
|
405
|
+
const unsubscribeAnswer = pi.events.on("pi-ask-question:answer", (data) => {
|
|
406
|
+
if (data === null || typeof data !== "object") return;
|
|
407
|
+
const request = data as Record<string, unknown>;
|
|
408
|
+
if (
|
|
409
|
+
typeof request.requestId !== "string" ||
|
|
410
|
+
!request.requestId.trim() ||
|
|
411
|
+
request.requestId.length > 128
|
|
412
|
+
)
|
|
413
|
+
return;
|
|
414
|
+
// Other live owners on the shared bus must not acknowledge this prompt.
|
|
415
|
+
if (request.promptId !== promptId) return;
|
|
416
|
+
const receipt = (status: "answered" | "stale" | "invalid") =>
|
|
417
|
+
pi.events.emit("pi-ask-question:result", {
|
|
418
|
+
requestId: request.requestId,
|
|
419
|
+
promptId,
|
|
420
|
+
status,
|
|
421
|
+
});
|
|
422
|
+
const question = current();
|
|
423
|
+
if (
|
|
424
|
+
finished ||
|
|
425
|
+
signal?.aborted ||
|
|
426
|
+
editing ||
|
|
427
|
+
!question ||
|
|
428
|
+
request.questionId !== question.id ||
|
|
429
|
+
request.revision !== revision ||
|
|
430
|
+
acceptedRequests.has(request.requestId) ||
|
|
431
|
+
!observer.matches(request, timeout.deadline) ||
|
|
432
|
+
(timeout.active && Date.now() >= timeout.deadline)
|
|
433
|
+
) {
|
|
434
|
+
receipt("stale");
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
const answer = request.answer;
|
|
438
|
+
const validText =
|
|
439
|
+
typeof answer === "string" &&
|
|
440
|
+
answer.length <= MAX_REMOTE_ANSWER_LENGTH &&
|
|
441
|
+
Boolean(clean(answer));
|
|
442
|
+
const validOptions =
|
|
443
|
+
Array.isArray(answer) &&
|
|
444
|
+
question.multiSelect &&
|
|
445
|
+
answer.length > 0 &&
|
|
446
|
+
answer.length <= question.options.length &&
|
|
447
|
+
new Set(answer).size === answer.length &&
|
|
448
|
+
Array.from(answer).every(
|
|
449
|
+
(value) => typeof value === "string" && question.options.includes(value),
|
|
450
|
+
) &&
|
|
451
|
+
answer.reduce((size, value) => size + value.length, 0) <= MAX_REMOTE_ANSWER_LENGTH;
|
|
452
|
+
// ponytail: bound deduplication to 1024 commits per prompt; raise with a higher-volume client.
|
|
453
|
+
if ((!validText && !validOptions) || acceptedRequests.size >= MAX_REMOTE_REQUESTS) {
|
|
454
|
+
receipt("invalid");
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
const target = {
|
|
458
|
+
promptId,
|
|
459
|
+
questionId: question.id,
|
|
460
|
+
revision,
|
|
461
|
+
deadline: timeout.deadline,
|
|
462
|
+
};
|
|
463
|
+
acceptedRequests.add(request.requestId);
|
|
464
|
+
timeout.stop();
|
|
465
|
+
if (typeof answer === "string") {
|
|
466
|
+
if (question.multiSelect) {
|
|
467
|
+
saveMultiCustomAnswer(question, answer.trim());
|
|
468
|
+
moveForwardIfAnswered(question);
|
|
469
|
+
} else saveSingleAnswer(answer.trim(), true);
|
|
470
|
+
} else {
|
|
471
|
+
const selected = selectedSet(question);
|
|
472
|
+
selected.clear();
|
|
473
|
+
customAnswers.delete(question.id);
|
|
474
|
+
for (const choice of answer as string[]) selected.add(choice);
|
|
475
|
+
syncMultiAnswer(question);
|
|
476
|
+
moveForwardIfAnswered(question);
|
|
477
|
+
}
|
|
478
|
+
if (allAnswered()) finish(false);
|
|
479
|
+
observer.accept(target, request.requestId, answer as string | string[]);
|
|
480
|
+
receipt("answered");
|
|
481
|
+
});
|
|
482
|
+
const unsubscribeShutdown = pi.on("session_shutdown", abort);
|
|
483
|
+
disposeBridge = () => {
|
|
484
|
+
if (finished) return;
|
|
485
|
+
finished = true;
|
|
486
|
+
signal?.removeEventListener("abort", abort);
|
|
487
|
+
unwatch();
|
|
488
|
+
unsubscribeAnswer();
|
|
489
|
+
unsubscribeShutdown();
|
|
490
|
+
unsubscribeInput();
|
|
491
|
+
ui.setWidget(widgetKey, undefined);
|
|
492
|
+
publishState();
|
|
493
|
+
};
|
|
494
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
495
|
+
if (signal?.aborted) finish(true);
|
|
496
|
+
// Advertise only after the native host has installed this component.
|
|
497
|
+
announceBridge = () => {
|
|
498
|
+
announced = true;
|
|
499
|
+
if (signal?.aborted) abort();
|
|
500
|
+
else publishState();
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
function moveTab(next: number) {
|
|
504
|
+
tab = (next + questions.length + 1) % (questions.length + 1);
|
|
505
|
+
option = 0;
|
|
506
|
+
optionScroll = 0;
|
|
507
|
+
labelScroll = 0;
|
|
508
|
+
questionScroll = 0;
|
|
509
|
+
reviewScroll = undefined;
|
|
493
510
|
editing = false;
|
|
494
511
|
editor.setText("");
|
|
495
512
|
stateChanged();
|
|
496
513
|
refresh();
|
|
497
|
-
return;
|
|
498
514
|
}
|
|
499
|
-
editor.handleInput(data);
|
|
500
|
-
refresh();
|
|
501
|
-
return;
|
|
502
|
-
}
|
|
503
515
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
516
|
+
function selectedSet(question: NormalizedQuestion): Set<string> {
|
|
517
|
+
let selected = multiAnswers.get(question.id);
|
|
518
|
+
if (!selected) {
|
|
519
|
+
selected = new Set<string>();
|
|
520
|
+
multiAnswers.set(question.id, selected);
|
|
521
|
+
}
|
|
522
|
+
return selected;
|
|
523
|
+
}
|
|
510
524
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
525
|
+
function syncMultiAnswer(question: NormalizedQuestion) {
|
|
526
|
+
stateChanged();
|
|
527
|
+
const selected = [...selectedSet(question)];
|
|
528
|
+
if (!selected.length) {
|
|
529
|
+
answers.delete(question.id);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
answers.set(question.id, {
|
|
533
|
+
id: question.id,
|
|
534
|
+
question: question.question,
|
|
535
|
+
answer: selected.join(", "),
|
|
536
|
+
selectedOptions: selected,
|
|
537
|
+
wasCustom: selected.some((answer) => !question.options.includes(answer)),
|
|
538
|
+
});
|
|
516
539
|
}
|
|
517
|
-
|
|
518
|
-
|
|
540
|
+
|
|
541
|
+
function toggleMultiChoice(question: NormalizedQuestion, choice: string) {
|
|
542
|
+
const selected = selectedSet(question);
|
|
543
|
+
if (selected.has(choice)) selected.delete(choice);
|
|
544
|
+
else selected.add(choice);
|
|
545
|
+
syncMultiAnswer(question);
|
|
546
|
+
labelScroll = 0;
|
|
519
547
|
refresh();
|
|
520
|
-
return;
|
|
521
548
|
}
|
|
522
|
-
if (keys.matches(data, "tui.select.confirm") && allAnswered()) finish(false);
|
|
523
|
-
return;
|
|
524
|
-
}
|
|
525
549
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
}
|
|
540
|
-
if (keys.matches(data, "tui.select.up")) {
|
|
541
|
-
option = Math.max(0, option - 1);
|
|
542
|
-
labelScroll = 0;
|
|
543
|
-
refresh();
|
|
544
|
-
return;
|
|
545
|
-
}
|
|
546
|
-
if (keys.matches(data, "tui.select.down")) {
|
|
547
|
-
option = Math.min(options.length - 1, option + 1);
|
|
548
|
-
labelScroll = 0;
|
|
549
|
-
refresh();
|
|
550
|
-
return;
|
|
551
|
-
}
|
|
552
|
-
if (question.multiSelect && matchesKey(data, Key.space)) {
|
|
553
|
-
const picked = options[option];
|
|
554
|
-
if (picked === customOption()) startCustomEdit(question);
|
|
555
|
-
else if (picked) toggleMultiChoice(question, picked);
|
|
556
|
-
return;
|
|
557
|
-
}
|
|
558
|
-
if (keys.matches(data, "tui.select.confirm")) {
|
|
559
|
-
const picked = options[option];
|
|
560
|
-
if (picked === customOption()) {
|
|
561
|
-
startCustomEdit(question);
|
|
562
|
-
} else if (picked && question.multiSelect) {
|
|
563
|
-
moveForwardIfAnswered(question);
|
|
564
|
-
} else if (picked) {
|
|
565
|
-
saveSingleAnswer(picked, false);
|
|
550
|
+
function startCustomEdit(question: NormalizedQuestion) {
|
|
551
|
+
const currentCustom = question.multiSelect
|
|
552
|
+
? customAnswers.get(question.id)
|
|
553
|
+
: answers.get(question.id)?.wasCustom
|
|
554
|
+
? answers.get(question.id)?.answer
|
|
555
|
+
: undefined;
|
|
556
|
+
editor.setText(currentCustom ?? "");
|
|
557
|
+
editing = true;
|
|
558
|
+
labelScroll = 0;
|
|
559
|
+
optionScroll = 0;
|
|
560
|
+
questionScroll = 0;
|
|
561
|
+
stateChanged();
|
|
562
|
+
refresh();
|
|
566
563
|
}
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
564
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
const height = tui.terminal.rows;
|
|
579
|
-
if (cachedLines && cachedWidth === width && cachedHeight === height) return cachedLines;
|
|
580
|
-
if (cachedWidth !== width) {
|
|
581
|
-
optionScroll = 0;
|
|
582
|
-
labelScroll = 0;
|
|
583
|
-
questionScroll = 0;
|
|
584
|
-
}
|
|
585
|
-
const lines: string[] = [];
|
|
586
|
-
let questionStart = 0;
|
|
587
|
-
let questionEnd = 0;
|
|
588
|
-
let optionStart = 0;
|
|
589
|
-
let optionEnd = 0;
|
|
590
|
-
let reviewStart = 0;
|
|
591
|
-
let reviewEnd = 0;
|
|
592
|
-
const optionRows: Array<{ start: number; end: number }> = [];
|
|
593
|
-
const add = (line = "") => lines.push(visibleWidth(line) > width ? sliceByColumn(line, 0, width, true) : line);
|
|
594
|
-
const addWrapped = (
|
|
595
|
-
text: string,
|
|
596
|
-
options: { firstPrefix?: string; restPrefix?: string; style?: (text: string) => string; prefixStyle?: (text: string) => string; preserveWhitespace?: boolean } = {},
|
|
597
|
-
) => {
|
|
598
|
-
const firstPrefix = options.firstPrefix ?? "";
|
|
599
|
-
const restPrefix = options.restPrefix ?? "";
|
|
600
|
-
const style = options.style ?? ((value: string) => value);
|
|
601
|
-
const prefixStyle = options.prefixStyle ?? ((value: string) => value);
|
|
602
|
-
const wrap = options.preserveWhitespace ? wrapByColumn : wrapTextWithAnsi;
|
|
603
|
-
const prefixWidth = visibleWidth(firstPrefix);
|
|
604
|
-
if (prefixWidth >= width || (options.preserveWhitespace && width - prefixWidth < 2)) {
|
|
605
|
-
for (const line of wrap(`${prefixStyle(firstPrefix)}${style(text)}`, width)) add(line);
|
|
606
|
-
return;
|
|
565
|
+
function saveMultiCustomAnswer(question: NormalizedQuestion, answer: string) {
|
|
566
|
+
const selected = selectedSet(question);
|
|
567
|
+
const previous = customAnswers.get(question.id);
|
|
568
|
+
if (previous) selected.delete(previous);
|
|
569
|
+
customAnswers.set(question.id, answer);
|
|
570
|
+
selected.add(answer);
|
|
571
|
+
syncMultiAnswer(question);
|
|
572
|
+
refresh();
|
|
607
573
|
}
|
|
608
|
-
const wrapped = wrap(style(text), width - prefixWidth);
|
|
609
|
-
add(`${prefixStyle(firstPrefix)}${wrapped[0] ?? ""}`);
|
|
610
|
-
for (let index = 1; index < wrapped.length; index += 1) add(`${prefixStyle(restPrefix)}${wrapped[index]}`);
|
|
611
|
-
};
|
|
612
|
-
const border = theme.fg("accent", "─".repeat(width));
|
|
613
|
-
|
|
614
|
-
add(border);
|
|
615
|
-
if (showTabs && !editing) {
|
|
616
|
-
addWrapped(
|
|
617
|
-
[
|
|
618
|
-
...questions.map((question, index) => {
|
|
619
|
-
const marker = answers.has(question.id) ? "■" : "□";
|
|
620
|
-
const label = ` ${marker} Q${index + 1} `;
|
|
621
|
-
return index === tab ? theme.bg("selectedBg", theme.fg("text", label)) : theme.fg("muted", label);
|
|
622
|
-
}),
|
|
623
|
-
tab === submitTab ? theme.bg("selectedBg", theme.fg("text", " Submit ")) : theme.fg(allAnswered() ? "success" : "dim", " Submit "),
|
|
624
|
-
].join(" "),
|
|
625
|
-
);
|
|
626
|
-
add();
|
|
627
|
-
}
|
|
628
574
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
reviewStart = lines.length;
|
|
633
|
-
for (const question of questions) {
|
|
634
|
-
const answer = answers.get(question.id);
|
|
635
|
-
addWrapped(formatAnswer(answer), {
|
|
636
|
-
firstPrefix: `${question.id}: `,
|
|
637
|
-
restPrefix: " ".repeat(visibleWidth(`${question.id}: `)),
|
|
638
|
-
style: answer ? undefined : (text) => theme.fg("warning", text),
|
|
639
|
-
preserveWhitespace: Boolean(answer?.selectedOptions),
|
|
640
|
-
});
|
|
575
|
+
function moveForwardIfAnswered(question: NormalizedQuestion) {
|
|
576
|
+
if (answers.has(question.id)) moveTab(Math.min(tab + 1, submitTab));
|
|
577
|
+
else refresh();
|
|
641
578
|
}
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
const selected = question.multiSelect ? selectedSet(question) : undefined;
|
|
652
|
-
questionStart = lines.length;
|
|
653
|
-
addWrapped(question.multiSelect ? `${question.question} (select one or more)` : question.question);
|
|
654
|
-
questionEnd = lines.length;
|
|
655
|
-
add();
|
|
656
|
-
optionStart = lines.length;
|
|
657
|
-
options.forEach((choice, index) => {
|
|
658
|
-
if (editing && index !== option) return;
|
|
659
|
-
const start = lines.length - optionStart;
|
|
660
|
-
const highlighted = index === option;
|
|
661
|
-
const checked = selected?.has(choice) ?? false;
|
|
662
|
-
const marker = question.multiSelect ? (choice === customOption() ? "✎" : checked ? "☑" : "☐") : `${index + 1}.`;
|
|
663
|
-
const prefix = `${highlighted ? ">" : " "} ${marker} `;
|
|
664
|
-
addWrapped(choice, {
|
|
665
|
-
firstPrefix: prefix,
|
|
666
|
-
restPrefix: " ".repeat(prefix.length),
|
|
667
|
-
style: (text) => theme.fg(highlighted ? "accent" : "text", text),
|
|
668
|
-
prefixStyle: (text) => highlighted && text.includes(">") ? theme.fg("accent", text) : text,
|
|
579
|
+
|
|
580
|
+
function saveSingleAnswer(answer: string, wasCustom: boolean) {
|
|
581
|
+
const question = current();
|
|
582
|
+
if (!question) return;
|
|
583
|
+
answers.set(question.id, {
|
|
584
|
+
id: question.id,
|
|
585
|
+
question: question.question,
|
|
586
|
+
answer,
|
|
587
|
+
wasCustom,
|
|
669
588
|
});
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
if (answer && !editing) {
|
|
674
|
-
const custom = question.multiSelect ? customAnswers.get(question.id) : undefined;
|
|
675
|
-
const summary = custom && selected?.has(custom) ? `${custom} (${selected.size} selected)` : answer.answer;
|
|
676
|
-
const preview = `Current answer: ${summary.replace(/\s+/g, " ")}`;
|
|
677
|
-
add(theme.fg("muted", truncateToWidth(preview, width, "…")));
|
|
589
|
+
stateChanged();
|
|
590
|
+
if (questions.length === 1) finish(false);
|
|
591
|
+
else moveTab(Math.min(tab + 1, submitTab));
|
|
678
592
|
}
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
const
|
|
682
|
-
const
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
593
|
+
|
|
594
|
+
editor.onSubmit = (value) => {
|
|
595
|
+
const answer = clean(value);
|
|
596
|
+
const question = current();
|
|
597
|
+
editing = false;
|
|
598
|
+
editor.setText("");
|
|
599
|
+
if (!answer || !question) {
|
|
600
|
+
stateChanged();
|
|
601
|
+
refresh();
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
if (question.multiSelect) saveMultiCustomAnswer(question, answer);
|
|
605
|
+
else saveSingleAnswer(answer, true);
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
function handleInput(data: string) {
|
|
609
|
+
if (finished) return;
|
|
610
|
+
markInteraction();
|
|
611
|
+
if (editing) {
|
|
612
|
+
if (maxQuestionScroll && matchesKey(data, Key.shift("pageUp"))) {
|
|
613
|
+
questionScroll = Math.max(0, questionScroll - Math.max(1, questionPageRows - 1));
|
|
614
|
+
refresh();
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
if (maxQuestionScroll && matchesKey(data, Key.shift("pageDown"))) {
|
|
618
|
+
questionScroll = Math.min(
|
|
619
|
+
maxQuestionScroll,
|
|
620
|
+
questionScroll + Math.max(1, questionPageRows - 1),
|
|
621
|
+
);
|
|
622
|
+
refresh();
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
if (keys.matches(data, "tui.select.cancel")) {
|
|
626
|
+
editing = false;
|
|
627
|
+
editor.setText("");
|
|
628
|
+
stateChanged();
|
|
629
|
+
refresh();
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
editor.handleInput(data);
|
|
633
|
+
refresh();
|
|
634
|
+
return;
|
|
688
635
|
}
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
636
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
637
|
+
if (showTabs && (matchesKey(data, Key.right) || keys.matches(data, "tui.input.tab")))
|
|
638
|
+
return moveTab(tab + 1);
|
|
639
|
+
if (showTabs && (matchesKey(data, Key.left) || matchesKey(data, Key.shift("tab"))))
|
|
640
|
+
return moveTab(tab - 1);
|
|
641
|
+
if (keys.matches(data, "tui.select.cancel")) return finish(true);
|
|
642
|
+
// Fullscreen reserves plain Page keys for transcript scrolling.
|
|
643
|
+
const pageUp =
|
|
644
|
+
keys.matches(data, "tui.select.pageUp") || matchesKey(data, Key.shift("pageUp"));
|
|
645
|
+
const pageDown =
|
|
646
|
+
keys.matches(data, "tui.select.pageDown") || matchesKey(data, Key.shift("pageDown"));
|
|
647
|
+
|
|
648
|
+
if (tab === submitTab) {
|
|
649
|
+
if (maxReviewScroll && pageUp) {
|
|
650
|
+
reviewScroll = Math.max(
|
|
651
|
+
0,
|
|
652
|
+
(reviewScroll ?? maxReviewScroll) - Math.max(1, reviewPageRows - 1),
|
|
653
|
+
);
|
|
654
|
+
refresh();
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
if (maxReviewScroll && pageDown) {
|
|
658
|
+
reviewScroll = Math.min(
|
|
659
|
+
maxReviewScroll,
|
|
660
|
+
(reviewScroll ?? maxReviewScroll) + Math.max(1, reviewPageRows - 1),
|
|
661
|
+
);
|
|
662
|
+
refresh();
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
if (keys.matches(data, "tui.select.confirm") && allAnswered()) finish(false);
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
722
668
|
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
669
|
+
const question = current();
|
|
670
|
+
if (!question) return;
|
|
671
|
+
const options = choices();
|
|
672
|
+
if ((maxQuestionScroll || maxLabelScroll) && pageUp) {
|
|
673
|
+
if (labelScroll > 0) labelScroll = Math.max(0, labelScroll - Math.max(1, pageRows - 1));
|
|
674
|
+
else questionScroll = Math.max(0, questionScroll - Math.max(1, questionPageRows - 1));
|
|
675
|
+
refresh();
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
if ((maxQuestionScroll || maxLabelScroll) && pageDown) {
|
|
679
|
+
if (questionScroll < maxQuestionScroll)
|
|
680
|
+
questionScroll = Math.min(
|
|
681
|
+
maxQuestionScroll,
|
|
682
|
+
questionScroll + Math.max(1, questionPageRows - 1),
|
|
683
|
+
);
|
|
684
|
+
else labelScroll = Math.min(maxLabelScroll, labelScroll + Math.max(1, pageRows - 1));
|
|
685
|
+
refresh();
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
if (keys.matches(data, "tui.select.up")) {
|
|
689
|
+
option = Math.max(0, option - 1);
|
|
690
|
+
labelScroll = 0;
|
|
691
|
+
refresh();
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
if (keys.matches(data, "tui.select.down")) {
|
|
695
|
+
option = Math.min(options.length - 1, option + 1);
|
|
696
|
+
labelScroll = 0;
|
|
697
|
+
refresh();
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
if (question.multiSelect && matchesKey(data, Key.space)) {
|
|
701
|
+
const picked = options[option];
|
|
702
|
+
if (picked === customOption()) startCustomEdit(question);
|
|
703
|
+
else if (picked) toggleMultiChoice(question, picked);
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
if (keys.matches(data, "tui.select.confirm")) {
|
|
707
|
+
const picked = options[option];
|
|
708
|
+
if (picked === customOption()) {
|
|
709
|
+
startCustomEdit(question);
|
|
710
|
+
} else if (picked && question.multiSelect) {
|
|
711
|
+
moveForwardIfAnswered(question);
|
|
712
|
+
} else if (picked) {
|
|
713
|
+
saveSingleAnswer(picked, false);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
769
716
|
}
|
|
770
|
-
}
|
|
771
717
|
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
718
|
+
function render(width: number): string[] {
|
|
719
|
+
if (readingContext) {
|
|
720
|
+
const hint = truncateToWidth(
|
|
721
|
+
"ctrl+o return to question • scroll conversation",
|
|
722
|
+
width,
|
|
723
|
+
"…",
|
|
724
|
+
);
|
|
725
|
+
contextHintStart = 0;
|
|
726
|
+
contextHintEnd = 1;
|
|
727
|
+
contextHintWidth = visibleWidth(hint);
|
|
728
|
+
return [theme.fg("dim", hint)];
|
|
729
|
+
}
|
|
730
|
+
const height = tui.terminal.rows;
|
|
731
|
+
if (cachedLines && cachedWidth === width && cachedHeight === height) return cachedLines;
|
|
732
|
+
if (cachedWidth !== width) {
|
|
733
|
+
optionScroll = 0;
|
|
734
|
+
labelScroll = 0;
|
|
735
|
+
questionScroll = 0;
|
|
736
|
+
}
|
|
737
|
+
const lines: string[] = [];
|
|
738
|
+
let questionStart = 0;
|
|
739
|
+
let questionEnd = 0;
|
|
740
|
+
let optionStart = 0;
|
|
741
|
+
let optionEnd = 0;
|
|
742
|
+
let reviewStart = 0;
|
|
743
|
+
let reviewEnd = 0;
|
|
744
|
+
const optionRows: Array<{ start: number; end: number }> = [];
|
|
745
|
+
const add = (line = "") =>
|
|
746
|
+
lines.push(visibleWidth(line) > width ? sliceByColumn(line, 0, width, true) : line);
|
|
747
|
+
const addWrapped = (
|
|
748
|
+
text: string,
|
|
749
|
+
options: {
|
|
750
|
+
firstPrefix?: string;
|
|
751
|
+
restPrefix?: string;
|
|
752
|
+
style?: (text: string) => string;
|
|
753
|
+
prefixStyle?: (text: string) => string;
|
|
754
|
+
preserveWhitespace?: boolean;
|
|
755
|
+
} = {},
|
|
756
|
+
) => {
|
|
757
|
+
const firstPrefix = options.firstPrefix ?? "";
|
|
758
|
+
const restPrefix = options.restPrefix ?? "";
|
|
759
|
+
const style = options.style ?? ((value: string) => value);
|
|
760
|
+
const prefixStyle = options.prefixStyle ?? ((value: string) => value);
|
|
761
|
+
const wrap = options.preserveWhitespace ? wrapByColumn : wrapTextWithAnsi;
|
|
762
|
+
const prefixWidth = visibleWidth(firstPrefix);
|
|
763
|
+
if (prefixWidth >= width || (options.preserveWhitespace && width - prefixWidth < 2)) {
|
|
764
|
+
for (const line of wrap(`${prefixStyle(firstPrefix)}${style(text)}`, width))
|
|
765
|
+
add(line);
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
const wrapped = wrap(style(text), width - prefixWidth);
|
|
769
|
+
add(`${prefixStyle(firstPrefix)}${wrapped[0] ?? ""}`);
|
|
770
|
+
for (let index = 1; index < wrapped.length; index += 1)
|
|
771
|
+
add(`${prefixStyle(restPrefix)}${wrapped[index]}`);
|
|
772
|
+
};
|
|
773
|
+
const border = theme.fg("accent", "─".repeat(width));
|
|
774
|
+
|
|
775
|
+
add(border);
|
|
776
|
+
if (showTabs && !editing) {
|
|
777
|
+
addWrapped(
|
|
778
|
+
[
|
|
779
|
+
...questions.map((question, index) => {
|
|
780
|
+
const marker = answers.has(question.id) ? "■" : "□";
|
|
781
|
+
const label = ` ${marker} Q${index + 1} `;
|
|
782
|
+
return index === tab
|
|
783
|
+
? theme.bg("selectedBg", theme.fg("text", label))
|
|
784
|
+
: theme.fg("muted", label);
|
|
785
|
+
}),
|
|
786
|
+
tab === submitTab
|
|
787
|
+
? theme.bg("selectedBg", theme.fg("text", " Submit "))
|
|
788
|
+
: theme.fg(allAnswered() ? "success" : "dim", " Submit "),
|
|
789
|
+
].join(" "),
|
|
790
|
+
);
|
|
791
|
+
add();
|
|
792
|
+
}
|
|
784
793
|
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
794
|
+
if (tab === submitTab) {
|
|
795
|
+
addWrapped(theme.bold("Review answers"));
|
|
796
|
+
add();
|
|
797
|
+
reviewStart = lines.length;
|
|
798
|
+
for (const question of questions) {
|
|
799
|
+
const answer = answers.get(question.id);
|
|
800
|
+
addWrapped(formatAnswer(answer), {
|
|
801
|
+
firstPrefix: `${question.id}: `,
|
|
802
|
+
restPrefix: " ".repeat(visibleWidth(`${question.id}: `)),
|
|
803
|
+
...(answer ? {} : { style: (text: string) => theme.fg("warning", text) }),
|
|
804
|
+
preserveWhitespace: Boolean(answer?.selectedOptions),
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
reviewEnd = lines.length;
|
|
808
|
+
add();
|
|
809
|
+
addWrapped(
|
|
810
|
+
allAnswered()
|
|
811
|
+
? `${keyText("tui.select.confirm")} to submit`
|
|
812
|
+
: "Answer all questions before submitting",
|
|
813
|
+
{
|
|
814
|
+
style: (text) => theme.fg(allAnswered() ? "success" : "warning", text),
|
|
815
|
+
},
|
|
816
|
+
);
|
|
817
|
+
} else {
|
|
818
|
+
const question = current();
|
|
819
|
+
if (!question) return lines;
|
|
820
|
+
const options = choices();
|
|
821
|
+
const answer = answers.get(question.id);
|
|
822
|
+
const selected = question.multiSelect ? selectedSet(question) : undefined;
|
|
823
|
+
questionStart = lines.length;
|
|
824
|
+
addWrapped(
|
|
825
|
+
question.multiSelect
|
|
826
|
+
? `${question.question} (select one or more)`
|
|
827
|
+
: question.question,
|
|
828
|
+
);
|
|
829
|
+
questionEnd = lines.length;
|
|
830
|
+
add();
|
|
831
|
+
optionStart = lines.length;
|
|
832
|
+
options.forEach((choice, index) => {
|
|
833
|
+
if (editing && index !== option) return;
|
|
834
|
+
const start = lines.length - optionStart;
|
|
835
|
+
const highlighted = index === option;
|
|
836
|
+
const checked = selected?.has(choice) ?? false;
|
|
837
|
+
const marker = question.multiSelect
|
|
838
|
+
? choice === customOption()
|
|
839
|
+
? "✎"
|
|
840
|
+
: checked
|
|
841
|
+
? "☑"
|
|
842
|
+
: "☐"
|
|
843
|
+
: `${index + 1}.`;
|
|
844
|
+
const prefix = `${highlighted ? ">" : " "} ${marker} `;
|
|
845
|
+
addWrapped(choice, {
|
|
846
|
+
firstPrefix: prefix,
|
|
847
|
+
restPrefix: " ".repeat(prefix.length),
|
|
848
|
+
style: (text) => theme.fg(highlighted ? "accent" : "text", text),
|
|
849
|
+
prefixStyle: (text) =>
|
|
850
|
+
highlighted && text.includes(">") ? theme.fg("accent", text) : text,
|
|
851
|
+
});
|
|
852
|
+
optionRows.push({ start, end: lines.length - optionStart });
|
|
853
|
+
});
|
|
854
|
+
optionEnd = lines.length;
|
|
855
|
+
if (answer && !editing) {
|
|
856
|
+
const custom = question.multiSelect ? customAnswers.get(question.id) : undefined;
|
|
857
|
+
const summary =
|
|
858
|
+
custom && selected?.has(custom)
|
|
859
|
+
? `${custom} (${selected.size} selected)`
|
|
860
|
+
: answer.answer;
|
|
861
|
+
const preview = `Current answer: ${summary.replace(/\s+/g, " ")}`;
|
|
862
|
+
add(theme.fg("muted", truncateToWidth(preview, width, "…")));
|
|
863
|
+
}
|
|
864
|
+
if (editing) {
|
|
865
|
+
addWrapped(theme.fg("muted", "Your answer:"));
|
|
866
|
+
const indent = width > 1 ? " " : "";
|
|
867
|
+
const editorWidth = Math.max(1, width - visibleWidth(indent));
|
|
868
|
+
for (const line of editor.render(Math.max(3, editorWidth))) {
|
|
869
|
+
const cursorIndex = line.indexOf(CURSOR_MARKER);
|
|
870
|
+
const cursorColumn =
|
|
871
|
+
cursorIndex === -1 ? 0 : visibleWidth(line.slice(0, cursorIndex));
|
|
872
|
+
const startColumn = Math.max(0, cursorColumn - editorWidth + 1);
|
|
873
|
+
add(`${indent}${sliceByColumn(line, startColumn, editorWidth, true)}`);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
if (!editing) add();
|
|
879
|
+
addWrapped(
|
|
880
|
+
timeout.active
|
|
881
|
+
? "Auto-continues after 5 minutes without interaction."
|
|
882
|
+
: "No time limit while you answer.",
|
|
883
|
+
{
|
|
884
|
+
style: (text) => theme.fg("dim", text),
|
|
885
|
+
},
|
|
886
|
+
);
|
|
887
|
+
addWrapped(
|
|
888
|
+
editing
|
|
889
|
+
? `${keyText("tui.input.submit")} save answer • ${keyText("tui.select.cancel")} cancel edit`
|
|
890
|
+
: showTabs
|
|
891
|
+
? `←/→ or ${keyText("tui.input.tab")} questions • ${keyText("tui.select.up")}/${keyText("tui.select.down")} options • Space toggle multi-select • ${keyText("tui.select.confirm")} next/submit • ${keyText("tui.select.cancel")} cancel`
|
|
892
|
+
: `${keyText("tui.select.up")}/${keyText("tui.select.down")} options • ${keyText("tui.select.confirm")} select • ${keyText("tui.select.cancel")} cancel`,
|
|
893
|
+
{ style: (text) => theme.fg("dim", text) },
|
|
894
|
+
);
|
|
895
|
+
const contextHint = truncateToWidth("ctrl+o read context", width, "…");
|
|
896
|
+
add(
|
|
897
|
+
`${theme.fg("dim", contextHint)}${theme.fg("accent", "─".repeat(Math.max(0, width - visibleWidth(contextHint))))}`,
|
|
898
|
+
);
|
|
899
|
+
|
|
900
|
+
if (tab !== submitTab) {
|
|
901
|
+
// Row ranges exist for every rendered choice; indexing is total by construction.
|
|
902
|
+
const rowRange = (index: number): { start: number; end: number } => {
|
|
903
|
+
const range = optionRows[index];
|
|
904
|
+
if (range) return range;
|
|
905
|
+
throw new Error(`ask-question: option row ${index} is missing`);
|
|
906
|
+
};
|
|
907
|
+
const questionLines = lines.slice(questionStart, questionEnd);
|
|
908
|
+
const selectedRow = editing ? 0 : option;
|
|
909
|
+
const selectedHeight = rowRange(selectedRow).end - rowRange(selectedRow).start;
|
|
910
|
+
const room = Math.max(
|
|
911
|
+
2,
|
|
912
|
+
height - (lines.length - questionLines.length - (optionEnd - optionStart)),
|
|
913
|
+
);
|
|
914
|
+
const questionRows = Math.max(1, room - selectedHeight);
|
|
915
|
+
maxQuestionScroll = Math.max(0, questionLines.length - questionRows);
|
|
916
|
+
questionScroll = Math.min(questionScroll, maxQuestionScroll);
|
|
917
|
+
questionPageRows = questionRows;
|
|
918
|
+
if (maxQuestionScroll) {
|
|
919
|
+
lines.splice(
|
|
920
|
+
questionStart,
|
|
921
|
+
questionLines.length,
|
|
922
|
+
...questionLines.slice(questionScroll, questionScroll + questionRows),
|
|
923
|
+
);
|
|
924
|
+
const removed = questionLines.length - questionRows;
|
|
925
|
+
optionStart -= removed;
|
|
926
|
+
optionEnd -= removed;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const optionLines = lines.slice(optionStart, optionEnd);
|
|
930
|
+
const available = Math.max(1, height);
|
|
931
|
+
const visibleOptions = Math.max(1, available - (lines.length - optionLines.length));
|
|
932
|
+
let optionStatus = "";
|
|
933
|
+
if (optionLines.length > visibleOptions) {
|
|
934
|
+
// Scroll at choice boundaries so labels that fit are shown in full.
|
|
935
|
+
optionScroll = Math.min(optionScroll, selectedRow);
|
|
936
|
+
while (
|
|
937
|
+
optionScroll < selectedRow &&
|
|
938
|
+
rowRange(selectedRow).end - rowRange(optionScroll).start > visibleOptions
|
|
939
|
+
)
|
|
940
|
+
optionScroll++;
|
|
941
|
+
let end = selectedRow + 1;
|
|
942
|
+
while (
|
|
943
|
+
end < optionRows.length &&
|
|
944
|
+
rowRange(end).end - rowRange(optionScroll).start <= visibleOptions
|
|
945
|
+
)
|
|
946
|
+
end++;
|
|
947
|
+
while (
|
|
948
|
+
optionScroll > 0 &&
|
|
949
|
+
rowRange(end - 1).end - rowRange(optionScroll - 1).start <= visibleOptions
|
|
950
|
+
)
|
|
951
|
+
optionScroll--;
|
|
952
|
+
maxLabelScroll = Math.max(
|
|
953
|
+
0,
|
|
954
|
+
rowRange(selectedRow).end - rowRange(selectedRow).start - visibleOptions,
|
|
955
|
+
);
|
|
956
|
+
labelScroll = Math.min(labelScroll, maxLabelScroll);
|
|
957
|
+
pageRows = visibleOptions;
|
|
958
|
+
const startLine = rowRange(optionScroll).start + labelScroll;
|
|
959
|
+
const endLine = Math.min(rowRange(end - 1).end, startLine + visibleOptions);
|
|
960
|
+
const above = optionScroll > 0 || labelScroll > 0 ? "↑ " : "";
|
|
961
|
+
const below = endLine < optionLines.length ? " ↓" : "";
|
|
962
|
+
optionStatus = `${above}${option + 1}/${choices().length}${below}`;
|
|
963
|
+
lines.splice(
|
|
964
|
+
optionStart,
|
|
965
|
+
optionLines.length,
|
|
966
|
+
...optionLines.slice(startLine, endLine),
|
|
967
|
+
);
|
|
968
|
+
} else {
|
|
969
|
+
optionScroll = 0;
|
|
970
|
+
labelScroll = 0;
|
|
971
|
+
maxLabelScroll = 0;
|
|
972
|
+
}
|
|
973
|
+
if (maxQuestionScroll || optionStatus) {
|
|
974
|
+
const questionStatus = maxQuestionScroll
|
|
975
|
+
? `Q ${questionScroll + 1}-${Math.min(questionScroll + questionRows, questionLines.length)}/${questionLines.length}`
|
|
976
|
+
: "";
|
|
977
|
+
const read =
|
|
978
|
+
!editing && (maxQuestionScroll || maxLabelScroll)
|
|
979
|
+
? ` • ${keyText("tui.select.pageUp")}/${keyText("tui.select.pageDown")} read (Shift+Page fullscreen)`
|
|
980
|
+
: "";
|
|
981
|
+
const status =
|
|
982
|
+
editing && maxQuestionScroll
|
|
983
|
+
? `Shift+PageUp/Down question • ${questionStatus}`
|
|
984
|
+
: `${[questionStatus, optionStatus].filter(Boolean).join(" • ")}${read}`;
|
|
985
|
+
lines[optionStart - 1] = sliceByColumn(theme.fg("dim", ` ${status}`), 0, width, true);
|
|
986
|
+
}
|
|
987
|
+
} else {
|
|
988
|
+
const answerLines = lines.slice(reviewStart, reviewEnd);
|
|
989
|
+
const visibleAnswers = Math.max(1, height - (lines.length - answerLines.length));
|
|
990
|
+
const wasAtEnd = reviewScroll === undefined || reviewScroll === maxReviewScroll;
|
|
991
|
+
maxReviewScroll = Math.max(0, answerLines.length - visibleAnswers);
|
|
992
|
+
reviewScroll = wasAtEnd
|
|
993
|
+
? maxReviewScroll
|
|
994
|
+
: Math.min(reviewScroll ?? 0, maxReviewScroll);
|
|
995
|
+
reviewPageRows = visibleAnswers;
|
|
996
|
+
if (maxReviewScroll) {
|
|
997
|
+
const above = reviewScroll > 0 ? "↑ " : "";
|
|
998
|
+
const below = reviewScroll < maxReviewScroll ? " ↓" : "";
|
|
999
|
+
const read = `${keyText("tui.select.pageUp")}/${keyText("tui.select.pageDown")} read answers (Shift+Page fullscreen)`;
|
|
1000
|
+
lines[reviewStart - 1] = sliceByColumn(
|
|
1001
|
+
theme.fg("dim", ` ${above}${read}${below}`),
|
|
1002
|
+
0,
|
|
1003
|
+
width,
|
|
1004
|
+
true,
|
|
1005
|
+
);
|
|
1006
|
+
lines.splice(
|
|
1007
|
+
reviewStart,
|
|
1008
|
+
answerLines.length,
|
|
1009
|
+
...answerLines.slice(reviewScroll, reviewScroll + visibleAnswers),
|
|
1010
|
+
);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
cachedWidth = width;
|
|
1015
|
+
cachedHeight = height;
|
|
1016
|
+
cachedLines = lines;
|
|
1017
|
+
contextHintStart = lines.length - 1;
|
|
1018
|
+
contextHintEnd = lines.length;
|
|
1019
|
+
contextHintWidth = visibleWidth(contextHint);
|
|
1020
|
+
if (reservedRows !== lines.length) {
|
|
1021
|
+
reservedRows = lines.length;
|
|
1022
|
+
tui.requestRender();
|
|
1023
|
+
}
|
|
1024
|
+
return lines;
|
|
802
1025
|
}
|
|
803
|
-
|
|
1026
|
+
|
|
1027
|
+
return {
|
|
1028
|
+
get focused() {
|
|
1029
|
+
return editor.focused;
|
|
1030
|
+
},
|
|
1031
|
+
set focused(value: boolean) {
|
|
1032
|
+
if (editor.focused === value) return;
|
|
1033
|
+
editor.focused = value;
|
|
1034
|
+
cachedLines = undefined;
|
|
1035
|
+
tui.requestRender();
|
|
1036
|
+
},
|
|
1037
|
+
render,
|
|
1038
|
+
handleInput,
|
|
1039
|
+
handleMouse(event: TuiMouseEvent) {
|
|
1040
|
+
markInteraction();
|
|
1041
|
+
if (
|
|
1042
|
+
event.type === "click" &&
|
|
1043
|
+
event.button === "left" &&
|
|
1044
|
+
event.y >= contextHintStart &&
|
|
1045
|
+
event.y < contextHintEnd &&
|
|
1046
|
+
event.x < contextHintWidth
|
|
1047
|
+
) {
|
|
1048
|
+
toggleContext();
|
|
1049
|
+
return { handled: true };
|
|
1050
|
+
}
|
|
1051
|
+
return undefined;
|
|
1052
|
+
},
|
|
1053
|
+
invalidate: () => {
|
|
1054
|
+
cachedLines = undefined;
|
|
1055
|
+
},
|
|
1056
|
+
dispose: () => finish(true),
|
|
1057
|
+
};
|
|
804
1058
|
},
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
}).finally(() => disposeBridge());
|
|
1059
|
+
{
|
|
1060
|
+
overlay: true,
|
|
1061
|
+
overlayOptions: { width: "100%", maxHeight: "100%", anchor: "bottom-center" },
|
|
1062
|
+
onHandle: (handle) => {
|
|
1063
|
+
overlayHandle = handle;
|
|
1064
|
+
announceBridge();
|
|
1065
|
+
},
|
|
1066
|
+
},
|
|
1067
|
+
)
|
|
1068
|
+
.finally(() => disposeBridge());
|
|
816
1069
|
}
|
|
817
1070
|
|
|
818
1071
|
async function askWithDialogs(
|
|
@@ -830,22 +1083,31 @@ async function askWithDialogs(
|
|
|
830
1083
|
const doneOption = uniqueOptionLabel(DONE_OPTION, question.options);
|
|
831
1084
|
|
|
832
1085
|
while (true) {
|
|
833
|
-
const options = [
|
|
1086
|
+
const options = [
|
|
1087
|
+
...question.options,
|
|
1088
|
+
...selected.filter((answer) => !question.options.includes(answer)),
|
|
1089
|
+
];
|
|
834
1090
|
const labels = question.multiSelect
|
|
835
1091
|
? options.map((answer) => `${selected.includes(answer) ? "☑" : "☐"} ${answer}`)
|
|
836
1092
|
: options;
|
|
837
1093
|
const choices = [...labels, customOption];
|
|
838
1094
|
if (question.multiSelect && selected.length) choices.push(doneOption);
|
|
839
|
-
const choice = await ui.select(question.question, choices, { signal });
|
|
840
|
-
if (choice === undefined)
|
|
1095
|
+
const choice = await ui.select(question.question, choices, ...(signal ? [{ signal }] : []));
|
|
1096
|
+
if (choice === undefined)
|
|
1097
|
+
return { answers: orderedAnswers(questions, answers), cancelled: true };
|
|
841
1098
|
timeout.stop();
|
|
842
1099
|
if (choice === doneOption) break;
|
|
843
1100
|
|
|
844
1101
|
let answer = options[labels.indexOf(choice)] ?? choice;
|
|
845
1102
|
let wasCustom = false;
|
|
846
1103
|
if (choice === customOption) {
|
|
847
|
-
const input = await ui.input(
|
|
848
|
-
|
|
1104
|
+
const input = await ui.input(
|
|
1105
|
+
question.question,
|
|
1106
|
+
"Type your answer",
|
|
1107
|
+
...(signal ? [{ signal }] : []),
|
|
1108
|
+
);
|
|
1109
|
+
if (input === undefined)
|
|
1110
|
+
return { answers: orderedAnswers(questions, answers), cancelled: true };
|
|
849
1111
|
const custom = clean(input);
|
|
850
1112
|
if (!custom) continue;
|
|
851
1113
|
answer = custom;
|
|
@@ -856,13 +1118,16 @@ async function askWithDialogs(
|
|
|
856
1118
|
if (question.multiSelect && !wasCustom && index !== -1) selected.splice(index, 1);
|
|
857
1119
|
else if (index === -1) selected.push(answer);
|
|
858
1120
|
if (!selected.length) answers.delete(question.id);
|
|
859
|
-
else
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
1121
|
+
else
|
|
1122
|
+
answers.set(question.id, {
|
|
1123
|
+
id: question.id,
|
|
1124
|
+
question: question.question,
|
|
1125
|
+
answer: question.multiSelect ? selected.join(", ") : answer,
|
|
1126
|
+
...(question.multiSelect ? { selectedOptions: [...selected] } : {}),
|
|
1127
|
+
wasCustom: question.multiSelect
|
|
1128
|
+
? selected.some((answer) => !question.options.includes(answer))
|
|
1129
|
+
: wasCustom,
|
|
1130
|
+
});
|
|
866
1131
|
if (!question.multiSelect) break;
|
|
867
1132
|
}
|
|
868
1133
|
}
|
|
@@ -891,7 +1156,12 @@ function completeGrillMeArgs(prefix: string): AutocompleteItem[] | null {
|
|
|
891
1156
|
}
|
|
892
1157
|
|
|
893
1158
|
function isGrillMeState(data: unknown): data is GrillMeState {
|
|
894
|
-
return
|
|
1159
|
+
return (
|
|
1160
|
+
data !== null &&
|
|
1161
|
+
typeof data === "object" &&
|
|
1162
|
+
"enabled" in data &&
|
|
1163
|
+
typeof data.enabled === "boolean"
|
|
1164
|
+
);
|
|
895
1165
|
}
|
|
896
1166
|
|
|
897
1167
|
function restoreGrillMeState(entries: Iterable<unknown>): boolean {
|
|
@@ -899,94 +1169,114 @@ function restoreGrillMeState(entries: Iterable<unknown>): boolean {
|
|
|
899
1169
|
for (const entry of entries) {
|
|
900
1170
|
if (entry === null || typeof entry !== "object") continue;
|
|
901
1171
|
const record = entry as { type?: unknown; customType?: unknown; data?: unknown };
|
|
902
|
-
if (
|
|
1172
|
+
if (
|
|
1173
|
+
record.type === "custom" &&
|
|
1174
|
+
record.customType === GRILL_ME_STATE_TYPE &&
|
|
1175
|
+
isGrillMeState(record.data)
|
|
1176
|
+
) {
|
|
903
1177
|
enabled = record.data.enabled;
|
|
904
1178
|
}
|
|
905
1179
|
}
|
|
906
1180
|
return enabled;
|
|
907
1181
|
}
|
|
908
1182
|
|
|
909
|
-
const askQuestionTool = (pi: ExtensionAPI, observer: QuestionObserver) =>
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
"
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
? await askWithKeyboard(questions, ctx.ui, pi, observer, timeout, questionSignal)
|
|
946
|
-
: await askWithDialogs(questions, ctx.ui, timeout, questionSignal);
|
|
947
|
-
const answers = new Map(result.answers.map((answer) => [answer.id, answer]));
|
|
948
|
-
const timedOut = result.cancelled && timeoutController.signal.aborted && !signal?.aborted;
|
|
949
|
-
const cancelled = result.cancelled && !timedOut;
|
|
950
|
-
|
|
951
|
-
return {
|
|
952
|
-
content: [{ type: "text", text: summarize(questions, answers, cancelled, timedOut) }],
|
|
953
|
-
details: { questions, answers: result.answers, cancelled, timedOut } satisfies AskQuestionDetails,
|
|
1183
|
+
const askQuestionTool = (pi: ExtensionAPI, observer: QuestionObserver) =>
|
|
1184
|
+
defineTool({
|
|
1185
|
+
name: "ask_question",
|
|
1186
|
+
label: "Ask Question",
|
|
1187
|
+
description:
|
|
1188
|
+
"Ask the user one or more clarifying questions through pi's UI. After 5 minutes without interaction, returns an AFK reply so you can continue using your best judgement.",
|
|
1189
|
+
promptSnippet: "Ask the user clarifying questions through pi's UI",
|
|
1190
|
+
promptGuidelines: [
|
|
1191
|
+
"For ask_question, list options from most recommended to least; the first option is the recommended choice, and do not label it as recommended.",
|
|
1192
|
+
"For ask_question, set multiSelect:true only when the user may need to choose more than one option.",
|
|
1193
|
+
"For ask_question, a typed custom-answer option is added automatically; do not include your own.",
|
|
1194
|
+
],
|
|
1195
|
+
parameters: AskQuestionParams,
|
|
1196
|
+
executionMode: "sequential",
|
|
1197
|
+
|
|
1198
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
1199
|
+
const questions = normalize(params);
|
|
1200
|
+
if (!questions.length) throw new Error("ask_question needs either question or questions[].");
|
|
1201
|
+
if (!ctx.hasUI) throw new Error("ask_question needs Pi TUI or RPC UI support.");
|
|
1202
|
+
|
|
1203
|
+
observer.identify(ctx);
|
|
1204
|
+
const timeoutController = new AbortController();
|
|
1205
|
+
const deadline = Date.now() + QUESTION_TIMEOUT_MS;
|
|
1206
|
+
const timer = setTimeout(() => timeoutController.abort(), QUESTION_TIMEOUT_MS);
|
|
1207
|
+
let active = true;
|
|
1208
|
+
const timeout: QuestionTimeout = {
|
|
1209
|
+
deadline,
|
|
1210
|
+
get active() {
|
|
1211
|
+
return active;
|
|
1212
|
+
},
|
|
1213
|
+
stop() {
|
|
1214
|
+
if (!active) return false;
|
|
1215
|
+
active = false;
|
|
1216
|
+
clearTimeout(timer);
|
|
1217
|
+
return true;
|
|
1218
|
+
},
|
|
954
1219
|
};
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
1220
|
+
const questionSignal = signal
|
|
1221
|
+
? AbortSignal.any([signal, timeoutController.signal])
|
|
1222
|
+
: timeoutController.signal;
|
|
1223
|
+
try {
|
|
1224
|
+
const result =
|
|
1225
|
+
ctx.mode === "tui"
|
|
1226
|
+
? await askWithKeyboard(questions, ctx.ui, pi, observer, timeout, questionSignal)
|
|
1227
|
+
: await askWithDialogs(questions, ctx.ui, timeout, questionSignal);
|
|
1228
|
+
const answers = new Map(result.answers.map((answer) => [answer.id, answer]));
|
|
1229
|
+
const timedOut = result.cancelled && timeoutController.signal.aborted && !signal?.aborted;
|
|
1230
|
+
const cancelled = result.cancelled && !timedOut;
|
|
1231
|
+
|
|
1232
|
+
return {
|
|
1233
|
+
content: [{ type: "text", text: summarize(questions, answers, cancelled, timedOut) }],
|
|
1234
|
+
details: {
|
|
1235
|
+
questions,
|
|
1236
|
+
answers: result.answers,
|
|
1237
|
+
cancelled,
|
|
1238
|
+
timedOut,
|
|
1239
|
+
} satisfies AskQuestionDetails,
|
|
1240
|
+
};
|
|
1241
|
+
} finally {
|
|
1242
|
+
clearTimeout(timer);
|
|
1243
|
+
}
|
|
1244
|
+
},
|
|
959
1245
|
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
1246
|
+
renderCall(args, theme, _context) {
|
|
1247
|
+
const questions = normalize(args);
|
|
1248
|
+
const count = questions.length;
|
|
1249
|
+
const labels = questions.map((question) => question.id).join(", ");
|
|
1250
|
+
let text = theme.fg("toolTitle", theme.bold("ask_question "));
|
|
1251
|
+
text += theme.fg("muted", `${count} question${count !== 1 ? "s" : ""}`);
|
|
1252
|
+
if (labels) text += theme.fg("dim", ` (${labels})`);
|
|
1253
|
+
return new Text(text, 0, 0);
|
|
1254
|
+
},
|
|
969
1255
|
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1256
|
+
renderResult(result, _options, theme, _context) {
|
|
1257
|
+
const details = result.details as AskQuestionDetails | undefined;
|
|
1258
|
+
if (!details) {
|
|
1259
|
+
const content = result.content[0];
|
|
1260
|
+
return new Text(content?.type === "text" ? content.text : "", 0, 0);
|
|
1261
|
+
}
|
|
1262
|
+
if (details.timedOut) {
|
|
1263
|
+
const content = result.content[0];
|
|
1264
|
+
const text = theme.fg("warning", content?.type === "text" ? content.text : TIMEOUT_MESSAGE);
|
|
1265
|
+
return details.answers.some((answer) => answer.selectedOptions)
|
|
1266
|
+
? exactText(text)
|
|
1267
|
+
: new Text(text, 0, 0);
|
|
1268
|
+
}
|
|
1269
|
+
if (details.cancelled) return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
|
1270
|
+
const lines = details.questions.map((question) => {
|
|
1271
|
+
const answer = details.answers.find((entry) => entry.id === question.id);
|
|
1272
|
+
return `${theme.fg("success", "✓ ")}${theme.fg("accent", question.id)}: ${formatAnswer(answer)}`;
|
|
1273
|
+
});
|
|
1274
|
+
const text = lines.join("\n");
|
|
1275
|
+
return details.answers.some((answer) => answer.selectedOptions)
|
|
1276
|
+
? exactText(text)
|
|
1277
|
+
: new Text(text, 0, 0);
|
|
1278
|
+
},
|
|
1279
|
+
});
|
|
990
1280
|
|
|
991
1281
|
function registerGrillMe(pi: ExtensionAPI) {
|
|
992
1282
|
let grillMeMode = false;
|
|
@@ -997,7 +1287,10 @@ function registerGrillMe(pi: ExtensionAPI) {
|
|
|
997
1287
|
|
|
998
1288
|
function updateGrillMeStatus(ctx: Pick<ExtensionContext, "mode" | "ui">) {
|
|
999
1289
|
if (ctx.mode !== "tui") return;
|
|
1000
|
-
ctx.ui.setStatus(
|
|
1290
|
+
ctx.ui.setStatus(
|
|
1291
|
+
GRILL_ME_STATUS_KEY,
|
|
1292
|
+
grillMeMode ? ctx.ui.theme.fg("dim", "grill-mode") : undefined,
|
|
1293
|
+
);
|
|
1001
1294
|
}
|
|
1002
1295
|
|
|
1003
1296
|
function restoreGrillMeMode(ctx: ExtensionContext) {
|