@fitchmultz/pi-ask-question 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -1
- package/README.md +33 -96
- package/extensions/ask-question.ts +185 -9
- package/extensions/lib/question-observer.ts +93 -0
- package/package.json +8 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0 — 2026-10-31
|
|
4
|
+
|
|
5
|
+
- Keep preceding conversation text visible above TUI questions and let users read the transcript without cancelling or losing their answer.
|
|
6
|
+
- Let cooperative observers query a current TUI question after attachment, including its original deadline and runtime owner, and reconcile accepted answer IDs after a lost result without reapplying them.
|
|
7
|
+
- Rewrite the README for human readers, add a question-flow diagram, and move API and maintainer details into linked guides.
|
|
8
|
+
- Add a cooperative `pi.events` bridge for exact, correlated answers to live TUI questions, with receipts after applying the existing answer state.
|
|
9
|
+
- Preserve local keyboard/dialog behavior and the shared five-minute deadline; reject stale/duplicate replies and dispose subscriptions on completion, cancellation, timeout, and reload.
|
|
10
|
+
- Support literal custom text and exact multi-select arrays without interpreting speech as option approvals.
|
|
11
|
+
|
|
3
12
|
## 0.5.1 — 2026-10-04
|
|
4
13
|
|
|
5
14
|
- Distribute this project publicly as `@fitchmultz/pi-ask-question`, preserving the existing version line, Git installation, `ask_question` tool, and `/grill-me` behavior.
|
|
6
|
-
- Lead installation guidance with the owned scoped package
|
|
15
|
+
- Lead installation guidance with the owned scoped package.
|
|
7
16
|
- Qualify the latest stable official Pi and maintained fork main once per run, freezing their SDK and companion graphs across existing TUI/RPC, type, package, and native install checks.
|
|
8
17
|
- Add a main-only, opt-in repository npm release pipeline that requires both host qualifications before publishing.
|
|
9
18
|
|
package/README.md
CHANGED
|
@@ -1,129 +1,66 @@
|
|
|
1
|
-
# pi
|
|
1
|
+
# pi-ask-question
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
This [Pi](https://github.com/earendil-works/pi) extension asks for your decisions before the agent continues a task. Use `/grill-me` to guide the agent through unclear requirements.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
- Adds the `ask_question` custom tool
|
|
8
|
-
- Supports one question or a sequence of questions
|
|
9
|
-
- Supports ordered choices, multi-select, and typed custom answers
|
|
10
|
-
- Adds the custom-answer option automatically
|
|
11
|
-
- Returns answers in tool result text and structured `details`
|
|
12
|
-
- Stops waiting after five minutes and lets the agent continue with an AFK reply
|
|
13
|
-
- Adds `/grill-me` to toggle pressure-test mode
|
|
14
|
-
|
|
15
|
-
## Requirements
|
|
16
|
-
|
|
17
|
-
- Pi 1.0.0 or later (official releases and the maintained fork)
|
|
18
|
-
- Node.js 24 or later
|
|
5
|
+

|
|
19
6
|
|
|
20
7
|
## Install
|
|
21
8
|
|
|
22
|
-
|
|
9
|
+
Requires Pi 1.0.0+ and Node.js 24+. Supports official Pi releases and Mitch's Pi fork.
|
|
10
|
+
|
|
11
|
+
Run these commands:
|
|
23
12
|
|
|
24
13
|
```bash
|
|
25
14
|
pi install npm:@fitchmultz/pi-ask-question
|
|
15
|
+
pi
|
|
26
16
|
```
|
|
27
17
|
|
|
28
|
-
|
|
18
|
+
Use `/reload` if Pi is already open. See [Git installation](docs/reference.md#installation-and-updates) for the alternative source.
|
|
29
19
|
|
|
30
|
-
|
|
20
|
+
## Try it
|
|
31
21
|
|
|
32
|
-
|
|
33
|
-
pi install https://github.com/fitchmultz/pi-ask-question
|
|
34
|
-
```
|
|
22
|
+
Send this request to Pi:
|
|
35
23
|
|
|
36
|
-
|
|
24
|
+
> Plan a settings page. Use ask_question to ask which settings to include first.
|
|
37
25
|
|
|
38
|
-
|
|
26
|
+
Select a choice or select **Type a custom answer**. Multi-select questions let you select more than one choice.
|
|
39
27
|
|
|
40
|
-
|
|
41
|
-
pi -e ./extensions/ask-question.ts
|
|
42
|
-
```
|
|
28
|
+
Use Up/Down to move between choices. Press Enter to answer. For multi-select questions, press Space to change selected choices. Submit question sets and multi-select answers from the final review.
|
|
43
29
|
|
|
44
|
-
|
|
30
|
+
Press Ctrl+O to read the conversation, then press it again to return to the question. Your current answer stays in place.
|
|
45
31
|
|
|
46
|
-
|
|
32
|
+
Escape leaves the custom answer editor; otherwise, it cancels the question. Your editor draft stays unchanged. See [terminal controls](docs/reference.md#terminal-controls) for question navigation, long text, and custom bindings.
|
|
47
33
|
|
|
48
|
-
|
|
49
|
-
/grill-me
|
|
50
|
-
```
|
|
34
|
+
## Limits
|
|
51
35
|
|
|
52
|
-
|
|
36
|
+
Each call has one five-minute limit for all questions. At timeout, the agent receives an AFK reply and can use its best judgment.
|
|
53
37
|
|
|
54
|
-
|
|
55
|
-
/grill-me on
|
|
56
|
-
/grill-me off
|
|
57
|
-
/grill-me status
|
|
58
|
-
```
|
|
38
|
+
The tool keeps saved answers. It does not submit unsaved custom answer text or select an answer for you.
|
|
59
39
|
|
|
60
|
-
|
|
40
|
+
Question dialogs require the terminal UI or an RPC client that supports Pi dialogs. Print and JSON modes return a tool error.
|
|
61
41
|
|
|
62
|
-
##
|
|
42
|
+
## Check requirements with `/grill-me`
|
|
63
43
|
|
|
64
|
-
|
|
44
|
+
Enable the mode before your request:
|
|
65
45
|
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
"question": "Which implementation should I use?",
|
|
69
|
-
"options": ["Smallest working change", "Full refactor"],
|
|
70
|
-
"multiSelect": false
|
|
71
|
-
}
|
|
46
|
+
```text
|
|
47
|
+
/grill-me on
|
|
72
48
|
```
|
|
73
49
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
```json
|
|
77
|
-
{
|
|
78
|
-
"questions": [
|
|
79
|
-
{
|
|
80
|
-
"id": "scope",
|
|
81
|
-
"question": "What scope should I use?",
|
|
82
|
-
"options": ["Minimal", "Complete"]
|
|
83
|
-
},
|
|
84
|
-
{
|
|
85
|
-
"id": "checks",
|
|
86
|
-
"question": "Which checks should I run?",
|
|
87
|
-
"options": ["Typecheck", "Tests", "Runtime smoke"],
|
|
88
|
-
"multiSelect": true
|
|
89
|
-
}
|
|
90
|
-
]
|
|
91
|
-
}
|
|
92
|
-
```
|
|
50
|
+
The mode guides the agent to check available evidence and ask one blocking question at a time. The agent puts its suggested answer first. The agent can continue with obvious, low-risk steps.
|
|
93
51
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
- Options should be ordered from most recommended to least recommended.
|
|
97
|
-
- Do not include a custom-answer option; the UI adds one.
|
|
98
|
-
- Multi-select results include `selectedOptions` in `details.answers`; tool text and the Review tab show an unambiguous JSON array. The existing comma-joined `answer` field remains available.
|
|
99
|
-
- `ask_question` uses its full keyboard UI in TUI mode and sequential Pi dialogs in RPC mode.
|
|
100
|
-
- RPC multi-select dialogs mark selected choices with ☑ and unselected choices with ☐; choose a checked choice again to remove it, including a custom answer.
|
|
101
|
-
- TUI questions appear over the current screen, temporarily covering the footer and widgets while preserving the editor draft. Long lists scroll by complete wrapped choices with Up/Down.
|
|
102
|
-
- PageUp/PageDown let you read a question, choice, or answer review longer than the screen; Shift+PageUp/Shift+PageDown also work in fullscreen. While typing a custom answer, the list shows only that choice; Escape returns to the list.
|
|
103
|
-
- Saved answers have a one-line preview while choosing; the Review tab retains their full text.
|
|
104
|
-
- Each `ask_question` call has one five-minute limit, shared across all questions and dialogs. Answering part of a question set does not restart the timer.
|
|
105
|
-
- At timeout, the tool returns: “Mitch is currently AFK. Use your best judgement to choose the option Mitch would choose.” It reports `timedOut: true`, not cancellation, and does not select an option or invent a user answer.
|
|
106
|
-
- Saved answers and multi-select choices are preserved on timeout; unfinished custom-answer text is not submitted.
|
|
107
|
-
- The timeout closes the TUI question and stops server-side waiting in RPC mode. RPC clients are responsible for dismissing their own dialogs; Pi does not send a dismissal event.
|
|
108
|
-
- Blank RPC custom answers return to option selection, matching TUI behavior; dismissing the input cancels.
|
|
109
|
-
- Print and JSON modes return a tool error because they cannot collect user input.
|
|
110
|
-
- `/grill-me` state is saved in the current session branch and survives `/reload`.
|
|
111
|
-
- The footer status is cleared when `/grill-me` is disabled.
|
|
112
|
-
- The configured selection-cancel binding cancels and reports cancellation to the model.
|
|
113
|
-
|
|
114
|
-
## Development
|
|
52
|
+
Use `/grill-me` to toggle the mode. Use `/grill-me off` to disable the mode. Use `/grill-me status` to check the mode.
|
|
115
53
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
```
|
|
54
|
+
The terminal footer shows `grill-mode` when enabled. Pi saves the setting in your current session branch. The setting survives `/reload`.
|
|
55
|
+
|
|
56
|
+
In print and JSON modes, the agent asks questions in normal text.
|
|
120
57
|
|
|
121
|
-
|
|
58
|
+
## Reference
|
|
122
59
|
|
|
123
|
-
|
|
60
|
+
[Tool reference](docs/reference.md#tool-shape) covers input examples, result fields, and UI details. [Cooperative TUI answers](docs/reference.md#cooperative-tui-answers) documents the event protocol for trusted extensions.
|
|
124
61
|
|
|
125
|
-
|
|
62
|
+
[Development](docs/development.md) covers local tests, host compatibility, and publication. See the [changelog](CHANGELOG.md) for changes. [Report an issue](https://github.com/fitchmultz/pi-ask-question/issues) if the extension fails.
|
|
126
63
|
|
|
127
|
-
|
|
64
|
+
## License
|
|
128
65
|
|
|
129
|
-
|
|
66
|
+
[MIT](LICENSE), Mitch Fultz.
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { CURSOR_MARKER, Editor, Key, matchesKey, sliceByColumn, Text, truncateToWidth, visibleWidth, wrapTextWithAnsi, type AutocompleteItem, type Keybinding } from "@earendil-works/pi-tui";
|
|
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";
|
|
3
4
|
import { Type } from "typebox";
|
|
5
|
+
import { createQuestionObserver, type QuestionObserver } from "./lib/question-observer.ts";
|
|
4
6
|
|
|
5
7
|
type Question = {
|
|
6
8
|
id?: string;
|
|
@@ -44,6 +46,8 @@ const DONE_OPTION = "Done selecting";
|
|
|
44
46
|
const GRILL_ME_STATE_TYPE = "ask-question.grill-me";
|
|
45
47
|
const GRILL_ME_STATUS_KEY = "ask-question.grill-me";
|
|
46
48
|
const GRILL_ME_ARGUMENTS = ["on", "off", "status"];
|
|
49
|
+
const MAX_REMOTE_ANSWER_LENGTH = 16_384;
|
|
50
|
+
const MAX_REMOTE_REQUESTS = 1024;
|
|
47
51
|
|
|
48
52
|
function grillMePrompt(useTool: boolean): string {
|
|
49
53
|
return `IMPORTANT: /grill-me mode is active.
|
|
@@ -170,10 +174,23 @@ function summarize(questions: NormalizedQuestion[], answers: Map<string, Answer>
|
|
|
170
174
|
async function askWithKeyboard(
|
|
171
175
|
questions: NormalizedQuestion[],
|
|
172
176
|
ui: ExtensionContext["ui"],
|
|
177
|
+
pi: ExtensionAPI,
|
|
178
|
+
observer: QuestionObserver,
|
|
179
|
+
deadline: number,
|
|
173
180
|
signal?: AbortSignal,
|
|
174
181
|
): Promise<{ answers: Answer[]; cancelled: boolean }> {
|
|
175
182
|
if (signal?.aborted) return { answers: [], cancelled: true };
|
|
183
|
+
const promptId = randomUUID();
|
|
184
|
+
let disposeBridge = () => {};
|
|
185
|
+
let announceBridge = () => {};
|
|
186
|
+
let overlayHandle: OverlayHandle | undefined;
|
|
176
187
|
return ui.custom<{ answers: Answer[]; cancelled: boolean }>((tui, theme, keys, done) => {
|
|
188
|
+
let reservedRows = 0;
|
|
189
|
+
const widgetKey = `ask-question.${promptId}`;
|
|
190
|
+
ui.setWidget(widgetKey, () => ({
|
|
191
|
+
render: () => Array(reservedRows).fill(""),
|
|
192
|
+
invalidate() {},
|
|
193
|
+
}));
|
|
177
194
|
const answers = new Map<string, Answer>();
|
|
178
195
|
const multiAnswers = new Map<string, Set<string>>();
|
|
179
196
|
const customAnswers = new Map<string, string>();
|
|
@@ -205,6 +222,10 @@ async function askWithKeyboard(
|
|
|
205
222
|
let reviewScroll: number | undefined;
|
|
206
223
|
let reviewPageRows = 0;
|
|
207
224
|
let maxReviewScroll = 0;
|
|
225
|
+
let readingContext = false;
|
|
226
|
+
let contextHintStart = 0;
|
|
227
|
+
let contextHintEnd = 0;
|
|
228
|
+
let contextHintWidth = 0;
|
|
208
229
|
|
|
209
230
|
const submitTab = questions.length;
|
|
210
231
|
const showTabs = questions.length > 1 || questions.some((question) => question.multiSelect);
|
|
@@ -216,15 +237,130 @@ async function askWithKeyboard(
|
|
|
216
237
|
cachedLines = undefined;
|
|
217
238
|
tui.requestRender();
|
|
218
239
|
};
|
|
240
|
+
const toggleContext = () => {
|
|
241
|
+
if (!overlayHandle) return;
|
|
242
|
+
readingContext = !readingContext;
|
|
243
|
+
if (readingContext) {
|
|
244
|
+
reservedRows = 0;
|
|
245
|
+
overlayHandle.unfocus({ target: null });
|
|
246
|
+
} else {
|
|
247
|
+
overlayHandle.focus();
|
|
248
|
+
}
|
|
249
|
+
refresh();
|
|
250
|
+
};
|
|
251
|
+
const unsubscribeInput = tui.addInputListener((data) => {
|
|
252
|
+
if (matchesKey(data, Key.ctrl("o")) && (readingContext || overlayHandle?.isFocused())) {
|
|
253
|
+
toggleContext();
|
|
254
|
+
return { consume: true };
|
|
255
|
+
}
|
|
256
|
+
return undefined;
|
|
257
|
+
});
|
|
219
258
|
let finished = false;
|
|
259
|
+
let revision = 0;
|
|
260
|
+
let publishQueued = false;
|
|
261
|
+
let closed = false;
|
|
262
|
+
let announced = false;
|
|
263
|
+
const acceptedRequests = new Set<string>();
|
|
264
|
+
function waitingState() {
|
|
265
|
+
const question = current();
|
|
266
|
+
if (!announced || finished || signal?.aborted || editing || !question || Date.now() >= deadline) return undefined;
|
|
267
|
+
return {
|
|
268
|
+
status: "waiting" as const, ...observer.identity(), promptId, questionId: question.id, revision, deadline,
|
|
269
|
+
question: question.question, options: [...question.options], multiSelect: question.multiSelect,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
const unwatch = observer.watch(waitingState);
|
|
273
|
+
function publishState() {
|
|
274
|
+
const waiting = waitingState();
|
|
275
|
+
if (!waiting) {
|
|
276
|
+
if (!closed) pi.events.emit("pi-ask-question:state", { status: "closed", promptId });
|
|
277
|
+
closed = true;
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
closed = false;
|
|
281
|
+
pi.events.emit("pi-ask-question:state", waiting);
|
|
282
|
+
}
|
|
283
|
+
function stateChanged() {
|
|
284
|
+
revision += 1;
|
|
285
|
+
if (publishQueued) return;
|
|
286
|
+
publishQueued = true;
|
|
287
|
+
queueMicrotask(() => {
|
|
288
|
+
publishQueued = false;
|
|
289
|
+
publishState();
|
|
290
|
+
});
|
|
291
|
+
}
|
|
220
292
|
const finish = (cancelled: boolean) => {
|
|
221
293
|
if (finished) return;
|
|
222
|
-
|
|
223
|
-
|
|
294
|
+
if (readingContext) overlayHandle?.focus();
|
|
295
|
+
disposeBridge();
|
|
224
296
|
done({ answers: orderedAnswers(questions, answers), cancelled });
|
|
225
297
|
};
|
|
226
298
|
const abort = () => finish(true);
|
|
299
|
+
const unsubscribeAnswer = pi.events.on("pi-ask-question:answer", (data) => {
|
|
300
|
+
if (data === null || typeof data !== "object") return;
|
|
301
|
+
const request = data as Record<string, unknown>;
|
|
302
|
+
if (typeof request.requestId !== "string" || !request.requestId.trim() || request.requestId.length > 128) return;
|
|
303
|
+
// Other live owners on the shared bus must not acknowledge this prompt.
|
|
304
|
+
if (request.promptId !== promptId) return;
|
|
305
|
+
const receipt = (status: "answered" | "stale" | "invalid") =>
|
|
306
|
+
pi.events.emit("pi-ask-question:result", { requestId: request.requestId, promptId, status });
|
|
307
|
+
const question = current();
|
|
308
|
+
if (finished || signal?.aborted || editing || !question || request.questionId !== question.id ||
|
|
309
|
+
request.revision !== revision || acceptedRequests.has(request.requestId) ||
|
|
310
|
+
!observer.matches(request, deadline) || Date.now() >= deadline) {
|
|
311
|
+
receipt("stale");
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const answer = request.answer;
|
|
315
|
+
const validText = typeof answer === "string" && answer.length <= MAX_REMOTE_ANSWER_LENGTH && Boolean(clean(answer));
|
|
316
|
+
const validOptions = Array.isArray(answer) && question.multiSelect && answer.length > 0 &&
|
|
317
|
+
answer.length <= question.options.length && new Set(answer).size === answer.length &&
|
|
318
|
+
Array.from(answer).every((value) => typeof value === "string" && question.options.includes(value)) &&
|
|
319
|
+
answer.reduce((size, value) => size + value.length, 0) <= MAX_REMOTE_ANSWER_LENGTH;
|
|
320
|
+
// ponytail: bound deduplication to 1024 commits per prompt; raise with a higher-volume client.
|
|
321
|
+
if ((!validText && !validOptions) || acceptedRequests.size >= MAX_REMOTE_REQUESTS) {
|
|
322
|
+
receipt("invalid");
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const target = { promptId, questionId: question.id, revision, deadline };
|
|
326
|
+
acceptedRequests.add(request.requestId);
|
|
327
|
+
if (typeof answer === "string") {
|
|
328
|
+
if (question.multiSelect) {
|
|
329
|
+
saveMultiCustomAnswer(question, answer.trim());
|
|
330
|
+
moveForwardIfAnswered(question);
|
|
331
|
+
} else saveSingleAnswer(answer.trim(), true);
|
|
332
|
+
} else {
|
|
333
|
+
const selected = selectedSet(question);
|
|
334
|
+
selected.clear();
|
|
335
|
+
customAnswers.delete(question.id);
|
|
336
|
+
for (const choice of answer as string[]) selected.add(choice);
|
|
337
|
+
syncMultiAnswer(question);
|
|
338
|
+
moveForwardIfAnswered(question);
|
|
339
|
+
}
|
|
340
|
+
if (allAnswered()) finish(false);
|
|
341
|
+
observer.accept(target, request.requestId, answer as string | string[]);
|
|
342
|
+
receipt("answered");
|
|
343
|
+
});
|
|
344
|
+
const unsubscribeShutdown = pi.on("session_shutdown", abort);
|
|
345
|
+
disposeBridge = () => {
|
|
346
|
+
if (finished) return;
|
|
347
|
+
finished = true;
|
|
348
|
+
signal?.removeEventListener("abort", abort);
|
|
349
|
+
unwatch();
|
|
350
|
+
unsubscribeAnswer();
|
|
351
|
+
unsubscribeShutdown();
|
|
352
|
+
unsubscribeInput();
|
|
353
|
+
ui.setWidget(widgetKey, undefined);
|
|
354
|
+
publishState();
|
|
355
|
+
};
|
|
227
356
|
signal?.addEventListener("abort", abort, { once: true });
|
|
357
|
+
if (signal?.aborted) finish(true);
|
|
358
|
+
// Advertise only after the native host has installed this component.
|
|
359
|
+
announceBridge = () => {
|
|
360
|
+
announced = true;
|
|
361
|
+
if (signal?.aborted) abort();
|
|
362
|
+
else publishState();
|
|
363
|
+
};
|
|
228
364
|
|
|
229
365
|
function moveTab(next: number) {
|
|
230
366
|
tab = (next + questions.length + 1) % (questions.length + 1);
|
|
@@ -235,6 +371,7 @@ async function askWithKeyboard(
|
|
|
235
371
|
reviewScroll = undefined;
|
|
236
372
|
editing = false;
|
|
237
373
|
editor.setText("");
|
|
374
|
+
stateChanged();
|
|
238
375
|
refresh();
|
|
239
376
|
}
|
|
240
377
|
|
|
@@ -248,6 +385,7 @@ async function askWithKeyboard(
|
|
|
248
385
|
}
|
|
249
386
|
|
|
250
387
|
function syncMultiAnswer(question: NormalizedQuestion) {
|
|
388
|
+
stateChanged();
|
|
251
389
|
const selected = [...selectedSet(question)];
|
|
252
390
|
if (!selected.length) {
|
|
253
391
|
answers.delete(question.id);
|
|
@@ -280,6 +418,7 @@ async function askWithKeyboard(
|
|
|
280
418
|
labelScroll = 0;
|
|
281
419
|
optionScroll = 0;
|
|
282
420
|
questionScroll = 0;
|
|
421
|
+
stateChanged();
|
|
283
422
|
refresh();
|
|
284
423
|
}
|
|
285
424
|
|
|
@@ -302,6 +441,7 @@ async function askWithKeyboard(
|
|
|
302
441
|
const question = current();
|
|
303
442
|
if (!question) return;
|
|
304
443
|
answers.set(question.id, { id: question.id, question: question.question, answer, wasCustom });
|
|
444
|
+
stateChanged();
|
|
305
445
|
if (questions.length === 1) finish(false);
|
|
306
446
|
else moveTab(Math.min(tab + 1, submitTab));
|
|
307
447
|
}
|
|
@@ -312,6 +452,7 @@ async function askWithKeyboard(
|
|
|
312
452
|
editing = false;
|
|
313
453
|
editor.setText("");
|
|
314
454
|
if (!answer || !question) {
|
|
455
|
+
stateChanged();
|
|
315
456
|
refresh();
|
|
316
457
|
return;
|
|
317
458
|
}
|
|
@@ -320,6 +461,7 @@ async function askWithKeyboard(
|
|
|
320
461
|
};
|
|
321
462
|
|
|
322
463
|
function handleInput(data: string) {
|
|
464
|
+
if (finished) return;
|
|
323
465
|
if (editing) {
|
|
324
466
|
if (maxQuestionScroll && matchesKey(data, Key.shift("pageUp"))) {
|
|
325
467
|
questionScroll = Math.max(0, questionScroll - Math.max(1, questionPageRows - 1));
|
|
@@ -334,6 +476,7 @@ async function askWithKeyboard(
|
|
|
334
476
|
if (keys.matches(data, "tui.select.cancel")) {
|
|
335
477
|
editing = false;
|
|
336
478
|
editor.setText("");
|
|
479
|
+
stateChanged();
|
|
337
480
|
refresh();
|
|
338
481
|
return;
|
|
339
482
|
}
|
|
@@ -409,6 +552,13 @@ async function askWithKeyboard(
|
|
|
409
552
|
}
|
|
410
553
|
|
|
411
554
|
function render(width: number): string[] {
|
|
555
|
+
if (readingContext) {
|
|
556
|
+
const hint = truncateToWidth("ctrl+o return to question • scroll conversation", width, "…");
|
|
557
|
+
contextHintStart = 0;
|
|
558
|
+
contextHintEnd = 1;
|
|
559
|
+
contextHintWidth = visibleWidth(hint);
|
|
560
|
+
return [theme.fg("dim", hint)];
|
|
561
|
+
}
|
|
412
562
|
const height = tui.terminal.rows;
|
|
413
563
|
if (cachedLines && cachedWidth === width && cachedHeight === height) return cachedLines;
|
|
414
564
|
if (cachedWidth !== width) {
|
|
@@ -533,7 +683,8 @@ async function askWithKeyboard(
|
|
|
533
683
|
: `${keyText("tui.select.up")}/${keyText("tui.select.down")} options • ${keyText("tui.select.confirm")} select • ${keyText("tui.select.cancel")} cancel`,
|
|
534
684
|
{ style: (text) => theme.fg("dim", text) },
|
|
535
685
|
);
|
|
536
|
-
|
|
686
|
+
const contextHint = truncateToWidth("ctrl+o read context", width, "…");
|
|
687
|
+
add(`${theme.fg("dim", contextHint)}${theme.fg("accent", "─".repeat(Math.max(0, width - visibleWidth(contextHint))))}`);
|
|
537
688
|
|
|
538
689
|
if (tab !== submitTab) {
|
|
539
690
|
const questionLines = lines.slice(questionStart, questionEnd);
|
|
@@ -603,6 +754,13 @@ async function askWithKeyboard(
|
|
|
603
754
|
cachedWidth = width;
|
|
604
755
|
cachedHeight = height;
|
|
605
756
|
cachedLines = lines;
|
|
757
|
+
contextHintStart = lines.length - 1;
|
|
758
|
+
contextHintEnd = lines.length;
|
|
759
|
+
contextHintWidth = visibleWidth(contextHint);
|
|
760
|
+
if (reservedRows !== lines.length) {
|
|
761
|
+
reservedRows = lines.length;
|
|
762
|
+
tui.requestRender();
|
|
763
|
+
}
|
|
606
764
|
return lines;
|
|
607
765
|
}
|
|
608
766
|
|
|
@@ -616,10 +774,26 @@ async function askWithKeyboard(
|
|
|
616
774
|
},
|
|
617
775
|
render,
|
|
618
776
|
handleInput,
|
|
777
|
+
handleMouse(event: TuiMouseEvent) {
|
|
778
|
+
if (event.type === "click" && event.button === "left" &&
|
|
779
|
+
event.y >= contextHintStart && event.y < contextHintEnd &&
|
|
780
|
+
event.x < contextHintWidth) {
|
|
781
|
+
toggleContext();
|
|
782
|
+
return { handled: true };
|
|
783
|
+
}
|
|
784
|
+
return undefined;
|
|
785
|
+
},
|
|
619
786
|
invalidate: () => { cachedLines = undefined; },
|
|
620
|
-
dispose: () =>
|
|
787
|
+
dispose: () => finish(true),
|
|
621
788
|
};
|
|
622
|
-
}, {
|
|
789
|
+
}, {
|
|
790
|
+
overlay: true,
|
|
791
|
+
overlayOptions: { width: "100%", maxHeight: "100%", anchor: "bottom-center" },
|
|
792
|
+
onHandle: (handle) => {
|
|
793
|
+
overlayHandle = handle;
|
|
794
|
+
announceBridge();
|
|
795
|
+
},
|
|
796
|
+
}).finally(() => disposeBridge());
|
|
623
797
|
}
|
|
624
798
|
|
|
625
799
|
async function askWithDialogs(
|
|
@@ -711,7 +885,7 @@ function restoreGrillMeState(entries: Iterable<unknown>): boolean {
|
|
|
711
885
|
return enabled;
|
|
712
886
|
}
|
|
713
887
|
|
|
714
|
-
const askQuestionTool = defineTool({
|
|
888
|
+
const askQuestionTool = (pi: ExtensionAPI, observer: QuestionObserver) => defineTool({
|
|
715
889
|
name: "ask_question",
|
|
716
890
|
label: "Ask Question",
|
|
717
891
|
description: "Ask the user one or more clarifying questions through pi's UI. After 5 minutes, returns an AFK reply so you can continue using your best judgement.",
|
|
@@ -729,12 +903,14 @@ const askQuestionTool = defineTool({
|
|
|
729
903
|
if (!questions.length) throw new Error("ask_question needs either question or questions[].");
|
|
730
904
|
if (!ctx.hasUI) throw new Error("ask_question needs Pi TUI or RPC UI support.");
|
|
731
905
|
|
|
906
|
+
observer.identify(ctx);
|
|
732
907
|
const timeout = new AbortController();
|
|
908
|
+
const deadline = Date.now() + QUESTION_TIMEOUT_MS;
|
|
733
909
|
const timer = setTimeout(() => timeout.abort(), QUESTION_TIMEOUT_MS);
|
|
734
910
|
const questionSignal = signal ? AbortSignal.any([signal, timeout.signal]) : timeout.signal;
|
|
735
911
|
try {
|
|
736
912
|
const result = ctx.mode === "tui"
|
|
737
|
-
? await askWithKeyboard(questions, ctx.ui, questionSignal)
|
|
913
|
+
? await askWithKeyboard(questions, ctx.ui, pi, observer, deadline, questionSignal)
|
|
738
914
|
: await askWithDialogs(questions, ctx.ui, questionSignal);
|
|
739
915
|
const answers = new Map(result.answers.map((answer) => [answer.id, answer]));
|
|
740
916
|
const timedOut = result.cancelled && timeout.signal.aborted && !signal?.aborted;
|
|
@@ -838,6 +1014,6 @@ function registerGrillMe(pi: ExtensionAPI) {
|
|
|
838
1014
|
}
|
|
839
1015
|
|
|
840
1016
|
export default function askQuestion(pi: ExtensionAPI) {
|
|
841
|
-
pi.registerTool(askQuestionTool);
|
|
1017
|
+
pi.registerTool(askQuestionTool(pi, createQuestionObserver(pi)));
|
|
842
1018
|
registerGrillMe(pi);
|
|
843
1019
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
type Target = {
|
|
5
|
+
promptId: string;
|
|
6
|
+
questionId: string;
|
|
7
|
+
revision: number;
|
|
8
|
+
deadline: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type Waiting = Target & {
|
|
12
|
+
status: "waiting";
|
|
13
|
+
question: string;
|
|
14
|
+
options: string[];
|
|
15
|
+
multiSelect: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function record(data: unknown): Record<string, unknown> | undefined {
|
|
19
|
+
return data !== null && typeof data === "object" ? data as Record<string, unknown> : undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function validId(value: unknown): value is string {
|
|
23
|
+
return typeof value === "string" && Boolean(value.trim()) && value.length <= 128;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Runtime-local receipts attest application only. They are not a durable answer queue.
|
|
27
|
+
export function createQuestionObserver(pi: ExtensionAPI) {
|
|
28
|
+
const ownerGeneration = randomUUID();
|
|
29
|
+
const protocolVersion = 1;
|
|
30
|
+
const accepted = new Map<string, Target & { requestId: string; answerFingerprint: string }>();
|
|
31
|
+
let nativeRoot: string | undefined;
|
|
32
|
+
let mode: ExtensionContext["mode"] | undefined;
|
|
33
|
+
let current: (() => Waiting | undefined) | undefined;
|
|
34
|
+
|
|
35
|
+
const identity = () => ({ protocolVersion, ownerGeneration, nativeRoot });
|
|
36
|
+
const identify = (ctx: ExtensionContext) => {
|
|
37
|
+
nativeRoot = ctx.sessionManager.getSessionId();
|
|
38
|
+
mode = ctx.mode;
|
|
39
|
+
};
|
|
40
|
+
const unsubscribeQuery = pi.events.on("pi-ask-question:query", (data) => {
|
|
41
|
+
const query = record(data);
|
|
42
|
+
if (!query || !validId(query.queryId)) return;
|
|
43
|
+
const waiting = query.protocolVersion === protocolVersion && mode === "tui" ? current?.() : undefined;
|
|
44
|
+
pi.events.emit("pi-ask-question:snapshot", {
|
|
45
|
+
queryId: query.queryId, ...identity(), mode,
|
|
46
|
+
status: query.protocolVersion !== protocolVersion || mode !== "tui" ? "unsupported" : "idle",
|
|
47
|
+
...waiting,
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
const unsubscribeLookup = pi.events.on("pi-ask-question:lookup", (data) => {
|
|
51
|
+
const query = record(data);
|
|
52
|
+
if (!query || !validId(query.queryId) || !validId(query.requestId) || typeof query.promptId !== "string") return;
|
|
53
|
+
const status = query.protocolVersion !== protocolVersion || mode !== "tui" ? "unsupported"
|
|
54
|
+
: query.ownerGeneration !== ownerGeneration || query.nativeRoot !== nativeRoot ? "stale" : undefined;
|
|
55
|
+
const receipt = status ? undefined : accepted.get(JSON.stringify([query.promptId, query.requestId]));
|
|
56
|
+
pi.events.emit("pi-ask-question:lookup-result", {
|
|
57
|
+
queryId: query.queryId, ...identity(), promptId: query.promptId, requestId: query.requestId,
|
|
58
|
+
status: status ?? (receipt ? "answered" : "unknown"), ...receipt,
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
pi.on("session_start", (_event, ctx) => { identify(ctx); });
|
|
62
|
+
pi.on("session_shutdown", () => {
|
|
63
|
+
current = undefined;
|
|
64
|
+
accepted.clear();
|
|
65
|
+
unsubscribeQuery();
|
|
66
|
+
unsubscribeLookup();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
identify,
|
|
71
|
+
identity,
|
|
72
|
+
watch(getState: () => Waiting | undefined) {
|
|
73
|
+
current = getState;
|
|
74
|
+
return () => { if (current === getState) current = undefined; };
|
|
75
|
+
},
|
|
76
|
+
matches(request: Record<string, unknown>, deadline: number) {
|
|
77
|
+
// Existing unversioned clients keep their original prompt/revision contract.
|
|
78
|
+
return request.protocolVersion === undefined ||
|
|
79
|
+
(request.protocolVersion === protocolVersion && request.ownerGeneration === ownerGeneration &&
|
|
80
|
+
request.nativeRoot === nativeRoot && request.deadline === deadline);
|
|
81
|
+
},
|
|
82
|
+
accept(target: Target, requestId: string, answer: string | string[]) {
|
|
83
|
+
const answerFingerprint = createHash("sha256")
|
|
84
|
+
.update(JSON.stringify([protocolVersion, ownerGeneration, nativeRoot, target.promptId,
|
|
85
|
+
target.questionId, target.revision, target.deadline, answer]))
|
|
86
|
+
.digest("hex");
|
|
87
|
+
accepted.set(JSON.stringify([target.promptId, requestId]), { ...target, requestId, answerFingerprint });
|
|
88
|
+
if (accepted.size > 1024) accepted.delete(accepted.keys().next().value!);
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type QuestionObserver = ReturnType<typeof createQuestionObserver>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fitchmultz/pi-ask-question",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Pi extension that adds an interactive ask_question tool and /grill-me pressure-test mode",
|
|
5
5
|
"author": "Mitch Fultz (https://github.com/fitchmultz)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -63,8 +63,8 @@
|
|
|
63
63
|
}
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
|
-
"@earendil-works/pi-coding-agent": "1.
|
|
67
|
-
"@earendil-works/pi-tui": "1.
|
|
66
|
+
"@earendil-works/pi-coding-agent": "1.4.0",
|
|
67
|
+
"@earendil-works/pi-tui": "1.4.0",
|
|
68
68
|
"@types/node": "24.19.1",
|
|
69
69
|
"typebox": "1.3.34",
|
|
70
70
|
"typescript": "7.0.2"
|
|
@@ -72,5 +72,10 @@
|
|
|
72
72
|
"packageManager": "npm@12.2.0",
|
|
73
73
|
"publishConfig": {
|
|
74
74
|
"access": "public"
|
|
75
|
+
},
|
|
76
|
+
"overrides": {
|
|
77
|
+
"@earendil-works/pi-coding-agent": {
|
|
78
|
+
"undici": "8.12.3"
|
|
79
|
+
}
|
|
75
80
|
}
|
|
76
81
|
}
|