@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.
@@ -1,6 +1,24 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import { CURSOR_MARKER, Editor, Key, matchesKey, sliceByColumn, Text, truncateToWidth, visibleWidth, wrapTextWithAnsi, type AutocompleteItem, type Keybinding, type OverlayHandle, type TuiMouseEvent } from "@earendil-works/pi-tui";
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 = "Mitch is currently AFK. Use your best judgement to choose the option Mitch would choose.";
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(Type.String({ description: "Stable answer id. Defaults to question_<n>; duplicates are auto-suffixed." })),
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(Type.Array(Type.String({ minLength: 1 }), { description: "Choices, ordered from most recommended to least recommended." })),
76
- multiSelect: Type.Optional(Type.Boolean({ description: "Allow selecting more than one option for this question. Defaults to false." })),
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(Type.Array(Type.String({ minLength: 1 }), { description: "Choices, ordered from most recommended to least recommended." })),
85
- multiSelect: Type.Optional(Type.Boolean({ description: "Allow selecting more than one option for the single question. Defaults to false." })),
86
- questions: Type.Optional(Type.Array(QuestionSchema, { description: "Ask several questions in order. When provided, takes precedence over the single-question fields." })),
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: { question?: string; options?: string[]; multiSelect?: boolean; questions?: Question[] }): NormalizedQuestion[] {
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
- : [{ id: "question_1", question: params.question ?? "", options: params.options, multiSelect: params.multiSelect }];
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 (let index = 0; index < raw.length; index += 1) {
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.map((question) => answers.get(question.id)).filter((answer): answer is Answer => Boolean(answer));
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.split("\n").flatMap((line) => wrapByColumn(line, width)).join("\n");
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(questions: NormalizedQuestion[], answers: Map<string, Answer>, cancelled: boolean, timedOut: boolean): string {
168
- if (timedOut) return [
169
- `Timed out after 5 minutes. ${TIMEOUT_MESSAGE}`,
170
- ...(answers.size ? ["", "Answers already provided:", ...orderedAnswers(questions, answers).map((answer) => `- ${answer.id}: ${formatAnswer(answer)}`)] : []),
171
- ].join("\n");
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
- if (questions.length === 1) return `User answered: ${formatAnswer(answers.get(questions[0].id))}`;
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.custom<{ answers: Answer[]; cancelled: boolean }>((tui, theme, keys, done) => {
194
- let reservedRows = 0;
195
- const widgetKey = `ask-question.${promptId}`;
196
- ui.setWidget(widgetKey, () => ({
197
- render: () => Array(reservedRows).fill(""),
198
- invalidate() {},
199
- }));
200
- const answers = new Map<string, Answer>();
201
- const multiAnswers = new Map<string, Set<string>>();
202
- const customAnswers = new Map<string, string>();
203
- const keyText = (binding: Keybinding) => keys.getKeys(binding).join("/");
204
- const editor = new Editor(tui, {
205
- borderColor: (text) => theme.fg("accent", text),
206
- selectList: {
207
- selectedPrefix: (text) => theme.fg("accent", text),
208
- selectedText: (text) => theme.fg("accent", text),
209
- description: (text) => theme.fg("muted", text),
210
- scrollInfo: (text) => theme.fg("dim", text),
211
- noMatch: (text) => theme.fg("warning", text),
212
- },
213
- });
214
-
215
- let tab = 0;
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
- editor.onSubmit = (value) => {
465
- const answer = clean(value);
466
- const question = current();
467
- editing = false;
468
- editor.setText("");
469
- if (!answer || !question) {
470
- stateChanged();
471
- refresh();
472
- return;
473
- }
474
- if (question.multiSelect) saveMultiCustomAnswer(question, answer);
475
- else saveSingleAnswer(answer, true);
476
- };
477
-
478
- function handleInput(data: string) {
479
- if (finished) return;
480
- markInteraction();
481
- if (editing) {
482
- if (maxQuestionScroll && matchesKey(data, Key.shift("pageUp"))) {
483
- questionScroll = Math.max(0, questionScroll - Math.max(1, questionPageRows - 1));
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
- return;
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
- if (maxQuestionScroll && matchesKey(data, Key.shift("pageDown"))) {
488
- questionScroll = Math.min(maxQuestionScroll, questionScroll + Math.max(1, questionPageRows - 1));
384
+ function markInteraction() {
385
+ if (!timeout.stop()) return;
489
386
  refresh();
490
- return;
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
- if (keys.matches(data, "tui.select.cancel")) {
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
- if (showTabs && (matchesKey(data, Key.right) || keys.matches(data, "tui.input.tab"))) return moveTab(tab + 1);
505
- if (showTabs && (matchesKey(data, Key.left) || matchesKey(data, Key.shift("tab")))) return moveTab(tab - 1);
506
- if (keys.matches(data, "tui.select.cancel")) return finish(true);
507
- // Fullscreen reserves plain Page keys for transcript scrolling.
508
- const pageUp = keys.matches(data, "tui.select.pageUp") || matchesKey(data, Key.shift("pageUp"));
509
- const pageDown = keys.matches(data, "tui.select.pageDown") || matchesKey(data, Key.shift("pageDown"));
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
- if (tab === submitTab) {
512
- if (maxReviewScroll && pageUp) {
513
- reviewScroll = Math.max(0, (reviewScroll ?? maxReviewScroll) - Math.max(1, reviewPageRows - 1));
514
- refresh();
515
- return;
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
- if (maxReviewScroll && pageDown) {
518
- reviewScroll = Math.min(maxReviewScroll, (reviewScroll ?? maxReviewScroll) + Math.max(1, reviewPageRows - 1));
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
- const question = current();
527
- const options = choices();
528
- if ((maxQuestionScroll || maxLabelScroll) && pageUp) {
529
- if (labelScroll > 0) labelScroll = Math.max(0, labelScroll - Math.max(1, pageRows - 1));
530
- else questionScroll = Math.max(0, questionScroll - Math.max(1, questionPageRows - 1));
531
- refresh();
532
- return;
533
- }
534
- if ((maxQuestionScroll || maxLabelScroll) && pageDown) {
535
- if (questionScroll < maxQuestionScroll) questionScroll = Math.min(maxQuestionScroll, questionScroll + Math.max(1, questionPageRows - 1));
536
- else labelScroll = Math.min(maxLabelScroll, labelScroll + Math.max(1, pageRows - 1));
537
- refresh();
538
- return;
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
- function render(width: number): string[] {
571
- if (readingContext) {
572
- const hint = truncateToWidth("ctrl+o return to question • scroll conversation", width, "…");
573
- contextHintStart = 0;
574
- contextHintEnd = 1;
575
- contextHintWidth = visibleWidth(hint);
576
- return [theme.fg("dim", hint)];
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
- if (tab === submitTab) {
630
- addWrapped(theme.bold("Review answers"));
631
- add();
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
- reviewEnd = lines.length;
643
- add();
644
- addWrapped(allAnswered() ? `${keyText("tui.select.confirm")} to submit` : "Answer all questions before submitting", {
645
- style: (text) => theme.fg(allAnswered() ? "success" : "warning", text),
646
- });
647
- } else {
648
- const question = current();
649
- const options = choices();
650
- const answer = answers.get(question.id);
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
- optionRows.push({ start, end: lines.length - optionStart });
671
- });
672
- optionEnd = lines.length;
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
- if (editing) {
680
- addWrapped(theme.fg("muted", "Your answer:"));
681
- const indent = width > 1 ? " " : "";
682
- const editorWidth = Math.max(1, width - visibleWidth(indent));
683
- for (const line of editor.render(Math.max(3, editorWidth))) {
684
- const cursorIndex = line.indexOf(CURSOR_MARKER);
685
- const cursorColumn = cursorIndex === -1 ? 0 : visibleWidth(line.slice(0, cursorIndex));
686
- const startColumn = Math.max(0, cursorColumn - editorWidth + 1);
687
- add(`${indent}${sliceByColumn(line, startColumn, editorWidth, true)}`);
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
- if (!editing) add();
693
- addWrapped(timeout.active ? "Auto-continues after 5 minutes without interaction." : "No time limit while you answer.", {
694
- style: (text) => theme.fg("dim", text),
695
- });
696
- addWrapped(
697
- editing
698
- ? `${keyText("tui.input.submit")} save answer • ${keyText("tui.select.cancel")} cancel edit`
699
- : showTabs
700
- ? `←/→ 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`
701
- : `${keyText("tui.select.up")}/${keyText("tui.select.down")} options • ${keyText("tui.select.confirm")} select • ${keyText("tui.select.cancel")} cancel`,
702
- { style: (text) => theme.fg("dim", text) },
703
- );
704
- const contextHint = truncateToWidth("ctrl+o read context", width, "…");
705
- add(`${theme.fg("dim", contextHint)}${theme.fg("accent", "─".repeat(Math.max(0, width - visibleWidth(contextHint))))}`);
706
-
707
- if (tab !== submitTab) {
708
- const questionLines = lines.slice(questionStart, questionEnd);
709
- const selectedRow = editing ? 0 : option;
710
- const selectedHeight = optionRows[selectedRow].end - optionRows[selectedRow].start;
711
- const room = Math.max(2, height - (lines.length - questionLines.length - (optionEnd - optionStart)));
712
- const questionRows = Math.max(1, room - selectedHeight);
713
- maxQuestionScroll = Math.max(0, questionLines.length - questionRows);
714
- questionScroll = Math.min(questionScroll, maxQuestionScroll);
715
- questionPageRows = questionRows;
716
- if (maxQuestionScroll) {
717
- lines.splice(questionStart, questionLines.length, ...questionLines.slice(questionScroll, questionScroll + questionRows));
718
- const removed = questionLines.length - questionRows;
719
- optionStart -= removed;
720
- optionEnd -= removed;
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
- const optionLines = lines.slice(optionStart, optionEnd);
724
- const available = Math.max(1, height);
725
- const visibleOptions = Math.max(1, available - (lines.length - optionLines.length));
726
- let optionStatus = "";
727
- if (optionLines.length > visibleOptions) {
728
- // Scroll at choice boundaries so labels that fit are shown in full.
729
- optionScroll = Math.min(optionScroll, selectedRow);
730
- while (optionScroll < selectedRow && optionRows[selectedRow].end - optionRows[optionScroll].start > visibleOptions) optionScroll++;
731
- let end = selectedRow + 1;
732
- while (end < optionRows.length && optionRows[end].end - optionRows[optionScroll].start <= visibleOptions) end++;
733
- while (optionScroll > 0 && optionRows[end - 1].end - optionRows[optionScroll - 1].start <= visibleOptions) optionScroll--;
734
- maxLabelScroll = Math.max(0, optionRows[selectedRow].end - optionRows[selectedRow].start - visibleOptions);
735
- labelScroll = Math.min(labelScroll, maxLabelScroll);
736
- pageRows = visibleOptions;
737
- const startLine = optionRows[optionScroll].start + labelScroll;
738
- const endLine = Math.min(optionRows[end - 1].end, startLine + visibleOptions);
739
- const above = optionScroll > 0 || labelScroll > 0 ? "↑ " : "";
740
- const below = endLine < optionLines.length ? " ↓" : "";
741
- optionStatus = `${above}${option + 1}/${choices().length}${below}`;
742
- lines.splice(optionStart, optionLines.length, ...optionLines.slice(startLine, endLine));
743
- } else {
744
- optionScroll = 0;
745
- labelScroll = 0;
746
- maxLabelScroll = 0;
747
- }
748
- if (maxQuestionScroll || optionStatus) {
749
- const questionStatus = maxQuestionScroll ? `Q ${questionScroll + 1}-${Math.min(questionScroll + questionRows, questionLines.length)}/${questionLines.length}` : "";
750
- const read = !editing && (maxQuestionScroll || maxLabelScroll) ? ` • ${keyText("tui.select.pageUp")}/${keyText("tui.select.pageDown")} read (Shift+Page fullscreen)` : "";
751
- const status = editing && maxQuestionScroll
752
- ? `Shift+PageUp/Down question • ${questionStatus}`
753
- : `${[questionStatus, optionStatus].filter(Boolean).join(" • ")}${read}`;
754
- lines[optionStart - 1] = sliceByColumn(theme.fg("dim", ` ${status}`), 0, width, true);
755
- }
756
- } else {
757
- const answerLines = lines.slice(reviewStart, reviewEnd);
758
- const visibleAnswers = Math.max(1, height - (lines.length - answerLines.length));
759
- const wasAtEnd = reviewScroll === undefined || reviewScroll === maxReviewScroll;
760
- maxReviewScroll = Math.max(0, answerLines.length - visibleAnswers);
761
- reviewScroll = wasAtEnd ? maxReviewScroll : Math.min(reviewScroll ?? 0, maxReviewScroll);
762
- reviewPageRows = visibleAnswers;
763
- if (maxReviewScroll) {
764
- const above = reviewScroll > 0 ? "↑ " : "";
765
- const below = reviewScroll < maxReviewScroll ? " ↓" : "";
766
- const read = `${keyText("tui.select.pageUp")}/${keyText("tui.select.pageDown")} read answers (Shift+Page fullscreen)`;
767
- lines[reviewStart - 1] = sliceByColumn(theme.fg("dim", ` ${above}${read}${below}`), 0, width, true);
768
- lines.splice(reviewStart, answerLines.length, ...answerLines.slice(reviewScroll, reviewScroll + visibleAnswers));
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
- cachedWidth = width;
773
- cachedHeight = height;
774
- cachedLines = lines;
775
- contextHintStart = lines.length - 1;
776
- contextHintEnd = lines.length;
777
- contextHintWidth = visibleWidth(contextHint);
778
- if (reservedRows !== lines.length) {
779
- reservedRows = lines.length;
780
- tui.requestRender();
781
- }
782
- return lines;
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
- return {
786
- get focused() { return editor.focused; },
787
- set focused(value: boolean) {
788
- if (editor.focused === value) return;
789
- editor.focused = value;
790
- cachedLines = undefined;
791
- tui.requestRender();
792
- },
793
- render,
794
- handleInput,
795
- handleMouse(event: TuiMouseEvent) {
796
- markInteraction();
797
- if (event.type === "click" && event.button === "left" &&
798
- event.y >= contextHintStart && event.y < contextHintEnd &&
799
- event.x < contextHintWidth) {
800
- toggleContext();
801
- return { handled: true };
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
- return undefined;
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
- invalidate: () => { cachedLines = undefined; },
806
- dispose: () => finish(true),
807
- };
808
- }, {
809
- overlay: true,
810
- overlayOptions: { width: "100%", maxHeight: "100%", anchor: "bottom-center" },
811
- onHandle: (handle) => {
812
- overlayHandle = handle;
813
- announceBridge();
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 = [...question.options, ...selected.filter((answer) => !question.options.includes(answer))];
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) return { answers: orderedAnswers(questions, answers), cancelled: true };
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(question.question, "Type your answer", { signal });
848
- if (input === undefined) return { answers: orderedAnswers(questions, answers), cancelled: true };
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 answers.set(question.id, {
860
- id: question.id,
861
- question: question.question,
862
- answer: question.multiSelect ? selected.join(", ") : answer,
863
- ...(question.multiSelect ? { selectedOptions: [...selected] } : {}),
864
- wasCustom: question.multiSelect ? selected.some((answer) => !question.options.includes(answer)) : wasCustom,
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 data !== null && typeof data === "object" && "enabled" in data && typeof data.enabled === "boolean";
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 (record.type === "custom" && record.customType === GRILL_ME_STATE_TYPE && isGrillMeState(record.data)) {
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) => defineTool({
910
- name: "ask_question",
911
- label: "Ask Question",
912
- description: "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.",
913
- promptSnippet: "Ask the user clarifying questions through pi's UI",
914
- promptGuidelines: [
915
- "For ask_question, list options from most recommended to least; the first option is the recommended choice, and do not label it as recommended.",
916
- "For ask_question, set multiSelect:true only when the user may need to choose more than one option.",
917
- "For ask_question, a typed custom-answer option is added automatically; do not include your own.",
918
- ],
919
- parameters: AskQuestionParams,
920
- executionMode: "sequential",
921
-
922
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
923
- const questions = normalize(params);
924
- if (!questions.length) throw new Error("ask_question needs either question or questions[].");
925
- if (!ctx.hasUI) throw new Error("ask_question needs Pi TUI or RPC UI support.");
926
-
927
- observer.identify(ctx);
928
- const timeoutController = new AbortController();
929
- const deadline = Date.now() + QUESTION_TIMEOUT_MS;
930
- const timer = setTimeout(() => timeoutController.abort(), QUESTION_TIMEOUT_MS);
931
- let active = true;
932
- const timeout: QuestionTimeout = {
933
- deadline,
934
- get active() { return active; },
935
- stop() {
936
- if (!active) return false;
937
- active = false;
938
- clearTimeout(timer);
939
- return true;
940
- },
941
- };
942
- const questionSignal = signal ? AbortSignal.any([signal, timeoutController.signal]) : timeoutController.signal;
943
- try {
944
- const result = ctx.mode === "tui"
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
- } finally {
956
- clearTimeout(timer);
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
- renderCall(args, theme, _context) {
961
- const questions = normalize(args);
962
- const count = questions.length;
963
- const labels = questions.map((question) => question.id).join(", ");
964
- let text = theme.fg("toolTitle", theme.bold("ask_question "));
965
- text += theme.fg("muted", `${count} question${count !== 1 ? "s" : ""}`);
966
- if (labels) text += theme.fg("dim", ` (${labels})`);
967
- return new Text(text, 0, 0);
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
- renderResult(result, _options, theme, _context) {
971
- const details = result.details as AskQuestionDetails | undefined;
972
- if (!details) {
973
- const content = result.content[0];
974
- return new Text(content?.type === "text" ? content.text : "", 0, 0);
975
- }
976
- if (details.timedOut) {
977
- const content = result.content[0];
978
- const text = theme.fg("warning", content?.type === "text" ? content.text : TIMEOUT_MESSAGE);
979
- return details.answers.some((answer) => answer.selectedOptions) ? exactText(text) : new Text(text, 0, 0);
980
- }
981
- if (details.cancelled) return new Text(theme.fg("warning", "Cancelled"), 0, 0);
982
- const lines = details.questions.map((question) => {
983
- const answer = details.answers.find((entry) => entry.id === question.id);
984
- return `${theme.fg("success", "✓ ")}${theme.fg("accent", question.id)}: ${formatAnswer(answer)}`;
985
- });
986
- const text = lines.join("\n");
987
- return details.answers.some((answer) => answer.selectedOptions) ? exactText(text) : new Text(text, 0, 0);
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(GRILL_ME_STATUS_KEY, grillMeMode ? ctx.ui.theme.fg("dim", "grill-mode") : undefined);
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) {