@fyeeme/pi-ask-user 2.0.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 ADDED
@@ -0,0 +1,53 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [2.0.0] - 2026-08-25
6
+
7
+ **Major release — first npm publication.** Ships as part of the 2.0 extensions family wave (pi-review / pi-dynamic-workflows / pi-subagents). Highlights below.
8
+
9
+ ### Added
10
+
11
+ - Review page: multi-question dialogs summarize all answers (custom inputs, notes, unanswered warnings) after the last question; `enter` confirms, `left` revises before submitting. Single-question dialogs keep submitting immediately.
12
+ - Per-question status strip (`●`/`○` chips labeled with `header`) showing which questions are answered.
13
+ - Inline free-text editor for `Other` answers and notes: the option list stays visible while typing, `esc` returns to the rows, an empty submit declines, and revising prefills the previous text. The dialog no longer closes and reopens around a plain input.
14
+ - Numbered options: rows render `1. label` and answers echo the number back to the LLM (`auth: 1. JWT`).
15
+ - `tab` / `shift+tab` as aliases for `right` / `left` question navigation.
16
+
17
+ ### Fixed
18
+
19
+ - Single-select questions no longer look multi-selectable: `space` is now a no-op outside `multi` mode, and selecting a different option replaces the previous `(o)` marker instead of stacking another one.
20
+ - Multi-select `enter` with nothing checked is now a no-op instead of recording an empty answer that lit the status chip `●` but echoed `(no selection)` back to the LLM. Users who mean "none of these" can say so via `Other`.
21
+
22
+ ### Changed
23
+
24
+ - Submitting a custom `Other` answer now also clears stale checkbox marks from the same question.
25
+ - Answering a revisited question now jumps to the next unanswered question (or straight to the review page when everything is answered) instead of always stepping one forward and forcing a re-walk of already-answered questions.
26
+
27
+ ## [1.2.0] - 2026-07-20
28
+
29
+ ### Added
30
+
31
+ - `/ask-demo` command: interactive four-phase battery covering all question types, free-form `Other` input, timeout auto-selection, chat redirect, and cancel semantics — each phase reports the exact text the LLM would receive.
32
+
33
+ ## [1.1.0] - 2026-07-20
34
+
35
+ ### Added
36
+
37
+ - Single full-screen dialog for all questions: `[n/m]` progress counter, `←`/`→` navigation between questions, answer revision with preserved cursor/selection state.
38
+ - `timeoutSeconds` parameter: overall budget; on expiry unanswered questions auto-select the recommended option and are flagged `timedOut` in the result.
39
+ - `header` question chip, option `preview` lines rendered under the cursored row, and `note` attachment via the `n` key.
40
+ - `Chat about this` reserved row: ends the call with a `chatRedirect` result so the LLM can switch to discussion.
41
+ - Reserved-label collision check in the execution path (fails fast instead of rendering duplicate rows).
42
+ - Agent abort now closes an open dialog and settles the tool as cancelled instead of leaving it pending.
43
+
44
+ ### Changed
45
+
46
+ - Multi-select `enter` records the current checkbox set as-is (`space` toggles); re-selecting a single-select option replaces a previous custom input.
47
+
48
+ ## [1.0.0] - 2026-07-20
49
+
50
+ ### Added
51
+
52
+ - `ask_user` tool: multi-question clarifying prompts with single/multi select, recommended defaults, option descriptions, and free-form "Other" input.
53
+ - Custom TUI picker (radio/checkbox) rendered via `ctx.ui.custom`; answers persisted in tool result details for branch-safe replay.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 fyeeme
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,134 @@
1
+ # pi-ask-user
2
+
3
+ **2.0.0 — first npm release**, part of the 2.0 extensions family wave. Release highlights:
4
+
5
+ - **Review page** — multi-question dialogs summarize every answer (custom inputs, notes, unanswered warnings) before submitting; `enter` confirms, `left` revises.
6
+ - **Inline free-text editor** — `Other` answers and notes type directly inside the dialog (option list stays visible, `esc` returns, empty submit declines).
7
+ - **Numbered options** — rows render `1. label` and answers echo the number back (`auth: 1. JWT`), plus a per-question status strip and `tab`/`shift+tab` navigation.
8
+ - Selection semantics hardened: single-select no longer stacks markers, multi-select `enter` with nothing checked is a no-op, custom `Other` answers clear stale checkboxes.
9
+
10
+ Structured `ask_user` tool for [pi](https://github.com/earendil-works/pi-coding-agent). It lets the LLM surface clarifying questions with selectable options while it works, instead of guessing when choices have materially different tradeoffs.
11
+
12
+ Ported from the interactive ask flow of [oh-my-pi](https://github.com/can1357/oh-my-pi), adapted to pi's public extension API.
13
+
14
+ ## Features
15
+
16
+ - **Multiple questions in one dialog** — all questions presented through a single dialog with a `[1/3]` progress counter and a per-question status strip (`● Framework ○ Style`); `←` revisits earlier questions to revise answers (cursor and selections preserved), `→` moves forward once the current question is answered.
17
+ - **Review before submit** — after the last question, multi-question dialogs show a summary page listing every answer (custom inputs, notes, unanswered warnings); `enter` confirms, `←` goes back to revise. Single-question dialogs still submit immediately.
18
+ - **Smart advance** — answering jumps to the next unanswered question, or straight to the review page once everything is answered; revising an earlier answer never forces a re-walk of already-answered ones.
19
+ - **Single or multi select** — `multi: true` renders checkboxes (`space` toggles, `enter` records the set and is a no-op while nothing is checked); single-select renders radio markers with the recommended option pre-cursored and suffixed `(Recommended)`.
20
+ - **Timeout auto-selection** — optional `timeoutSeconds` budget for the whole dialog; on expiry unanswered questions auto-select the recommended option (or the first) and are flagged `timedOut` so the LLM knows no human chose them.
21
+ - **Option descriptions & previews** — short tradeoff text under each label; an option's `preview` lines render while the cursor rests on it. Options render and echo back numbered (`1. label`), so answers read `auth: 1. JWT`.
22
+ - **Free-form "Other"** — every question gets an automatic `Other (type your own)` row that opens an editor embedded in the dialog: the option list stays visible while typing, `esc` returns to the rows, and an empty submit declines. Re-selecting an option clears a previous custom input.
23
+ - **Answer notes** — press `n` to attach a note through the same inline editor (prefilled when revising); notes are echoed back to the LLM.
24
+ - **"Chat about this" redirect** — a reserved row that ends the call with a `chatRedirect` result, telling the LLM the user prefers discussing over answering.
25
+ - **Abort-safe** — if the agent turn is aborted while a question is open, the dialog closes and the tool settles as cancelled instead of hanging.
26
+ - **Branch-safe state** — answers live in the tool result `details`, so `/tree` branching and session replay see exactly what was asked and answered.
27
+ - **Headless-safe** — throws a proper tool error in `-p`/JSON modes instead of hanging.
28
+
29
+ ## Tool schema
30
+
31
+ ```text
32
+ ask_user(
33
+ questions: [
34
+ {
35
+ id: string // stable identifier, echoed in the answer
36
+ question: string // shown to the user
37
+ header?: string // short chip rendered next to the progress counter
38
+ options: [{ // 2-6 options
39
+ label,
40
+ description?, // tradeoff text under the label
41
+ preview? // lines shown while the cursor rests on the option
42
+ }]
43
+ multi?: boolean // allow multiple selections
44
+ recommended?: number // 0-based index of the recommended option
45
+ }
46
+ ],
47
+ timeoutSeconds?: number // overall budget; expiry auto-selects recommended
48
+ )
49
+ ```
50
+
51
+ ## Keys
52
+
53
+ | Key | Action |
54
+ |-----|--------|
55
+ | `up` / `down` | Move cursor across rows |
56
+ | `space` | Toggle checkbox (multi-select only) |
57
+ | `enter` | Select / record answer / advance to the next unanswered question (no-op in multi-select with nothing checked); on the review page, submit |
58
+ | `←` / `→` | Previous / next question (`→` requires an answer first) |
59
+ | `tab` / `shift+tab` | Aliases for `→` / `←` |
60
+ | `n` | Attach a note to the current answer |
61
+ | `esc` | Cancel the whole call (inside the inline editor: back to the rows) |
62
+
63
+ ## Trying it out: `/ask-demo`
64
+
65
+ The extension registers an interactive battery that exercises every feature end-to-end:
66
+
67
+ 1. **All question types** — single-select with `(Recommended)` + cursor-rest `preview`, multi-select checkboxes, and an `Other (type your own)` free-form answer (add a note with `n` on the last question), then confirm on the review page.
68
+ 2. **Timeout** — a dialog with a 6-second budget; do nothing and watch it auto-select the recommended option.
69
+ 3. **Chat redirect** — pick the `Chat about this` row.
70
+ 4. **Cancel** — press `Esc` on the first of two questions and confirm the second is never asked.
71
+
72
+ Each phase reports the collected answers back through a notification so you can verify what the LLM would receive.
73
+
74
+ ## Install
75
+
76
+ ```bash
77
+ # per-project
78
+ mkdir -p .pi/extensions && cp -r packages/extensions/pi-ask-user .pi/extensions/
79
+
80
+ # or globally
81
+ cp -r packages/extensions/pi-ask-user ~/.pi/agent/extensions/
82
+ ```
83
+
84
+ Or load ad hoc:
85
+
86
+ ```bash
87
+ pi -e ./packages/extensions/pi-ask-user/index.ts
88
+ ```
89
+
90
+ ## Usage
91
+
92
+ Just ask the agent something ambiguous; with the tool active the LLM can call:
93
+
94
+ ```json
95
+ {
96
+ "questions": [
97
+ {
98
+ "id": "storage",
99
+ "question": "Which storage backend should this feature use?",
100
+ "options": [
101
+ { "label": "SQLite", "description": "Zero-config, file-based" },
102
+ { "label": "PostgreSQL", "description": "Full server, richer types" }
103
+ ],
104
+ "recommended": 0
105
+ },
106
+ {
107
+ "id": "flags",
108
+ "question": "Which extras should be enabled?",
109
+ "multi": true,
110
+ "options": [{ "label": "Telemetry" }, { "label": "Auto-update" }]
111
+ }
112
+ ]
113
+ }
114
+ ```
115
+
116
+ The user picks with arrow keys (`space` toggles in multi mode, `enter` submits, `←` revises earlier answers, `esc` cancels). Cancelling marks the call cancelled and tells the LLM to proceed conservatively.
117
+
118
+ ## Boundaries
119
+
120
+ - Requires an interactive session (TUI or RPC); in print/JSON mode the tool errors out.
121
+ - No per-question timers: `timeoutSeconds` budgets the whole dialog, not each question.
122
+ - No TTS, system-level notifications, or `/tree` re-answer branching — pi's extension API does not expose those surfaces; answers remain inspectable via persisted `details`.
123
+
124
+ ## Development
125
+
126
+ ```bash
127
+ npm install --ignore-scripts
128
+ npm run typecheck
129
+ npm test
130
+ ```
131
+
132
+ ## License
133
+
134
+ MIT
package/index.ts ADDED
@@ -0,0 +1,925 @@
1
+ /**
2
+ * pi-ask-user - Structured multi-question ask tool for pi
3
+ *
4
+ * Registers an `ask_user` tool that lets the LLM surface clarifying questions
5
+ * with options while it works: all questions presented through one dialog with
6
+ * left/right (or tab/shift+tab) navigation between them, a per-question status
7
+ * strip, single or multi select, a recommended default, optional
8
+ * descriptions/previews, numbered options, free-text "Other" and answer notes
9
+ * captured by an editor embedded in the dialog (the option list stays visible
10
+ * while typing), a review page that summarizes every answer before submitting,
11
+ * an optional overall timeout that auto-selects the recommended options, and a
12
+ * "Chat about this" redirect for deferring the decision into conversation.
13
+ *
14
+ * Answers are stored in tool result `details` so they survive branching and
15
+ * `/tree` inspection.
16
+ */
17
+
18
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionUIContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
19
+ import type { Theme } from "@earendil-works/pi-coding-agent";
20
+ import { Editor, type EditorTheme, Key, matchesKey, Text, type TUI, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
21
+ import { Type } from "typebox";
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Types & schema
25
+ // ---------------------------------------------------------------------------
26
+
27
+ export interface AskOption {
28
+ label: string;
29
+ description?: string;
30
+ preview?: string;
31
+ }
32
+
33
+ export interface AskQuestion {
34
+ id: string;
35
+ question: string;
36
+ header?: string;
37
+ options: AskOption[];
38
+ multi: boolean;
39
+ recommended?: number;
40
+ }
41
+
42
+ export interface QuestionResult {
43
+ id: string;
44
+ question: string;
45
+ options: string[];
46
+ multi: boolean;
47
+ selectedOptions: string[];
48
+ customInput?: string;
49
+ note?: string;
50
+ /** True when the answer was auto-selected because the dialog timed out. */
51
+ timedOut?: boolean;
52
+ }
53
+
54
+ export interface AskUserDetails {
55
+ /** Per-question answers in the order the questions were asked. */
56
+ results?: QuestionResult[];
57
+ /** Original question texts (present when the user cancelled mid-way). */
58
+ questions?: string[];
59
+ /** True when the user pressed Escape before finishing all questions. */
60
+ cancelled?: boolean;
61
+ /** True when the user chose to discuss the questions instead of answering. */
62
+ chatRedirect?: boolean;
63
+ }
64
+
65
+ const OTHER_OPTION = "Other (type your own)";
66
+ const CHAT_OPTION = "Chat about this";
67
+ const RECOMMENDED_SUFFIX = " (Recommended)";
68
+
69
+ const RESERVED_LABELS = new Set([OTHER_OPTION, CHAT_OPTION]);
70
+
71
+ const OptionSchema = Type.Object({
72
+ label: Type.String({ description: "Short display label (1-5 words)" }),
73
+ description: Type.Optional(Type.String({ description: "Optional tradeoff/explanation shown below the label" })),
74
+ preview: Type.Optional(
75
+ Type.String({ description: "Optional rich preview shown while the cursor rests on this option" }),
76
+ ),
77
+ });
78
+
79
+ const AskQuestionSchema = Type.Object({
80
+ id: Type.String({ description: "Stable identifier for the question, e.g. auth_method" }),
81
+ question: Type.String({ description: "The question text shown to the user" }),
82
+ header: Type.Optional(Type.String({ description: "Short display chip rendered next to the progress counter" })),
83
+ options: Type.Array(OptionSchema, {
84
+ minItems: 2,
85
+ maxItems: 6,
86
+ description: "2-6 distinct options",
87
+ }),
88
+ multi: Type.Optional(Type.Boolean({ description: "Allow selecting multiple options" })),
89
+ recommended: Type.Optional(
90
+ Type.Integer({ minimum: 0, description: "Index of the recommended option (0-based)" }),
91
+ ),
92
+ });
93
+
94
+ const AskParamsSchema = Type.Object({
95
+ questions: Type.Array(AskQuestionSchema, { minItems: 1, maxItems: 6, description: "Questions to ask" }),
96
+ timeoutSeconds: Type.Optional(
97
+ Type.Integer({
98
+ minimum: 1,
99
+ description:
100
+ "Overall time budget for the whole dialog; on expiry unanswered questions auto-select the recommended option",
101
+ }),
102
+ ),
103
+ });
104
+
105
+ interface AskParams {
106
+ questions: Array<{
107
+ id: string;
108
+ question: string;
109
+ header?: string;
110
+ options: Array<{ label: string; description?: string; preview?: string }>;
111
+ multi?: boolean;
112
+ recommended?: number;
113
+ }>;
114
+ timeoutSeconds?: number;
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Pure helpers (exported for tests)
119
+ // ---------------------------------------------------------------------------
120
+
121
+ /**
122
+ * Coerce untrusted call arguments (partially streamed, model-mangled, or
123
+ * double-encoded as a JSON string) into well-formed questions. Returns
124
+ * undefined when nothing renderable remains.
125
+ */
126
+ export function normalizeQuestions(raw: unknown): AskQuestion[] | undefined {
127
+ if (typeof raw === "string") {
128
+ try {
129
+ raw = JSON.parse(raw);
130
+ } catch {
131
+ return undefined;
132
+ }
133
+ }
134
+ if (!Array.isArray(raw)) return undefined;
135
+
136
+ const questions: AskQuestion[] = [];
137
+ for (const entry of raw) {
138
+ if (!entry || typeof entry !== "object") continue;
139
+ const q = entry as Record<string, unknown>;
140
+ if (typeof q.id !== "string" || typeof q.question !== "string") continue;
141
+ if (!Array.isArray(q.options)) continue;
142
+
143
+ const options: AskOption[] = [];
144
+ for (const opt of q.options) {
145
+ if (typeof opt === "string") {
146
+ options.push({ label: opt });
147
+ continue;
148
+ }
149
+ if (!opt || typeof opt !== "object") continue;
150
+ const o = opt as Record<string, unknown>;
151
+ if (typeof o.label !== "string" || RESERVED_LABELS.has(o.label)) continue;
152
+ const option: AskOption = { label: o.label };
153
+ if (typeof o.description === "string") option.description = o.description;
154
+ if (typeof o.preview === "string") option.preview = o.preview;
155
+ options.push(option);
156
+ }
157
+ if (options.length < 2) continue;
158
+
159
+ questions.push({
160
+ id: q.id,
161
+ question: q.question,
162
+ ...(typeof q.header === "string" ? { header: q.header } : {}),
163
+ options,
164
+ multi: q.multi === true,
165
+ recommended:
166
+ typeof q.recommended === "number" &&
167
+ Number.isInteger(q.recommended) &&
168
+ q.recommended >= 0 &&
169
+ q.recommended < options.length
170
+ ? q.recommended
171
+ : undefined,
172
+ });
173
+ }
174
+ return questions.length > 0 ? questions : undefined;
175
+ }
176
+
177
+ /** Add "(Recommended)" to the label at `index`; other labels pass through. */
178
+ export function addRecommendedSuffix(options: AskOption[], index: number | undefined): string[] {
179
+ return options.map((option, i) =>
180
+ i === index && !option.label.endsWith(RECOMMENDED_SUFFIX) ? option.label + RECOMMENDED_SUFFIX : option.label,
181
+ );
182
+ }
183
+
184
+ /** Strip a trailing "(Recommended)" marker added for display. */
185
+ export function stripRecommendedSuffix(label: string): string {
186
+ return label.endsWith(RECOMMENDED_SUFFIX) ? label.slice(0, -RECOMMENDED_SUFFIX.length) : label;
187
+ }
188
+
189
+ /** Auto-selection for one question on timeout: recommended option, else first. */
190
+ export function autoSelectionForQuestion(question: AskQuestion): string[] {
191
+ if (question.options.length === 0) return [];
192
+ const index =
193
+ typeof question.recommended === "number" && question.recommended >= 0 && question.recommended < question.options.length
194
+ ? question.recommended
195
+ : 0;
196
+ return [stripRecommendedSuffix(question.options[index]!.label)];
197
+ }
198
+
199
+ /** Prefix a label with its 1-based option number, e.g. `2. OAuth2` (raw label when not found). */
200
+ export function numberedLabel(options: readonly string[], label: string): string {
201
+ const index = options.indexOf(label);
202
+ return index >= 0 ? `${index + 1}. ${label}` : label;
203
+ }
204
+
205
+ /** Display form of an answer: `"custom text"`, `2. OAuth2`, `[1. A, 3. C]`, or `(no selection)`. */
206
+ export function formatAnswerValue(
207
+ options: string[],
208
+ multi: boolean,
209
+ answer: { selectedOptions: string[]; customInput?: string },
210
+ ): string {
211
+ if (answer.customInput !== undefined) return `"${answer.customInput}"`;
212
+ if (answer.selectedOptions.length === 0) return "(no selection)";
213
+ const numbered = answer.selectedOptions.map((label) => numberedLabel(options, label));
214
+ return multi ? `[${numbered.join(", ")}]` : numbered[0]!;
215
+ }
216
+
217
+ /** Human-readable answer line for one question, e.g. `auth_method: 1. JWT`. */
218
+ export function formatAnswerLine(result: QuestionResult): string {
219
+ let line = `${result.id}: ${formatAnswerValue(result.options, result.multi, result)}`;
220
+ if (result.timedOut) line += " (auto-selected after timeout)";
221
+ if (result.note !== undefined) line += ` (note: ${result.note})`;
222
+ return line;
223
+ }
224
+
225
+ /** Full text returned to the LLM describing what the user answered. */
226
+ export function formatAnswerText(results: QuestionResult[], cancelled = false): string {
227
+ const lines = results.map(formatAnswerLine);
228
+ const header = results.length === 1 ? "User answer:" : "User answers:";
229
+ let text = lines.length > 0 ? `${header}\n${lines.join("\n")}` : "No questions were answered";
230
+ if (cancelled) {
231
+ text +=
232
+ "\n\nThe user cancelled before answering every question. Proceed with what you have or take the least destructive action.";
233
+ }
234
+ return text;
235
+ }
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // Multi-question dialog (radio / checkbox list rendered via ctx.ui.custom)
239
+ // ---------------------------------------------------------------------------
240
+
241
+ type AskUi = Pick<ExtensionUIContext, "custom">;
242
+
243
+ interface DialogAction {
244
+ kind: "submit" | "chat" | "cancel";
245
+ timedOut?: boolean;
246
+ }
247
+
248
+ interface DialogState {
249
+ index: number;
250
+ cursors: number[]; // per question: highlighted row 0..options.length+1 (Other, Chat)
251
+ checked: Array<Set<number>>; // per question (multi mode)
252
+ answers: Array<{ selectedOptions: string[]; customInput?: string } | undefined>;
253
+ notes: Array<string | undefined>;
254
+ }
255
+
256
+ function initialDialogState(questions: AskQuestion[]): DialogState {
257
+ return {
258
+ index: 0,
259
+ cursors: questions.map((q) => Math.min(q.recommended ?? 0, q.options.length)),
260
+ checked: questions.map(() => new Set<number>()),
261
+ answers: questions.map(() => undefined),
262
+ notes: questions.map(() => undefined),
263
+ };
264
+ }
265
+
266
+ const MAX_PREVIEW_LINES = 6;
267
+
268
+ function createAskDialog(
269
+ tui: TUI,
270
+ theme: Theme,
271
+ questions: AskQuestion[],
272
+ state: DialogState,
273
+ deadline: number | undefined,
274
+ signal: AbortSignal | undefined,
275
+ done: (action: DialogAction) => void,
276
+ ): { render: (width: number) => string[]; invalidate: () => void; handleInput: (data: string) => boolean; dispose: () => void } {
277
+ let cachedLines: string[] | undefined;
278
+ let settled = false;
279
+ /** Free-text overlay: "other" captures a custom answer, "note" annotates the current one. */
280
+ let inputMode: "other" | "note" | null = null;
281
+
282
+ const settle = (action: DialogAction): void => {
283
+ if (settled) return;
284
+ settled = true;
285
+ clearTimeout(timerId);
286
+ signal?.removeEventListener("abort", onAbort);
287
+ done(action);
288
+ };
289
+
290
+ function onAbort(): void {
291
+ settle({ kind: "cancel" });
292
+ }
293
+ signal?.addEventListener("abort", onAbort, { once: true });
294
+
295
+ const remainingMs = deadline === undefined ? undefined : Math.max(0, deadline - Date.now());
296
+ const timerId =
297
+ remainingMs === undefined ? undefined : setTimeout(() => settle({ kind: "submit", timedOut: true }), remainingMs);
298
+ if (typeof timerId === "object" && timerId !== null && "unref" in timerId) timerId.unref();
299
+
300
+ const question = () => questions[state.index];
301
+ const onSummary = () => state.index === questions.length;
302
+ const allAnswered = () => questions.every((_q, i) => state.answers[i] !== undefined);
303
+
304
+ // Embedded single-line editor for "Other" answers and notes: the option
305
+ // list stays visible while typing and Esc returns to the rows.
306
+ const editorTheme: EditorTheme = {
307
+ borderColor: (s) => theme.fg("accent", s),
308
+ selectList: {
309
+ selectedPrefix: (t) => theme.fg("accent", t),
310
+ selectedText: (t) => theme.fg("accent", t),
311
+ description: (t) => theme.fg("muted", t),
312
+ scrollInfo: (t) => theme.fg("dim", t),
313
+ noMatch: (t) => theme.fg("warning", t),
314
+ },
315
+ };
316
+ const editor = new Editor(tui, editorTheme);
317
+
318
+ editor.onSubmit = (value: string): void => {
319
+ if (inputMode === "other") {
320
+ if (value.length > 0) {
321
+ state.answers[state.index] = { selectedOptions: [], customInput: value };
322
+ state.checked[state.index] = new Set();
323
+ advanceAfterAnswer();
324
+ } else {
325
+ exitInputMode(); // empty submit declines the custom input
326
+ }
327
+ } else if (inputMode === "note") {
328
+ state.notes[state.index] = value.length > 0 ? value : undefined;
329
+ exitInputMode();
330
+ }
331
+ };
332
+
333
+ function enterInputMode(mode: "other" | "note"): void {
334
+ inputMode = mode;
335
+ const prefill = mode === "note" ? state.notes[state.index] : state.answers[state.index]?.customInput;
336
+ editor.setText(prefill ?? "");
337
+ cachedLines = undefined;
338
+ }
339
+
340
+ function exitInputMode(): void {
341
+ inputMode = null;
342
+ editor.setText("");
343
+ cachedLines = undefined;
344
+ }
345
+
346
+ function move(delta: number): void {
347
+ if (onSummary()) return;
348
+ const rows = question().options.length + 2; // + Other + Chat
349
+ state.cursors[state.index] = Math.min(Math.max(state.cursors[state.index]! + delta, 0), rows - 1);
350
+ cachedLines = undefined;
351
+ }
352
+
353
+ function toggle(): void {
354
+ const q = question();
355
+ const cursor = state.cursors[state.index]!;
356
+ if (cursor < q.options.length) {
357
+ const checked = state.checked[state.index]!;
358
+ if (checked.has(cursor)) checked.delete(cursor);
359
+ else checked.add(cursor);
360
+ cachedLines = undefined;
361
+ }
362
+ }
363
+
364
+ function recordCurrentAnswer(): void {
365
+ const q = question();
366
+ const cursor = state.cursors[state.index]!;
367
+ if (cursor === q.options.length) return; // handled by caller ("other")
368
+ if (cursor === q.options.length + 1) return; // handled by caller ("chat")
369
+ if (!q.multi) {
370
+ const label = stripRecommendedSuffix(addRecommendedSuffix(q.options, q.recommended)[cursor]!);
371
+ // Re-selecting an option replaces any previous custom input (omp semantics).
372
+ state.answers[state.index] = { selectedOptions: [label] };
373
+ } else {
374
+ const labels = [...state.checked[state.index]!].sort((a, b) => a - b).map((i) => q.options[i]?.label ?? "");
375
+ state.answers[state.index] = { selectedOptions: labels };
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Advance after an answer. Single-question dialogs submit immediately;
381
+ * multi-question dialogs jump to the next unanswered question, or the
382
+ * review page once every question has an answer (revising an earlier
383
+ * answer skips the already-answered ones instead of re-walking them).
384
+ */
385
+ function advanceAfterAnswer(): void {
386
+ exitInputMode();
387
+ if (questions.length === 1) {
388
+ settle({ kind: "submit" });
389
+ return;
390
+ }
391
+ const next = questions.findIndex((_q, i) => state.answers[i] === undefined);
392
+ state.index = next === -1 ? questions.length : next; // -1 → review page
393
+ cachedLines = undefined;
394
+ }
395
+
396
+ return {
397
+ render(width: number): string[] {
398
+ if (cachedLines) return cachedLines;
399
+ const q = onSummary() ? undefined : question();
400
+ const lines: string[] = [];
401
+ const renderWidth = Math.max(1, width);
402
+
403
+ const wrapWithPrefix = (prefix: string, text: string) => {
404
+ const prefixWidth = visibleWidth(prefix);
405
+ const wrapped = wrapTextWithAnsi(text, Math.max(1, renderWidth - prefixWidth));
406
+ const continuationPrefix = " ".repeat(Math.min(prefixWidth, renderWidth));
407
+ for (let i = 0; i < wrapped.length; i++) {
408
+ lines.push(i === 0 ? prefix + wrapped[i] : continuationPrefix + wrapped[i]);
409
+ }
410
+ };
411
+
412
+ lines.push(theme.fg("accent", "-".repeat(renderWidth)));
413
+
414
+ // Per-question status strip (multi-question dialogs only).
415
+ if (questions.length > 1) {
416
+ const chips = questions.map((question_, i) => {
417
+ const answered = state.answers[i] !== undefined;
418
+ const marker = theme.fg(answered ? "success" : "dim", answered ? "●" : "○");
419
+ const label = question_.header ?? `Q${i + 1}`;
420
+ const color = i === state.index ? "text" : answered ? "muted" : "dim";
421
+ return `${marker} ${theme.fg(color, label)}`;
422
+ });
423
+ wrapWithPrefix(" ", chips.join(" "));
424
+ }
425
+
426
+ if (!q) {
427
+ // Review page: summarize every answer, confirm on Enter.
428
+ const answeredCount = questions.filter((_question, i) => state.answers[i] !== undefined).length;
429
+ wrapWithPrefix(
430
+ " ",
431
+ `${theme.fg("accent", "Review")} ${theme.fg("muted", `(${answeredCount}/${questions.length} answered)`)}`,
432
+ );
433
+ lines.push("");
434
+ questions.forEach((question_, i) => {
435
+ const answer = state.answers[i];
436
+ const label = question_.header ?? question_.question;
437
+ if (!answer) {
438
+ wrapWithPrefix(" ", `${theme.fg("dim", "○")} ${theme.fg("warning", `${label}: (unanswered)`)}`);
439
+ return;
440
+ }
441
+ const value = formatAnswerValue(
442
+ question_.options.map((option) => option.label),
443
+ question_.multi,
444
+ answer,
445
+ );
446
+ wrapWithPrefix(" ", `${theme.fg("success", "●")} ${theme.fg("muted", `${label}: `)}${theme.fg("accent", value)}`);
447
+ if (state.notes[i] !== undefined) {
448
+ wrapWithPrefix(" ", theme.fg("dim", `note: ${state.notes[i]}`));
449
+ }
450
+ });
451
+ lines.push("");
452
+ if (allAnswered()) {
453
+ wrapWithPrefix(" ", theme.fg("success", "Press Enter to submit"));
454
+ wrapWithPrefix(" ", theme.fg("dim", "left revise - esc cancel"));
455
+ } else {
456
+ const missing = questions
457
+ .filter((_question, i) => state.answers[i] === undefined)
458
+ .map((question_) => question_.header ?? question_.question)
459
+ .join(", ");
460
+ wrapWithPrefix(" ", theme.fg("warning", `Unanswered: ${missing}`));
461
+ }
462
+ } else {
463
+ const progress = `[${state.index + 1}/${questions.length}]`;
464
+ const headerChip = q.header ? ` ${theme.fg("muted", q.header)}` : "";
465
+ const noteMark = state.notes[state.index] !== undefined ? theme.fg("dim", " · n") : "";
466
+ wrapWithPrefix(" ", `${theme.fg("accent", progress)}${headerChip}${noteMark} ${theme.fg("text", q.question)}`);
467
+ lines.push("");
468
+
469
+ const labels = addRecommendedSuffix(q.options, q.multi ? undefined : q.recommended);
470
+ q.options.forEach((option, i) => {
471
+ const cursorHere = state.cursors[state.index] === i;
472
+ const checked = state.checked[state.index]!.has(i);
473
+ const marker = q.multi
474
+ ? checked
475
+ ? theme.fg("success", "[x]")
476
+ : theme.fg("dim", "[ ]")
477
+ : checked
478
+ ? theme.fg("success", "(o)")
479
+ : theme.fg("dim", "( )");
480
+ const cursorPrefix = cursorHere ? theme.fg("accent", "> ") : " ";
481
+ wrapWithPrefix(cursorPrefix, `${marker} ${theme.fg(checked ? "accent" : "text", `${i + 1}. ${labels[i]}`)}`);
482
+ if (option.description) wrapWithPrefix(" ", theme.fg("muted", option.description));
483
+ if (cursorHere && option.preview) {
484
+ for (const previewLine of option.preview.split("\n").slice(0, MAX_PREVIEW_LINES)) {
485
+ wrapWithPrefix(" ", theme.fg("dim", previewLine));
486
+ }
487
+ }
488
+ });
489
+
490
+ lines.push("");
491
+ const cursor = state.cursors[state.index]!;
492
+ const otherCursor = cursor === q.options.length;
493
+ const chatCursor = cursor === q.options.length + 1;
494
+ const otherLabel = inputMode === "other" ? `${OTHER_OPTION} ✎` : OTHER_OPTION;
495
+ wrapWithPrefix(
496
+ otherCursor ? theme.fg("accent", "> ") : " ",
497
+ theme.fg(otherCursor ? "accent" : "text", otherLabel),
498
+ );
499
+ wrapWithPrefix(
500
+ chatCursor ? theme.fg("accent", "> ") : " ",
501
+ theme.fg(chatCursor ? "accent" : "text", CHAT_OPTION),
502
+ );
503
+
504
+ if (inputMode !== null) {
505
+ lines.push("");
506
+ wrapWithPrefix(" ", theme.fg("muted", inputMode === "other" ? "Your answer:" : "Note:"));
507
+ for (const line of editor.render(Math.max(1, renderWidth - 2))) {
508
+ lines.push(` ${line}`);
509
+ }
510
+ lines.push("");
511
+ wrapWithPrefix(" ", theme.fg("dim", "enter submit - esc back"));
512
+ } else {
513
+ lines.push("");
514
+ const hints = q.multi
515
+ ? questions.length > 1
516
+ ? "space toggle - enter next - left/right/tab question - n note - esc cancel"
517
+ : "space toggle - enter submit - n note - esc cancel"
518
+ : questions.length > 1
519
+ ? "enter select - left/right/tab question - n note - esc cancel"
520
+ : "enter select - n note - esc cancel";
521
+ wrapWithPrefix(" ", theme.fg("dim", hints));
522
+ }
523
+ }
524
+
525
+ lines.push(theme.fg("accent", "-".repeat(renderWidth)));
526
+
527
+ cachedLines = lines;
528
+ return lines;
529
+ },
530
+
531
+ invalidate(): void {
532
+ cachedLines = undefined;
533
+ },
534
+
535
+ handleInput(data: string): boolean {
536
+ // Free-text mode: everything except Esc routes to the embedded editor.
537
+ if (inputMode !== null) {
538
+ if (matchesKey(data, Key.escape)) {
539
+ exitInputMode();
540
+ return true;
541
+ }
542
+ editor.handleInput(data);
543
+ cachedLines = undefined;
544
+ return true;
545
+ }
546
+
547
+ if (matchesKey(data, Key.up)) {
548
+ move(-1);
549
+ return true;
550
+ }
551
+ if (matchesKey(data, Key.down)) {
552
+ move(1);
553
+ return true;
554
+ }
555
+ if (matchesKey(data, Key.escape)) {
556
+ settle({ kind: "cancel" });
557
+ return true;
558
+ }
559
+
560
+ // Question navigation: arrows plus tab/shift+tab aliases. Forward
561
+ // moves require an answer for the current question; the review
562
+ // page is the last stop.
563
+ if (matchesKey(data, Key.left) || matchesKey(data, Key.shift("tab"))) {
564
+ if (state.index > 0) {
565
+ state.index -= 1;
566
+ cachedLines = undefined;
567
+ }
568
+ return true;
569
+ }
570
+ if (matchesKey(data, Key.right) || matchesKey(data, Key.tab)) {
571
+ if (
572
+ questions.length > 1 &&
573
+ state.index < questions.length &&
574
+ state.answers[state.index] !== undefined
575
+ ) {
576
+ state.index += 1; // may land on the review page
577
+ cachedLines = undefined;
578
+ }
579
+ return true;
580
+ }
581
+
582
+ // Review page: Enter submits once everything is answered.
583
+ if (onSummary()) {
584
+ if (matchesKey(data, Key.return) && allAnswered()) {
585
+ settle({ kind: "submit" });
586
+ }
587
+ return true;
588
+ }
589
+
590
+ if (data === "n" || data === "N") {
591
+ enterInputMode("note");
592
+ return true;
593
+ }
594
+
595
+ if (matchesKey(data, Key.return)) {
596
+ const q = question();
597
+ const cursor = state.cursors[state.index]!;
598
+ if (cursor === q.options.length) {
599
+ enterInputMode("other");
600
+ return true;
601
+ }
602
+ if (cursor === q.options.length + 1) {
603
+ settle({ kind: "chat" });
604
+ return true;
605
+ }
606
+ if (q.multi) {
607
+ // Enter records the current checkbox set as-is; space does the toggling.
608
+ // An empty set is not an answer: enter stays put instead of marking
609
+ // the question answered with a misleading "(no selection)".
610
+ if (state.checked[state.index]!.size === 0) return true;
611
+ } else {
612
+ // Single mode: exactly one marker — selecting a row replaces the
613
+ // previous one instead of stacking another (o).
614
+ state.checked[state.index] = new Set([cursor]);
615
+ }
616
+ recordCurrentAnswer();
617
+ advanceAfterAnswer();
618
+ return true;
619
+ }
620
+
621
+ if (matchesKey(data, Key.space)) {
622
+ // Toggling is multi-select only: in single mode space is a no-op so
623
+ // one question can never accumulate several (o) markers.
624
+ if (!onSummary() && question().multi) toggle();
625
+ return true;
626
+ }
627
+ return false;
628
+ },
629
+
630
+ dispose(): void {
631
+ settled = true;
632
+ clearTimeout(timerId);
633
+ signal?.removeEventListener("abort", onAbort);
634
+ },
635
+ };
636
+ }
637
+
638
+ interface DialogOutcome {
639
+ kind: "submit" | "chat" | "cancel";
640
+ timedOut: boolean;
641
+ state: DialogState;
642
+ }
643
+
644
+ /**
645
+ * Run the multi-question dialog to completion. "Other"/"note" free text is
646
+ * captured by an editor embedded in the dialog itself, so the component keeps
647
+ * keyboard focus and its navigation state throughout.
648
+ */
649
+ async function runAskDialog(
650
+ ui: AskUi,
651
+ questions: AskQuestion[],
652
+ options_: { deadline?: number; signal?: AbortSignal },
653
+ ): Promise<DialogOutcome> {
654
+ const state = initialDialogState(questions);
655
+ const action = await ui.custom<DialogAction>((tui, theme, _kb, done) =>
656
+ createAskDialog(tui, theme, questions, state, options_.deadline, options_.signal, done),
657
+ );
658
+ return { kind: action.kind, timedOut: action.timedOut === true, state };
659
+ }
660
+
661
+ function buildResults(questions: AskQuestion[], state: DialogState, timedOut: boolean): QuestionResult[] {
662
+ return questions.map((q, i) => {
663
+ const answer = state.answers[i];
664
+ const autoPicked = timedOut && answer === undefined;
665
+ const selected = autoPicked ? autoSelectionForQuestion(q) : (answer?.selectedOptions ?? []);
666
+ return {
667
+ id: q.id,
668
+ question: q.question,
669
+ options: q.options.map((option) => option.label),
670
+ multi: q.multi,
671
+ selectedOptions: selected,
672
+ ...(answer?.customInput !== undefined ? { customInput: answer.customInput } : {}),
673
+ ...(state.notes[i] !== undefined ? { note: state.notes[i] } : {}),
674
+ ...(autoPicked ? { timedOut: true } : {}),
675
+ };
676
+ });
677
+ }
678
+
679
+ // ---------------------------------------------------------------------------
680
+ // Tool registration
681
+ // ---------------------------------------------------------------------------
682
+
683
+ const askUserTool: ToolDefinition<typeof AskParamsSchema, AskUserDetails> = {
684
+ name: "ask_user",
685
+ label: "Ask User",
686
+ description:
687
+ "Ask the user one or more clarifying questions with selectable options. " +
688
+ "Use when choices have materially different tradeoffs the user must decide. " +
689
+ "The user can always provide free-form input via an automatic 'Other' option.",
690
+ promptSnippet: "Ask the user structured questions with options during execution",
691
+ promptGuidelines: [
692
+ "Use ask_user only after exhausting repo conventions, configs, and docs; reserve it for decisions whose options have materially different tradeoffs.",
693
+ ],
694
+ parameters: AskParamsSchema,
695
+ executionMode: "sequential",
696
+
697
+ async execute(_toolCallId, params: AskParams, signal, _onUpdate, ctx) {
698
+ if (!ctx.hasUI) {
699
+ throw new Error("ask_user requires an interactive session (TUI or RPC mode)");
700
+ }
701
+
702
+ // Reserved-label collision check (mirrors omp's schema narrow).
703
+ for (const q of params.questions) {
704
+ const clash = q.options.find((option) => RESERVED_LABELS.has(option.label ?? ""));
705
+ if (clash) {
706
+ throw new Error(
707
+ `ask_user: option label "${clash.label}" in question "${q.id}" collides with a reserved runtime label`,
708
+ );
709
+ }
710
+ }
711
+
712
+ const questions: AskQuestion[] = params.questions.map((q) => ({
713
+ id: q.id,
714
+ question: q.question,
715
+ ...(typeof q.header === "string" ? { header: q.header } : {}),
716
+ options: q.options.map((option) => ({
717
+ label: option.label,
718
+ ...(typeof option.description === "string" ? { description: option.description } : {}),
719
+ ...(typeof option.preview === "string" ? { preview: option.preview } : {}),
720
+ })),
721
+ multi: q.multi === true,
722
+ recommended: typeof q.recommended === "number" ? q.recommended : undefined,
723
+ }));
724
+
725
+ const deadline =
726
+ typeof params.timeoutSeconds === "number" && params.timeoutSeconds > 0 ? Date.now() + params.timeoutSeconds * 1000 : undefined;
727
+
728
+ const outcome = await runAskDialog(ctx.ui, questions, { deadline, signal });
729
+
730
+ if (outcome.kind === "chat") {
731
+ const questionTexts = questions.map((q) => q.question).join("\n");
732
+ return {
733
+ content: [
734
+ {
735
+ type: "text",
736
+ text: `User chose to chat about this instead of answering.\n\nQuestions asked:\n${questionTexts}`,
737
+ },
738
+ ],
739
+ details: { chatRedirect: true, questions: questions.map((q) => q.question), results: [] } satisfies AskUserDetails,
740
+ };
741
+ }
742
+
743
+ if (outcome.kind === "cancel") {
744
+ const details: AskUserDetails = {
745
+ results: [],
746
+ questions: questions.map((q) => q.question),
747
+ cancelled: true,
748
+ };
749
+ return {
750
+ content: [{ type: "text", text: formatAnswerText([], true) }],
751
+ details,
752
+ };
753
+ }
754
+
755
+ const results = buildResults(questions, outcome.state, outcome.timedOut);
756
+ const details: AskUserDetails = { results, questions: questions.map((q) => q.question) };
757
+ return {
758
+ content: [{ type: "text", text: formatAnswerText(results, false) }],
759
+ details,
760
+ };
761
+ },
762
+
763
+ renderCall(args, theme) {
764
+ const questions = normalizeQuestions(args?.questions);
765
+ if (!questions) {
766
+ return new Text(theme.fg("toolTitle", theme.bold("ask_user")), 0, 0);
767
+ }
768
+ const lines = questions.map((q) => {
769
+ const mode = q.multi ? "multi" : "single";
770
+ const header = q.header ? ` ${theme.fg("muted", q.header)}` : "";
771
+ return `${theme.fg("dim", `[${q.id}]`)}${header} ${theme.fg("text", q.question)} ${theme.fg("muted", `(${mode}, ${q.options.length} options)`)}`;
772
+ });
773
+ return new Text(`${theme.fg("toolTitle", theme.bold("ask_user"))}\n${lines.join("\n")}`, 0, 0);
774
+ },
775
+
776
+ renderResult(result, _options, theme) {
777
+ const details = result.details as AskUserDetails | undefined;
778
+
779
+ if (details?.chatRedirect) {
780
+ const lines = (details.questions ?? []).map((q) => theme.fg("dim", q));
781
+ return new Text(
782
+ `${theme.fg("warning", "~")} ${theme.fg("muted", "chat redirect")}\n${lines.map((l) => ` ${l}`).join("\n")}`,
783
+ 0,
784
+ 0,
785
+ );
786
+ }
787
+
788
+ const results = details?.results;
789
+ if (!results || results.length === 0) {
790
+ return new Text(theme.fg("warning", "Cancelled"), 0, 0);
791
+ }
792
+ const lines = results.map((r) => formatAnswerLine(r));
793
+ const status = details?.cancelled ? theme.fg("warning", "~") : theme.fg("success", "+");
794
+ return new Text(
795
+ `${status} ${theme.fg("muted", "answers:")}\n${lines.map((l) => ` ${theme.fg("accent", l)}`).join("\n")}`,
796
+ 0,
797
+ 0,
798
+ );
799
+ },
800
+ };
801
+
802
+ // ---------------------------------------------------------------------------
803
+ // /ask-demo: interactive battery covering every question type and path
804
+ // ---------------------------------------------------------------------------
805
+
806
+ const DEMO_TYPES: AskParams = {
807
+ questions: [
808
+ {
809
+ id: "framework",
810
+ question: "Which frontend framework should the demo project use?",
811
+ header: "Framework",
812
+ options: [
813
+ {
814
+ label: "React",
815
+ description: "Largest ecosystem, most hiring pool",
816
+ preview: "- Virtual DOM, hooks\n- Huge component marketplaces\n- Heavier bundle baseline",
817
+ },
818
+ { label: "Vue", description: "Gentle learning curve, SFC single-file components" },
819
+ { label: "Svelte", description: "Compile-time reactivity, smallest bundles" },
820
+ ],
821
+ recommended: 0,
822
+ },
823
+ {
824
+ id: "features",
825
+ question: "Which features should be enabled out of the box?",
826
+ header: "Features",
827
+ options: [
828
+ { label: "Telemetry", description: "Anonymous usage reporting" },
829
+ { label: "Auto-update", description: "Background self-update checks" },
830
+ { label: "Offline cache", description: "Service-worker asset caching" },
831
+ ],
832
+ multi: true,
833
+ },
834
+ {
835
+ id: "style",
836
+ question: "Which language for generated code? (try 'Other' + a note here)",
837
+ header: "Style",
838
+ options: [
839
+ { label: "TypeScript", description: "Strict types, better tooling" },
840
+ { label: "JavaScript", description: "Zero build setup for simple scripts" },
841
+ ],
842
+ },
843
+ ],
844
+ };
845
+
846
+ const DEMO_TIMEOUT: AskParams = {
847
+ questions: [
848
+ {
849
+ id: "deploy_target",
850
+ question: "Where should CI deploy? (do nothing, let it time out)",
851
+ header: "Deploy",
852
+ options: [
853
+ { label: "Staging", description: "Safe default ring" },
854
+ { label: "Production", description: "Straight to live traffic" },
855
+ ],
856
+ recommended: 0,
857
+ },
858
+ ],
859
+ timeoutSeconds: 6,
860
+ };
861
+
862
+ const DEMO_CHAT: AskParams = {
863
+ questions: [
864
+ {
865
+ id: "migration",
866
+ question: "Rewrite the legacy module now or plan first? (pick 'Chat about this')",
867
+ header: "Migration",
868
+ options: [
869
+ { label: "Rewrite now", description: "Fast but risky on shared code" },
870
+ { label: "Plan first", description: "Slower start, safer rollout" },
871
+ ],
872
+ },
873
+ ],
874
+ };
875
+
876
+ const DEMO_CANCEL: AskParams = {
877
+ questions: [
878
+ { id: "a", question: "First of two (press Esc to cancel)", options: [{ label: "x" }, { label: "y" }] },
879
+ { id: "b", question: "Second (should never be asked)", options: [{ label: "p" }, { label: "q" }] },
880
+ ],
881
+ };
882
+
883
+ async function runAskDemo(ctx: ExtensionCommandContext): Promise<void> {
884
+ if (!ctx.hasUI) {
885
+ ctx.ui.notify("ask-demo requires an interactive session", "warning");
886
+ return;
887
+ }
888
+
889
+ const run = async (label: string, params: AskParams): Promise<string> => {
890
+ ctx.ui.notify(`ask-demo ${label} — follow the dialog instructions`, "info");
891
+ try {
892
+ const result = await askUserTool.execute(`demo-${label}`, params, undefined, undefined, ctx);
893
+ const text = result.content[0]?.type === "text" ? result.content[0].text : "";
894
+ ctx.ui.notify(`ask-demo ${label} → ${text.split("\n").filter(Boolean).join(" | ").slice(0, 200)}`, "info");
895
+ return text;
896
+ } catch (error) {
897
+ ctx.ui.notify(`ask-demo ${label} failed: ${error instanceof Error ? error.message : String(error)}`, "error");
898
+ return "";
899
+ }
900
+ };
901
+
902
+ ctx.ui.notify(
903
+ "ask-demo 1/4 all question types: single+recommended+preview, multi, Other free input, note with n, review page before submit",
904
+ "info",
905
+ );
906
+ await run("types", DEMO_TYPES);
907
+
908
+ await run("timeout", DEMO_TIMEOUT); // dialog itself says: wait 6s
909
+
910
+ ctx.ui.notify("ask-demo 3/4 chat redirect: choose 'Chat about this' in the next dialog", "info");
911
+ await run("chat", DEMO_CHAT);
912
+
913
+ ctx.ui.notify("ask-demo 4/4 cancel: press Esc in the next dialog", "info");
914
+ await run("cancel", DEMO_CANCEL);
915
+
916
+ ctx.ui.notify("ask-demo finished — review the four results above", "info");
917
+ }
918
+
919
+ export default function askUserExtension(pi: ExtensionAPI): void {
920
+ pi.registerTool(askUserTool);
921
+ pi.registerCommand("ask-demo", {
922
+ description: "Interactive ask_user battery: all question types, timeout, chat redirect, cancel",
923
+ handler: (_args, ctx) => runAskDemo(ctx),
924
+ });
925
+ }
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@fyeeme/pi-ask-user",
3
+ "version": "2.0.0",
4
+ "description": "Structured ask_user tool for pi — lets the LLM surface clarifying questions with options, multi-select, recommended defaults, and free-form Other input.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "fyeeme",
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/fyeeme/pi-packages.git"
14
+ },
15
+ "bugs": {
16
+ "url": "https://github.com/fyeeme/pi-packages/issues"
17
+ },
18
+ "homepage": "https://github.com/fyeeme/pi-packages#readme",
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "keywords": [
23
+ "pi-package",
24
+ "pi",
25
+ "extension",
26
+ "ask-user",
27
+ "clarification",
28
+ "interactive"
29
+ ],
30
+ "files": [
31
+ "index.ts",
32
+ "README.md",
33
+ "LICENSE",
34
+ "CHANGELOG.md"
35
+ ],
36
+ "pi": {
37
+ "extensions": [
38
+ "./index.ts"
39
+ ]
40
+ },
41
+ "scripts": {
42
+ "test": "vitest --run",
43
+ "typecheck": "tsc"
44
+ },
45
+ "peerDependencies": {
46
+ "@earendil-works/pi-coding-agent": ">=0.84.1",
47
+ "@earendil-works/pi-tui": ">=0.84.1",
48
+ "typebox": ">=1.0.0"
49
+ },
50
+ "devDependencies": {
51
+ "@earendil-works/pi-coding-agent": "0.84.1",
52
+ "@earendil-works/pi-tui": "0.84.1",
53
+ "@types/node": "22.19.19",
54
+ "typebox": "1.1.38",
55
+ "typescript": "5.9.3",
56
+ "vitest": "3.2.7"
57
+ }
58
+ }