pi-ask-herdr 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/README.md ADDED
@@ -0,0 +1,127 @@
1
+ # pi-ask-herdr
2
+
3
+ A Pi extension that adds an `ask_user` tool, with optional integration with
4
+ [Herdr](https://herdr.dev) notifications and agent state.
5
+
6
+ ## Structure
7
+
8
+ ```text
9
+ index.ts # Pi auto-discovery entry point
10
+ src/
11
+ tool.ts # Tool registration and parameter schema
12
+ ui.ts # TUI prompts (text, confirm, select, multiselect)
13
+ herdr.ts # Herdr socket / event-bus integration
14
+ options.ts # Option normalization and display formatting
15
+ types.ts # Shared TypeScript types
16
+ ```
17
+
18
+ ## What it does
19
+
20
+ - Registers an `ask_user` tool that can ask the user for:
21
+ - free-form text (`type: "text"`)
22
+ - yes/no confirmation (`type: "confirm"`)
23
+ - single choice from a list (`type: "select"`)
24
+ - multiple choices from a list (`type: "multiselect"`)
25
+ - Waits for the answer and returns it to the agent.
26
+ - For `select` and `multiselect`, an **"Other (custom)"** option is always
27
+ available so the user can type their own answer.
28
+ - For `confirm`, you can enable the same custom option with
29
+ `"allow_custom": true`.
30
+ - No extra slash command is registered — only the `ask_user` tool.
31
+ - When Pi runs inside a Herdr pane, it:
32
+ - reports the pane state as `blocked` while waiting
33
+ - sends a Herdr toast notification so the user knows input is needed
34
+ - restores the pane state after the user answers
35
+
36
+ ## Install
37
+
38
+ ### From npm (recommended)
39
+
40
+ ```bash
41
+ pi install npm:pi-ask-herdr
42
+ ```
43
+
44
+ Then reload Pi with `/reload`.
45
+
46
+ ### From git
47
+
48
+ ```bash
49
+ pi install git:github.com/leset0ng/pi-ask-herdr@v0.1.0
50
+ ```
51
+
52
+ ### Manual
53
+
54
+ Copy or clone this directory into Pi's extensions folder:
55
+
56
+ ```bash
57
+ # Global
58
+ cp -R pi-ask-herdr ~/.pi/agent/extensions/
59
+
60
+ # Or project-local
61
+ cp -R pi-ask-herdr .pi/extensions/
62
+ ```
63
+
64
+ Then reload Pi with `/reload`, or test it directly with:
65
+
66
+ ```bash
67
+ pi -e ~/.pi/agent/extensions/pi-ask-herdr/index.ts
68
+ ```
69
+
70
+ ## Tool usage
71
+
72
+ Simple string options:
73
+
74
+ ```json
75
+ {
76
+ "question": "Which database should we use?",
77
+ "type": "select",
78
+ "options": ["PostgreSQL", "SQLite", "MySQL"]
79
+ }
80
+ ```
81
+
82
+ Options with descriptions:
83
+
84
+ ```json
85
+ {
86
+ "question": "Which database should we use?",
87
+ "type": "select",
88
+ "options": [
89
+ { "label": "PostgreSQL", "description": "Full-featured relational database" },
90
+ { "label": "SQLite", "description": "Zero-config file database" },
91
+ { "label": "MySQL", "description": "Widely used open-source RDBMS" }
92
+ ]
93
+ }
94
+ ```
95
+
96
+ ### Parameters
97
+
98
+ | Name | Type | Required | Description |
99
+ |--------------|----------------------|----------|--------------------------------------------------|
100
+ | question | string | yes | The question to display |
101
+ | type | string | no | `"text"`, `"confirm"`, `"select"`, `"multiselect"` (default: text) |
102
+ | options | string[] or object[] | no | Required when `type` is `select` or `multiselect`. Each item can be a string or `{ "label": string, "description"?: string }` |
103
+ | default | string | no | Prefilled value for text input |
104
+ | timeout | number | no | Auto-cancel after N milliseconds |
105
+ | allow_custom | boolean | no | For `confirm`: also offer "Other (custom)" |
106
+
107
+ ## Herdr integration
108
+
109
+ The extension detects Herdr via the environment variables that Herdr injects
110
+ into its panes:
111
+
112
+ - `HERDR_ENV=1`
113
+ - `HERDR_SOCKET_PATH`
114
+ - `HERDR_PANE_ID`
115
+
116
+ If these are present, the extension:
117
+
118
+ - emits `herdr:blocked` with `active: true` before prompting (the official
119
+ Herdr Pi integration turns this into a `blocked` pane state)
120
+ - calls `notification.show` with the question as the body
121
+ - emits `herdr:blocked` with `active: false` after the user answers
122
+
123
+ No extra configuration is required.
124
+
125
+ ## License
126
+
127
+ MIT
package/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * pi-ask-herdr
3
+ *
4
+ * A Pi extension that adds an `ask_user` tool and integrates with Herdr.
5
+ *
6
+ * Source modules live under `./src/`; this file is only the auto-discovered
7
+ * entry point that Pi loads.
8
+ */
9
+
10
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
+ import { registerAskuserTool } from "./src/tool.ts";
12
+
13
+ export default function askuserHerdr(pi: ExtensionAPI) {
14
+ registerAskuserTool(pi);
15
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "pi-ask-herdr",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension that adds an ask_user tool and integrates with Herdr notifications/agent state.",
5
+ "type": "module",
6
+ "main": "./index.ts",
7
+ "keywords": [
8
+ "pi-package",
9
+ "pi",
10
+ "pi-extension",
11
+ "herdr",
12
+ "ask-user"
13
+ ],
14
+ "homepage": "https://github.com/leset0ng/pi-ask-herdr#readme",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/leset0ng/pi-ask-herdr.git"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/leset0ng/pi-ask-herdr/issues"
21
+ },
22
+ "license": "MIT",
23
+ "files": [
24
+ "index.ts",
25
+ "src/**/*",
26
+ "README.md",
27
+ "package.json"
28
+ ],
29
+ "peerDependencies": {
30
+ "@earendil-works/pi-ai": "*",
31
+ "@earendil-works/pi-coding-agent": "*",
32
+ "@earendil-works/pi-tui": "*",
33
+ "typebox": "*"
34
+ },
35
+ "devDependencies": {
36
+ "typescript": "^5.0.0"
37
+ },
38
+ "pi": {
39
+ "extensions": [
40
+ "./index.ts"
41
+ ]
42
+ },
43
+ "scripts": {
44
+ "typecheck": "tsc --noEmit"
45
+ }
46
+ }
package/src/herdr.ts ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Herdr integration helpers.
3
+ *
4
+ * - Detects whether Pi is running inside a Herdr pane.
5
+ * - Sends toast notifications via the Herdr Unix socket.
6
+ * - Reports blocked/working state through the official Herdr Pi integration
7
+ * event bus (`herdr:blocked`).
8
+ */
9
+
10
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
+ import { createConnection } from "node:net";
12
+
13
+ export function isHerdrEnv(): boolean {
14
+ return process.env.HERDR_ENV === "1" && !!process.env.HERDR_SOCKET_PATH && !!process.env.HERDR_PANE_ID;
15
+ }
16
+
17
+ function herdrSocketPath(): string | undefined {
18
+ return process.env.HERDR_SOCKET_PATH;
19
+ }
20
+
21
+ /**
22
+ * Send a single JSON-RPC-style request to the Herdr Unix socket and return
23
+ * the matching result. Herdr speaks newline-delimited JSON.
24
+ */
25
+ function herdrRequest<T>(method: string, params: object): Promise<T | undefined> {
26
+ const socketPath = herdrSocketPath();
27
+ if (!socketPath) return Promise.resolve(undefined);
28
+
29
+ const id = `askuser-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
30
+
31
+ return new Promise<T | undefined>((resolve, reject) => {
32
+ let buffer = "";
33
+ const client = createConnection(socketPath);
34
+
35
+ const timer = setTimeout(() => {
36
+ client.end();
37
+ reject(new Error("herdr socket timeout"));
38
+ }, 5000);
39
+
40
+ client.on("connect", () => {
41
+ client.write(JSON.stringify({ id, method, params }) + "\n");
42
+ });
43
+
44
+ client.on("data", (data) => {
45
+ buffer += data.toString("utf8");
46
+ });
47
+
48
+ client.on("end", () => {
49
+ clearTimeout(timer);
50
+ const lines = buffer
51
+ .split("\n")
52
+ .map((l) => l.trim())
53
+ .filter(Boolean);
54
+
55
+ for (const line of lines) {
56
+ try {
57
+ const msg = JSON.parse(line) as { id?: string; result?: T; error?: { message: string } };
58
+ if (msg.id === id) {
59
+ if (msg.error) {
60
+ reject(new Error(msg.error.message));
61
+ } else {
62
+ resolve(msg.result);
63
+ }
64
+ return;
65
+ }
66
+ } catch {
67
+ // ignore malformed lines
68
+ }
69
+ }
70
+ resolve(undefined);
71
+ });
72
+
73
+ client.on("error", (err) => {
74
+ clearTimeout(timer);
75
+ reject(err);
76
+ });
77
+ });
78
+ }
79
+
80
+ export async function herdrNotify(title: string, body: string) {
81
+ try {
82
+ await herdrRequest<{ type: string; shown: boolean; reason?: string }>("notification.show", {
83
+ title,
84
+ body,
85
+ sound: "request",
86
+ });
87
+ } catch (err) {
88
+ console.error("[pi-ask-herdr] notification.show failed:", err);
89
+ }
90
+ }
91
+
92
+ export function setHerdrBlocked(pi: ExtensionAPI, active: boolean, label?: string) {
93
+ try {
94
+ pi.events.emit("herdr:blocked", { active, label });
95
+ } catch (err) {
96
+ console.error("[pi-ask-herdr] failed to emit herdr:blocked:", err);
97
+ }
98
+ }
package/src/options.ts ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Option normalization and display formatting for select / multiselect.
3
+ */
4
+
5
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import type { OptionItem, OptionObject } from "./types.ts";
7
+
8
+ export function normalizeOptions(options: (string | OptionObject)[]): OptionItem[] {
9
+ return options.map((opt) => {
10
+ if (typeof opt === "string") {
11
+ return { label: opt, value: opt };
12
+ }
13
+ return { label: opt.label, value: opt.label, description: opt.description };
14
+ });
15
+ }
16
+
17
+ /**
18
+ * Bold the label when a description is present so described options stand out.
19
+ */
20
+ export function formatOptionDisplay(
21
+ item: OptionItem,
22
+ theme: ExtensionContext["ui"]["theme"],
23
+ ): string {
24
+ if (!item.description) {
25
+ return item.label;
26
+ }
27
+ return `${theme.bold(item.label)} — ${item.description}`;
28
+ }
package/src/tool.ts ADDED
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Tool registration for ask_user.
3
+ */
4
+
5
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import { StringEnum } from "@earendil-works/pi-ai";
7
+ import { Type } from "typebox";
8
+ import { isHerdrEnv, herdrNotify, setHerdrBlocked } from "./herdr.ts";
9
+ import { askInTui, renderAnswer } from "./ui.ts";
10
+ import type { AskDetails } from "./types.ts";
11
+
12
+ export function registerAskuserTool(pi: ExtensionAPI) {
13
+ const OptionSchema = Type.Object({
14
+ label: Type.String({ description: "Display label and returned value" }),
15
+ description: Type.Optional(Type.String({ description: "Optional longer description shown in the menu" })),
16
+ });
17
+
18
+ const AskParamsSchema = Type.Object({
19
+ question: Type.String({ description: "The question to ask the user" }),
20
+ type: Type.Optional(
21
+ StringEnum(["text", "confirm", "select", "multiselect"] as const, {
22
+ description: "Input type: text (default), confirm, select, or multiselect",
23
+ }),
24
+ ),
25
+ options: Type.Optional(
26
+ Type.Array(Type.Union([Type.String(), OptionSchema]), {
27
+ description: "Required when type is select or multiselect; items can be strings or {label, description?}",
28
+ }),
29
+ ),
30
+ default: Type.Optional(Type.String({ description: "Default value for text input" })),
31
+ timeout: Type.Optional(
32
+ Type.Number({ description: "Optional timeout in milliseconds before auto-cancelling" }),
33
+ ),
34
+ allow_custom: Type.Optional(
35
+ Type.Boolean({
36
+ description:
37
+ "For confirm: also offer an 'Other (custom)' explain option. For select/multiselect it is always enabled.",
38
+ }),
39
+ ),
40
+ });
41
+
42
+ pi.registerTool({
43
+ name: "ask_user",
44
+ label: "Ask User",
45
+ description:
46
+ "Ask the user a question and wait for an answer. Use when you need user input to continue.",
47
+ promptSnippet: "Prompt the user for input when the next step depends on a choice or missing detail.",
48
+ promptGuidelines: [
49
+ "Use ask_user only when the task cannot proceed without user input.",
50
+ "For ask_user with type 'select' or 'multiselect', always provide the options array.",
51
+ "Keep the question concise but include enough context for the user to answer.",
52
+ "Users can always choose 'Other (custom)' for select/multiselect to type their own answer.",
53
+ "Use { label, description } objects for options when the user needs extra context to decide.",
54
+ ],
55
+ parameters: AskParamsSchema,
56
+ executionMode: "sequential",
57
+
58
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx: ExtensionContext) {
59
+ // Guard against non-interactive invocations.
60
+ if (ctx.mode !== "tui") {
61
+ return {
62
+ content: [
63
+ {
64
+ type: "text",
65
+ text: "ask_user can only be used in interactive TUI mode.",
66
+ },
67
+ ],
68
+ details: {
69
+ question: params.question,
70
+ type: params.type ?? "text",
71
+ options: params.options,
72
+ answer: null,
73
+ cancelled: true,
74
+ } satisfies AskDetails,
75
+ };
76
+ }
77
+
78
+ // Validate select/multiselect input up front.
79
+ if (
80
+ (params.type === "select" || params.type === "multiselect") &&
81
+ (!params.options || params.options.length === 0)
82
+ ) {
83
+ return {
84
+ content: [
85
+ {
86
+ type: "text",
87
+ text: `ask_user error: type '${params.type}' requires an options array.`,
88
+ },
89
+ ],
90
+ details: {
91
+ question: params.question,
92
+ type: params.type,
93
+ options: params.options,
94
+ answer: null,
95
+ cancelled: true,
96
+ } satisfies AskDetails,
97
+ };
98
+ }
99
+
100
+ // Tell Herdr that this pane is blocked waiting for input.
101
+ if (isHerdrEnv()) {
102
+ setHerdrBlocked(pi, true, "ask_user");
103
+ await herdrNotify("Pi needs input", params.question);
104
+ }
105
+
106
+ const details = await askInTui(params, ctx);
107
+
108
+ // Restore agent state now that the prompt is done.
109
+ if (isHerdrEnv()) {
110
+ setHerdrBlocked(pi, false);
111
+ }
112
+
113
+ return {
114
+ content: [{ type: "text", text: renderAnswer(details) }],
115
+ details,
116
+ };
117
+ },
118
+ });
119
+ }
package/src/types.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Shared types for the pi-ask-herdr extension.
3
+ */
4
+
5
+ export type AskType = "text" | "confirm" | "select" | "multiselect";
6
+
7
+ export interface OptionObject {
8
+ label: string;
9
+ description?: string;
10
+ }
11
+
12
+ export interface AskParams {
13
+ question: string;
14
+ type?: AskType;
15
+ options?: (string | OptionObject)[];
16
+ default?: string;
17
+ timeout?: number;
18
+ allow_custom?: boolean;
19
+ }
20
+
21
+ export interface OptionItem {
22
+ label: string;
23
+ value: string;
24
+ description?: string;
25
+ }
26
+
27
+ export interface AskDetails {
28
+ question: string;
29
+ type: AskType;
30
+ options: (string | OptionObject)[] | undefined;
31
+ answer: string | boolean | string[] | null;
32
+ cancelled: boolean;
33
+ custom?: boolean;
34
+ }
35
+
36
+ // Sentinel value used internally for the "Other (custom)" menu item.
37
+ export const CUSTOM_SENTINEL = "__askuser_custom__";
38
+ export const CUSTOM_LABEL = "✎ Other (custom)";
package/src/ui.ts ADDED
@@ -0,0 +1,248 @@
1
+ /**
2
+ * TUI prompting implementation for the ask_user tool.
3
+ */
4
+
5
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import type { AskDetails, AskParams, AskType, OptionItem } from "./types.ts";
7
+ import { CUSTOM_LABEL } from "./types.ts";
8
+ import { formatOptionDisplay, normalizeOptions } from "./options.ts";
9
+
10
+ function makeSignal(timeout?: number): AbortSignal | undefined {
11
+ if (!timeout || timeout <= 0) return undefined;
12
+ const controller = new AbortController();
13
+ setTimeout(() => controller.abort(), timeout);
14
+ return controller.signal;
15
+ }
16
+
17
+ async function askText(params: AskParams, ctx: ExtensionContext): Promise<AskDetails> {
18
+ const signal = makeSignal(params.timeout);
19
+ const text = await ctx.ui.input(params.question, params.default ?? "", signal ? { signal } : undefined);
20
+ return {
21
+ question: params.question,
22
+ type: "text",
23
+ options: undefined,
24
+ answer: text ?? null,
25
+ cancelled: text === null,
26
+ };
27
+ }
28
+
29
+ async function askConfirm(params: AskParams, ctx: ExtensionContext): Promise<AskDetails> {
30
+ const signal = makeSignal(params.timeout);
31
+ const allowCustom = params.allow_custom ?? false;
32
+
33
+ if (!allowCustom) {
34
+ const ok = await ctx.ui.confirm(params.question, undefined, signal ? { signal } : undefined);
35
+ return {
36
+ question: params.question,
37
+ type: "confirm",
38
+ options: undefined,
39
+ answer: ok === null ? null : ok,
40
+ cancelled: ok === null,
41
+ };
42
+ }
43
+
44
+ const options = ["Yes", "No", CUSTOM_LABEL];
45
+ const choice = await ctx.ui.select(params.question, options, signal ? { signal } : undefined);
46
+
47
+ if (choice === null) {
48
+ return {
49
+ question: params.question,
50
+ type: "confirm",
51
+ options: undefined,
52
+ answer: null,
53
+ cancelled: true,
54
+ };
55
+ }
56
+
57
+ if (choice === CUSTOM_LABEL) {
58
+ const text = await ctx.ui.input("Please explain your choice:");
59
+ return {
60
+ question: params.question,
61
+ type: "confirm",
62
+ options: undefined,
63
+ answer: text ?? null,
64
+ cancelled: text === null,
65
+ custom: true,
66
+ };
67
+ }
68
+
69
+ return {
70
+ question: params.question,
71
+ type: "confirm",
72
+ options: undefined,
73
+ answer: choice === "Yes",
74
+ cancelled: false,
75
+ };
76
+ }
77
+
78
+ async function askSelect(params: AskParams, ctx: ExtensionContext): Promise<AskDetails> {
79
+ const rawOptions = params.options ?? [];
80
+ if (rawOptions.length === 0) {
81
+ ctx.ui.notify("ask_user: select requires options", "error");
82
+ return {
83
+ question: params.question,
84
+ type: "select",
85
+ options: rawOptions,
86
+ answer: null,
87
+ cancelled: true,
88
+ };
89
+ }
90
+
91
+ const normalized = normalizeOptions(rawOptions);
92
+ const items = normalized.map((o) => ({
93
+ ...o,
94
+ display: formatOptionDisplay(o, ctx.ui.theme),
95
+ }));
96
+ const displayOptions = [...items.map((o) => o.display), CUSTOM_LABEL];
97
+ const choice = await ctx.ui.select(params.question, displayOptions);
98
+
99
+ if (choice === null) {
100
+ return {
101
+ question: params.question,
102
+ type: "select",
103
+ options: rawOptions,
104
+ answer: null,
105
+ cancelled: true,
106
+ };
107
+ }
108
+
109
+ if (choice === CUSTOM_LABEL) {
110
+ const text = await ctx.ui.input("Enter your custom answer:");
111
+ return {
112
+ question: params.question,
113
+ type: "select",
114
+ options: rawOptions,
115
+ answer: text ?? null,
116
+ cancelled: text === null,
117
+ custom: true,
118
+ };
119
+ }
120
+
121
+ const picked = items.find((o) => o.display === choice);
122
+ return {
123
+ question: params.question,
124
+ type: "select",
125
+ options: rawOptions,
126
+ answer: picked?.value ?? choice,
127
+ cancelled: false,
128
+ };
129
+ }
130
+
131
+ async function askMultiselect(params: AskParams, ctx: ExtensionContext): Promise<AskDetails> {
132
+ const rawOptions = params.options ?? [];
133
+ if (rawOptions.length === 0) {
134
+ ctx.ui.notify("ask_user: multiselect requires options", "error");
135
+ return {
136
+ question: params.question,
137
+ type: "multiselect",
138
+ options: rawOptions,
139
+ answer: null,
140
+ cancelled: true,
141
+ };
142
+ }
143
+
144
+ const normalized = normalizeOptions(rawOptions);
145
+ let optionItems: Array<OptionItem & { display: string }> = normalized.map((o) => ({
146
+ ...o,
147
+ display: formatOptionDisplay(o, ctx.ui.theme),
148
+ }));
149
+ const selected = new Set<string>();
150
+
151
+ while (true) {
152
+ const displayOptions: string[] = ["✓ Done", "✗ Cancel", CUSTOM_LABEL];
153
+ for (const item of optionItems) {
154
+ const prefix = selected.has(item.value) ? "[x]" : "[ ]";
155
+ displayOptions.push(`${prefix} ${item.display}`);
156
+ }
157
+
158
+ const choice = await ctx.ui.select(params.question, displayOptions);
159
+
160
+ if (choice === null || choice === "✗ Cancel") {
161
+ return {
162
+ question: params.question,
163
+ type: "multiselect",
164
+ options: rawOptions,
165
+ answer: null,
166
+ cancelled: true,
167
+ };
168
+ }
169
+
170
+ if (choice === "✓ Done") {
171
+ return {
172
+ question: params.question,
173
+ type: "multiselect",
174
+ options: rawOptions,
175
+ answer: Array.from(selected),
176
+ cancelled: false,
177
+ };
178
+ }
179
+
180
+ if (choice === CUSTOM_LABEL) {
181
+ const text = await ctx.ui.input("Enter a custom value to add:");
182
+ if (text) {
183
+ if (!optionItems.some((i) => i.value === text)) {
184
+ optionItems.push({
185
+ label: text,
186
+ value: text,
187
+ display: text,
188
+ });
189
+ }
190
+ selected.add(text);
191
+ }
192
+ continue;
193
+ }
194
+
195
+ // Toggle a regular option.
196
+ const display = choice.replace(/^\[[x ]\] /, "");
197
+ const item = optionItems.find((i) => i.display === display);
198
+ const value = item?.value ?? display;
199
+ if (selected.has(value)) {
200
+ selected.delete(value);
201
+ } else {
202
+ selected.add(value);
203
+ }
204
+ }
205
+ }
206
+
207
+ export async function askInTui(params: AskParams, ctx: ExtensionContext): Promise<AskDetails> {
208
+ const question = params.question.trim();
209
+ if (!question) {
210
+ return {
211
+ question: "",
212
+ type: params.type ?? "text",
213
+ options: params.options,
214
+ answer: null,
215
+ cancelled: true,
216
+ };
217
+ }
218
+
219
+ const type: AskType = params.type ?? "text";
220
+ switch (type) {
221
+ case "confirm":
222
+ return askConfirm(params, ctx);
223
+ case "select":
224
+ return askSelect(params, ctx);
225
+ case "multiselect":
226
+ return askMultiselect(params, ctx);
227
+ case "text":
228
+ default:
229
+ return askText(params, ctx);
230
+ }
231
+ }
232
+
233
+ export function renderAnswer(details: AskDetails): string {
234
+ if (details.cancelled) {
235
+ return "User cancelled the prompt.";
236
+ }
237
+ if (details.answer === null) {
238
+ return "No answer provided.";
239
+ }
240
+ if (typeof details.answer === "boolean") {
241
+ return details.answer ? "User answered yes." : "User answered no.";
242
+ }
243
+ if (Array.isArray(details.answer)) {
244
+ if (details.answer.length === 0) return "User selected nothing.";
245
+ return `User selected: ${details.answer.join(", ")}`;
246
+ }
247
+ return `User answered: ${details.answer}`;
248
+ }