@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 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 and distinguish the unrelated unscoped npm 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 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. Five minutes without interaction 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
+ 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
- ```text
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
- 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
+ The tool keeps saved answers. It does not submit unsaved custom answer text or select an answer for you.
61
41
 
62
- ## Tool shape
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
- Ask one question:
44
+ ## Check requirements with `/grill-me`
65
45
 
66
- ```json
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
- 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
- }
48
+ ```text
49
+ /grill-me on
92
50
  ```
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
+ 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
- ```bash
117
- npm install --ignore-scripts
118
- npm run check:compat # all behavior tests + typecheck + pack dry-run
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
- 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.
60
+ ## Reference
122
61
 
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.
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
- `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.
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
- Key file:
66
+ ## License
128
67
 
129
- - `extensions/ask-question.ts` — publishable extension implementation
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
- finished = true;
223
- signal?.removeEventListener("abort", abort);
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 with an AFK reply.", { style: (text) => theme.fg("dim", text) });
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
- add(border);
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: () => signal?.removeEventListener("abort", abort),
806
+ dispose: () => finish(true),
621
807
  };
622
- }, { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", anchor: "bottom-center" } });
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
- const timeout = new AbortController();
733
- const timer = setTimeout(() => timeout.abort(), QUESTION_TIMEOUT_MS);
734
- const questionSignal = signal ? AbortSignal.any([signal, timeout.signal]) : timeout.signal;
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 && timeout.signal.aborted && !signal?.aborted;
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.5.1",
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.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
  }