pi-plan-task 1.0.0 → 1.0.2

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 CHANGED
@@ -1,19 +1,45 @@
1
1
  # pi-plan-task
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/pi-plan-task.svg)](https://www.npmjs.com/package/pi-plan-task)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+
3
6
  One Pi package: `/plan`, `/build`, `/goal`, and `/tasks`. Progress lives on disk, so a new session or a Pi restart can continue from the next unfinished task.
4
7
 
8
+ Structured questions use [`@juicesharp/rpiv-ask-user-question`](https://pi.dev/packages/@juicesharp/rpiv-ask-user-question). This package does not register `ask_user_question`.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pi install npm:@juicesharp/rpiv-ask-user-question
14
+ pi install npm:pi-plan-task
15
+ ```
16
+
17
+ Restart Pi or run `/reload`. If juicesharp is missing, this package warns on session start.
18
+
19
+ Local checkout (development):
20
+
21
+ ```bash
22
+ pi install /absolute/path/to/pi-plan-task
23
+ ```
24
+
25
+ Or add the source to `packages` in `~/.pi/agent/settings.json`.
26
+
5
27
  ## Commands
6
28
 
7
29
  | Command | What it does |
8
- |---|---|
9
- | `/plan [file|goal]` | Read-only planning. Writes `.plan_task/plan.md` and `.plan_task/task.md`. |
10
- | `/build` | Execute the next unfinished task, then ask whether to continue here or in a new session. |
30
+ | --- | --- |
31
+ | `/plan [file or goal]` | Read-only planning. Writes `.plan_task/plan.md` and `.plan_task/task.md`. |
32
+ | `/build` | Ask where to run, execute the next unfinished task, then ask whether to continue here or in a new session. |
33
+ | `/build here` | Same as `/build`, but skip the first session prompt and stay in this session. |
34
+ | `/build new` | Same as `/build`, but skip the first session prompt and start a new session. |
11
35
  | `/goal` | Execute remaining tasks until `task.md` is complete. No session prompts. |
12
36
  | `/tasks` | Show the current task list and progress. |
13
37
 
14
38
  Restarting Pi does not auto-start work. Run `/build` again.
15
39
 
16
- `/plan` accepts a spec file, a prompt, or a file plus extra notes:
40
+ `/goal` never asks about sessions. After a `/build` task finishes, the continue-here / new-session prompt still appears. Those prompts use this package's `select` dialog, not `ask_user_question`.
41
+
42
+ ### `/plan` arguments
17
43
 
18
44
  ```
19
45
  /plan
@@ -36,7 +62,6 @@ That file keeps the same section headings as `planning-and-task-breakdown.md`, s
36
62
 
37
63
  This package does not install or load a skill. It also does not modify Pi's system prompt.
38
64
 
39
-
40
65
  ## Prompt injection
41
66
 
42
67
  Phase instructions are conversation messages, not system-prompt patches:
@@ -50,13 +75,13 @@ Session history still stores the injected messages. Filtering is non-destructive
50
75
 
51
76
  ## Ask user
52
77
 
53
- `ask_user_question` is available in every mode, not just `/plan`. Use it for consequential choices the repo cannot answer:
78
+ `/plan` and `/build` keep juicesharp's `ask_user_question` available and tell the model to call it for consequential choices the repo cannot answer:
54
79
 
55
- - 2-4 selectable options
56
- - optional recommended default, shown with
57
- - free-form Other path (on by default; set `allowOther: false` to hide it)
80
+ - 1-4 questions per call, each with a short `header` and 2-4 described options
81
+ - recommended option first, with `(Recommended)` on the label
82
+ - do not author `Other` or `Type something.` juicesharp appends a custom-answer row
58
83
 
59
- In the TUI, pick an option or choose **Other / type my answer**. Without a UI, the tool asks the agent to pose the question in chat.
84
+ Do not also register another `ask_user_question` tool. The names collide and the schemas differ.
60
85
 
61
86
  ## Files
62
87
 
@@ -89,12 +114,8 @@ Allowed `/plan` tools:
89
114
  }
90
115
  ```
91
116
 
92
- During `/plan`, `write` and `edit` stay available but can only touch the two plan files. Bash is limited to read-only commands.
117
+ During `/plan`, `write` and `edit` stay available but can only touch the two plan files. Bash is limited to read-only commands. `plan_task` and `ask_user_question` stay available in every mode.
93
118
 
94
- ## Install
95
-
96
- ```bash
97
- pi install D:/work/tools/pi-plan-task
98
- ```
119
+ ## License
99
120
 
100
- Or add the path to `packages` in `~/.pi/agent/settings.json`, then restart Pi or run `/reload`.
121
+ MIT
@@ -0,0 +1,18 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { parseBuildPlacement } from "./build-session.ts";
4
+
5
+ describe("parseBuildPlacement", () => {
6
+ it("asks by default", () => {
7
+ assert.equal(parseBuildPlacement(""), "ask");
8
+ assert.equal(parseBuildPlacement(" "), "ask");
9
+ assert.equal(parseBuildPlacement("unknown"), "ask");
10
+ });
11
+
12
+ it("accepts here and new aliases", () => {
13
+ assert.equal(parseBuildPlacement("here"), "here");
14
+ assert.equal(parseBuildPlacement(" --here extra"), "here");
15
+ assert.equal(parseBuildPlacement("NEW"), "new");
16
+ assert.equal(parseBuildPlacement("--new"), "new");
17
+ });
18
+ });
@@ -0,0 +1,8 @@
1
+ export type BuildPlacement = "ask" | "here" | "new";
2
+
3
+ export function parseBuildPlacement(args: string): BuildPlacement {
4
+ const token = args.trim().split(/\s+/)[0]?.toLowerCase() ?? "";
5
+ if (token === "here" || token === "--here") return "here";
6
+ if (token === "new" || token === "--new") return "new";
7
+ return "ask";
8
+ }
@@ -1,17 +1,14 @@
1
1
  import { StringEnum } from "@earendil-works/pi-ai";
2
2
  import {
3
3
  type ExtensionAPI,
4
+ type ExtensionCommandContext,
4
5
  type ExtensionContext,
5
6
  isToolCallEventType,
6
7
  } from "@earendil-works/pi-coding-agent";
7
8
  import { matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
8
9
  import { Type } from "typebox";
9
10
  import { isSafePlanCommand } from "./bash-guard.ts";
10
- import {
11
- ASK_QUESTION_GUIDELINES,
12
- ASK_USER_QUESTION_TOOL,
13
- executeAskQuestion,
14
- } from "./ask-question.ts";
11
+ import { parseBuildPlacement, type BuildPlacement } from "./build-session.ts";
15
12
  import { ensureDefaultGlobalConfig, loadConfig } from "./config.ts";
16
13
  import {
17
14
  ensurePlanDir,
@@ -40,8 +37,30 @@ type Mode = "idle" | "plan" | "build";
40
37
 
41
38
  const CONTINUE_THIS = "Continue in this session";
42
39
  const CONTINUE_NEW = "Continue in a new session";
40
+ const BUILD_HERE_COMMAND = "/build here";
41
+
42
+ async function chooseBuildPlacement(ctx: ExtensionCommandContext): Promise<"here" | "new" | undefined> {
43
+ if (!ctx.hasUI) return "here";
44
+ const choice = await ctx.ui.select("Start this task where?", [CONTINUE_THIS, CONTINUE_NEW]);
45
+ if (choice === CONTINUE_THIS) return "here";
46
+ if (choice === CONTINUE_NEW) return "new";
47
+ return undefined;
48
+ }
49
+
50
+ async function startBuildInNewSession(ctx: ExtensionCommandContext): Promise<void> {
51
+ const parentSession = ctx.sessionManager.getSessionFile();
52
+ const result = await ctx.newSession({
53
+ parentSession,
54
+ withSession: async (nextCtx) => {
55
+ await nextCtx.sendUserMessage(BUILD_HERE_COMMAND, { expandPromptTemplates: true });
56
+ },
57
+ });
58
+ if (result.cancelled) {
59
+ ctx.ui.notify("New session cancelled.", "info");
60
+ }
61
+ }
43
62
 
44
- const ALWAYS_ON_TOOLS = ["plan_task", ASK_USER_QUESTION_TOOL] as const;
63
+ const ALWAYS_ON_TOOLS = ["plan_task", "ask_user_question"] as const;
45
64
 
46
65
  function unique(names: string[]): string[] {
47
66
  return [...new Set(names)];
@@ -317,43 +336,6 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
317
336
  },
318
337
  });
319
338
 
320
- pi.registerTool({
321
- name: ASK_USER_QUESTION_TOOL,
322
- label: "Ask User Question",
323
- description:
324
- "Ask user a clarifying question with selectable options, a recommended default, and optional free-form input. Works in any mode.",
325
- promptSnippet:
326
- "Ask user a clarifying question with 2-4 options and a recommended default; works in any mode",
327
- promptGuidelines: ASK_QUESTION_GUIDELINES,
328
- parameters: Type.Object({
329
- question: Type.String({ description: "The clarifying question to ask" }),
330
- options: Type.Array(
331
- Type.Object({
332
- label: Type.String({ description: "Option label" }),
333
- description: Type.Optional(Type.String({ description: "Optional explanation" })),
334
- }),
335
- { description: "Options to choose from (2-4 required)", minItems: 2, maxItems: 4 },
336
- ),
337
- recommended: Type.Optional(
338
- Type.String({
339
- description:
340
- "Label of the recommended option (must match one option label). It is shown with a ★ marker.",
341
- }),
342
- ),
343
- allowOther: Type.Optional(Type.Boolean({ description: "Allow free-form user answer; default true" })),
344
- }),
345
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
346
- return executeAskQuestion(params, ctx);
347
- },
348
- renderCall(args, theme) {
349
- const question = typeof args.question === "string" ? args.question : "";
350
- return new Text(
351
- theme.fg("toolTitle", theme.bold("ask_user_question ")) + theme.fg("muted", question),
352
- 0,
353
- 0,
354
- );
355
- },
356
- });
357
339
  pi.registerCommand("plan", {
358
340
  description: "Write .plan_task/plan.md and .plan_task/task.md from a file path or prompt",
359
341
  handler: async (args, ctx) => {
@@ -384,7 +366,11 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
384
366
  },
385
367
  });
386
368
 
387
- async function beginBuild(ctx: ExtensionContext, runAll: boolean): Promise<void> {
369
+ async function beginBuild(
370
+ ctx: ExtensionCommandContext,
371
+ runAll: boolean,
372
+ placement: BuildPlacement = "ask",
373
+ ): Promise<void> {
388
374
  const file = await loadTaskFile(ctx.cwd);
389
375
  if (!file) {
390
376
  ctx.ui.notify("No plan found. Run /plan first.", "error");
@@ -398,6 +384,14 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
398
384
  ctx.ui.notify("All tasks are complete.", "info");
399
385
  return;
400
386
  }
387
+ if (!runAll) {
388
+ const resolved = placement === "ask" ? await chooseBuildPlacement(ctx) : placement;
389
+ if (!resolved) return;
390
+ if (resolved === "new") {
391
+ await startBuildInNewSession(ctx);
392
+ return;
393
+ }
394
+ }
401
395
  continueAll = runAll;
402
396
  await enterBuildMode(ctx);
403
397
  ctx.ui.notify(runAll ? "Building remaining tasks." : "Building the next task.", "info");
@@ -405,9 +399,9 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
405
399
  }
406
400
 
407
401
  pi.registerCommand("build", {
408
- description: "Execute the next planned task",
409
- handler: async (_args, ctx) => {
410
- await beginBuild(ctx, false);
402
+ description: "Choose this session or a new session, then execute the next planned task",
403
+ handler: async (args, ctx) => {
404
+ await beginBuild(ctx, false, parseBuildPlacement(args));
411
405
  },
412
406
  });
413
407
 
@@ -434,16 +428,7 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
434
428
  pi.registerCommand("build-next-session", {
435
429
  description: "Continue the next planned task in a new session",
436
430
  handler: async (_args, ctx) => {
437
- const parentSession = ctx.sessionManager.getSessionFile();
438
- const result = await ctx.newSession({
439
- parentSession,
440
- withSession: async (nextCtx) => {
441
- await nextCtx.sendUserMessage("/build", { expandPromptTemplates: true });
442
- },
443
- });
444
- if (result.cancelled) {
445
- ctx.ui.notify("New session cancelled.", "info");
446
- }
431
+ await startBuildInNewSession(ctx);
447
432
  },
448
433
  });
449
434
 
@@ -457,6 +442,12 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
457
442
  planSource = EMPTY_PLAN_SOURCE;
458
443
  resetFraming();
459
444
  pi.setActiveTools(withAlwaysOnTools(pi.getActiveTools()));
445
+ if (!pi.getAllTools().some((tool) => tool.name === "ask_user_question")) {
446
+ ctx.ui.notify(
447
+ "ask_user_question is missing. Install npm:@juicesharp/rpiv-ask-user-question.",
448
+ "warning",
449
+ );
450
+ }
460
451
  updateStatus(ctx);
461
452
  });
462
453
 
@@ -19,6 +19,8 @@ describe("planPrompt", () => {
19
19
  assert.doesNotMatch(prompt, /Planning skill/);
20
20
  assert.doesNotMatch(prompt, /tasks\/todo\.md/);
21
21
  assert.match(prompt, /ask_user_question/);
22
+ assert.match(prompt, /questions/);
23
+ assert.match(prompt, /\(Recommended\)/);
22
24
  });
23
25
 
24
26
  it("inlines a spec file and extra notes", () => {
@@ -51,6 +53,8 @@ describe("buildPrompt", () => {
51
53
  assert.match(prompt, /Form submits/);
52
54
  assert.match(prompt, /acceptance criteria/);
53
55
  assert.match(prompt, /ask_user_question/);
56
+ assert.match(prompt, /questions/);
57
+ assert.match(prompt, /\(Recommended\)/);
54
58
  assert.match(prompt, /Do not start the next task/);
55
59
  });
56
60
  });
@@ -35,7 +35,7 @@ Runtime rules:
35
35
  - Follow the planning method below for process, task sizing, templates, and verification.
36
36
  - The checklist lines in \`.plan_task/task.md\` must stay in the exact form \`- [ ] N. Title\` so later sessions can resume.
37
37
  - If a spec file is provided, treat it as the primary requirements.
38
- - If a consequential, user-answerable decision remains, call \`ask_user_question\` with 2-4 options, a recommended default, and an Other path. Do not leave blocking decisions as open questions in the plan.
38
+ - If a consequential, user-answerable decision remains, call \`ask_user_question\` with a \`questions\` array (1-4 questions). Each question needs a short \`header\` and 2-4 options with labels and descriptions. Put the recommended option first and append "(Recommended)" to its label. Do not author "Other" or "Type something." labels. Batch every blocking decision into one call. Do not leave blocking decisions as open questions in the plan.
39
39
  - When both files are written, stop and wait for /build.
40
40
 
41
41
  ${loadPlanningMethod()}`;
@@ -64,7 +64,7 @@ Rules:
64
64
  - Leave the system in a working state when the task ends.
65
65
  - Verify against this task's acceptance criteria and verification steps before marking it done.
66
66
  - Do not mark the task complete if acceptance criteria are unmet or verification failed.
67
- - If a consequential decision is still ambiguous, call \`ask_user_question\` instead of guessing.
67
+ - If a consequential decision is still ambiguous, call \`ask_user_question\` instead of guessing. Use a \`questions\` array with a short \`header\` and 2-4 described options. Put the recommended option first and append "(Recommended)" to its label. Do not author "Other" or "Type something." labels. Group related questions into one call.
68
68
  - When the task is done, call the plan_task tool with action "complete" and this task id, and keep the matching checklist box checked in \`.plan_task/task.md\`.
69
69
  - ${stopRule}`;
70
70
  }
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "pi-plan-task",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "license": "MIT",
5
- "description": "Pi package: /plan, /build, /goal, /tasks.",
6
- "keywords": ["pi-package"],
5
+ "description": "Pi package: /plan, /build, /goal, and /tasks.",
6
+ "keywords": ["pi-package", "pi-extension", "plan", "build"],
7
7
  "files": ["extensions", "README.md"],
8
8
  "type": "module",
9
9
  "pi": {
@@ -13,6 +13,7 @@
13
13
  "@earendil-works/pi-ai": "*",
14
14
  "@earendil-works/pi-coding-agent": "*",
15
15
  "@earendil-works/pi-tui": "*",
16
+ "@juicesharp/rpiv-ask-user-question": ">=2.7.0",
16
17
  "typebox": "*"
17
18
  }
18
19
  }
@@ -1,91 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import {
4
- OTHER_LABEL,
5
- questionDisplayLabels,
6
- validateQuestionParams,
7
- } from "./ask-question.ts";
8
-
9
- const options = [
10
- { label: "SQLite", description: "Local file" },
11
- { label: "Postgres", description: "Shared DB" },
12
- ];
13
-
14
- describe("validateQuestionParams", () => {
15
- it("accepts 2-4 unique labels and a matching recommended option", () => {
16
- const result = validateQuestionParams({
17
- question: "Which database?",
18
- options,
19
- recommended: "postgres",
20
- });
21
- assert.equal(result.recommendedIndex, 1);
22
- assert.equal(result.options.length, 2);
23
- });
24
-
25
- it("rejects blank, duplicate, Other-conflicting, and unmatched recommended labels", () => {
26
- assert.throws(() => validateQuestionParams({ question: "Q", options: [{ label: "A" }, { label: " " }] }), /non-blank/);
27
- assert.throws(
28
- () => validateQuestionParams({ question: "Q", options: [{ label: "A" }, { label: "A" }] }),
29
- /unique/,
30
- );
31
- assert.throws(
32
- () => validateQuestionParams({ question: "Q", options: [{ label: "A" }, { label: "Other path" }] }),
33
- /Other/,
34
- );
35
- assert.throws(
36
- () => validateQuestionParams({ question: "Q", options, recommended: "Redis" }),
37
- /recommended/,
38
- );
39
- });
40
- });
41
-
42
- describe("questionDisplayLabels", () => {
43
- it("marks the recommended option with a star", () => {
44
- assert.deepEqual(questionDisplayLabels(options, 1), [
45
- "SQLite — Local file",
46
- "★ Postgres — Shared DB",
47
- ]);
48
- assert.equal(OTHER_LABEL, "Other / type my answer");
49
- });
50
- });
51
-
52
- describe("executeAskQuestion", () => {
53
- it("asks in chat when no UI is available", async () => {
54
- const { executeAskQuestion } = await import("./ask-question.ts");
55
- const result = await executeAskQuestion(
56
- { question: "Which database?", options },
57
- { hasUI: false } as never,
58
- );
59
- assert.match(result.content[0]?.text ?? "", /UI is not available/);
60
- assert.equal(result.details.answer, null);
61
- });
62
-
63
- it("selects the starred option and accepts a free-form Other answer", async () => {
64
- const { executeAskQuestion, OTHER_LABEL } = await import("./ask-question.ts");
65
- const selected = await executeAskQuestion(
66
- { question: "Which database?", options, recommended: "Postgres" },
67
- {
68
- hasUI: true,
69
- ui: {
70
- select: async (_question: string, labels: string[]) => labels.find((label) => label.startsWith("★")),
71
- editor: async () => undefined,
72
- },
73
- } as never,
74
- );
75
- assert.equal(selected.details.answer, "Postgres");
76
- assert.equal(selected.details.wasCustom, false);
77
-
78
- const custom = await executeAskQuestion(
79
- { question: "Which database?", options },
80
- {
81
- hasUI: true,
82
- ui: {
83
- select: async () => OTHER_LABEL,
84
- editor: async () => " Redis ",
85
- },
86
- } as never,
87
- );
88
- assert.equal(custom.details.answer, "Redis");
89
- assert.equal(custom.details.wasCustom, true);
90
- });
91
- });
@@ -1,145 +0,0 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
-
3
- export const ASK_USER_QUESTION_TOOL = "ask_user_question";
4
- export const OTHER_LABEL = "Other / type my answer";
5
-
6
- export interface QuestionOption {
7
- label: string;
8
- description?: string;
9
- }
10
-
11
- export interface QuestionParams {
12
- question: string;
13
- options: QuestionOption[];
14
- recommended?: string;
15
- allowOther?: boolean;
16
- }
17
-
18
- export const ASK_QUESTION_GUIDELINES = [
19
- "Use only when repo research leaves a consequential ambiguity.",
20
- "Prefer 2-4 concrete options. Use short labels.",
21
- "Don't ask what's discoverable from repo.",
22
- "Respect user's stated preference.",
23
- "Provide a recommended option when one choice is clearly preferable.",
24
- ];
25
-
26
-
27
- export function validateQuestionParams(params: QuestionParams): {
28
- options: QuestionOption[];
29
- recommendedIndex: number | null;
30
- } {
31
- const question = params.question?.trim() ?? "";
32
- if (!question) throw new Error("question must be non-blank.");
33
- const options = params.options ?? [];
34
- if (options.length < 2 || options.length > 4) {
35
- throw new Error("Provide 2-4 options.");
36
- }
37
- const labels = options.map((option) => option.label.trim());
38
- if (labels.some((label) => !label)) {
39
- throw new Error("Each option must have a non-blank label.");
40
- }
41
- if (new Set(labels).size !== labels.length) {
42
- throw new Error("Option labels must be unique.");
43
- }
44
- if (labels.some((label) => label.toLowerCase() === "other" || label.toLowerCase().startsWith("other "))) {
45
- throw new Error('Option labels cannot conflict with the "Other" label.');
46
- }
47
- let recommendedIndex: number | null = null;
48
- if (params.recommended) {
49
- const recommended = params.recommended.trim();
50
- const matchIdx = labels.findIndex((label) => label.toLowerCase() === recommended.toLowerCase());
51
- if (matchIdx === -1) {
52
- throw new Error("recommended must match one of the option labels.");
53
- }
54
- recommendedIndex = matchIdx;
55
- }
56
- return { options, recommendedIndex };
57
- }
58
-
59
- export function questionDisplayLabels(
60
- options: QuestionOption[],
61
- recommendedIndex: number | null,
62
- ): string[] {
63
- return options.map((option, index) => {
64
- const star = recommendedIndex !== null && index === recommendedIndex && options.length > 1 ? "★ " : "";
65
- return option.description ? `${star}${option.label} — ${option.description}` : `${star}${option.label}`;
66
- });
67
- }
68
-
69
- function textResult(
70
- text: string,
71
- details: Record<string, unknown>,
72
- ): { content: [{ type: "text"; text: string }]; details: Record<string, unknown> } {
73
- return { content: [{ type: "text", text }], details };
74
- }
75
-
76
- export async function executeAskQuestion(
77
- params: unknown,
78
- ctx: ExtensionContext,
79
- ): Promise<{ content: [{ type: "text"; text: string }]; details: Record<string, unknown> }> {
80
- const typed = params as QuestionParams;
81
- let options: QuestionOption[];
82
- let recommendedIndex: number | null;
83
- try {
84
- ({ options, recommendedIndex } = validateQuestionParams(typed));
85
- } catch (error) {
86
- return textResult(error instanceof Error ? error.message : String(error), {
87
- question: typed.question,
88
- answer: null,
89
- cancelled: false,
90
- wasCustom: false,
91
- });
92
- }
93
- if (!ctx.hasUI) {
94
- return textResult("UI is not available. Ask this question directly in chat and wait for the user's answer.", {
95
- question: typed.question,
96
- options,
97
- answer: null,
98
- wasCustom: false,
99
- cancelled: false,
100
- });
101
- }
102
- const allowOther = typed.allowOther !== false;
103
- const displayLabels = questionDisplayLabels(options, recommendedIndex);
104
- const choice = await ctx.ui.select(typed.question, allowOther ? [...displayLabels, OTHER_LABEL] : displayLabels);
105
- if (!choice) {
106
- return textResult("User cancelled the question.", {
107
- question: typed.question,
108
- options,
109
- answer: null,
110
- cancelled: true,
111
- wasCustom: false,
112
- });
113
- }
114
- if (choice === OTHER_LABEL) {
115
- const answer = (await ctx.ui.editor("Your answer", ""))?.trim();
116
- if (!answer) {
117
- return textResult("User cancelled the question.", {
118
- question: typed.question,
119
- options,
120
- answer: null,
121
- cancelled: true,
122
- wasCustom: false,
123
- });
124
- }
125
- return textResult(`User wrote: ${answer}`, {
126
- question: typed.question,
127
- options,
128
- answer,
129
- wasCustom: true,
130
- cancelled: false,
131
- });
132
- }
133
- const selectedIndex = displayLabels.indexOf(choice);
134
- const selected = options[selectedIndex];
135
- const answer = selected?.label ?? choice;
136
- return textResult(`User selected: ${answer}`, {
137
- question: typed.question,
138
- options,
139
- answer,
140
- selectedIndex,
141
- recommendedIndex,
142
- wasCustom: false,
143
- cancelled: false,
144
- });
145
- }