@fitchmultz/pi-ask-question 0.5.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 ADDED
@@ -0,0 +1,53 @@
1
+ # Changelog
2
+
3
+ ## 0.5.1 — 2026-10-04
4
+
5
+ - 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.
7
+ - 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
+ - Add a main-only, opt-in repository npm release pipeline that requires both host qualifications before publishing.
9
+
10
+ ## 0.5.0 — 2026-10-01
11
+
12
+ - Require Pi 1.0.0 and Node.js 24, and qualify the exact official Pi 1.0.0 / TypeBox 1.3.27 cohort while retaining host-provided runtime peers.
13
+ - Update repository typechecking from TypeScript 6 to TypeScript 7.0.2.
14
+ - Exercise answer, Escape and caller abort in both default fullscreen and regular mode, preserving the editor draft and restoring focus.
15
+ - Add bundled-CLI RPC dialog coverage for multi-select, typed answers, cancellation and recovery after reload, and native SDK grill-mode tree/fork/resume/reload coverage.
16
+ - Compose `/grill-me` guidance with later extension prompt changes instead of replacing the full system prompt.
17
+ - Allow RPC multi-select answers to be deselected, including custom answers.
18
+ - Preserve exact multi-select choices in tool results and review, including labels containing commas.
19
+ - Keep selected choices visible in short terminals and after resizing, with scrolling for long questions, choices, and answer reviews.
20
+
21
+ ## 0.4.0 — 2026-09-07
22
+
23
+ - Stop waiting after five minutes per `ask_question` call and return an AFK reply so the agent can continue using its best judgement.
24
+ - Preserve saved answers and multi-select choices on timeout, and report timeout separately from user cancellation.
25
+
26
+ ## 0.3.0 — 2026-08-06
27
+
28
+ - Require Pi 0.84.0 or later.
29
+ - Keep the interactive question layout width-safe after terminal resizes, including Pi 0.84's fullscreen TUI mode.
30
+ - Forward custom-component focus to the embedded editor so IME candidate windows follow the active answer field.
31
+ - Add `/grill-me` pressure-test mode that survives `/reload`.
32
+ - Tighten `/grill-me` prompts to inspect first, walk the decision tree, and ask one recommended question at a time.
33
+ - Support `ask_question` through Pi's RPC extension UI protocol, including ordered multi-question and multi-select flows.
34
+ - Use `ask_question` for `/grill-me` in both TUI and RPC UI modes.
35
+ - Retry option selection after blank RPC custom input, matching TUI behavior.
36
+ - Forward tool cancellation to TUI and RPC dialogs.
37
+ - Keep print/JSON behavior explicit: the tool fails because no user-input UI is available.
38
+ - Name `ask_question` in every prompt guideline as required by Pi's flattened prompt metadata.
39
+
40
+ ## 0.2.0 — 2026-06-24
41
+
42
+ - Fix text wrapping to be ANSI/wide-glyph safe via `wrapTextWithAnsi`/`visibleWidth` (replaces naive `.length` math that overflowed on CJK/emoji).
43
+ - Mark `ask_question` `executionMode: "sequential"` so concurrent calls cannot fight for keyboard focus.
44
+ - Add compact `renderCall`/`renderResult` for the tool (question count/ids, answers, cancelled).
45
+ - Add `/grill-me` argument completion (`on`, `off`, `status`).
46
+ - Skip the grill-me footer status update outside TUI mode.
47
+ - Auto-suffix duplicate `questions[].id` instead of colliding in the answers map.
48
+ - Trim `ask_question` prompt guidelines from 6 bullets to 3 to cut system-prompt tax.
49
+ - Document single-vs-`questions` precedence and default id behavior in the schema.
50
+
51
+ ## 0.1.0
52
+
53
+ - Package the existing `ask_question` extension as `pi-ask-question`.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mitch Fultz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # pi ask-question extension
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.
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
19
+
20
+ ## Install
21
+
22
+ Install the scoped package with pi:
23
+
24
+ ```bash
25
+ pi install npm:@fitchmultz/pi-ask-question
26
+ ```
27
+
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.
29
+
30
+ GitHub remains a supported fallback, including existing tags:
31
+
32
+ ```bash
33
+ pi install https://github.com/fitchmultz/pi-ask-question
34
+ ```
35
+
36
+ Use `/reload` after updating extension code on current official Pi and the maintained fork. Restart Pi after changing dependencies or the host runtime.
37
+
38
+ For local development:
39
+
40
+ ```bash
41
+ pi -e ./extensions/ask-question.ts
42
+ ```
43
+
44
+ ## Grill-me mode
45
+
46
+ Toggle the mode:
47
+
48
+ ```text
49
+ /grill-me
50
+ ```
51
+
52
+ Explicit commands:
53
+
54
+ ```text
55
+ /grill-me on
56
+ /grill-me off
57
+ /grill-me status
58
+ ```
59
+
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.
61
+
62
+ ## Tool shape
63
+
64
+ Ask one question:
65
+
66
+ ```json
67
+ {
68
+ "question": "Which implementation should I use?",
69
+ "options": ["Smallest working change", "Full refactor"],
70
+ "multiSelect": false
71
+ }
72
+ ```
73
+
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
+ ```
93
+
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
115
+
116
+ ```bash
117
+ npm install --ignore-scripts
118
+ npm run check:compat # all behavior tests + typecheck + pack dry-run
119
+ ```
120
+
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.
122
+
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.
124
+
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.
126
+
127
+ Key file:
128
+
129
+ - `extensions/ask-question.ts` — publishable extension implementation
@@ -0,0 +1,843 @@
1
+ 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 { Type } from "typebox";
4
+
5
+ type Question = {
6
+ id?: string;
7
+ question: string;
8
+ options?: string[];
9
+ multiSelect?: boolean;
10
+ };
11
+
12
+ type NormalizedQuestion = {
13
+ id: string;
14
+ question: string;
15
+ options: string[];
16
+ multiSelect: boolean;
17
+ };
18
+
19
+ type Answer = {
20
+ id: string;
21
+ question: string;
22
+ answer: string;
23
+ selectedOptions?: string[];
24
+ wasCustom: boolean;
25
+ };
26
+
27
+ type AskQuestionDetails = {
28
+ questions: NormalizedQuestion[];
29
+ answers: Answer[];
30
+ cancelled: boolean;
31
+ timedOut: boolean;
32
+ };
33
+
34
+ type GrillMeState = {
35
+ enabled: boolean;
36
+ };
37
+
38
+ type GrillMeAction = "toggle" | "enable" | "disable" | "status" | "invalid";
39
+
40
+ const QUESTION_TIMEOUT_MS = 5 * 60 * 1000;
41
+ const TIMEOUT_MESSAGE = "Mitch is currently AFK. Use your best judgement to choose the option Mitch would choose.";
42
+ const CUSTOM_OPTION = "Type a custom answer";
43
+ const DONE_OPTION = "Done selecting";
44
+ const GRILL_ME_STATE_TYPE = "ask-question.grill-me";
45
+ const GRILL_ME_STATUS_KEY = "ask-question.grill-me";
46
+ const GRILL_ME_ARGUMENTS = ["on", "off", "status"];
47
+
48
+ function grillMePrompt(useTool: boolean): string {
49
+ return `IMPORTANT: /grill-me mode is active.
50
+
51
+ Pressure-test the user's request before doing meaningful work:
52
+ - Walk the decision tree: resolve upstream choices before downstream details.
53
+ - If a question can be answered by reading local files, docs, tests, or command output, inspect those sources instead of asking the user.
54
+ - For non-trivial, risky, underspecified, or strategic requests, ${useTool ? "call ask_question first" : "ask clarifying questions first in normal text"}.
55
+ - Ask exactly one blocking question at a time, then wait for the answer before asking the next one.
56
+ - For every question, include your recommended answer as the first option and make it specific.
57
+ - Do not ask when the next step is obvious, low-risk, or already constrained; proceed normally.
58
+ - After the user answers, continue the work directly.`;
59
+ }
60
+
61
+ const QuestionSchema = Type.Object(
62
+ {
63
+ id: Type.Optional(Type.String({ description: "Stable answer id. Defaults to question_<n>; duplicates are auto-suffixed." })),
64
+ question: Type.String({ minLength: 1, description: "Question to ask the user." }),
65
+ options: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { description: "Choices, ordered from most recommended to least recommended." })),
66
+ multiSelect: Type.Optional(Type.Boolean({ description: "Allow selecting more than one option for this question. Defaults to false." })),
67
+ },
68
+ { additionalProperties: false },
69
+ );
70
+
71
+ const AskQuestionParams = Type.Object(
72
+ {
73
+ question: Type.Optional(Type.String({ minLength: 1, description: "Single question to ask." })),
74
+ options: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { description: "Choices, ordered from most recommended to least recommended." })),
75
+ multiSelect: Type.Optional(Type.Boolean({ description: "Allow selecting more than one option for the single question. Defaults to false." })),
76
+ questions: Type.Optional(Type.Array(QuestionSchema, { description: "Ask several questions in order. When provided, takes precedence over the single-question fields." })),
77
+ },
78
+ { additionalProperties: false },
79
+ );
80
+
81
+ function clean(value: string | undefined): string | undefined {
82
+ const trimmed = value?.trim();
83
+ return trimmed || undefined;
84
+ }
85
+
86
+ function uniqueOptionLabel(label: string, options: string[]): string {
87
+ let result = label;
88
+ for (let suffix = 2; options.includes(result); suffix += 1) result = `${label} (${suffix})`;
89
+ return result;
90
+ }
91
+
92
+ function normalize(params: { question?: string; options?: string[]; multiSelect?: boolean; questions?: Question[] }): NormalizedQuestion[] {
93
+ const raw = params.questions?.length
94
+ ? params.questions
95
+ : [{ id: "question_1", question: params.question ?? "", options: params.options, multiSelect: params.multiSelect }];
96
+
97
+ const assigned = new Set<string>();
98
+ const result: NormalizedQuestion[] = [];
99
+
100
+ for (let index = 0; index < raw.length; index += 1) {
101
+ const question = raw[index];
102
+ const text = clean(question.question);
103
+ if (!text) continue;
104
+
105
+ const baseId = clean(question.id) ?? `question_${index + 1}`;
106
+ let id = baseId;
107
+ let suffix = 2;
108
+ while (assigned.has(id)) {
109
+ id = `${baseId}_${suffix}`;
110
+ suffix += 1;
111
+ }
112
+ assigned.add(id);
113
+
114
+ result.push({
115
+ id,
116
+ question: text,
117
+ options: (question.options ?? [])
118
+ .map(clean)
119
+ .filter((option): option is string => Boolean(option)),
120
+ multiSelect: question.multiSelect === true,
121
+ });
122
+ }
123
+
124
+ return result;
125
+ }
126
+
127
+ function orderedAnswers(questions: NormalizedQuestion[], answers: Map<string, Answer>): Answer[] {
128
+ return questions.map((question) => answers.get(question.id)).filter((answer): answer is Answer => Boolean(answer));
129
+ }
130
+
131
+ function formatAnswer(answer?: Answer): string {
132
+ if (!answer) return "unanswered";
133
+ return answer.selectedOptions ? JSON.stringify(answer.selectedOptions) : answer.answer;
134
+ }
135
+
136
+ // Word wrapping drops spaces at line breaks; JSON answer text must keep them.
137
+ function wrapByColumn(text: string, width: number): string[] {
138
+ const lines: string[] = [];
139
+ while (visibleWidth(text) > width) {
140
+ const part = sliceByColumn(text, 0, width, true) || sliceByColumn(text, 0, width);
141
+ lines.push(part);
142
+ text = text.slice(part.length);
143
+ }
144
+ return [...lines, text];
145
+ }
146
+
147
+ function exactText(text: string) {
148
+ return {
149
+ render(width: number) {
150
+ const wrapped = text.split("\n").flatMap((line) => wrapByColumn(line, width)).join("\n");
151
+ return new Text(wrapped, 0, 0).render(width);
152
+ },
153
+ invalidate() {},
154
+ };
155
+ }
156
+
157
+ function summarize(questions: NormalizedQuestion[], answers: Map<string, Answer>, cancelled: boolean, timedOut: boolean): string {
158
+ if (timedOut) return [
159
+ `Timed out after 5 minutes. ${TIMEOUT_MESSAGE}`,
160
+ ...(answers.size ? ["", "Answers already provided:", ...orderedAnswers(questions, answers).map((answer) => `- ${answer.id}: ${formatAnswer(answer)}`)] : []),
161
+ ].join("\n");
162
+ if (cancelled) return "User cancelled the question.";
163
+ if (questions.length === 1) return `User answered: ${formatAnswer(answers.get(questions[0].id))}`;
164
+ return [
165
+ "User answered:",
166
+ ...questions.map((question) => `- ${question.id}: ${formatAnswer(answers.get(question.id))}`),
167
+ ].join("\n");
168
+ }
169
+
170
+ async function askWithKeyboard(
171
+ questions: NormalizedQuestion[],
172
+ ui: ExtensionContext["ui"],
173
+ signal?: AbortSignal,
174
+ ): Promise<{ answers: Answer[]; cancelled: boolean }> {
175
+ if (signal?.aborted) return { answers: [], cancelled: true };
176
+ return ui.custom<{ answers: Answer[]; cancelled: boolean }>((tui, theme, keys, done) => {
177
+ const answers = new Map<string, Answer>();
178
+ const multiAnswers = new Map<string, Set<string>>();
179
+ const customAnswers = new Map<string, string>();
180
+ const keyText = (binding: Keybinding) => keys.getKeys(binding).join("/");
181
+ const editor = new Editor(tui, {
182
+ borderColor: (text) => theme.fg("accent", text),
183
+ selectList: {
184
+ selectedPrefix: (text) => theme.fg("accent", text),
185
+ selectedText: (text) => theme.fg("accent", text),
186
+ description: (text) => theme.fg("muted", text),
187
+ scrollInfo: (text) => theme.fg("dim", text),
188
+ noMatch: (text) => theme.fg("warning", text),
189
+ },
190
+ });
191
+
192
+ let tab = 0;
193
+ let option = 0;
194
+ let editing = false;
195
+ let cachedWidth: number | undefined;
196
+ let cachedHeight: number | undefined;
197
+ let cachedLines: string[] | undefined;
198
+ let optionScroll = 0;
199
+ let labelScroll = 0;
200
+ let pageRows = 0;
201
+ let maxLabelScroll = 0;
202
+ let questionScroll = 0;
203
+ let questionPageRows = 0;
204
+ let maxQuestionScroll = 0;
205
+ let reviewScroll: number | undefined;
206
+ let reviewPageRows = 0;
207
+ let maxReviewScroll = 0;
208
+
209
+ const submitTab = questions.length;
210
+ const showTabs = questions.length > 1 || questions.some((question) => question.multiSelect);
211
+ const current = () => questions[tab];
212
+ const customOption = () => uniqueOptionLabel(CUSTOM_OPTION, current()?.options ?? []);
213
+ const choices = () => [...(current()?.options ?? []), customOption()];
214
+ const allAnswered = () => questions.every((question) => answers.has(question.id));
215
+ const refresh = () => {
216
+ cachedLines = undefined;
217
+ tui.requestRender();
218
+ };
219
+ let finished = false;
220
+ const finish = (cancelled: boolean) => {
221
+ if (finished) return;
222
+ finished = true;
223
+ signal?.removeEventListener("abort", abort);
224
+ done({ answers: orderedAnswers(questions, answers), cancelled });
225
+ };
226
+ const abort = () => finish(true);
227
+ signal?.addEventListener("abort", abort, { once: true });
228
+
229
+ function moveTab(next: number) {
230
+ tab = (next + questions.length + 1) % (questions.length + 1);
231
+ option = 0;
232
+ optionScroll = 0;
233
+ labelScroll = 0;
234
+ questionScroll = 0;
235
+ reviewScroll = undefined;
236
+ editing = false;
237
+ editor.setText("");
238
+ refresh();
239
+ }
240
+
241
+ function selectedSet(question: NormalizedQuestion): Set<string> {
242
+ let selected = multiAnswers.get(question.id);
243
+ if (!selected) {
244
+ selected = new Set<string>();
245
+ multiAnswers.set(question.id, selected);
246
+ }
247
+ return selected;
248
+ }
249
+
250
+ function syncMultiAnswer(question: NormalizedQuestion) {
251
+ const selected = [...selectedSet(question)];
252
+ if (!selected.length) {
253
+ answers.delete(question.id);
254
+ return;
255
+ }
256
+ answers.set(question.id, {
257
+ id: question.id,
258
+ question: question.question,
259
+ answer: selected.join(", "),
260
+ selectedOptions: selected,
261
+ wasCustom: selected.some((answer) => !question.options.includes(answer)),
262
+ });
263
+ }
264
+
265
+ function toggleMultiChoice(question: NormalizedQuestion, choice: string) {
266
+ const selected = selectedSet(question);
267
+ if (selected.has(choice)) selected.delete(choice);
268
+ else selected.add(choice);
269
+ syncMultiAnswer(question);
270
+ labelScroll = 0;
271
+ refresh();
272
+ }
273
+
274
+ function startCustomEdit(question: NormalizedQuestion) {
275
+ const currentCustom = question.multiSelect
276
+ ? customAnswers.get(question.id)
277
+ : answers.get(question.id)?.wasCustom ? answers.get(question.id)?.answer : undefined;
278
+ editor.setText(currentCustom ?? "");
279
+ editing = true;
280
+ labelScroll = 0;
281
+ optionScroll = 0;
282
+ questionScroll = 0;
283
+ refresh();
284
+ }
285
+
286
+ function saveMultiCustomAnswer(question: NormalizedQuestion, answer: string) {
287
+ const selected = selectedSet(question);
288
+ const previous = customAnswers.get(question.id);
289
+ if (previous) selected.delete(previous);
290
+ customAnswers.set(question.id, answer);
291
+ selected.add(answer);
292
+ syncMultiAnswer(question);
293
+ refresh();
294
+ }
295
+
296
+ function moveForwardIfAnswered(question: NormalizedQuestion) {
297
+ if (answers.has(question.id)) moveTab(Math.min(tab + 1, submitTab));
298
+ else refresh();
299
+ }
300
+
301
+ function saveSingleAnswer(answer: string, wasCustom: boolean) {
302
+ const question = current();
303
+ if (!question) return;
304
+ answers.set(question.id, { id: question.id, question: question.question, answer, wasCustom });
305
+ if (questions.length === 1) finish(false);
306
+ else moveTab(Math.min(tab + 1, submitTab));
307
+ }
308
+
309
+ editor.onSubmit = (value) => {
310
+ const answer = clean(value);
311
+ const question = current();
312
+ editing = false;
313
+ editor.setText("");
314
+ if (!answer || !question) {
315
+ refresh();
316
+ return;
317
+ }
318
+ if (question.multiSelect) saveMultiCustomAnswer(question, answer);
319
+ else saveSingleAnswer(answer, true);
320
+ };
321
+
322
+ function handleInput(data: string) {
323
+ if (editing) {
324
+ if (maxQuestionScroll && matchesKey(data, Key.shift("pageUp"))) {
325
+ questionScroll = Math.max(0, questionScroll - Math.max(1, questionPageRows - 1));
326
+ refresh();
327
+ return;
328
+ }
329
+ if (maxQuestionScroll && matchesKey(data, Key.shift("pageDown"))) {
330
+ questionScroll = Math.min(maxQuestionScroll, questionScroll + Math.max(1, questionPageRows - 1));
331
+ refresh();
332
+ return;
333
+ }
334
+ if (keys.matches(data, "tui.select.cancel")) {
335
+ editing = false;
336
+ editor.setText("");
337
+ refresh();
338
+ return;
339
+ }
340
+ editor.handleInput(data);
341
+ refresh();
342
+ return;
343
+ }
344
+
345
+ if (showTabs && (matchesKey(data, Key.right) || keys.matches(data, "tui.input.tab"))) return moveTab(tab + 1);
346
+ if (showTabs && (matchesKey(data, Key.left) || matchesKey(data, Key.shift("tab")))) return moveTab(tab - 1);
347
+ if (keys.matches(data, "tui.select.cancel")) return finish(true);
348
+ // Fullscreen reserves plain Page keys for transcript scrolling.
349
+ const pageUp = keys.matches(data, "tui.select.pageUp") || matchesKey(data, Key.shift("pageUp"));
350
+ const pageDown = keys.matches(data, "tui.select.pageDown") || matchesKey(data, Key.shift("pageDown"));
351
+
352
+ if (tab === submitTab) {
353
+ if (maxReviewScroll && pageUp) {
354
+ reviewScroll = Math.max(0, (reviewScroll ?? maxReviewScroll) - Math.max(1, reviewPageRows - 1));
355
+ refresh();
356
+ return;
357
+ }
358
+ if (maxReviewScroll && pageDown) {
359
+ reviewScroll = Math.min(maxReviewScroll, (reviewScroll ?? maxReviewScroll) + Math.max(1, reviewPageRows - 1));
360
+ refresh();
361
+ return;
362
+ }
363
+ if (keys.matches(data, "tui.select.confirm") && allAnswered()) finish(false);
364
+ return;
365
+ }
366
+
367
+ const question = current();
368
+ const options = choices();
369
+ if ((maxQuestionScroll || maxLabelScroll) && pageUp) {
370
+ if (labelScroll > 0) labelScroll = Math.max(0, labelScroll - Math.max(1, pageRows - 1));
371
+ else questionScroll = Math.max(0, questionScroll - Math.max(1, questionPageRows - 1));
372
+ refresh();
373
+ return;
374
+ }
375
+ if ((maxQuestionScroll || maxLabelScroll) && pageDown) {
376
+ if (questionScroll < maxQuestionScroll) questionScroll = Math.min(maxQuestionScroll, questionScroll + Math.max(1, questionPageRows - 1));
377
+ else labelScroll = Math.min(maxLabelScroll, labelScroll + Math.max(1, pageRows - 1));
378
+ refresh();
379
+ return;
380
+ }
381
+ if (keys.matches(data, "tui.select.up")) {
382
+ option = Math.max(0, option - 1);
383
+ labelScroll = 0;
384
+ refresh();
385
+ return;
386
+ }
387
+ if (keys.matches(data, "tui.select.down")) {
388
+ option = Math.min(options.length - 1, option + 1);
389
+ labelScroll = 0;
390
+ refresh();
391
+ return;
392
+ }
393
+ if (question.multiSelect && matchesKey(data, Key.space)) {
394
+ const picked = options[option];
395
+ if (picked === customOption()) startCustomEdit(question);
396
+ else if (picked) toggleMultiChoice(question, picked);
397
+ return;
398
+ }
399
+ if (keys.matches(data, "tui.select.confirm")) {
400
+ const picked = options[option];
401
+ if (picked === customOption()) {
402
+ startCustomEdit(question);
403
+ } else if (picked && question.multiSelect) {
404
+ moveForwardIfAnswered(question);
405
+ } else if (picked) {
406
+ saveSingleAnswer(picked, false);
407
+ }
408
+ }
409
+ }
410
+
411
+ function render(width: number): string[] {
412
+ const height = tui.terminal.rows;
413
+ if (cachedLines && cachedWidth === width && cachedHeight === height) return cachedLines;
414
+ if (cachedWidth !== width) {
415
+ optionScroll = 0;
416
+ labelScroll = 0;
417
+ questionScroll = 0;
418
+ }
419
+ const lines: string[] = [];
420
+ let questionStart = 0;
421
+ let questionEnd = 0;
422
+ let optionStart = 0;
423
+ let optionEnd = 0;
424
+ let reviewStart = 0;
425
+ let reviewEnd = 0;
426
+ const optionRows: Array<{ start: number; end: number }> = [];
427
+ const add = (line = "") => lines.push(visibleWidth(line) > width ? sliceByColumn(line, 0, width, true) : line);
428
+ const addWrapped = (
429
+ text: string,
430
+ options: { firstPrefix?: string; restPrefix?: string; style?: (text: string) => string; prefixStyle?: (text: string) => string; preserveWhitespace?: boolean } = {},
431
+ ) => {
432
+ const firstPrefix = options.firstPrefix ?? "";
433
+ const restPrefix = options.restPrefix ?? "";
434
+ const style = options.style ?? ((value: string) => value);
435
+ const prefixStyle = options.prefixStyle ?? ((value: string) => value);
436
+ const wrap = options.preserveWhitespace ? wrapByColumn : wrapTextWithAnsi;
437
+ const prefixWidth = visibleWidth(firstPrefix);
438
+ if (prefixWidth >= width || (options.preserveWhitespace && width - prefixWidth < 2)) {
439
+ for (const line of wrap(`${prefixStyle(firstPrefix)}${style(text)}`, width)) add(line);
440
+ return;
441
+ }
442
+ const wrapped = wrap(style(text), width - prefixWidth);
443
+ add(`${prefixStyle(firstPrefix)}${wrapped[0] ?? ""}`);
444
+ for (let index = 1; index < wrapped.length; index += 1) add(`${prefixStyle(restPrefix)}${wrapped[index]}`);
445
+ };
446
+ const border = theme.fg("accent", "─".repeat(width));
447
+
448
+ add(border);
449
+ if (showTabs && !editing) {
450
+ addWrapped(
451
+ [
452
+ ...questions.map((question, index) => {
453
+ const marker = answers.has(question.id) ? "■" : "□";
454
+ const label = ` ${marker} Q${index + 1} `;
455
+ return index === tab ? theme.bg("selectedBg", theme.fg("text", label)) : theme.fg("muted", label);
456
+ }),
457
+ tab === submitTab ? theme.bg("selectedBg", theme.fg("text", " Submit ")) : theme.fg(allAnswered() ? "success" : "dim", " Submit "),
458
+ ].join(" "),
459
+ );
460
+ add();
461
+ }
462
+
463
+ if (tab === submitTab) {
464
+ addWrapped(theme.bold("Review answers"));
465
+ add();
466
+ reviewStart = lines.length;
467
+ for (const question of questions) {
468
+ const answer = answers.get(question.id);
469
+ addWrapped(formatAnswer(answer), {
470
+ firstPrefix: `${question.id}: `,
471
+ restPrefix: " ".repeat(visibleWidth(`${question.id}: `)),
472
+ style: answer ? undefined : (text) => theme.fg("warning", text),
473
+ preserveWhitespace: Boolean(answer?.selectedOptions),
474
+ });
475
+ }
476
+ reviewEnd = lines.length;
477
+ add();
478
+ addWrapped(allAnswered() ? `${keyText("tui.select.confirm")} to submit` : "Answer all questions before submitting", {
479
+ style: (text) => theme.fg(allAnswered() ? "success" : "warning", text),
480
+ });
481
+ } else {
482
+ const question = current();
483
+ const options = choices();
484
+ const answer = answers.get(question.id);
485
+ const selected = question.multiSelect ? selectedSet(question) : undefined;
486
+ questionStart = lines.length;
487
+ addWrapped(question.multiSelect ? `${question.question} (select one or more)` : question.question);
488
+ questionEnd = lines.length;
489
+ add();
490
+ optionStart = lines.length;
491
+ options.forEach((choice, index) => {
492
+ if (editing && index !== option) return;
493
+ const start = lines.length - optionStart;
494
+ const highlighted = index === option;
495
+ const checked = selected?.has(choice) ?? false;
496
+ const marker = question.multiSelect ? (choice === customOption() ? "✎" : checked ? "☑" : "☐") : `${index + 1}.`;
497
+ const prefix = `${highlighted ? ">" : " "} ${marker} `;
498
+ addWrapped(choice, {
499
+ firstPrefix: prefix,
500
+ restPrefix: " ".repeat(prefix.length),
501
+ style: (text) => theme.fg(highlighted ? "accent" : "text", text),
502
+ prefixStyle: (text) => highlighted && text.includes(">") ? theme.fg("accent", text) : text,
503
+ });
504
+ optionRows.push({ start, end: lines.length - optionStart });
505
+ });
506
+ optionEnd = lines.length;
507
+ if (answer && !editing) {
508
+ const custom = question.multiSelect ? customAnswers.get(question.id) : undefined;
509
+ const summary = custom && selected?.has(custom) ? `${custom} (${selected.size} selected)` : answer.answer;
510
+ const preview = `Current answer: ${summary.replace(/\s+/g, " ")}`;
511
+ add(theme.fg("muted", truncateToWidth(preview, width, "…")));
512
+ }
513
+ if (editing) {
514
+ addWrapped(theme.fg("muted", "Your answer:"));
515
+ const indent = width > 1 ? " " : "";
516
+ const editorWidth = Math.max(1, width - visibleWidth(indent));
517
+ for (const line of editor.render(Math.max(3, editorWidth))) {
518
+ const cursorIndex = line.indexOf(CURSOR_MARKER);
519
+ const cursorColumn = cursorIndex === -1 ? 0 : visibleWidth(line.slice(0, cursorIndex));
520
+ const startColumn = Math.max(0, cursorColumn - editorWidth + 1);
521
+ add(`${indent}${sliceByColumn(line, startColumn, editorWidth, true)}`);
522
+ }
523
+ }
524
+ }
525
+
526
+ if (!editing) add();
527
+ addWrapped("Auto-continues after 5 minutes with an AFK reply.", { style: (text) => theme.fg("dim", text) });
528
+ addWrapped(
529
+ editing
530
+ ? `${keyText("tui.input.submit")} save answer • ${keyText("tui.select.cancel")} cancel edit`
531
+ : showTabs
532
+ ? `←/→ or ${keyText("tui.input.tab")} questions • ${keyText("tui.select.up")}/${keyText("tui.select.down")} options • Space toggle multi-select • ${keyText("tui.select.confirm")} next/submit • ${keyText("tui.select.cancel")} cancel`
533
+ : `${keyText("tui.select.up")}/${keyText("tui.select.down")} options • ${keyText("tui.select.confirm")} select • ${keyText("tui.select.cancel")} cancel`,
534
+ { style: (text) => theme.fg("dim", text) },
535
+ );
536
+ add(border);
537
+
538
+ if (tab !== submitTab) {
539
+ const questionLines = lines.slice(questionStart, questionEnd);
540
+ const selectedRow = editing ? 0 : option;
541
+ const selectedHeight = optionRows[selectedRow].end - optionRows[selectedRow].start;
542
+ const room = Math.max(2, height - (lines.length - questionLines.length - (optionEnd - optionStart)));
543
+ const questionRows = Math.max(1, room - selectedHeight);
544
+ maxQuestionScroll = Math.max(0, questionLines.length - questionRows);
545
+ questionScroll = Math.min(questionScroll, maxQuestionScroll);
546
+ questionPageRows = questionRows;
547
+ if (maxQuestionScroll) {
548
+ lines.splice(questionStart, questionLines.length, ...questionLines.slice(questionScroll, questionScroll + questionRows));
549
+ const removed = questionLines.length - questionRows;
550
+ optionStart -= removed;
551
+ optionEnd -= removed;
552
+ }
553
+
554
+ const optionLines = lines.slice(optionStart, optionEnd);
555
+ const available = Math.max(1, height);
556
+ const visibleOptions = Math.max(1, available - (lines.length - optionLines.length));
557
+ let optionStatus = "";
558
+ if (optionLines.length > visibleOptions) {
559
+ // Scroll at choice boundaries so labels that fit are shown in full.
560
+ optionScroll = Math.min(optionScroll, selectedRow);
561
+ while (optionScroll < selectedRow && optionRows[selectedRow].end - optionRows[optionScroll].start > visibleOptions) optionScroll++;
562
+ let end = selectedRow + 1;
563
+ while (end < optionRows.length && optionRows[end].end - optionRows[optionScroll].start <= visibleOptions) end++;
564
+ while (optionScroll > 0 && optionRows[end - 1].end - optionRows[optionScroll - 1].start <= visibleOptions) optionScroll--;
565
+ maxLabelScroll = Math.max(0, optionRows[selectedRow].end - optionRows[selectedRow].start - visibleOptions);
566
+ labelScroll = Math.min(labelScroll, maxLabelScroll);
567
+ pageRows = visibleOptions;
568
+ const startLine = optionRows[optionScroll].start + labelScroll;
569
+ const endLine = Math.min(optionRows[end - 1].end, startLine + visibleOptions);
570
+ const above = optionScroll > 0 || labelScroll > 0 ? "↑ " : "";
571
+ const below = endLine < optionLines.length ? " ↓" : "";
572
+ optionStatus = `${above}${option + 1}/${choices().length}${below}`;
573
+ lines.splice(optionStart, optionLines.length, ...optionLines.slice(startLine, endLine));
574
+ } else {
575
+ optionScroll = 0;
576
+ labelScroll = 0;
577
+ maxLabelScroll = 0;
578
+ }
579
+ if (maxQuestionScroll || optionStatus) {
580
+ const questionStatus = maxQuestionScroll ? `Q ${questionScroll + 1}-${Math.min(questionScroll + questionRows, questionLines.length)}/${questionLines.length}` : "";
581
+ const read = !editing && (maxQuestionScroll || maxLabelScroll) ? ` • ${keyText("tui.select.pageUp")}/${keyText("tui.select.pageDown")} read (Shift+Page fullscreen)` : "";
582
+ const status = editing && maxQuestionScroll
583
+ ? `Shift+PageUp/Down question • ${questionStatus}`
584
+ : `${[questionStatus, optionStatus].filter(Boolean).join(" • ")}${read}`;
585
+ lines[optionStart - 1] = sliceByColumn(theme.fg("dim", ` ${status}`), 0, width, true);
586
+ }
587
+ } else {
588
+ const answerLines = lines.slice(reviewStart, reviewEnd);
589
+ const visibleAnswers = Math.max(1, height - (lines.length - answerLines.length));
590
+ const wasAtEnd = reviewScroll === undefined || reviewScroll === maxReviewScroll;
591
+ maxReviewScroll = Math.max(0, answerLines.length - visibleAnswers);
592
+ reviewScroll = wasAtEnd ? maxReviewScroll : Math.min(reviewScroll ?? 0, maxReviewScroll);
593
+ reviewPageRows = visibleAnswers;
594
+ if (maxReviewScroll) {
595
+ const above = reviewScroll > 0 ? "↑ " : "";
596
+ const below = reviewScroll < maxReviewScroll ? " ↓" : "";
597
+ const read = `${keyText("tui.select.pageUp")}/${keyText("tui.select.pageDown")} read answers (Shift+Page fullscreen)`;
598
+ lines[reviewStart - 1] = sliceByColumn(theme.fg("dim", ` ${above}${read}${below}`), 0, width, true);
599
+ lines.splice(reviewStart, answerLines.length, ...answerLines.slice(reviewScroll, reviewScroll + visibleAnswers));
600
+ }
601
+ }
602
+
603
+ cachedWidth = width;
604
+ cachedHeight = height;
605
+ cachedLines = lines;
606
+ return lines;
607
+ }
608
+
609
+ return {
610
+ get focused() { return editor.focused; },
611
+ set focused(value: boolean) {
612
+ if (editor.focused === value) return;
613
+ editor.focused = value;
614
+ cachedLines = undefined;
615
+ tui.requestRender();
616
+ },
617
+ render,
618
+ handleInput,
619
+ invalidate: () => { cachedLines = undefined; },
620
+ dispose: () => signal?.removeEventListener("abort", abort),
621
+ };
622
+ }, { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", anchor: "bottom-center" } });
623
+ }
624
+
625
+ async function askWithDialogs(
626
+ questions: NormalizedQuestion[],
627
+ ui: ExtensionContext["ui"],
628
+ signal?: AbortSignal,
629
+ ): Promise<{ answers: Answer[]; cancelled: boolean }> {
630
+ const answers = new Map<string, Answer>();
631
+
632
+ for (const question of questions) {
633
+ if (signal?.aborted) return { answers: orderedAnswers(questions, answers), cancelled: true };
634
+ const selected: string[] = [];
635
+ const customOption = uniqueOptionLabel(CUSTOM_OPTION, question.options);
636
+ const doneOption = uniqueOptionLabel(DONE_OPTION, question.options);
637
+
638
+ while (true) {
639
+ const options = [...question.options, ...selected.filter((answer) => !question.options.includes(answer))];
640
+ const labels = question.multiSelect
641
+ ? options.map((answer) => `${selected.includes(answer) ? "☑" : "☐"} ${answer}`)
642
+ : options;
643
+ const choices = [...labels, customOption];
644
+ if (question.multiSelect && selected.length) choices.push(doneOption);
645
+ const choice = await ui.select(question.question, choices, { signal });
646
+ if (choice === undefined) return { answers: orderedAnswers(questions, answers), cancelled: true };
647
+ if (choice === doneOption) break;
648
+
649
+ let answer = options[labels.indexOf(choice)] ?? choice;
650
+ let wasCustom = false;
651
+ if (choice === customOption) {
652
+ const input = await ui.input(question.question, "Type your answer", { signal });
653
+ if (input === undefined) return { answers: orderedAnswers(questions, answers), cancelled: true };
654
+ const custom = clean(input);
655
+ if (!custom) continue;
656
+ answer = custom;
657
+ wasCustom = true;
658
+ }
659
+
660
+ const index = selected.indexOf(answer);
661
+ if (question.multiSelect && !wasCustom && index !== -1) selected.splice(index, 1);
662
+ else if (index === -1) selected.push(answer);
663
+ if (!selected.length) answers.delete(question.id);
664
+ else answers.set(question.id, {
665
+ id: question.id,
666
+ question: question.question,
667
+ answer: question.multiSelect ? selected.join(", ") : answer,
668
+ ...(question.multiSelect ? { selectedOptions: [...selected] } : {}),
669
+ wasCustom: question.multiSelect ? selected.some((answer) => !question.options.includes(answer)) : wasCustom,
670
+ });
671
+ if (!question.multiSelect) break;
672
+ }
673
+ }
674
+
675
+ return { answers: orderedAnswers(questions, answers), cancelled: false };
676
+ }
677
+
678
+ function parseGrillMeAction(args: string): GrillMeAction {
679
+ switch (args.trim().toLowerCase()) {
680
+ case "":
681
+ return "toggle";
682
+ case "on":
683
+ return "enable";
684
+ case "off":
685
+ return "disable";
686
+ case "status":
687
+ return "status";
688
+ default:
689
+ return "invalid";
690
+ }
691
+ }
692
+
693
+ function completeGrillMeArgs(prefix: string): AutocompleteItem[] | null {
694
+ const matches = GRILL_ME_ARGUMENTS.filter((arg) => arg.startsWith(prefix.trim().toLowerCase()));
695
+ return matches.length ? matches.map((value) => ({ value, label: value })) : null;
696
+ }
697
+
698
+ function isGrillMeState(data: unknown): data is GrillMeState {
699
+ return data !== null && typeof data === "object" && "enabled" in data && typeof data.enabled === "boolean";
700
+ }
701
+
702
+ function restoreGrillMeState(entries: Iterable<unknown>): boolean {
703
+ let enabled = false;
704
+ for (const entry of entries) {
705
+ if (entry === null || typeof entry !== "object") continue;
706
+ const record = entry as { type?: unknown; customType?: unknown; data?: unknown };
707
+ if (record.type === "custom" && record.customType === GRILL_ME_STATE_TYPE && isGrillMeState(record.data)) {
708
+ enabled = record.data.enabled;
709
+ }
710
+ }
711
+ return enabled;
712
+ }
713
+
714
+ const askQuestionTool = defineTool({
715
+ name: "ask_question",
716
+ 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.",
718
+ promptSnippet: "Ask the user clarifying questions through pi's UI",
719
+ promptGuidelines: [
720
+ "For ask_question, list options from most recommended to least; the first option is the recommended choice, and do not label it as recommended.",
721
+ "For ask_question, set multiSelect:true only when the user may need to choose more than one option.",
722
+ "For ask_question, a typed custom-answer option is added automatically; do not include your own.",
723
+ ],
724
+ parameters: AskQuestionParams,
725
+ executionMode: "sequential",
726
+
727
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
728
+ const questions = normalize(params);
729
+ if (!questions.length) throw new Error("ask_question needs either question or questions[].");
730
+ if (!ctx.hasUI) throw new Error("ask_question needs Pi TUI or RPC UI support.");
731
+
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;
735
+ try {
736
+ const result = ctx.mode === "tui"
737
+ ? await askWithKeyboard(questions, ctx.ui, questionSignal)
738
+ : await askWithDialogs(questions, ctx.ui, questionSignal);
739
+ const answers = new Map(result.answers.map((answer) => [answer.id, answer]));
740
+ const timedOut = result.cancelled && timeout.signal.aborted && !signal?.aborted;
741
+ const cancelled = result.cancelled && !timedOut;
742
+
743
+ return {
744
+ content: [{ type: "text", text: summarize(questions, answers, cancelled, timedOut) }],
745
+ details: { questions, answers: result.answers, cancelled, timedOut } satisfies AskQuestionDetails,
746
+ };
747
+ } finally {
748
+ clearTimeout(timer);
749
+ }
750
+ },
751
+
752
+ renderCall(args, theme, _context) {
753
+ const questions = normalize(args);
754
+ const count = questions.length;
755
+ const labels = questions.map((question) => question.id).join(", ");
756
+ let text = theme.fg("toolTitle", theme.bold("ask_question "));
757
+ text += theme.fg("muted", `${count} question${count !== 1 ? "s" : ""}`);
758
+ if (labels) text += theme.fg("dim", ` (${labels})`);
759
+ return new Text(text, 0, 0);
760
+ },
761
+
762
+ renderResult(result, _options, theme, _context) {
763
+ const details = result.details as AskQuestionDetails | undefined;
764
+ if (!details) {
765
+ const content = result.content[0];
766
+ return new Text(content?.type === "text" ? content.text : "", 0, 0);
767
+ }
768
+ if (details.timedOut) {
769
+ const content = result.content[0];
770
+ const text = theme.fg("warning", content?.type === "text" ? content.text : TIMEOUT_MESSAGE);
771
+ return details.answers.some((answer) => answer.selectedOptions) ? exactText(text) : new Text(text, 0, 0);
772
+ }
773
+ if (details.cancelled) return new Text(theme.fg("warning", "Cancelled"), 0, 0);
774
+ const lines = details.questions.map((question) => {
775
+ const answer = details.answers.find((entry) => entry.id === question.id);
776
+ return `${theme.fg("success", "✓ ")}${theme.fg("accent", question.id)}: ${formatAnswer(answer)}`;
777
+ });
778
+ const text = lines.join("\n");
779
+ return details.answers.some((answer) => answer.selectedOptions) ? exactText(text) : new Text(text, 0, 0);
780
+ },
781
+ });
782
+
783
+ function registerGrillMe(pi: ExtensionAPI) {
784
+ let grillMeMode = false;
785
+
786
+ function persistGrillMeMode() {
787
+ pi.appendEntry<GrillMeState>(GRILL_ME_STATE_TYPE, { enabled: grillMeMode });
788
+ }
789
+
790
+ function updateGrillMeStatus(ctx: Pick<ExtensionContext, "mode" | "ui">) {
791
+ if (ctx.mode !== "tui") return;
792
+ ctx.ui.setStatus(GRILL_ME_STATUS_KEY, grillMeMode ? ctx.ui.theme.fg("dim", "grill-mode") : undefined);
793
+ }
794
+
795
+ function restoreGrillMeMode(ctx: ExtensionContext) {
796
+ grillMeMode = restoreGrillMeState(ctx.sessionManager.getBranch());
797
+ }
798
+
799
+ pi.registerCommand("grill-me", {
800
+ description: "Toggle a mode that makes the agent pressure-test requests with ask_question",
801
+ getArgumentCompletions: completeGrillMeArgs,
802
+ handler: async (args, ctx) => {
803
+ const action = parseGrillMeAction(args);
804
+ if (action === "enable") grillMeMode = true;
805
+ else if (action === "disable") grillMeMode = false;
806
+ else if (action === "toggle") grillMeMode = !grillMeMode;
807
+ else if (action === "invalid") {
808
+ ctx.ui.notify("Usage: /grill-me [on|off|status]", "warning");
809
+ return;
810
+ }
811
+
812
+ if (action !== "status") persistGrillMeMode();
813
+ updateGrillMeStatus(ctx);
814
+ ctx.ui.notify(`Grill-me mode ${grillMeMode ? "enabled" : "disabled"}`, "info");
815
+ },
816
+ });
817
+
818
+ pi.on("session_start", async (_event, ctx) => {
819
+ restoreGrillMeMode(ctx);
820
+ updateGrillMeStatus(ctx);
821
+ });
822
+
823
+ pi.on("session_tree", async (_event, ctx) => {
824
+ restoreGrillMeMode(ctx);
825
+ updateGrillMeStatus(ctx);
826
+ });
827
+
828
+ pi.on("before_agent_start", async (event, ctx) => {
829
+ if (!grillMeMode) return undefined;
830
+ const useTool = ctx.hasUI && pi.getActiveTools().includes("ask_question");
831
+ const guidance = grillMePrompt(useTool);
832
+ if (event.systemPromptOptions.forceSystemPrompt === undefined) {
833
+ event.systemPromptOptions.sections.grill_me = guidance;
834
+ return;
835
+ }
836
+ return { systemPrompt: `${event.systemPrompt}\n\n${guidance}` };
837
+ });
838
+ }
839
+
840
+ export default function askQuestion(pi: ExtensionAPI) {
841
+ pi.registerTool(askQuestionTool);
842
+ registerGrillMe(pi);
843
+ }
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "@fitchmultz/pi-ask-question",
3
+ "version": "0.5.1",
4
+ "description": "Pi extension that adds an interactive ask_question tool and /grill-me pressure-test mode",
5
+ "author": "Mitch Fultz (https://github.com/fitchmultz)",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "engines": {
9
+ "node": ">=24.0.0"
10
+ },
11
+ "keywords": [
12
+ "pi-package",
13
+ "pi",
14
+ "pi-extension",
15
+ "extension",
16
+ "ask-question",
17
+ "clarifying-questions",
18
+ "grill-me",
19
+ "custom-tool",
20
+ "tui",
21
+ "typescript"
22
+ ],
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/fitchmultz/pi-ask-question.git"
26
+ },
27
+ "bugs": {
28
+ "url": "https://github.com/fitchmultz/pi-ask-question/issues"
29
+ },
30
+ "homepage": "https://github.com/fitchmultz/pi-ask-question#readme",
31
+ "scripts": {
32
+ "test": "node --test tests/*.test.ts",
33
+ "typecheck": "tsc --noEmit",
34
+ "check:compat": "npm run check",
35
+ "check": "npm test && npm run typecheck && npm pack --dry-run",
36
+ "prepublishOnly": "npm run check"
37
+ },
38
+ "files": [
39
+ "extensions",
40
+ "README.md",
41
+ "CHANGELOG.md",
42
+ "LICENSE"
43
+ ],
44
+ "pi": {
45
+ "extensions": [
46
+ "./extensions"
47
+ ]
48
+ },
49
+ "peerDependencies": {
50
+ "@earendil-works/pi-coding-agent": "*",
51
+ "@earendil-works/pi-tui": "*",
52
+ "typebox": "*"
53
+ },
54
+ "peerDependenciesMeta": {
55
+ "@earendil-works/pi-coding-agent": {
56
+ "optional": true
57
+ },
58
+ "@earendil-works/pi-tui": {
59
+ "optional": true
60
+ },
61
+ "typebox": {
62
+ "optional": true
63
+ }
64
+ },
65
+ "devDependencies": {
66
+ "@earendil-works/pi-coding-agent": "1.0.0",
67
+ "@earendil-works/pi-tui": "1.0.0",
68
+ "@types/node": "24.19.1",
69
+ "typebox": "1.3.34",
70
+ "typescript": "7.0.2"
71
+ },
72
+ "packageManager": "npm@12.2.0",
73
+ "publishConfig": {
74
+ "access": "public"
75
+ }
76
+ }