@fitchmultz/pi-ask-question 0.6.5 → 0.6.6

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,1344 +1,11 @@
1
- import { randomUUID } from "node:crypto";
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";
22
- import { Type } from "typebox";
23
- import { createQuestionObserver, type QuestionObserver } from "./lib/question-observer.ts";
1
+ /** pi extension entry: registers the ask_question tool and /grill-me mode. */
24
2
 
25
- type Question = {
26
- id?: string;
27
- question: string;
28
- options?: string[];
29
- multiSelect?: boolean;
30
- };
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { registerGrillMe } from "./lib/grill-me.ts";
5
+ import { createQuestionObserver } from "./lib/question-observer.ts";
6
+ import { createAskQuestionTool } from "./lib/tool.ts";
31
7
 
32
- type NormalizedQuestion = {
33
- id: string;
34
- question: string;
35
- options: string[];
36
- multiSelect: boolean;
37
- };
38
-
39
- type Answer = {
40
- id: string;
41
- question: string;
42
- answer: string;
43
- selectedOptions?: string[];
44
- wasCustom: boolean;
45
- };
46
-
47
- type AskQuestionDetails = {
48
- questions: NormalizedQuestion[];
49
- answers: Answer[];
50
- cancelled: boolean;
51
- timedOut: boolean;
52
- };
53
-
54
- type GrillMeState = {
55
- enabled: boolean;
56
- };
57
-
58
- type GrillMeAction = "toggle" | "enable" | "disable" | "status" | "invalid";
59
-
60
- type QuestionTimeout = {
61
- deadline: number;
62
- readonly active: boolean;
63
- stop(): boolean;
64
- };
65
-
66
- const QUESTION_TIMEOUT_MS = 5 * 60 * 1000;
67
- const TIMEOUT_MESSAGE =
68
- "Mitch is currently AFK. Use your best judgement to choose the option Mitch would choose.";
69
- const CUSTOM_OPTION = "Type a custom answer";
70
- const DONE_OPTION = "Done selecting";
71
- const GRILL_ME_STATE_TYPE = "ask-question.grill-me";
72
- const GRILL_ME_STATUS_KEY = "ask-question.grill-me";
73
- const GRILL_ME_ARGUMENTS = ["on", "off", "status"];
74
- const MAX_REMOTE_ANSWER_LENGTH = 16_384;
75
- const MAX_REMOTE_REQUESTS = 1024;
76
-
77
- function grillMePrompt(useTool: boolean): string {
78
- return `IMPORTANT: /grill-me mode is active.
79
-
80
- Pressure-test the user's request before doing meaningful work:
81
- - Walk the decision tree: resolve upstream choices before downstream details.
82
- - If a question can be answered by reading local files, docs, tests, or command output, inspect those sources instead of asking the user.
83
- - For non-trivial, risky, underspecified, or strategic requests, ${useTool ? "call ask_question first" : "ask clarifying questions first in normal text"}.
84
- - Ask exactly one blocking question at a time, then wait for the answer before asking the next one.
85
- - For every question, include your recommended answer as the first option and make it specific.
86
- - Do not ask when the next step is obvious, low-risk, or already constrained; proceed normally.
87
- - After the user answers, continue the work directly.`;
88
- }
89
-
90
- const QuestionSchema = Type.Object(
91
- {
92
- id: Type.Optional(
93
- Type.String({
94
- description: "Stable answer id. Defaults to question_<n>; duplicates are auto-suffixed.",
95
- }),
96
- ),
97
- question: Type.String({ minLength: 1, description: "Question to ask the user." }),
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
- ),
108
- },
109
- { additionalProperties: false },
110
- );
111
-
112
- const AskQuestionParams = Type.Object(
113
- {
114
- question: Type.Optional(Type.String({ minLength: 1, description: "Single question to ask." })),
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
- ),
132
- },
133
- { additionalProperties: false },
134
- );
135
-
136
- function clean(value: string | undefined): string | undefined {
137
- const trimmed = value?.trim();
138
- return trimmed || undefined;
139
- }
140
-
141
- function uniqueOptionLabel(label: string, options: string[]): string {
142
- let result = label;
143
- for (let suffix = 2; options.includes(result); suffix += 1) result = `${label} (${suffix})`;
144
- return result;
145
- }
146
-
147
- function normalize(params: {
148
- question?: string;
149
- options?: string[];
150
- multiSelect?: boolean;
151
- questions?: Question[];
152
- }): NormalizedQuestion[] {
153
- const raw = params.questions?.length
154
- ? params.questions
155
- : [
156
- {
157
- id: "question_1",
158
- question: params.question ?? "",
159
- options: params.options,
160
- multiSelect: params.multiSelect,
161
- },
162
- ];
163
-
164
- const assigned = new Set<string>();
165
- const result: NormalizedQuestion[] = [];
166
-
167
- for (const [index, question] of raw.entries()) {
168
- const text = clean(question.question);
169
- if (!text) continue;
170
-
171
- const baseId = clean(question.id) ?? `question_${index + 1}`;
172
- let id = baseId;
173
- let suffix = 2;
174
- while (assigned.has(id)) {
175
- id = `${baseId}_${suffix}`;
176
- suffix += 1;
177
- }
178
- assigned.add(id);
179
-
180
- result.push({
181
- id,
182
- question: text,
183
- options: (question.options ?? [])
184
- .map(clean)
185
- .filter((option): option is string => Boolean(option)),
186
- multiSelect: question.multiSelect === true,
187
- });
188
- }
189
-
190
- return result;
191
- }
192
-
193
- function orderedAnswers(questions: NormalizedQuestion[], answers: Map<string, Answer>): Answer[] {
194
- return questions
195
- .map((question) => answers.get(question.id))
196
- .filter((answer): answer is Answer => Boolean(answer));
197
- }
198
-
199
- function formatAnswer(answer?: Answer): string {
200
- if (!answer) return "unanswered";
201
- return answer.selectedOptions ? JSON.stringify(answer.selectedOptions) : answer.answer;
202
- }
203
-
204
- // Word wrapping drops spaces at line breaks; JSON answer text must keep them.
205
- function wrapByColumn(text: string, width: number): string[] {
206
- const lines: string[] = [];
207
- while (visibleWidth(text) > width) {
208
- const part = sliceByColumn(text, 0, width, true) || sliceByColumn(text, 0, width);
209
- lines.push(part);
210
- text = text.slice(part.length);
211
- }
212
- return [...lines, text];
213
- }
214
-
215
- function exactText(text: string) {
216
- return {
217
- render(width: number) {
218
- const wrapped = text
219
- .split("\n")
220
- .flatMap((line) => wrapByColumn(line, width))
221
- .join("\n");
222
- return new Text(wrapped, 0, 0).render(width);
223
- },
224
- invalidate() {},
225
- };
226
- }
227
-
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");
247
- if (cancelled) return "User cancelled the question.";
248
- const [only] = questions;
249
- if (questions.length === 1 && only) return `User answered: ${formatAnswer(answers.get(only.id))}`;
250
- return [
251
- "User answered:",
252
- ...questions.map((question) => `- ${question.id}: ${formatAnswer(answers.get(question.id))}`),
253
- ].join("\n");
254
- }
255
-
256
- async function askWithKeyboard(
257
- questions: NormalizedQuestion[],
258
- ui: ExtensionContext["ui"],
259
- pi: ExtensionAPI,
260
- observer: QuestionObserver,
261
- timeout: QuestionTimeout,
262
- signal?: AbortSignal,
263
- ): Promise<{ answers: Answer[]; cancelled: boolean }> {
264
- if (signal?.aborted) return { answers: [], cancelled: true };
265
- const promptId = randomUUID();
266
- let disposeBridge = () => {};
267
- let announceBridge = () => {};
268
- let overlayHandle: OverlayHandle | undefined;
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
- });
292
-
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
- }
333
- refresh();
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);
383
- }
384
- function markInteraction() {
385
- if (!timeout.stop()) return;
386
- refresh();
387
- publishState();
388
- }
389
- function stateChanged() {
390
- revision += 1;
391
- if (publishQueued) return;
392
- publishQueued = true;
393
- queueMicrotask(() => {
394
- publishQueued = false;
395
- publishState();
396
- });
397
- }
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;
510
- editing = false;
511
- editor.setText("");
512
- stateChanged();
513
- refresh();
514
- }
515
-
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
- }
524
-
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
- });
539
- }
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;
547
- refresh();
548
- }
549
-
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();
563
- }
564
-
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();
573
- }
574
-
575
- function moveForwardIfAnswered(question: NormalizedQuestion) {
576
- if (answers.has(question.id)) moveTab(Math.min(tab + 1, submitTab));
577
- else refresh();
578
- }
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,
588
- });
589
- stateChanged();
590
- if (questions.length === 1) finish(false);
591
- else moveTab(Math.min(tab + 1, submitTab));
592
- }
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;
635
- }
636
-
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
- }
668
-
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
- }
716
- }
717
-
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
- }
793
-
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;
1025
- }
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
- };
1058
- },
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());
1069
- }
1070
-
1071
- async function askWithDialogs(
1072
- questions: NormalizedQuestion[],
1073
- ui: ExtensionContext["ui"],
1074
- timeout: QuestionTimeout,
1075
- signal?: AbortSignal,
1076
- ): Promise<{ answers: Answer[]; cancelled: boolean }> {
1077
- const answers = new Map<string, Answer>();
1078
-
1079
- for (const question of questions) {
1080
- if (signal?.aborted) return { answers: orderedAnswers(questions, answers), cancelled: true };
1081
- const selected: string[] = [];
1082
- const customOption = uniqueOptionLabel(CUSTOM_OPTION, question.options);
1083
- const doneOption = uniqueOptionLabel(DONE_OPTION, question.options);
1084
-
1085
- while (true) {
1086
- const options = [
1087
- ...question.options,
1088
- ...selected.filter((answer) => !question.options.includes(answer)),
1089
- ];
1090
- const labels = question.multiSelect
1091
- ? options.map((answer) => `${selected.includes(answer) ? "☑" : "☐"} ${answer}`)
1092
- : options;
1093
- const choices = [...labels, customOption];
1094
- if (question.multiSelect && selected.length) choices.push(doneOption);
1095
- const choice = await ui.select(question.question, choices, ...(signal ? [{ signal }] : []));
1096
- if (choice === undefined)
1097
- return { answers: orderedAnswers(questions, answers), cancelled: true };
1098
- timeout.stop();
1099
- if (choice === doneOption) break;
1100
-
1101
- let answer = options[labels.indexOf(choice)] ?? choice;
1102
- let wasCustom = false;
1103
- if (choice === customOption) {
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 };
1111
- const custom = clean(input);
1112
- if (!custom) continue;
1113
- answer = custom;
1114
- wasCustom = true;
1115
- }
1116
-
1117
- const index = selected.indexOf(answer);
1118
- if (question.multiSelect && !wasCustom && index !== -1) selected.splice(index, 1);
1119
- else if (index === -1) selected.push(answer);
1120
- if (!selected.length) answers.delete(question.id);
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
- });
1131
- if (!question.multiSelect) break;
1132
- }
1133
- }
1134
-
1135
- return { answers: orderedAnswers(questions, answers), cancelled: false };
1136
- }
1137
-
1138
- function parseGrillMeAction(args: string): GrillMeAction {
1139
- switch (args.trim().toLowerCase()) {
1140
- case "":
1141
- return "toggle";
1142
- case "on":
1143
- return "enable";
1144
- case "off":
1145
- return "disable";
1146
- case "status":
1147
- return "status";
1148
- default:
1149
- return "invalid";
1150
- }
1151
- }
1152
-
1153
- function completeGrillMeArgs(prefix: string): AutocompleteItem[] | null {
1154
- const matches = GRILL_ME_ARGUMENTS.filter((arg) => arg.startsWith(prefix.trim().toLowerCase()));
1155
- return matches.length ? matches.map((value) => ({ value, label: value })) : null;
1156
- }
1157
-
1158
- function isGrillMeState(data: unknown): data is GrillMeState {
1159
- return (
1160
- data !== null &&
1161
- typeof data === "object" &&
1162
- "enabled" in data &&
1163
- typeof data.enabled === "boolean"
1164
- );
1165
- }
1166
-
1167
- function restoreGrillMeState(entries: Iterable<unknown>): boolean {
1168
- let enabled = false;
1169
- for (const entry of entries) {
1170
- if (entry === null || typeof entry !== "object") continue;
1171
- const record = entry as { type?: unknown; customType?: unknown; data?: unknown };
1172
- if (
1173
- record.type === "custom" &&
1174
- record.customType === GRILL_ME_STATE_TYPE &&
1175
- isGrillMeState(record.data)
1176
- ) {
1177
- enabled = record.data.enabled;
1178
- }
1179
- }
1180
- return enabled;
1181
- }
1182
-
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
- },
1219
- };
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
- },
1245
-
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
- },
1255
-
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
- });
1280
-
1281
- function registerGrillMe(pi: ExtensionAPI) {
1282
- let grillMeMode = false;
1283
-
1284
- function persistGrillMeMode() {
1285
- pi.appendEntry<GrillMeState>(GRILL_ME_STATE_TYPE, { enabled: grillMeMode });
1286
- }
1287
-
1288
- function updateGrillMeStatus(ctx: Pick<ExtensionContext, "mode" | "ui">) {
1289
- if (ctx.mode !== "tui") return;
1290
- ctx.ui.setStatus(
1291
- GRILL_ME_STATUS_KEY,
1292
- grillMeMode ? ctx.ui.theme.fg("dim", "grill-mode") : undefined,
1293
- );
1294
- }
1295
-
1296
- function restoreGrillMeMode(ctx: ExtensionContext) {
1297
- grillMeMode = restoreGrillMeState(ctx.sessionManager.getBranch());
1298
- }
1299
-
1300
- pi.registerCommand("grill-me", {
1301
- description: "Toggle a mode that makes the agent pressure-test requests with ask_question",
1302
- getArgumentCompletions: completeGrillMeArgs,
1303
- handler: async (args, ctx) => {
1304
- const action = parseGrillMeAction(args);
1305
- if (action === "enable") grillMeMode = true;
1306
- else if (action === "disable") grillMeMode = false;
1307
- else if (action === "toggle") grillMeMode = !grillMeMode;
1308
- else if (action === "invalid") {
1309
- ctx.ui.notify("Usage: /grill-me [on|off|status]", "warning");
1310
- return;
1311
- }
1312
-
1313
- if (action !== "status") persistGrillMeMode();
1314
- updateGrillMeStatus(ctx);
1315
- ctx.ui.notify(`Grill-me mode ${grillMeMode ? "enabled" : "disabled"}`, "info");
1316
- },
1317
- });
1318
-
1319
- pi.on("session_start", async (_event, ctx) => {
1320
- restoreGrillMeMode(ctx);
1321
- updateGrillMeStatus(ctx);
1322
- });
1323
-
1324
- pi.on("session_tree", async (_event, ctx) => {
1325
- restoreGrillMeMode(ctx);
1326
- updateGrillMeStatus(ctx);
1327
- });
1328
-
1329
- pi.on("before_agent_start", async (event, ctx) => {
1330
- if (!grillMeMode) return undefined;
1331
- const useTool = ctx.hasUI && pi.getActiveTools().includes("ask_question");
1332
- const guidance = grillMePrompt(useTool);
1333
- if (event.systemPromptOptions.forceSystemPrompt === undefined) {
1334
- event.systemPromptOptions.sections.grill_me = guidance;
1335
- return;
1336
- }
1337
- return { systemPrompt: `${event.systemPrompt}\n\n${guidance}` };
1338
- });
1339
- }
1340
-
1341
- export default function askQuestion(pi: ExtensionAPI) {
1342
- pi.registerTool(askQuestionTool(pi, createQuestionObserver(pi)));
8
+ export default function askQuestion(pi: ExtensionAPI): void {
9
+ pi.registerTool(createAskQuestionTool(pi, createQuestionObserver(pi)));
1343
10
  registerGrillMe(pi);
1344
11
  }