@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 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 and distinguish the unrelated unscoped npm 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 ask-question extension
1
+ # pi-ask-question
2
2
 
3
- A [pi](https://github.com/earendil-works/pi-mono) extension that adds `ask_question`: a model-callable tool with interactive UIs in TUI and RPC modes for asking one or more clarifying questions before work continues. It also adds `/grill-me`, a mode that makes the agent pressure-test underspecified requests before doing the work.
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
- ## What it does
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
+ ![Pi calls ask_question, shows choices, and returns your answer to the agent. A separate five-minute timeout returns an AFK reply.](.github/readme/question-flow.png)
19
6
 
20
7
  ## Install
21
8
 
22
- Install the scoped package with pi:
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
- **The unscoped npm package `pi-ask-question` is not this project and is maintained by someone else.** Use `@fitchmultz/pi-ask-question` for this repository.
18
+ Use `/reload` if Pi is already open. See [Git installation](docs/reference.md#installation-and-updates) for the alternative source.
29
19
 
30
- GitHub remains a supported fallback, including existing tags:
20
+ ## Try it
31
21
 
32
- ```bash
33
- pi install https://github.com/fitchmultz/pi-ask-question
34
- ```
22
+ Send this request to Pi:
35
23
 
36
- Use `/reload` after updating extension code on current official Pi and the maintained fork. Restart Pi after changing dependencies or the host runtime.
24
+ > Plan a settings page. Use ask_question to ask which settings to include first.
37
25
 
38
- For local development:
26
+ Select a choice or select **Type a custom answer**. Multi-select questions let you select more than one choice.
39
27
 
40
- ```bash
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
- ## Grill-me mode
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
- Toggle the mode:
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
- ```text
49
- /grill-me
50
- ```
34
+ ## Limits
51
35
 
52
- Explicit commands:
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
- ```text
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
- When enabled, the footer shows subtle `grill-mode` text, and the agent is told to walk the decision tree, inspect available repo evidence before asking, ask one blocking question at a time, and include its recommended answer first. It uses `ask_question` in TUI and RPC modes when the tool is active; in print and JSON modes, it asks in normal text instead.
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
- ## Tool shape
42
+ ## Check requirements with `/grill-me`
63
43
 
64
- Ask one question:
44
+ Enable the mode before your request:
65
45
 
66
- ```json
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
- Ask several questions:
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
- ## Behavior notes
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
- ```bash
117
- npm install --ignore-scripts
118
- npm run check:compat # all behavior tests + typecheck + pack dry-run
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
- The lockfile is a reproducible development snapshot, not a release-qualification target. CI resolves the latest stable official Pi and the maintained fork's `main` commit once per run, then freezes those identities across tests, types, packing, and native Git/npm CLI checks. Shared automation selects each host's actual SDK and companion dependency graph; tests resolve that selected host from the isolated checkout's `node_modules`. Runtime host peers remain wildcard as required by Pi packages, and no production build or `prepare` is needed. Both default fullscreen and regular mode use the host renderer; no additional fork TUI APIs are required.
58
+ ## Reference
122
59
 
123
- The repository's main-only `npm-release.yml` pipeline publishes intentional stable version bumps with nonempty versioned changelog notes only after both frozen host qualifications pass. Publishing stays off unless the repository variable `NPM_RELEASE_ENABLED` is `true`; release planning and publishing use the same host inputs.
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
- `tests/native-tui.test.ts` loads the extension through Pi's native SDK and runs `InteractiveMode` with an in-memory `Terminal`. It checks answer, Escape, caller abort in default fullscreen and regular mode, short-terminal choices and editing, paging, resize, and widget overlap, plus draft and keyboard ownership restoration and native grill-mode tree/fork/resume/reload restoration. It does not touch the process terminal or clipboard, use provider credentials, or make model calls. Have `fd` and `rg` on PATH for native TUI initialization without downloads. Unit tests retain timeout, custom answer, multi-select, and grill-mode coverage. `tests/rpc-ui.test.ts` exercises actual bundled-CLI RPC selection, custom input, multi-select, cancellation and reload recovery without provider calls.
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
- Key file:
64
+ ## License
128
65
 
129
- - `extensions/ask-question.ts` — publishable extension implementation
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
- finished = true;
223
- signal?.removeEventListener("abort", abort);
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
- add(border);
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: () => signal?.removeEventListener("abort", abort),
787
+ dispose: () => finish(true),
621
788
  };
622
- }, { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", anchor: "bottom-center" } });
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.5.1",
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.0.0",
67
- "@earendil-works/pi-tui": "1.0.0",
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
  }