@agnishc/edb-ask-user 0.1.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,9 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ### Added
6
+ - Initial release: `ask_user` tool with text and choice question types
7
+ - Single-question focused UI and multi-question tabbed wizard
8
+ - `allowOther` inline editor for choice questions
9
+ - Submit review tab showing all answers before submission
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Agnish Chakraborty
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,43 @@
1
+ # @agnishc/edb-ask-user
2
+
3
+ A Pi CLI extension that registers an `ask_user` tool — lets the LLM ask the user structured questions directly in the terminal UI without an extra model round-trip.
4
+
5
+ ## Question types
6
+
7
+ | Type | UI | Use when |
8
+ |------|----|----------|
9
+ | `text` | Inline editor | Free-form answer needed |
10
+ | `choice` | Numbered option list | Picking from a known set |
11
+
12
+ Mix both types freely in one call. Single questions show a focused UI; multiple questions show a **tabbed wizard** with a Submit review tab.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pi install npm:@agnishc/edb-ask-user
18
+ ```
19
+
20
+ ## Features
21
+
22
+ - **No extra LLM call** — answers are collected immediately and returned to the model
23
+ - **`allowOther`** on choice questions — adds a "Type something" option that opens an inline editor
24
+ - **Multi-step wizard** — tab bar with answered/unanswered indicators and a Submit review tab
25
+ - **Pre-filled answers** — revisiting a tab restores the previously entered value
26
+
27
+ ## Example
28
+
29
+ ```json
30
+ {
31
+ "questions": [
32
+ { "id": "env", "label": "Environment", "type": "choice",
33
+ "prompt": "Deploy to which environment?",
34
+ "options": [{ "value": "dev", "label": "Development" }, { "value": "prod", "label": "Production" }] },
35
+ { "id": "message", "label": "Message", "type": "text",
36
+ "prompt": "Describe this deployment:" }
37
+ ]
38
+ }
39
+ ```
40
+
41
+ ## License
42
+
43
+ [MIT](LICENSE) © Agnish Chakraborty
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@agnishc/edb-ask-user",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension: ask_user tool for structured questions — text, choice, and multi-step wizard",
5
+ "keywords": ["pi-package", "pi-extension", "edb"],
6
+ "type": "module",
7
+ "license": "MIT",
8
+ "author": "Agnish Chakraborty",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/agnishcc/pi-extention-monorepo.git",
12
+ "directory": "packages/edb-ask-user"
13
+ },
14
+ "homepage": "https://github.com/agnishcc/pi-extention-monorepo/tree/main/packages/edb-ask-user#readme",
15
+ "bugs": { "url": "https://github.com/agnishcc/pi-extention-monorepo/issues" },
16
+ "publishConfig": { "access": "public" },
17
+ "scripts": { "test": "vitest run" },
18
+ "files": ["src", "README.md", "LICENSE", "CHANGELOG.md"],
19
+ "pi": {
20
+ "extensions": ["./src/index.ts"]
21
+ },
22
+ "peerDependencies": {
23
+ "@mariozechner/pi-ai": "*",
24
+ "@mariozechner/pi-coding-agent": "*",
25
+ "@mariozechner/pi-tui": "*",
26
+ "typebox": "*"
27
+ }
28
+ }
@@ -0,0 +1,382 @@
1
+ import { Editor, type EditorTheme, Key, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
2
+ import type { Answer, AskQuestion, AskResult, RenderOption } from "./types";
3
+
4
+ // ── Component factory ──────────────────────────────────────────────────────────
5
+
6
+ /**
7
+ * Builds and returns the TUI component object for the ask_user dialog.
8
+ * Handles both single-question (focused) and multi-question (tabbed wizard) UIs.
9
+ */
10
+ export function createAskUserComponent(
11
+ tui: any,
12
+ theme: any,
13
+ done: (result: AskResult) => void,
14
+ questions: AskQuestion[],
15
+ ) {
16
+ const isMulti = questions.length > 1;
17
+ const totalTabs = questions.length + 1; // questions + Submit tab
18
+
19
+ // ── Shared state ──────────────────────────────────────────────────────────
20
+ let currentTab = 0;
21
+ let optionIndex = 0;
22
+ let inputMode = false;
23
+ let inputQuestionId: string | null = null;
24
+ let cachedLines: string[] | undefined;
25
+
26
+ const answers = new Map<string, Answer>();
27
+
28
+ // ── Inline editor ─────────────────────────────────────────────────────────
29
+ const editorTheme: EditorTheme = {
30
+ borderColor: (s) => theme.fg("accent", s),
31
+ selectList: {
32
+ selectedPrefix: (t) => theme.fg("accent", t),
33
+ selectedText: (t) => theme.fg("accent", t),
34
+ description: (t) => theme.fg("muted", t),
35
+ scrollInfo: (t) => theme.fg("dim", t),
36
+ noMatch: (t) => theme.fg("warning", t),
37
+ },
38
+ };
39
+ const editor = new Editor(tui, editorTheme);
40
+
41
+ // ── Helpers ───────────────────────────────────────────────────────────────
42
+
43
+ function refresh() {
44
+ cachedLines = undefined;
45
+ tui.requestRender();
46
+ }
47
+
48
+ function submitAll(cancelled: boolean) {
49
+ done({ questions, answers: Array.from(answers.values()), cancelled });
50
+ }
51
+
52
+ function currentQuestion(): AskQuestion | undefined {
53
+ return questions[currentTab];
54
+ }
55
+
56
+ function currentOptions(): RenderOption[] {
57
+ const q = currentQuestion();
58
+ if (!q || q.type !== "choice") return [];
59
+ const opts: RenderOption[] = [...(q.options ?? [])];
60
+ if (q.allowOther !== false) opts.push({ value: "__other__", label: "Type something.", isOther: true });
61
+ return opts;
62
+ }
63
+
64
+ function allAnswered(): boolean {
65
+ return questions.every((q) => answers.has(q.id));
66
+ }
67
+
68
+ function advanceAfterAnswer() {
69
+ if (!isMulti) {
70
+ submitAll(false);
71
+ return;
72
+ }
73
+ for (let i = currentTab + 1; i < questions.length; i++) {
74
+ if (!answers.has(questions[i]!.id)) {
75
+ switchToTab(i);
76
+ return;
77
+ }
78
+ }
79
+ switchToTab(questions.length);
80
+ }
81
+
82
+ function saveAnswer(questionId: string, value: string, label: string, wasCustom: boolean, optIndex?: number) {
83
+ const q = questions.find((x) => x.id === questionId);
84
+ answers.set(questionId, {
85
+ id: questionId,
86
+ value,
87
+ label,
88
+ type: q?.type ?? "text",
89
+ wasCustom,
90
+ optionIndex: optIndex,
91
+ });
92
+ }
93
+
94
+ function switchToTab(tabIndex: number) {
95
+ currentTab = tabIndex;
96
+ optionIndex = 0;
97
+ const q = questions[tabIndex];
98
+ if (q && q.type === "text") {
99
+ inputMode = true;
100
+ inputQuestionId = q.id;
101
+ const existing = answers.get(q.id);
102
+ editor.setText(existing ? existing.label : "");
103
+ } else {
104
+ inputMode = false;
105
+ inputQuestionId = null;
106
+ editor.setText("");
107
+ }
108
+ refresh();
109
+ }
110
+
111
+ editor.onSubmit = (value) => {
112
+ if (!inputQuestionId) return;
113
+ const trimmed = value.trim() || "(no response)";
114
+ saveAnswer(inputQuestionId, trimmed, trimmed, true);
115
+ inputMode = false;
116
+ inputQuestionId = null;
117
+ editor.setText("");
118
+ advanceAfterAnswer();
119
+ };
120
+
121
+ // ── Input handler ─────────────────────────────────────────────────────────
122
+
123
+ function handleInput(data: string) {
124
+ // ── Editor is active ───────────────────────────────────────────────
125
+ if (inputMode) {
126
+ if (matchesKey(data, Key.escape)) {
127
+ const q = currentQuestion();
128
+ if (q?.type === "text") {
129
+ submitAll(true);
130
+ } else {
131
+ inputMode = false;
132
+ inputQuestionId = null;
133
+ editor.setText("");
134
+ refresh();
135
+ }
136
+ return;
137
+ }
138
+ editor.handleInput(data);
139
+ refresh();
140
+ return;
141
+ }
142
+
143
+ // ── Tab bar navigation (multi-question only) ───────────────────────
144
+ if (isMulti) {
145
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
146
+ switchToTab((currentTab + 1) % totalTabs);
147
+ return;
148
+ }
149
+ if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
150
+ switchToTab((currentTab - 1 + totalTabs) % totalTabs);
151
+ return;
152
+ }
153
+ }
154
+
155
+ // ── Submit tab ─────────────────────────────────────────────────────
156
+ if (isMulti && currentTab === questions.length) {
157
+ const submitItems = questions.length + 1;
158
+ if (matchesKey(data, Key.up)) {
159
+ optionIndex = Math.max(0, optionIndex - 1);
160
+ refresh();
161
+ return;
162
+ }
163
+ if (matchesKey(data, Key.down)) {
164
+ optionIndex = Math.min(submitItems - 1, optionIndex + 1);
165
+ refresh();
166
+ return;
167
+ }
168
+ if (matchesKey(data, Key.enter)) {
169
+ if (optionIndex < questions.length) {
170
+ switchToTab(optionIndex);
171
+ } else if (allAnswered()) {
172
+ submitAll(false);
173
+ }
174
+ return;
175
+ }
176
+ if (matchesKey(data, Key.escape)) {
177
+ submitAll(true);
178
+ }
179
+ return;
180
+ }
181
+
182
+ const q = currentQuestion();
183
+
184
+ // ── Choice question: option navigation ─────────────────────────────
185
+ if (q?.type === "choice") {
186
+ const opts = currentOptions();
187
+ if (matchesKey(data, Key.up)) {
188
+ optionIndex = Math.max(0, optionIndex - 1);
189
+ refresh();
190
+ return;
191
+ }
192
+ if (matchesKey(data, Key.down)) {
193
+ optionIndex = Math.min(opts.length - 1, optionIndex + 1);
194
+ refresh();
195
+ return;
196
+ }
197
+ if (matchesKey(data, Key.enter)) {
198
+ const opt = opts[optionIndex];
199
+ if (!opt) return;
200
+ if (opt.isOther) {
201
+ inputMode = true;
202
+ inputQuestionId = q.id;
203
+ const existing = answers.get(q.id);
204
+ editor.setText(existing?.wasCustom ? existing.label : "");
205
+ refresh();
206
+ } else {
207
+ saveAnswer(q.id, opt.value, opt.label, false, optionIndex + 1);
208
+ advanceAfterAnswer();
209
+ }
210
+ return;
211
+ }
212
+ if (matchesKey(data, Key.escape)) {
213
+ submitAll(true);
214
+ return;
215
+ }
216
+ }
217
+ }
218
+
219
+ // ── Renderer ──────────────────────────────────────────────────────────────
220
+
221
+ function render(width: number): string[] {
222
+ if (cachedLines) return cachedLines;
223
+
224
+ const lines: string[] = [];
225
+ const add = (s: string) => lines.push(truncateToWidth(s, width));
226
+ const hr = () => add(theme.fg("accent", "─".repeat(width)));
227
+ hr();
228
+
229
+ const canSubmit = allAnswered();
230
+
231
+ // ── Tab bar ─────────────────────────────────────────────────────────
232
+ if (isMulti) {
233
+ const parts: string[] = [" "];
234
+ for (let i = 0; i < questions.length; i++) {
235
+ const isActive = i === currentTab;
236
+ const isAnswered = answers.has(questions[i]!.id);
237
+ const bullet = isAnswered ? "■" : "□";
238
+ const color: "success" | "muted" = isAnswered ? "success" : "muted";
239
+ const lbl = ` ${bullet} ${questions[i]!.label} `;
240
+ parts.push(isActive ? `${theme.bg("selectedBg", theme.fg("text", lbl))} ` : `${theme.fg(color, lbl)} `);
241
+ }
242
+ const isSubmitActive = currentTab === questions.length;
243
+ const submitLabel = " ✓ Submit ";
244
+ parts.push(
245
+ isSubmitActive
246
+ ? theme.bg("selectedBg", theme.fg("text", submitLabel))
247
+ : theme.fg(canSubmit ? "success" : "dim", submitLabel),
248
+ );
249
+ add(parts.join(""));
250
+ lines.push("");
251
+ }
252
+
253
+ // ── Content ─────────────────────────────────────────────────────────
254
+ const q = currentQuestion();
255
+ const opts = currentOptions();
256
+
257
+ if (isMulti && currentTab === questions.length) {
258
+ // Submit tab
259
+ add(theme.fg("accent", theme.bold(" Review your answers")));
260
+ lines.push("");
261
+ for (let i = 0; i < questions.length; i++) {
262
+ const question = questions[i]!;
263
+ const ans = answers.get(question.id);
264
+ const sel = optionIndex === i;
265
+ const prefix = sel ? theme.fg("accent", "> ") : " ";
266
+ add(prefix + (sel ? theme.fg("accent", question.prompt) : theme.fg("text", question.prompt)));
267
+ if (ans) {
268
+ const pre = ans.wasCustom ? theme.fg("muted", "(wrote) ") : theme.fg("dim", `${ans.optionIndex}. `);
269
+ add(` ${theme.fg("success", "✓ ")}${pre}${theme.fg(sel ? "accent" : "muted", ans.label)}`);
270
+ } else {
271
+ add(
272
+ ` ${theme.fg("warning", "✗ unanswered")}${sel ? theme.fg("dim", " — press Enter to answer") : ""}`,
273
+ );
274
+ }
275
+ lines.push("");
276
+ }
277
+ const submitSel = optionIndex === questions.length;
278
+ const submitPrefix = submitSel ? theme.fg("accent", "> ") : " ";
279
+ if (canSubmit) {
280
+ add(
281
+ submitPrefix +
282
+ (submitSel ? theme.fg("accent", theme.bold("✓ Submit All")) : theme.fg("success", "✓ Submit All")),
283
+ );
284
+ } else {
285
+ const missing = questions
286
+ .filter((x) => !answers.has(x.id))
287
+ .map((x) => x.label)
288
+ .join(", ");
289
+ add(` ${theme.fg("dim", "✓ Submit All")} ${theme.fg("warning", `(unanswered: ${missing})`)}`);
290
+ }
291
+ lines.push("");
292
+ add(theme.fg("dim", " ↑↓ navigate • Enter to edit answer or submit • Tab/←→ switch tab • Esc cancel"));
293
+ } else if (q?.type === "text") {
294
+ add(theme.fg("text", ` ${q.prompt}`));
295
+ if (q.placeholder) add(theme.fg("dim", ` ${q.placeholder}`));
296
+ lines.push("");
297
+ for (const line of editor.render(width - 2)) add(` ${line}`);
298
+ lines.push("");
299
+ add(theme.fg("dim", " Enter to submit • Esc to cancel"));
300
+ } else if (q?.type === "choice") {
301
+ add(theme.fg("text", ` ${q.prompt}`));
302
+ lines.push("");
303
+ if (inputMode) {
304
+ // Inline "other" editor inside option list
305
+ for (let i = 0; i < opts.length; i++) {
306
+ const opt = opts[i]!;
307
+ const selected = i === optionIndex;
308
+ const prefix = selected ? theme.fg("accent", "> ") : " ";
309
+ if (opt.isOther) {
310
+ add(prefix + theme.fg("accent", `${i + 1}. ${opt.label} ✎`));
311
+ } else {
312
+ add(
313
+ prefix +
314
+ (selected
315
+ ? theme.fg("accent", `${i + 1}. ${opt.label}`)
316
+ : theme.fg("text", `${i + 1}. ${opt.label}`)),
317
+ );
318
+ if (opt.description) add(` ${theme.fg("muted", opt.description)}`);
319
+ }
320
+ }
321
+ lines.push("");
322
+ add(theme.fg("muted", " Your answer:"));
323
+ for (const line of editor.render(width - 2)) add(` ${line}`);
324
+ lines.push("");
325
+ add(theme.fg("dim", " Enter to submit • Esc to go back"));
326
+ } else {
327
+ for (let i = 0; i < opts.length; i++) {
328
+ const opt = opts[i]!;
329
+ const selected = i === optionIndex;
330
+ const isAnswered =
331
+ !opt.isOther && answers.get(q.id)?.value === opt.value && !answers.get(q.id)?.wasCustom;
332
+ const prefix = selected ? theme.fg("accent", "> ") : " ";
333
+ const checkmark = isAnswered ? theme.fg("success", " ✓") : "";
334
+ add(
335
+ prefix +
336
+ (selected
337
+ ? theme.fg("accent", `${i + 1}. ${opt.label}`)
338
+ : theme.fg("text", `${i + 1}. ${opt.label}`)) +
339
+ checkmark,
340
+ );
341
+ if (opt.description) add(` ${theme.fg("muted", opt.description)}`);
342
+ }
343
+ const existing = answers.get(q.id);
344
+ if (existing?.wasCustom) {
345
+ lines.push("");
346
+ add(
347
+ theme.fg("dim", " Current: ") +
348
+ theme.fg("success", "✓ ") +
349
+ theme.fg("muted", "(wrote) ") +
350
+ theme.fg("accent", existing.label),
351
+ );
352
+ }
353
+ lines.push("");
354
+ add(
355
+ theme.fg(
356
+ "dim",
357
+ isMulti
358
+ ? " ↑↓ navigate • Enter select • Tab/←→ switch tab • Esc cancel"
359
+ : " ↑↓ navigate • Enter select • Esc cancel",
360
+ ),
361
+ );
362
+ }
363
+ }
364
+
365
+ lines.push("");
366
+ hr();
367
+
368
+ cachedLines = lines;
369
+ return lines;
370
+ }
371
+
372
+ // ── Bootstrap: activate first tab ─────────────────────────────────────────
373
+ switchToTab(0);
374
+
375
+ return {
376
+ render,
377
+ invalidate: () => {
378
+ cachedLines = undefined;
379
+ },
380
+ handleInput,
381
+ };
382
+ }
package/src/index.ts ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * pi-ask-user
3
+ *
4
+ * Provides an `ask_user` tool that lets the LLM ask the user structured
5
+ * questions directly in the terminal UI — without an extra model round-trip.
6
+ *
7
+ * Supports three question modes (freely mixed in one call):
8
+ * - text : free-form text input via inline editor
9
+ * - choice : pick from a numbered option list (+ optional "Type something")
10
+ *
11
+ * Single question → focused UI, no tab bar, immediate return
12
+ * Multiple questions → tab-based wizard with a Submit tab
13
+ */
14
+
15
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
16
+ import { createAskUserComponent } from "./component";
17
+ import { renderCall, renderResult } from "./render";
18
+ import { AskUserParams } from "./schemas";
19
+ import type { AskQuestion, AskResult } from "./types";
20
+
21
+ // ── Extension ──────────────────────────────────────────────────────────────────
22
+
23
+ export default function askUserExtension(pi: ExtensionAPI): void {
24
+ pi.registerTool({
25
+ name: "ask_user",
26
+ label: "Ask User",
27
+ description:
28
+ "Ask the user one or more structured questions directly in the terminal UI. " +
29
+ "Supports free-text input, multiple-choice option lists, and multi-step " +
30
+ "questionnaires. Use this instead of embedding questions in your response text " +
31
+ "to avoid an extra model round-trip and give the user a clear, interactive prompt.",
32
+ promptSnippet: "Ask the user a question or questionnaire and get structured answers",
33
+ promptGuidelines: [
34
+ "Use ask_user whenever you need information, a preference, or confirmation from the user before proceeding.",
35
+ "Prefer ask_user over posing questions in your response text — it collects answers immediately without an extra LLM call.",
36
+ "For a single free-form question set type to 'text'. " +
37
+ "For a multiple-choice question set type to 'choice' and provide options. " +
38
+ "Pass several questions together for a multi-step flow.",
39
+ "For choice questions, set allowOther to false only when an open-ended answer is not acceptable.",
40
+ ],
41
+ parameters: AskUserParams,
42
+
43
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
44
+ // ── Guard: interactive mode only ──────────────────────────────────
45
+ if (!ctx.hasUI) {
46
+ return {
47
+ content: [
48
+ { type: "text", text: "Error: ask_user requires an interactive terminal (UI not available)." },
49
+ ],
50
+ details: { questions: [], answers: [], cancelled: true } satisfies AskResult,
51
+ };
52
+ }
53
+
54
+ if (params.questions.length === 0) {
55
+ return {
56
+ content: [{ type: "text", text: "Error: No questions provided." }],
57
+ details: { questions: [], answers: [], cancelled: true } satisfies AskResult,
58
+ };
59
+ }
60
+
61
+ // Validate: choice questions must have at least one option
62
+ for (const q of params.questions) {
63
+ if (q.type === "choice" && (!q.options || q.options.length === 0)) {
64
+ return {
65
+ content: [{ type: "text", text: `Error: Question "${q.id}" is type 'choice' but has no options.` }],
66
+ details: { questions: [], answers: [], cancelled: true } satisfies AskResult,
67
+ };
68
+ }
69
+ }
70
+
71
+ // Normalise questions (fill in derived defaults)
72
+ const questions: AskQuestion[] = params.questions.map((q, i) => ({
73
+ ...q,
74
+ label: q.label || `Q${i + 1}`,
75
+ allowOther: true,
76
+ }));
77
+
78
+ // ── Build & show the custom TUI ───────────────────────────────────
79
+ const result = await ctx.ui.custom<AskResult>((tui, theme, _kb, done) =>
80
+ createAskUserComponent(tui, theme, done, questions),
81
+ );
82
+
83
+ // ── Build response for LLM ────────────────────────────────────────
84
+ if (result.cancelled) {
85
+ return {
86
+ content: [{ type: "text", text: "User cancelled the prompt." }],
87
+ details: result satisfies AskResult,
88
+ };
89
+ }
90
+
91
+ const lines = result.answers.map((a) => {
92
+ const q = questions.find((x) => x.id === a.id);
93
+ const label = q?.label ?? a.id;
94
+ if (a.wasCustom) return `${label} (${a.id}): user wrote: ${a.label}`;
95
+ return `${label} (${a.id}): user selected: ${a.optionIndex}. ${a.label}`;
96
+ });
97
+
98
+ return {
99
+ content: [{ type: "text", text: lines.join("\n") }],
100
+ details: result satisfies AskResult,
101
+ };
102
+ },
103
+
104
+ renderCall,
105
+ renderResult,
106
+ });
107
+ }
package/src/render.ts ADDED
@@ -0,0 +1,48 @@
1
+ import { Text, truncateToWidth } from "@mariozechner/pi-tui";
2
+ import type { AskQuestion, AskResult } from "./types";
3
+
4
+ // ── TUI rendering ──────────────────────────────────────────────────────────────
5
+
6
+ export function renderCall(args: any, theme: any): any {
7
+ const qs = (args.questions as AskQuestion[]) ?? [];
8
+ const count = qs.length;
9
+ let text = theme.fg("toolTitle", theme.bold("ask_user "));
10
+ if (count === 1) {
11
+ const q = qs[0];
12
+ const typeTag = theme.fg("muted", `[${q?.type ?? "?"}] `);
13
+ text += typeTag + theme.fg("muted", q?.prompt ?? "");
14
+ } else {
15
+ text += theme.fg("muted", `${count} questions`);
16
+ const labels = qs.map((q, i) => q.label || `Q${i + 1}`).join(", ");
17
+ if (labels) text += theme.fg("dim", ` (${truncateToWidth(labels, 48)})`);
18
+ }
19
+ return new Text(text, 0, 0);
20
+ }
21
+
22
+ export function renderResult(result: any, _options: any, theme: any): any {
23
+ const details = result.details as AskResult | undefined;
24
+ if (!details) {
25
+ const first = result.content[0];
26
+ return new Text(first?.type === "text" ? first.text : "", 0, 0);
27
+ }
28
+ if (details.cancelled) {
29
+ return new Text(theme.fg("warning", "Cancelled"), 0, 0);
30
+ }
31
+
32
+ const lines = details.answers.map((a) => {
33
+ const q = details.questions.find((x) => x.id === a.id);
34
+ const label = q?.label ?? a.id;
35
+ if (a.wasCustom) {
36
+ return (
37
+ theme.fg("success", "✓ ") +
38
+ theme.fg("accent", label) +
39
+ theme.fg("dim", ": ") +
40
+ theme.fg("muted", "(wrote) ") +
41
+ a.label
42
+ );
43
+ }
44
+ const num = a.optionIndex ? `${a.optionIndex}. ` : "";
45
+ return theme.fg("success", "✓ ") + theme.fg("accent", label) + theme.fg("dim", ": ") + num + a.label;
46
+ });
47
+ return new Text(lines.join("\n"), 0, 0);
48
+ }
package/src/schemas.ts ADDED
@@ -0,0 +1,66 @@
1
+ import { StringEnum } from "@mariozechner/pi-ai";
2
+ import { Type } from "typebox";
3
+
4
+ // ── Schemas ────────────────────────────────────────────────────────────────────
5
+
6
+ export const OptionSchema = Type.Object({
7
+ value: Type.String({
8
+ description: "Machine-readable value returned when this option is selected",
9
+ }),
10
+ label: Type.String({
11
+ description: "Human-readable display label for the option",
12
+ }),
13
+ description: Type.Optional(
14
+ Type.String({
15
+ description: "Optional sub-label shown below the option (e.g. a clarifying note)",
16
+ }),
17
+ ),
18
+ });
19
+
20
+ export const QuestionSchema = Type.Object({
21
+ id: Type.String({
22
+ description: "Unique key for this question — used to identify it in the returned answers map",
23
+ }),
24
+ prompt: Type.String({
25
+ description: "The question text shown to the user",
26
+ }),
27
+ type: StringEnum(["text", "choice"] as const, {
28
+ description:
29
+ "text: user types a free-form answer via inline editor; " + "choice: user picks from a numbered option list",
30
+ }),
31
+ label: Type.Optional(
32
+ Type.String({
33
+ description:
34
+ "Short label shown in the tab bar when multiple questions are asked " +
35
+ "(e.g. 'Scope', 'Priority'). Defaults to Q1, Q2, …",
36
+ }),
37
+ ),
38
+ options: Type.Optional(
39
+ Type.Array(OptionSchema, {
40
+ description: "Required when type is 'choice'. The options the user can choose from.",
41
+ }),
42
+ ),
43
+ placeholder: Type.Optional(
44
+ Type.String({
45
+ description:
46
+ "Hint text shown inside the editor for text questions " +
47
+ "(e.g. 'Enter your API key…'). Purely informational.",
48
+ }),
49
+ ),
50
+ allowOther: Type.Optional(
51
+ Type.Boolean({
52
+ description:
53
+ "For choice questions: whether to append a 'Type something' option " +
54
+ "that opens a free-text editor. Defaults to true.",
55
+ }),
56
+ ),
57
+ });
58
+
59
+ export const AskUserParams = Type.Object({
60
+ questions: Type.Array(QuestionSchema, {
61
+ description:
62
+ "One or more questions to ask the user. " +
63
+ "Single-item arrays show a focused UI. " +
64
+ "Multi-item arrays show a tabbed wizard with a Submit step.",
65
+ }),
66
+ });
package/src/types.ts ADDED
@@ -0,0 +1,34 @@
1
+ // ── Types ──────────────────────────────────────────────────────────────────────
2
+
3
+ export interface QuestionOption {
4
+ value: string;
5
+ label: string;
6
+ description?: string;
7
+ }
8
+
9
+ export type RenderOption = QuestionOption & { isOther?: boolean };
10
+
11
+ export interface AskQuestion {
12
+ id: string;
13
+ prompt: string;
14
+ type: "text" | "choice";
15
+ options?: QuestionOption[];
16
+ placeholder?: string;
17
+ allowOther?: boolean;
18
+ label?: string;
19
+ }
20
+
21
+ export interface Answer {
22
+ id: string;
23
+ value: string;
24
+ label: string;
25
+ type: "text" | "choice";
26
+ wasCustom: boolean;
27
+ optionIndex?: number;
28
+ }
29
+
30
+ export interface AskResult {
31
+ questions: AskQuestion[];
32
+ answers: Answer[];
33
+ cancelled: boolean;
34
+ }