pi-ui-extend 1.0.38 → 1.0.39

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.
Files changed (40) hide show
  1. package/dist/app/extensions/extension-ui-controller.js +3 -0
  2. package/dist/app/rendering/extension-entry-renderer.js +2 -0
  3. package/dist/bundled-extensions/question/contract.d.ts +3 -0
  4. package/dist/bundled-extensions/question/contract.js +24 -0
  5. package/dist/bundled-extensions/question/desktop.d.ts +11 -0
  6. package/dist/bundled-extensions/question/desktop.js +143 -0
  7. package/dist/bundled-extensions/question/index.d.ts +1 -0
  8. package/dist/bundled-extensions/question/index.js +5 -1
  9. package/dist/bundled-extensions/question/render.js +6 -0
  10. package/dist/bundled-extensions/question/result.js +71 -9
  11. package/dist/bundled-extensions/question/tool-description.js +4 -3
  12. package/dist/bundled-extensions/question/tui.js +127 -12
  13. package/dist/bundled-extensions/question/types.d.ts +23 -2
  14. package/dist/tool-renderers/question.js +20 -1
  15. package/docs/desktop-markdown-media.md +77 -0
  16. package/docs/desktop-mvp.md +134 -0
  17. package/docs/desktop-task-manager.md +124 -0
  18. package/external/pi-tools-suite/package.json +3 -3
  19. package/external/pi-tools-suite/src/async-subagents/async-subagents.sample.jsonc +8 -0
  20. package/external/pi-tools-suite/src/async-subagents/core/agent-strategy.ts +41 -2
  21. package/external/pi-tools-suite/src/async-subagents/core/config.ts +12 -0
  22. package/external/pi-tools-suite/src/async-subagents/index.ts +6 -2
  23. package/external/pi-tools-suite/src/async-subagents/subagent-overlay.ts +1 -1
  24. package/external/pi-tools-suite/src/dcp/auto-compress.ts +1 -1
  25. package/external/pi-tools-suite/src/dcp/compression-blocks.ts +1 -51
  26. package/external/pi-tools-suite/src/dcp/debug-log.ts +6 -0
  27. package/external/pi-tools-suite/src/dcp/index.ts +27 -125
  28. package/external/pi-tools-suite/src/dcp/prompts.ts +2 -2
  29. package/external/pi-tools-suite/src/dcp/provider-tool-results.ts +2 -1
  30. package/external/pi-tools-suite/src/dcp/pruner-candidates.ts +31 -10
  31. package/external/pi-tools-suite/src/dcp/pruner-compression-blocks.ts +6 -7
  32. package/external/pi-tools-suite/src/dcp/pruner-message-ids.ts +153 -111
  33. package/external/pi-tools-suite/src/dcp/pruner-metadata.ts +28 -17
  34. package/external/pi-tools-suite/src/dcp/pruner-nudge.ts +61 -27
  35. package/external/pi-tools-suite/src/dcp/pruner.ts +21 -13
  36. package/external/pi-tools-suite/src/dcp/state.ts +59 -0
  37. package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +4 -0
  38. package/external/pi-tools-suite/src/lib/rpc-session-state.ts +34 -0
  39. package/external/pi-tools-suite/src/todo/todo.ts +8 -3
  40. package/package.json +8 -4
@@ -238,6 +238,9 @@ export class ExtensionUiController {
238
238
  process.title = title;
239
239
  renderIfRunning();
240
240
  },
241
+ // pi-tui 0.85 declares Component.handleMouse(TuiMouseEvent), while pix's
242
+ // input pipeline delivers its own ExtensionInputMouseEvent shape; the
243
+ // double cast bridges the two contracts at this adapter boundary.
241
244
  custom: (async (factory) => await this.showCustomUi(factory, { scopeKey: contextScopeKey })),
242
245
  pasteToEditor: (text) => {
243
246
  if (!this.isScopeActive(contextScopeKey))
@@ -86,6 +86,8 @@ function extensionRendererTheme(theme) {
86
86
  thinkingXhigh: colors.thinkingXHigh,
87
87
  thinkingMax: colors.thinkingMax,
88
88
  bashMode: colors.toolBash,
89
+ scrollbarTrack: colors.muted,
90
+ scrollbarThumb: colors.foreground,
89
91
  };
90
92
  const backgrounds = {
91
93
  selectedBg: colors.popupSelectedBackground,
@@ -17,6 +17,9 @@ export declare const questionParameters: Type.TObject<{
17
17
  label: Type.TString;
18
18
  description: Type.TOptional<Type.TString>;
19
19
  }>>;
20
+ multiple: Type.TOptional<Type.TBoolean>;
21
+ minSelections: Type.TOptional<Type.TInteger>;
22
+ maxSelections: Type.TOptional<Type.TInteger>;
20
23
  }>>;
21
24
  }>;
22
25
  export declare function normalizeQuestionInput(input: QuestionToolInput): NormalizedQuestion[];
@@ -20,6 +20,9 @@ export const questionParameters = Type.Object({
20
20
  maxItems: MAX_CHOICES,
21
21
  description: "Two to five meaningful predefined choices.",
22
22
  }),
23
+ multiple: Type.Optional(Type.Boolean({ description: "Allow selecting more than one answer for this question." })),
24
+ minSelections: Type.Optional(Type.Integer({ minimum: 1, description: "Minimum answers required when multiple is true. The custom answer counts as one." })),
25
+ maxSelections: Type.Optional(Type.Integer({ minimum: 1, description: "Maximum answers allowed when multiple is true. The custom answer counts as one." })),
23
26
  }), {
24
27
  minItems: MIN_QUESTIONS,
25
28
  maxItems: MAX_QUESTIONS,
@@ -54,6 +57,19 @@ export function normalizeQuestionInput(input) {
54
57
  }
55
58
  const seenChoiceValues = new Set();
56
59
  const seenChoiceLabels = new Set();
60
+ const multiple = question.multiple ?? false;
61
+ if (typeof multiple !== "boolean")
62
+ throwInvalid(`Question "${id}" multiple must be a boolean.`, "Retry with multiple omitted, true, or false.");
63
+ if (!multiple && (question.minSelections !== undefined || question.maxSelections !== undefined)) {
64
+ throwInvalid(`Question "${id}" sets selection limits without multiple: true.`, "Remove minSelections/maxSelections or enable multiple selection.");
65
+ }
66
+ const maximumAvailable = question.choices.length + 1;
67
+ const minSelections = multiple ? normalizeSelectionLimit(question.minSelections, 1, `question "${id}" minSelections`) : 1;
68
+ const maxSelections = multiple ? normalizeSelectionLimit(question.maxSelections, maximumAvailable, `question "${id}" maxSelections`) : 1;
69
+ if (multiple && minSelections > maxSelections)
70
+ throwInvalid(`Question "${id}" has minSelections ${minSelections} greater than maxSelections ${maxSelections}.`, "Retry with minSelections less than or equal to maxSelections.");
71
+ if (multiple && maxSelections > maximumAvailable)
72
+ throwInvalid(`Question "${id}" has maxSelections ${maxSelections}, but only ${maximumAvailable} answers are available including the custom answer.`, `Retry with maxSelections at most ${maximumAvailable}.`);
57
73
  return {
58
74
  id,
59
75
  label,
@@ -77,9 +93,17 @@ export function normalizeQuestionInput(input) {
77
93
  throwInvalid(`Question "${id}" Choice label "${choiceLabel}" collides with the implicit Custom Answer row label "${CUSTOM_ANSWER_LABEL}".`, "Retry with a different predefined Choice label; the Custom Answer row is added automatically.");
78
94
  return description === undefined ? { value, label: choiceLabel } : { value, label: choiceLabel, description };
79
95
  }),
96
+ ...(multiple ? { multiple: true, minSelections, maxSelections } : {}),
80
97
  };
81
98
  });
82
99
  }
100
+ function normalizeSelectionLimit(value, fallback, field) {
101
+ if (value === undefined)
102
+ return fallback;
103
+ if (!Number.isInteger(value) || value < 1)
104
+ throwInvalid(`${field} must be a positive integer.`, "Retry with a whole number greater than or equal to one.");
105
+ return value;
106
+ }
83
107
  export function trimString(value, field) {
84
108
  const trimmed = value.trim();
85
109
  if (!trimmed)
@@ -0,0 +1,11 @@
1
+ import type { NormalizedQuestion, QuestionSelection, QuestionUiContext } from "./types.js";
2
+ export declare const PIX_QUESTION_RPC_ENV = "PIX_QUESTION_RPC_BRIDGE";
3
+ export declare const PIX_QUESTION_EDITOR_TITLE = "__pix_question_v1__";
4
+ export declare const PIX_QUESTION_PROTOCOL_VERSION = 1;
5
+ export declare const MAX_DESKTOP_QUESTION_IMAGES = 10;
6
+ export declare const MAX_DESKTOP_QUESTION_IMAGE_BYTES: number;
7
+ export declare const MAX_DESKTOP_QUESTION_IMAGE_BYTES_TOTAL: number;
8
+ export declare function shouldUseDesktopQuestionBridge(ctx: QuestionUiContext, environment?: NodeJS.ProcessEnv): boolean;
9
+ export declare function runDesktopQuestionnaire(questions: NormalizedQuestion[], ctx: QuestionUiContext): Promise<QuestionSelection[] | null>;
10
+ export declare function parseDesktopQuestionResponse(value: string, questions: NormalizedQuestion[]): QuestionSelection[] | null;
11
+ export declare function createDesktopQuestionResponse(selections: QuestionSelection[]): string;
@@ -0,0 +1,143 @@
1
+ export const PIX_QUESTION_RPC_ENV = "PIX_QUESTION_RPC_BRIDGE";
2
+ export const PIX_QUESTION_EDITOR_TITLE = "__pix_question_v1__";
3
+ export const PIX_QUESTION_PROTOCOL_VERSION = 1;
4
+ export const MAX_DESKTOP_QUESTION_IMAGES = 10;
5
+ export const MAX_DESKTOP_QUESTION_IMAGE_BYTES = 25 * 1024 * 1024;
6
+ export const MAX_DESKTOP_QUESTION_IMAGE_BYTES_TOTAL = 50 * 1024 * 1024;
7
+ export function shouldUseDesktopQuestionBridge(ctx, environment = process.env) {
8
+ return environment[PIX_QUESTION_RPC_ENV] === "1" && typeof ctx.ui.editor === "function";
9
+ }
10
+ export async function runDesktopQuestionnaire(questions, ctx) {
11
+ const answer = await ctx.ui.editor?.(PIX_QUESTION_EDITOR_TITLE, JSON.stringify({ version: PIX_QUESTION_PROTOCOL_VERSION, questions }));
12
+ if (answer === undefined)
13
+ return null;
14
+ return parseDesktopQuestionResponse(answer, questions);
15
+ }
16
+ export function parseDesktopQuestionResponse(value, questions) {
17
+ let parsed;
18
+ try {
19
+ parsed = JSON.parse(value);
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ if (!isRecord(parsed) || parsed.version !== PIX_QUESTION_PROTOCOL_VERSION || !Array.isArray(parsed.selections)) {
25
+ return null;
26
+ }
27
+ if (parsed.selections.length !== questions.length)
28
+ return null;
29
+ const questionById = new Map(questions.map((question) => [question.id, question]));
30
+ const seenIds = new Set();
31
+ const selections = [];
32
+ let imageCount = 0;
33
+ let imageBytes = 0;
34
+ for (const rawSelection of parsed.selections) {
35
+ if (!isRecord(rawSelection) || typeof rawSelection.id !== "string")
36
+ return null;
37
+ const question = questionById.get(rawSelection.id);
38
+ if (!question || seenIds.has(rawSelection.id))
39
+ return null;
40
+ seenIds.add(rawSelection.id);
41
+ if (question.multiple) {
42
+ if (Object.keys(rawSelection).some((key) => !["id", "choiceValues", "customText", "images"].includes(key)))
43
+ return null;
44
+ if (!Array.isArray(rawSelection.choiceValues) || rawSelection.choiceValues.some((choice) => typeof choice !== "string"))
45
+ return null;
46
+ const selectedValues = new Set(rawSelection.choiceValues);
47
+ if (selectedValues.size !== rawSelection.choiceValues.length)
48
+ return null;
49
+ if ([...selectedValues].some((value) => !question.choices.some((choice) => choice.value === value)))
50
+ return null;
51
+ if (rawSelection.customText !== undefined && typeof rawSelection.customText !== "string")
52
+ return null;
53
+ if (rawSelection.images !== undefined && (rawSelection.customText === undefined || !Array.isArray(rawSelection.images)))
54
+ return null;
55
+ const images = [];
56
+ for (const rawImage of rawSelection.images ?? []) {
57
+ const image = parseImage(rawImage);
58
+ if (!image)
59
+ return null;
60
+ const bytes = decodedBase64Size(image.data);
61
+ imageCount += 1;
62
+ imageBytes += bytes;
63
+ if (imageCount > MAX_DESKTOP_QUESTION_IMAGES
64
+ || bytes > MAX_DESKTOP_QUESTION_IMAGE_BYTES
65
+ || imageBytes > MAX_DESKTOP_QUESTION_IMAGE_BYTES_TOTAL)
66
+ return null;
67
+ images.push(image);
68
+ }
69
+ const hasCustom = rawSelection.customText !== undefined;
70
+ if (hasCustom && !rawSelection.customText.trim() && images.length === 0)
71
+ return null;
72
+ const selectionCount = selectedValues.size + (hasCustom ? 1 : 0);
73
+ if (selectionCount < (question.minSelections ?? 1) || selectionCount > (question.maxSelections ?? question.choices.length + 1))
74
+ return null;
75
+ selections.push({
76
+ id: rawSelection.id,
77
+ choiceValues: question.choices.filter((choice) => selectedValues.has(choice.value)).map((choice) => choice.value),
78
+ ...(hasCustom ? { customText: rawSelection.customText } : {}),
79
+ ...(images.length > 0 ? { images } : {}),
80
+ });
81
+ continue;
82
+ }
83
+ if (typeof rawSelection.choiceValue === "string") {
84
+ if (Object.keys(rawSelection).some((key) => !["id", "choiceValue"].includes(key)))
85
+ return null;
86
+ if (!question.choices.some((choice) => choice.value === rawSelection.choiceValue))
87
+ return null;
88
+ selections.push({ id: rawSelection.id, choiceValue: rawSelection.choiceValue });
89
+ continue;
90
+ }
91
+ if (typeof rawSelection.customText !== "string")
92
+ return null;
93
+ if (Object.keys(rawSelection).some((key) => !["id", "customText", "images"].includes(key)))
94
+ return null;
95
+ if (rawSelection.images !== undefined && !Array.isArray(rawSelection.images))
96
+ return null;
97
+ const images = [];
98
+ for (const rawImage of rawSelection.images ?? []) {
99
+ const image = parseImage(rawImage);
100
+ if (!image)
101
+ return null;
102
+ const bytes = decodedBase64Size(image.data);
103
+ imageCount += 1;
104
+ imageBytes += bytes;
105
+ if (imageCount > MAX_DESKTOP_QUESTION_IMAGES
106
+ || bytes > MAX_DESKTOP_QUESTION_IMAGE_BYTES
107
+ || imageBytes > MAX_DESKTOP_QUESTION_IMAGE_BYTES_TOTAL)
108
+ return null;
109
+ images.push(image);
110
+ }
111
+ if (!rawSelection.customText.trim() && images.length === 0)
112
+ return null;
113
+ selections.push({
114
+ id: rawSelection.id,
115
+ customText: rawSelection.customText,
116
+ ...(images.length > 0 ? { images } : {}),
117
+ });
118
+ }
119
+ return selections;
120
+ }
121
+ export function createDesktopQuestionResponse(selections) {
122
+ const response = { version: PIX_QUESTION_PROTOCOL_VERSION, selections };
123
+ return JSON.stringify(response);
124
+ }
125
+ function parseImage(value) {
126
+ if (!isRecord(value) || Object.keys(value).some((key) => !["type", "data", "mimeType"].includes(key)))
127
+ return null;
128
+ if (value.type !== "image" || typeof value.data !== "string" || typeof value.mimeType !== "string")
129
+ return null;
130
+ if (!/^image\/[a-z0-9.+-]+$/i.test(value.mimeType) || !isBase64(value.data))
131
+ return null;
132
+ return { type: "image", data: value.data, mimeType: value.mimeType };
133
+ }
134
+ function isBase64(value) {
135
+ return value.length > 0 && value.length % 4 === 0 && /^[A-Za-z0-9+/]*={0,2}$/.test(value);
136
+ }
137
+ function decodedBase64Size(value) {
138
+ const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
139
+ return (value.length / 4) * 3 - padding;
140
+ }
141
+ function isRecord(value) {
142
+ return typeof value === "object" && value !== null && !Array.isArray(value);
143
+ }
@@ -3,5 +3,6 @@ interface ExtensionApiLike {
3
3
  }
4
4
  export default function questionExtension(pi: ExtensionApiLike): void;
5
5
  export { questionParameters, normalizeQuestionInput } from "./contract.js";
6
+ export { createDesktopQuestionResponse, parseDesktopQuestionResponse } from "./desktop.js";
6
7
  export { createCanceledQuestionResult, createFallbackPrompt, createQuestionToolResult, createSuccessfulQuestionResult, summarizeQuestionResult } from "./result.js";
7
8
  export type { QuestionResultDetails, QuestionToolInput } from "./types.js";
@@ -2,6 +2,7 @@ import { QUESTION_TOOL_DESCRIPTION } from "./tool-description.js";
2
2
  import { questionParameters, normalizeQuestionInput } from "./contract.js";
3
3
  import { renderQuestionCall, renderQuestionResult } from "./render.js";
4
4
  import { createCanceledQuestionResult, createQuestionToolResult, createSuccessfulQuestionResult } from "./result.js";
5
+ import { runDesktopQuestionnaire, shouldUseDesktopQuestionBridge } from "./desktop.js";
5
6
  import { runQuestionnaire } from "./tui.js";
6
7
  export default function questionExtension(pi) {
7
8
  pi.registerTool({
@@ -17,7 +18,9 @@ export default function questionExtension(pi) {
17
18
  const questions = normalizeQuestionInput(params);
18
19
  if (!ctx.hasUI)
19
20
  return createQuestionToolResult(createCanceledQuestionResult("ui_unavailable", questions), questions);
20
- const selections = await runQuestionnaire(questions, ctx);
21
+ const selections = shouldUseDesktopQuestionBridge(ctx)
22
+ ? await runDesktopQuestionnaire(questions, ctx)
23
+ : await runQuestionnaire(questions, ctx);
21
24
  if (selections == null)
22
25
  return createQuestionToolResult(createCanceledQuestionResult("user_canceled"), questions);
23
26
  const images = selections.flatMap((selection) => "customText" in selection ? selection.images ?? [] : []);
@@ -26,4 +29,5 @@ export default function questionExtension(pi) {
26
29
  });
27
30
  }
28
31
  export { questionParameters, normalizeQuestionInput } from "./contract.js";
32
+ export { createDesktopQuestionResponse, parseDesktopQuestionResponse } from "./desktop.js";
29
33
  export { createCanceledQuestionResult, createFallbackPrompt, createQuestionToolResult, createSuccessfulQuestionResult, summarizeQuestionResult } from "./result.js";
@@ -20,6 +20,12 @@ export function renderQuestionResult(result, theme, args) {
20
20
  const labels = new Map((Array.isArray(args?.questions) ? args.questions : []).map((question) => [question.id, question.label]));
21
21
  return new Text(details.answers.map((answer) => {
22
22
  const questionLabel = labels.get(answer.id) ?? answer.id;
23
+ if ("multiple" in answer) {
24
+ const selections = answer.selections.map((selection) => (selection.wasCustom
25
+ ? `custom ${selection.label}${selection.imageCount ? ` (+${selection.imageCount} image${selection.imageCount === 1 ? "" : "s"})` : ""}`
26
+ : `${selection.index}. ${selection.label}`)).join(", ");
27
+ return `${theme.fg("success", "✓ ")}${theme.fg("accent", `${questionLabel}:`)} ${selections}`;
28
+ }
23
29
  if (answer.wasCustom)
24
30
  return `${theme.fg("success", "✓ ")}${theme.fg("accent", `${questionLabel}:`)} ${theme.fg("muted", "custom ")}${answer.label}`;
25
31
  return `${theme.fg("success", "✓ ")}${theme.fg("accent", `${questionLabel}:`)} ${answer.index}. ${answer.label}`;
@@ -3,24 +3,38 @@ function formatAttachedImages(count) {
3
3
  return `${count} image${count === 1 ? "" : "s"} attached`;
4
4
  }
5
5
  export function createSuccessfulQuestionResult(questions, selections) {
6
+ if (selections.length !== questions.length)
7
+ throwInvalid(`Expected ${questions.length} question selections; received ${selections.length}.`, "Retry with exactly one grouped selection for each normalized question.");
8
+ const questionIds = new Set(questions.map((question) => question.id));
9
+ const seenSelectionIds = new Set();
10
+ for (const selection of selections) {
11
+ if (!questionIds.has(selection.id))
12
+ throwInvalid(`Received an answer for unknown question "${selection.id}".`, "Retry with only normalized question ids.");
13
+ if (seenSelectionIds.has(selection.id))
14
+ throwInvalid(`Received duplicate grouped answers for question "${selection.id}".`, "Retry with one grouped selection for each question.");
15
+ seenSelectionIds.add(selection.id);
16
+ }
6
17
  const selectionsById = new Map(selections.map((selection) => [selection.id, selection]));
7
18
  return {
8
19
  answers: questions.map((question) => {
9
20
  const selection = selectionsById.get(question.id);
10
21
  if (!selection)
11
22
  throwInvalid(`Missing answer for question "${question.id}".`, "Retry after collecting one answer for each normalized question.");
23
+ if (question.multiple) {
24
+ if (!("choiceValues" in selection))
25
+ throwInvalid(`Question "${question.id}" requires multiple selections.`, "Retry with choiceValues and an optional custom answer for this multi-select question.");
26
+ return {
27
+ id: question.id,
28
+ multiple: true,
29
+ selections: createMultipleAnswerSelections(question, selection),
30
+ };
31
+ }
32
+ if ("choiceValues" in selection)
33
+ throwInvalid(`Question "${question.id}" accepts only one selection.`, "Retry with one choiceValue or one custom answer for this single-select question.");
12
34
  if ("customText" in selection) {
13
- const customText = selection.customText.trim();
14
- const imageCount = selection.images?.length ?? 0;
15
- if (!customText && imageCount === 0)
16
- throwInvalid(`Custom Answer for question "${question.id}" is empty after trimming.`, "Retry with non-empty Custom Answer text, an image, or choose a predefined Choice.");
17
- const label = customText || formatAttachedImages(imageCount);
18
35
  return {
19
36
  id: question.id,
20
- value: customText || label,
21
- label,
22
- wasCustom: true,
23
- ...(imageCount > 0 ? { imageCount } : {}),
37
+ ...createCustomAnswerSelection(question.id, selection.customText, selection.images),
24
38
  };
25
39
  }
26
40
  const choiceIndex = question.choices.findIndex((choice) => choice.value === selection.choiceValue);
@@ -38,6 +52,41 @@ export function createSuccessfulQuestionResult(questions, selections) {
38
52
  canceled: false,
39
53
  };
40
54
  }
55
+ function createMultipleAnswerSelections(question, selection) {
56
+ const uniqueValues = new Set(selection.choiceValues);
57
+ if (uniqueValues.size !== selection.choiceValues.length)
58
+ throwInvalid(`Question "${question.id}" contains duplicate predefined selections.`, "Retry with each choice value at most once.");
59
+ for (const value of uniqueValues) {
60
+ if (!question.choices.some((choice) => choice.value === value))
61
+ throwInvalid(`Question "${question.id}" has no predefined Choice with value "${value}".`, "Retry with only normalized predefined Choice values for that question.");
62
+ }
63
+ if (selection.images && selection.customText === undefined)
64
+ throwInvalid(`Question "${question.id}" includes custom images without a custom answer.`, "Retry with customText, which may be empty for an image-only answer.");
65
+ const answers = question.choices.flatMap((choice, index) => (uniqueValues.has(choice.value)
66
+ ? [{ value: choice.value, label: choice.label, wasCustom: false, index: index + 1 }]
67
+ : []));
68
+ if (selection.customText !== undefined)
69
+ answers.push(createCustomAnswerSelection(question.id, selection.customText, selection.images));
70
+ const minSelections = question.minSelections ?? 1;
71
+ const maxSelections = question.maxSelections ?? question.choices.length + 1;
72
+ if (answers.length < minSelections || answers.length > maxSelections) {
73
+ throwInvalid(`Question "${question.id}" requires ${minSelections} to ${maxSelections} selections; received ${answers.length}.`, "Retry with a number of answers inside the configured selection limits.");
74
+ }
75
+ return answers;
76
+ }
77
+ function createCustomAnswerSelection(questionId, text, images) {
78
+ const customText = text.trim();
79
+ const imageCount = images?.length ?? 0;
80
+ if (!customText && imageCount === 0)
81
+ throwInvalid(`Custom Answer for question "${questionId}" is empty after trimming.`, "Retry with non-empty Custom Answer text, an image, or choose a predefined Choice.");
82
+ const label = customText || formatAttachedImages(imageCount);
83
+ return {
84
+ value: customText || label,
85
+ label,
86
+ wasCustom: true,
87
+ ...(imageCount > 0 ? { imageCount } : {}),
88
+ };
89
+ }
41
90
  export function createCanceledQuestionResult(reason, questions = []) {
42
91
  return {
43
92
  answers: [],
@@ -52,6 +101,8 @@ export function createFallbackPrompt(questions) {
52
101
  ];
53
102
  questions.forEach((question, index) => {
54
103
  lines.push("", `${index + 1}. ${question.label}: ${question.prompt}`);
104
+ if (question.multiple)
105
+ lines.push(` Select ${question.minSelections ?? 1} to ${question.maxSelections ?? question.choices.length + 1} answers.`);
55
106
  question.choices.forEach((choice, choiceIndex) => {
56
107
  const suffix = choice.description ? ` — ${choice.description}` : "";
57
108
  lines.push(` ${choiceIndex + 1}. ${choice.label}${suffix}`);
@@ -74,6 +125,9 @@ export function summarizeQuestionResult(result, questions = []) {
74
125
  const questionLabels = new Map(questions.map((question) => [question.id, question.label]));
75
126
  return `question answers: ${result.answers.map((answer) => {
76
127
  const questionLabel = questionLabels.get(answer.id) ?? answer.id;
128
+ if ("multiple" in answer) {
129
+ return `${questionLabel}: ${answer.selections.map((item) => summarizeAnswerSelection(item)).join(", ")}`;
130
+ }
77
131
  if (answer.wasCustom) {
78
132
  const imageLabel = answer.imageCount ? formatAttachedImages(answer.imageCount) : undefined;
79
133
  const imageSuffix = imageLabel && answer.label !== imageLabel ? `; ${imageLabel}` : "";
@@ -82,6 +136,14 @@ export function summarizeQuestionResult(result, questions = []) {
82
136
  return `${questionLabel}: ${answer.label} (choice ${answer.index})`;
83
137
  }).join("; ")}.`;
84
138
  }
139
+ function summarizeAnswerSelection(answer) {
140
+ if (answer.wasCustom) {
141
+ const imageLabel = answer.imageCount ? formatAttachedImages(answer.imageCount) : undefined;
142
+ const imageSuffix = imageLabel && answer.label !== imageLabel ? `; ${imageLabel}` : "";
143
+ return `${answer.label} (custom answer${imageSuffix})`;
144
+ }
145
+ return `${answer.label} (choice ${answer.index})`;
146
+ }
85
147
  export function createQuestionToolResult(details, questions = [], images = []) {
86
148
  return {
87
149
  content: [
@@ -1,10 +1,11 @@
1
1
  export const QUESTION_TOOL_DESCRIPTION = {
2
2
  name: "question",
3
3
  label: "Question",
4
- description: "Ask the user one to five structured questions, each with two to five choices plus an implicit custom answer path.",
5
- promptSnippet: "Gather structured user feedback with predefined choices and an always-available custom answer path.",
4
+ description: "Ask the user one to five structured single- or multi-select questions, each with two to five choices plus an implicit custom answer path.",
5
+ promptSnippet: "Gather structured user feedback with predefined single- or multi-select choices and an always-available custom answer path.",
6
6
  promptGuidelines: [
7
7
  "Use question to gather feedback, choose alternatives, confirm direction, or resolve ambiguity when you can provide at least two meaningful choices.",
8
- "question accepts 1–5 questions with 2–5 choices each and an implicit custom answer path; avoid it for purely open-ended input or when normal conversation is clearer.",
8
+ "question accepts 1–5 questions with 2–5 choices each and an implicit custom answer path; set multiple: true with optional minSelections/maxSelections when several answers may apply.",
9
+ "For multi-select questions, the custom answer can be combined with predefined choices and counts as one selection toward the limits.",
9
10
  ],
10
11
  };