@fitchmultz/pi-ask-question 0.5.1 → 0.6.1
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 +14 -1
- package/README.md +35 -96
- package/extensions/ask-question.ts +224 -16
- package/extensions/lib/question-observer.ts +94 -0
- package/package.json +8 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.1 — 2026-10-31
|
|
4
|
+
|
|
5
|
+
- Disable the AFK timeout after TUI interaction or a submitted RPC choice, so an in-progress answer remains open.
|
|
6
|
+
|
|
7
|
+
## 0.6.0 — 2026-10-31
|
|
8
|
+
|
|
9
|
+
- Keep preceding conversation text visible above TUI questions and let users read the transcript without cancelling or losing their answer.
|
|
10
|
+
- 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.
|
|
11
|
+
- Rewrite the README for human readers, add a question-flow diagram, and move API and maintainer details into linked guides.
|
|
12
|
+
- Add a cooperative `pi.events` bridge for exact, correlated answers to live TUI questions, with receipts after applying the existing answer state.
|
|
13
|
+
- Preserve local keyboard/dialog behavior and the shared five-minute deadline; reject stale/duplicate replies and dispose subscriptions on completion, cancellation, timeout, and reload.
|
|
14
|
+
- Support literal custom text and exact multi-select arrays without interpreting speech as option approvals.
|
|
15
|
+
|
|
3
16
|
## 0.5.1 — 2026-10-04
|
|
4
17
|
|
|
5
18
|
- 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
|
|
19
|
+
- Lead installation guidance with the owned scoped package.
|
|
7
20
|
- 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
21
|
- Add a main-only, opt-in repository npm release pipeline that requires both host qualifications before publishing.
|
|
9
22
|
|
package/README.md
CHANGED
|
@@ -1,129 +1,68 @@
|
|
|
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
|
+
An untouched question times out after five minutes, and the agent receives an AFK reply. Once you interact, there is no time limit for that question or question set. Each new call starts its own timer.
|
|
53
37
|
|
|
54
|
-
|
|
55
|
-
/grill-me on
|
|
56
|
-
/grill-me off
|
|
57
|
-
/grill-me status
|
|
58
|
-
```
|
|
38
|
+
In RPC mode, Pi learns about interaction when the client submits a dialog choice; navigating an unsubmitted dialog is not visible to Pi.
|
|
59
39
|
|
|
60
|
-
|
|
40
|
+
The tool keeps saved answers. It does not submit unsaved custom answer text or select an answer for you.
|
|
61
41
|
|
|
62
|
-
|
|
42
|
+
Question dialogs require the terminal UI or an RPC client that supports Pi dialogs. Print and JSON modes return a tool error.
|
|
63
43
|
|
|
64
|
-
|
|
44
|
+
## Check requirements with `/grill-me`
|
|
65
45
|
|
|
66
|
-
|
|
67
|
-
{
|
|
68
|
-
"question": "Which implementation should I use?",
|
|
69
|
-
"options": ["Smallest working change", "Full refactor"],
|
|
70
|
-
"multiSelect": false
|
|
71
|
-
}
|
|
72
|
-
```
|
|
46
|
+
Enable the mode before your request:
|
|
73
47
|
|
|
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
|
-
}
|
|
48
|
+
```text
|
|
49
|
+
/grill-me on
|
|
92
50
|
```
|
|
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
|
+
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.
|
|
115
53
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
54
|
+
Use `/grill-me` to toggle the mode. Use `/grill-me off` to disable the mode. Use `/grill-me status` to check the mode.
|
|
55
|
+
|
|
56
|
+
The terminal footer shows `grill-mode` when enabled. Pi saves the setting in your current session branch. The setting survives `/reload`.
|
|
57
|
+
|
|
58
|
+
In print and JSON modes, the agent asks questions in normal text.
|
|
120
59
|
|
|
121
|
-
|
|
60
|
+
## Reference
|
|
122
61
|
|
|
123
|
-
|
|
62
|
+
[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
63
|
|
|
125
|
-
|
|
64
|
+
[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
65
|
|
|
127
|
-
|
|
66
|
+
## License
|
|
128
67
|
|
|
129
|
-
|
|
68
|
+
[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;
|
|
@@ -37,6 +39,12 @@ type GrillMeState = {
|
|
|
37
39
|
|
|
38
40
|
type GrillMeAction = "toggle" | "enable" | "disable" | "status" | "invalid";
|
|
39
41
|
|
|
42
|
+
type QuestionTimeout = {
|
|
43
|
+
deadline: number;
|
|
44
|
+
readonly active: boolean;
|
|
45
|
+
stop(): boolean;
|
|
46
|
+
};
|
|
47
|
+
|
|
40
48
|
const QUESTION_TIMEOUT_MS = 5 * 60 * 1000;
|
|
41
49
|
const TIMEOUT_MESSAGE = "Mitch is currently AFK. Use your best judgement to choose the option Mitch would choose.";
|
|
42
50
|
const CUSTOM_OPTION = "Type a custom answer";
|
|
@@ -44,6 +52,8 @@ const DONE_OPTION = "Done selecting";
|
|
|
44
52
|
const GRILL_ME_STATE_TYPE = "ask-question.grill-me";
|
|
45
53
|
const GRILL_ME_STATUS_KEY = "ask-question.grill-me";
|
|
46
54
|
const GRILL_ME_ARGUMENTS = ["on", "off", "status"];
|
|
55
|
+
const MAX_REMOTE_ANSWER_LENGTH = 16_384;
|
|
56
|
+
const MAX_REMOTE_REQUESTS = 1024;
|
|
47
57
|
|
|
48
58
|
function grillMePrompt(useTool: boolean): string {
|
|
49
59
|
return `IMPORTANT: /grill-me mode is active.
|
|
@@ -170,10 +180,23 @@ function summarize(questions: NormalizedQuestion[], answers: Map<string, Answer>
|
|
|
170
180
|
async function askWithKeyboard(
|
|
171
181
|
questions: NormalizedQuestion[],
|
|
172
182
|
ui: ExtensionContext["ui"],
|
|
183
|
+
pi: ExtensionAPI,
|
|
184
|
+
observer: QuestionObserver,
|
|
185
|
+
timeout: QuestionTimeout,
|
|
173
186
|
signal?: AbortSignal,
|
|
174
187
|
): Promise<{ answers: Answer[]; cancelled: boolean }> {
|
|
175
188
|
if (signal?.aborted) return { answers: [], cancelled: true };
|
|
189
|
+
const promptId = randomUUID();
|
|
190
|
+
let disposeBridge = () => {};
|
|
191
|
+
let announceBridge = () => {};
|
|
192
|
+
let overlayHandle: OverlayHandle | undefined;
|
|
176
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
|
+
}));
|
|
177
200
|
const answers = new Map<string, Answer>();
|
|
178
201
|
const multiAnswers = new Map<string, Set<string>>();
|
|
179
202
|
const customAnswers = new Map<string, string>();
|
|
@@ -205,6 +228,10 @@ async function askWithKeyboard(
|
|
|
205
228
|
let reviewScroll: number | undefined;
|
|
206
229
|
let reviewPageRows = 0;
|
|
207
230
|
let maxReviewScroll = 0;
|
|
231
|
+
let readingContext = false;
|
|
232
|
+
let contextHintStart = 0;
|
|
233
|
+
let contextHintEnd = 0;
|
|
234
|
+
let contextHintWidth = 0;
|
|
208
235
|
|
|
209
236
|
const submitTab = questions.length;
|
|
210
237
|
const showTabs = questions.length > 1 || questions.some((question) => question.multiSelect);
|
|
@@ -216,15 +243,139 @@ async function askWithKeyboard(
|
|
|
216
243
|
cachedLines = undefined;
|
|
217
244
|
tui.requestRender();
|
|
218
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
|
+
});
|
|
219
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
|
+
}
|
|
220
306
|
const finish = (cancelled: boolean) => {
|
|
221
307
|
if (finished) return;
|
|
222
|
-
|
|
223
|
-
|
|
308
|
+
if (readingContext) overlayHandle?.focus();
|
|
309
|
+
disposeBridge();
|
|
224
310
|
done({ answers: orderedAnswers(questions, answers), cancelled });
|
|
225
311
|
};
|
|
226
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
|
+
};
|
|
227
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
|
+
};
|
|
228
379
|
|
|
229
380
|
function moveTab(next: number) {
|
|
230
381
|
tab = (next + questions.length + 1) % (questions.length + 1);
|
|
@@ -235,6 +386,7 @@ async function askWithKeyboard(
|
|
|
235
386
|
reviewScroll = undefined;
|
|
236
387
|
editing = false;
|
|
237
388
|
editor.setText("");
|
|
389
|
+
stateChanged();
|
|
238
390
|
refresh();
|
|
239
391
|
}
|
|
240
392
|
|
|
@@ -248,6 +400,7 @@ async function askWithKeyboard(
|
|
|
248
400
|
}
|
|
249
401
|
|
|
250
402
|
function syncMultiAnswer(question: NormalizedQuestion) {
|
|
403
|
+
stateChanged();
|
|
251
404
|
const selected = [...selectedSet(question)];
|
|
252
405
|
if (!selected.length) {
|
|
253
406
|
answers.delete(question.id);
|
|
@@ -280,6 +433,7 @@ async function askWithKeyboard(
|
|
|
280
433
|
labelScroll = 0;
|
|
281
434
|
optionScroll = 0;
|
|
282
435
|
questionScroll = 0;
|
|
436
|
+
stateChanged();
|
|
283
437
|
refresh();
|
|
284
438
|
}
|
|
285
439
|
|
|
@@ -302,6 +456,7 @@ async function askWithKeyboard(
|
|
|
302
456
|
const question = current();
|
|
303
457
|
if (!question) return;
|
|
304
458
|
answers.set(question.id, { id: question.id, question: question.question, answer, wasCustom });
|
|
459
|
+
stateChanged();
|
|
305
460
|
if (questions.length === 1) finish(false);
|
|
306
461
|
else moveTab(Math.min(tab + 1, submitTab));
|
|
307
462
|
}
|
|
@@ -312,6 +467,7 @@ async function askWithKeyboard(
|
|
|
312
467
|
editing = false;
|
|
313
468
|
editor.setText("");
|
|
314
469
|
if (!answer || !question) {
|
|
470
|
+
stateChanged();
|
|
315
471
|
refresh();
|
|
316
472
|
return;
|
|
317
473
|
}
|
|
@@ -320,6 +476,8 @@ async function askWithKeyboard(
|
|
|
320
476
|
};
|
|
321
477
|
|
|
322
478
|
function handleInput(data: string) {
|
|
479
|
+
if (finished) return;
|
|
480
|
+
markInteraction();
|
|
323
481
|
if (editing) {
|
|
324
482
|
if (maxQuestionScroll && matchesKey(data, Key.shift("pageUp"))) {
|
|
325
483
|
questionScroll = Math.max(0, questionScroll - Math.max(1, questionPageRows - 1));
|
|
@@ -334,6 +492,7 @@ async function askWithKeyboard(
|
|
|
334
492
|
if (keys.matches(data, "tui.select.cancel")) {
|
|
335
493
|
editing = false;
|
|
336
494
|
editor.setText("");
|
|
495
|
+
stateChanged();
|
|
337
496
|
refresh();
|
|
338
497
|
return;
|
|
339
498
|
}
|
|
@@ -409,6 +568,13 @@ async function askWithKeyboard(
|
|
|
409
568
|
}
|
|
410
569
|
|
|
411
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
|
+
}
|
|
412
578
|
const height = tui.terminal.rows;
|
|
413
579
|
if (cachedLines && cachedWidth === width && cachedHeight === height) return cachedLines;
|
|
414
580
|
if (cachedWidth !== width) {
|
|
@@ -524,7 +690,9 @@ async function askWithKeyboard(
|
|
|
524
690
|
}
|
|
525
691
|
|
|
526
692
|
if (!editing) add();
|
|
527
|
-
addWrapped("Auto-continues after 5 minutes
|
|
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
|
+
});
|
|
528
696
|
addWrapped(
|
|
529
697
|
editing
|
|
530
698
|
? `${keyText("tui.input.submit")} save answer • ${keyText("tui.select.cancel")} cancel edit`
|
|
@@ -533,7 +701,8 @@ async function askWithKeyboard(
|
|
|
533
701
|
: `${keyText("tui.select.up")}/${keyText("tui.select.down")} options • ${keyText("tui.select.confirm")} select • ${keyText("tui.select.cancel")} cancel`,
|
|
534
702
|
{ style: (text) => theme.fg("dim", text) },
|
|
535
703
|
);
|
|
536
|
-
|
|
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))))}`);
|
|
537
706
|
|
|
538
707
|
if (tab !== submitTab) {
|
|
539
708
|
const questionLines = lines.slice(questionStart, questionEnd);
|
|
@@ -603,6 +772,13 @@ async function askWithKeyboard(
|
|
|
603
772
|
cachedWidth = width;
|
|
604
773
|
cachedHeight = height;
|
|
605
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
|
+
}
|
|
606
782
|
return lines;
|
|
607
783
|
}
|
|
608
784
|
|
|
@@ -616,15 +792,33 @@ async function askWithKeyboard(
|
|
|
616
792
|
},
|
|
617
793
|
render,
|
|
618
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 };
|
|
802
|
+
}
|
|
803
|
+
return undefined;
|
|
804
|
+
},
|
|
619
805
|
invalidate: () => { cachedLines = undefined; },
|
|
620
|
-
dispose: () =>
|
|
806
|
+
dispose: () => finish(true),
|
|
621
807
|
};
|
|
622
|
-
}, {
|
|
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());
|
|
623
816
|
}
|
|
624
817
|
|
|
625
818
|
async function askWithDialogs(
|
|
626
819
|
questions: NormalizedQuestion[],
|
|
627
820
|
ui: ExtensionContext["ui"],
|
|
821
|
+
timeout: QuestionTimeout,
|
|
628
822
|
signal?: AbortSignal,
|
|
629
823
|
): Promise<{ answers: Answer[]; cancelled: boolean }> {
|
|
630
824
|
const answers = new Map<string, Answer>();
|
|
@@ -644,6 +838,7 @@ async function askWithDialogs(
|
|
|
644
838
|
if (question.multiSelect && selected.length) choices.push(doneOption);
|
|
645
839
|
const choice = await ui.select(question.question, choices, { signal });
|
|
646
840
|
if (choice === undefined) return { answers: orderedAnswers(questions, answers), cancelled: true };
|
|
841
|
+
timeout.stop();
|
|
647
842
|
if (choice === doneOption) break;
|
|
648
843
|
|
|
649
844
|
let answer = options[labels.indexOf(choice)] ?? choice;
|
|
@@ -711,10 +906,10 @@ function restoreGrillMeState(entries: Iterable<unknown>): boolean {
|
|
|
711
906
|
return enabled;
|
|
712
907
|
}
|
|
713
908
|
|
|
714
|
-
const askQuestionTool = defineTool({
|
|
909
|
+
const askQuestionTool = (pi: ExtensionAPI, observer: QuestionObserver) => defineTool({
|
|
715
910
|
name: "ask_question",
|
|
716
911
|
label: "Ask Question",
|
|
717
|
-
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.",
|
|
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.",
|
|
718
913
|
promptSnippet: "Ask the user clarifying questions through pi's UI",
|
|
719
914
|
promptGuidelines: [
|
|
720
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.",
|
|
@@ -729,15 +924,28 @@ const askQuestionTool = defineTool({
|
|
|
729
924
|
if (!questions.length) throw new Error("ask_question needs either question or questions[].");
|
|
730
925
|
if (!ctx.hasUI) throw new Error("ask_question needs Pi TUI or RPC UI support.");
|
|
731
926
|
|
|
732
|
-
|
|
733
|
-
const
|
|
734
|
-
const
|
|
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;
|
|
735
943
|
try {
|
|
736
944
|
const result = ctx.mode === "tui"
|
|
737
|
-
? await askWithKeyboard(questions, ctx.ui, questionSignal)
|
|
738
|
-
: await askWithDialogs(questions, ctx.ui, questionSignal);
|
|
945
|
+
? await askWithKeyboard(questions, ctx.ui, pi, observer, timeout, questionSignal)
|
|
946
|
+
: await askWithDialogs(questions, ctx.ui, timeout, questionSignal);
|
|
739
947
|
const answers = new Map(result.answers.map((answer) => [answer.id, answer]));
|
|
740
|
-
const timedOut = result.cancelled &&
|
|
948
|
+
const timedOut = result.cancelled && timeoutController.signal.aborted && !signal?.aborted;
|
|
741
949
|
const cancelled = result.cancelled && !timedOut;
|
|
742
950
|
|
|
743
951
|
return {
|
|
@@ -838,6 +1046,6 @@ function registerGrillMe(pi: ExtensionAPI) {
|
|
|
838
1046
|
}
|
|
839
1047
|
|
|
840
1048
|
export default function askQuestion(pi: ExtensionAPI) {
|
|
841
|
-
pi.registerTool(askQuestionTool);
|
|
1049
|
+
pi.registerTool(askQuestionTool(pi, createQuestionObserver(pi)));
|
|
842
1050
|
registerGrillMe(pi);
|
|
843
1051
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
timeoutActive: boolean;
|
|
14
|
+
question: string;
|
|
15
|
+
options: string[];
|
|
16
|
+
multiSelect: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function record(data: unknown): Record<string, unknown> | undefined {
|
|
20
|
+
return data !== null && typeof data === "object" ? data as Record<string, unknown> : undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function validId(value: unknown): value is string {
|
|
24
|
+
return typeof value === "string" && Boolean(value.trim()) && value.length <= 128;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Runtime-local receipts attest application only. They are not a durable answer queue.
|
|
28
|
+
export function createQuestionObserver(pi: ExtensionAPI) {
|
|
29
|
+
const ownerGeneration = randomUUID();
|
|
30
|
+
const protocolVersion = 1;
|
|
31
|
+
const accepted = new Map<string, Target & { requestId: string; answerFingerprint: string }>();
|
|
32
|
+
let nativeRoot: string | undefined;
|
|
33
|
+
let mode: ExtensionContext["mode"] | undefined;
|
|
34
|
+
let current: (() => Waiting | undefined) | undefined;
|
|
35
|
+
|
|
36
|
+
const identity = () => ({ protocolVersion, ownerGeneration, nativeRoot });
|
|
37
|
+
const identify = (ctx: ExtensionContext) => {
|
|
38
|
+
nativeRoot = ctx.sessionManager.getSessionId();
|
|
39
|
+
mode = ctx.mode;
|
|
40
|
+
};
|
|
41
|
+
const unsubscribeQuery = pi.events.on("pi-ask-question:query", (data) => {
|
|
42
|
+
const query = record(data);
|
|
43
|
+
if (!query || !validId(query.queryId)) return;
|
|
44
|
+
const waiting = query.protocolVersion === protocolVersion && mode === "tui" ? current?.() : undefined;
|
|
45
|
+
pi.events.emit("pi-ask-question:snapshot", {
|
|
46
|
+
queryId: query.queryId, ...identity(), mode,
|
|
47
|
+
status: query.protocolVersion !== protocolVersion || mode !== "tui" ? "unsupported" : "idle",
|
|
48
|
+
...waiting,
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
const unsubscribeLookup = pi.events.on("pi-ask-question:lookup", (data) => {
|
|
52
|
+
const query = record(data);
|
|
53
|
+
if (!query || !validId(query.queryId) || !validId(query.requestId) || typeof query.promptId !== "string") return;
|
|
54
|
+
const status = query.protocolVersion !== protocolVersion || mode !== "tui" ? "unsupported"
|
|
55
|
+
: query.ownerGeneration !== ownerGeneration || query.nativeRoot !== nativeRoot ? "stale" : undefined;
|
|
56
|
+
const receipt = status ? undefined : accepted.get(JSON.stringify([query.promptId, query.requestId]));
|
|
57
|
+
pi.events.emit("pi-ask-question:lookup-result", {
|
|
58
|
+
queryId: query.queryId, ...identity(), promptId: query.promptId, requestId: query.requestId,
|
|
59
|
+
status: status ?? (receipt ? "answered" : "unknown"), ...receipt,
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
pi.on("session_start", (_event, ctx) => { identify(ctx); });
|
|
63
|
+
pi.on("session_shutdown", () => {
|
|
64
|
+
current = undefined;
|
|
65
|
+
accepted.clear();
|
|
66
|
+
unsubscribeQuery();
|
|
67
|
+
unsubscribeLookup();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
identify,
|
|
72
|
+
identity,
|
|
73
|
+
watch(getState: () => Waiting | undefined) {
|
|
74
|
+
current = getState;
|
|
75
|
+
return () => { if (current === getState) current = undefined; };
|
|
76
|
+
},
|
|
77
|
+
matches(request: Record<string, unknown>, deadline: number) {
|
|
78
|
+
// Existing unversioned clients keep their original prompt/revision contract.
|
|
79
|
+
return request.protocolVersion === undefined ||
|
|
80
|
+
(request.protocolVersion === protocolVersion && request.ownerGeneration === ownerGeneration &&
|
|
81
|
+
request.nativeRoot === nativeRoot && request.deadline === deadline);
|
|
82
|
+
},
|
|
83
|
+
accept(target: Target, requestId: string, answer: string | string[]) {
|
|
84
|
+
const answerFingerprint = createHash("sha256")
|
|
85
|
+
.update(JSON.stringify([protocolVersion, ownerGeneration, nativeRoot, target.promptId,
|
|
86
|
+
target.questionId, target.revision, target.deadline, answer]))
|
|
87
|
+
.digest("hex");
|
|
88
|
+
accepted.set(JSON.stringify([target.promptId, requestId]), { ...target, requestId, answerFingerprint });
|
|
89
|
+
if (accepted.size > 1024) accepted.delete(accepted.keys().next().value!);
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
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.1",
|
|
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
|
}
|