pi-mono-all 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENCE.md +7 -0
  3. package/node_modules/pi-common/package.json +22 -0
  4. package/node_modules/pi-common/src/auth-config.ts +290 -0
  5. package/node_modules/pi-common/src/auth.ts +63 -0
  6. package/node_modules/pi-common/src/cache.ts +60 -0
  7. package/node_modules/pi-common/src/errors.ts +47 -0
  8. package/node_modules/pi-common/src/http-client.ts +118 -0
  9. package/node_modules/pi-common/src/index.ts +7 -0
  10. package/node_modules/pi-common/src/rate-limiter.ts +32 -0
  11. package/node_modules/pi-common/src/tool-result.ts +27 -0
  12. package/node_modules/pi-mono-ask-user-question/CHANGELOG.md +185 -0
  13. package/node_modules/pi-mono-ask-user-question/README.md +226 -0
  14. package/node_modules/pi-mono-ask-user-question/index.ts +923 -0
  15. package/node_modules/pi-mono-ask-user-question/package.json +29 -0
  16. package/node_modules/pi-mono-auto-fix/CHANGELOG.md +59 -0
  17. package/node_modules/pi-mono-auto-fix/README.md +77 -0
  18. package/node_modules/pi-mono-auto-fix/index.ts +488 -0
  19. package/node_modules/pi-mono-auto-fix/package.json +23 -0
  20. package/node_modules/pi-mono-btw/CHANGELOG.md +180 -0
  21. package/node_modules/pi-mono-btw/README.md +24 -0
  22. package/node_modules/pi-mono-btw/index.ts +499 -0
  23. package/node_modules/pi-mono-btw/package.json +29 -0
  24. package/node_modules/pi-mono-clear/CHANGELOG.md +180 -0
  25. package/node_modules/pi-mono-clear/README.md +40 -0
  26. package/node_modules/pi-mono-clear/index.ts +45 -0
  27. package/node_modules/pi-mono-clear/package.json +29 -0
  28. package/node_modules/pi-mono-context/CHANGELOG.md +12 -0
  29. package/node_modules/pi-mono-context/README.md +74 -0
  30. package/node_modules/pi-mono-context/index.ts +641 -0
  31. package/node_modules/pi-mono-context/package.json +29 -0
  32. package/node_modules/pi-mono-context-guard/CHANGELOG.md +195 -0
  33. package/node_modules/pi-mono-context-guard/README.md +81 -0
  34. package/node_modules/pi-mono-context-guard/index.ts +212 -0
  35. package/node_modules/pi-mono-context-guard/package.json +23 -0
  36. package/node_modules/pi-mono-figma/CHANGELOG.md +59 -0
  37. package/node_modules/pi-mono-figma/README.md +236 -0
  38. package/node_modules/pi-mono-figma/__tests__/code-connect.test.ts +32 -0
  39. package/node_modules/pi-mono-figma/__tests__/figma-assets.test.ts +38 -0
  40. package/node_modules/pi-mono-figma/__tests__/figma-component-hints.test.ts +23 -0
  41. package/node_modules/pi-mono-figma/__tests__/figma-implementation-layout.test.ts +47 -0
  42. package/node_modules/pi-mono-figma/__tests__/figma-search.test.ts +51 -0
  43. package/node_modules/pi-mono-figma/__tests__/figma-summarizer.test.ts +65 -0
  44. package/node_modules/pi-mono-figma/__tests__/fixtures/complex-auto-layout.json +115 -0
  45. package/node_modules/pi-mono-figma/__tests__/fixtures/component-instance.json +50 -0
  46. package/node_modules/pi-mono-figma/__tests__/fixtures/hidden-and-vectors.json +28 -0
  47. package/node_modules/pi-mono-figma/__tests__/fixtures/variables-and-styles.json +40 -0
  48. package/node_modules/pi-mono-figma/docs/live-selection-bridge.md +16 -0
  49. package/node_modules/pi-mono-figma/index.ts +6 -0
  50. package/node_modules/pi-mono-figma/package.json +33 -0
  51. package/node_modules/pi-mono-figma/skills/figma/SKILL.md +143 -0
  52. package/node_modules/pi-mono-figma/src/code-connect.ts +110 -0
  53. package/node_modules/pi-mono-figma/src/figma-assets.ts +146 -0
  54. package/node_modules/pi-mono-figma/src/figma-cache.ts +6 -0
  55. package/node_modules/pi-mono-figma/src/figma-client.ts +471 -0
  56. package/node_modules/pi-mono-figma/src/figma-component-hints.ts +87 -0
  57. package/node_modules/pi-mono-figma/src/figma-implementation.ts +264 -0
  58. package/node_modules/pi-mono-figma/src/figma-schemas.ts +139 -0
  59. package/node_modules/pi-mono-figma/src/figma-search.ts +195 -0
  60. package/node_modules/pi-mono-figma/src/figma-summarizer.ts +673 -0
  61. package/node_modules/pi-mono-figma/src/figma-tokens.ts +57 -0
  62. package/node_modules/pi-mono-figma/src/figma-tools.ts +352 -0
  63. package/node_modules/pi-mono-linear/CHANGELOG.md +44 -0
  64. package/node_modules/pi-mono-linear/README.md +159 -0
  65. package/node_modules/pi-mono-linear/index.ts +6 -0
  66. package/node_modules/pi-mono-linear/package.json +30 -0
  67. package/node_modules/pi-mono-linear/skills/linear/SKILL.md +107 -0
  68. package/node_modules/pi-mono-linear/src/linear-client.ts +339 -0
  69. package/node_modules/pi-mono-linear/src/linear-queries.ts +101 -0
  70. package/node_modules/pi-mono-linear/src/linear-schemas.ts +90 -0
  71. package/node_modules/pi-mono-linear/src/linear-tools.ts +362 -0
  72. package/node_modules/pi-mono-loop/CHANGELOG.md +163 -0
  73. package/node_modules/pi-mono-loop/README.md +54 -0
  74. package/node_modules/pi-mono-loop/index.ts +291 -0
  75. package/node_modules/pi-mono-loop/package.json +26 -0
  76. package/node_modules/pi-mono-multi-edit/CHANGELOG.md +232 -0
  77. package/node_modules/pi-mono-multi-edit/README.md +244 -0
  78. package/node_modules/pi-mono-multi-edit/__tests__/classic.test.ts +277 -0
  79. package/node_modules/pi-mono-multi-edit/__tests__/diff.test.ts +77 -0
  80. package/node_modules/pi-mono-multi-edit/__tests__/patch.test.ts +287 -0
  81. package/node_modules/pi-mono-multi-edit/benchmark-edits.ts +966 -0
  82. package/node_modules/pi-mono-multi-edit/classic.ts +435 -0
  83. package/node_modules/pi-mono-multi-edit/diff.ts +143 -0
  84. package/node_modules/pi-mono-multi-edit/index.ts +266 -0
  85. package/node_modules/pi-mono-multi-edit/package.json +37 -0
  86. package/node_modules/pi-mono-multi-edit/patch.ts +463 -0
  87. package/node_modules/pi-mono-multi-edit/types.ts +53 -0
  88. package/node_modules/pi-mono-multi-edit/workspace.ts +85 -0
  89. package/node_modules/pi-mono-review/CHANGELOG.md +190 -0
  90. package/node_modules/pi-mono-review/README.md +30 -0
  91. package/node_modules/pi-mono-review/common.ts +930 -0
  92. package/node_modules/pi-mono-review/index.ts +8 -0
  93. package/node_modules/pi-mono-review/package.json +29 -0
  94. package/node_modules/pi-mono-review/review-tui.ts +194 -0
  95. package/node_modules/pi-mono-review/review.ts +119 -0
  96. package/node_modules/pi-mono-review/reviewer.ts +339 -0
  97. package/node_modules/pi-mono-sentinel/CHANGELOG.md +158 -0
  98. package/node_modules/pi-mono-sentinel/README.md +87 -0
  99. package/node_modules/pi-mono-sentinel/__tests__/output-scanner.test.ts +109 -0
  100. package/node_modules/pi-mono-sentinel/__tests__/permissions.test.ts +202 -0
  101. package/node_modules/pi-mono-sentinel/__tests__/whitelist.test.ts +59 -0
  102. package/node_modules/pi-mono-sentinel/guards/execution-tracker.ts +281 -0
  103. package/node_modules/pi-mono-sentinel/guards/output-scanner.ts +232 -0
  104. package/node_modules/pi-mono-sentinel/guards/permission-gate.ts +170 -0
  105. package/node_modules/pi-mono-sentinel/index.ts +43 -0
  106. package/node_modules/pi-mono-sentinel/package.json +26 -0
  107. package/node_modules/pi-mono-sentinel/patterns/permissions.ts +175 -0
  108. package/node_modules/pi-mono-sentinel/patterns/read-targets.ts +104 -0
  109. package/node_modules/pi-mono-sentinel/patterns/secrets.ts +143 -0
  110. package/node_modules/pi-mono-sentinel/session.ts +95 -0
  111. package/node_modules/pi-mono-sentinel/specs/2026/04/sentinel/001-permission-gate.md +145 -0
  112. package/node_modules/pi-mono-sentinel/types.ts +39 -0
  113. package/node_modules/pi-mono-sentinel/whitelist.ts +86 -0
  114. package/node_modules/pi-mono-simplify/CHANGELOG.md +163 -0
  115. package/node_modules/pi-mono-simplify/README.md +56 -0
  116. package/node_modules/pi-mono-simplify/index.ts +78 -0
  117. package/node_modules/pi-mono-simplify/package.json +29 -0
  118. package/node_modules/pi-mono-status-line/CHANGELOG.md +180 -0
  119. package/node_modules/pi-mono-status-line/README.md +96 -0
  120. package/node_modules/pi-mono-status-line/basic.ts +89 -0
  121. package/node_modules/pi-mono-status-line/expert.ts +689 -0
  122. package/node_modules/pi-mono-status-line/index.ts +54 -0
  123. package/node_modules/pi-mono-status-line/package.json +29 -0
  124. package/node_modules/pi-mono-team-mode/CHANGELOG.md +278 -0
  125. package/node_modules/pi-mono-team-mode/README.md +246 -0
  126. package/node_modules/pi-mono-team-mode/__tests__/agent-manager-transient.test.ts +75 -0
  127. package/node_modules/pi-mono-team-mode/__tests__/delegation-manager.test.ts +118 -0
  128. package/node_modules/pi-mono-team-mode/__tests__/formatters.test.ts +104 -0
  129. package/node_modules/pi-mono-team-mode/__tests__/model-config.test.ts +272 -0
  130. package/node_modules/pi-mono-team-mode/__tests__/notification-box.test.ts +34 -0
  131. package/node_modules/pi-mono-team-mode/__tests__/parallel-utils.test.ts +32 -0
  132. package/node_modules/pi-mono-team-mode/__tests__/pi-stream-parser.test.ts +64 -0
  133. package/node_modules/pi-mono-team-mode/__tests__/prompts.test.ts +106 -0
  134. package/node_modules/pi-mono-team-mode/__tests__/store.test.ts +164 -0
  135. package/node_modules/pi-mono-team-mode/__tests__/tasks.test.ts +267 -0
  136. package/node_modules/pi-mono-team-mode/__tests__/teammate-specs.test.ts +114 -0
  137. package/node_modules/pi-mono-team-mode/__tests__/widget.test.ts +41 -0
  138. package/node_modules/pi-mono-team-mode/__tests__/worktree.test.ts +78 -0
  139. package/node_modules/pi-mono-team-mode/core/chain-utils.ts +90 -0
  140. package/node_modules/pi-mono-team-mode/core/fs-utils.ts +44 -0
  141. package/node_modules/pi-mono-team-mode/core/model-config.ts +432 -0
  142. package/node_modules/pi-mono-team-mode/core/parallel-utils.ts +48 -0
  143. package/node_modules/pi-mono-team-mode/core/prompts.ts +158 -0
  144. package/node_modules/pi-mono-team-mode/core/store.ts +156 -0
  145. package/node_modules/pi-mono-team-mode/core/tasks.ts +99 -0
  146. package/node_modules/pi-mono-team-mode/core/teammate-specs.ts +124 -0
  147. package/node_modules/pi-mono-team-mode/core/types.ts +160 -0
  148. package/node_modules/pi-mono-team-mode/index.ts +825 -0
  149. package/node_modules/pi-mono-team-mode/managers/agent-manager.ts +654 -0
  150. package/node_modules/pi-mono-team-mode/managers/delegation-manager.ts +211 -0
  151. package/node_modules/pi-mono-team-mode/managers/task-manager.ts +238 -0
  152. package/node_modules/pi-mono-team-mode/managers/team-manager.ts +59 -0
  153. package/node_modules/pi-mono-team-mode/package.json +33 -0
  154. package/node_modules/pi-mono-team-mode/runtime/pi-stream-parser.ts +194 -0
  155. package/node_modules/pi-mono-team-mode/runtime/subprocess.ts +183 -0
  156. package/node_modules/pi-mono-team-mode/runtime/transient-session.ts +196 -0
  157. package/node_modules/pi-mono-team-mode/runtime/worktree.ts +90 -0
  158. package/node_modules/pi-mono-team-mode/ui/formatters.ts +149 -0
  159. package/node_modules/pi-mono-team-mode/ui/notification-box.ts +55 -0
  160. package/node_modules/pi-mono-team-mode/ui/widget.ts +94 -0
  161. package/package.json +76 -0
@@ -0,0 +1,923 @@
1
+ /**
2
+ * ask-user-question — Interactive form tool for pi
3
+ *
4
+ * A powerful tool that the LLM can call to ask the user one or more questions
5
+ * using rich form controls: radio buttons, checkboxes, and text inputs.
6
+ * Each question type supports an optional "Other..." escape hatch for custom input.
7
+ *
8
+ * Question types:
9
+ * - radio: Single-select from options (with optional custom "Other")
10
+ * - checkbox: Multi-select from options (with optional custom "Other")
11
+ * - text: Free-form text input
12
+ *
13
+ * Navigation:
14
+ * - Tab / Shift+Tab to move between questions
15
+ * - Up/Down to navigate options within a question
16
+ * - Space to toggle checkboxes
17
+ * - Enter to select radio / submit text / advance
18
+ * - Esc to cancel
19
+ */
20
+
21
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
22
+ import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
23
+ import { Type } from "@sinclair/typebox";
24
+
25
+ // ─── Types ───────────────────────────────────────────────────────────────────
26
+
27
+ interface QuestionOption {
28
+ value: string;
29
+ label: string;
30
+ description?: string;
31
+ }
32
+
33
+ interface Question {
34
+ id: string;
35
+ type: "radio" | "checkbox" | "text";
36
+ prompt: string;
37
+ label?: string;
38
+ options?: QuestionOption[];
39
+ allowOther?: boolean;
40
+ required?: boolean;
41
+ placeholder?: string;
42
+ default?: string | string[];
43
+ }
44
+
45
+ interface NormalizedQuestion extends Question {
46
+ label: string;
47
+ options: QuestionOption[];
48
+ allowOther: boolean;
49
+ required: boolean;
50
+ }
51
+
52
+ interface Answer {
53
+ id: string;
54
+ type: "radio" | "checkbox" | "text";
55
+ value: string | string[];
56
+ wasCustom: boolean;
57
+ }
58
+
59
+ interface FormResult {
60
+ title?: string;
61
+ questions: NormalizedQuestion[];
62
+ answers: Answer[];
63
+ cancelled: boolean;
64
+ }
65
+
66
+ // ─── Schema ──────────────────────────────────────────────────────────────────
67
+
68
+ const OptionSchema = Type.Object({
69
+ value: Type.String({ description: "Value returned when selected" }),
70
+ label: Type.String({ description: "Display label" }),
71
+ description: Type.Optional(Type.String({ description: "Help text shown below the label" })),
72
+ });
73
+
74
+ const QuestionSchema = Type.Object({
75
+ id: Type.String({ description: "Unique identifier for this question" }),
76
+ type: Type.Unsafe<"radio" | "checkbox" | "text">({
77
+ type: "string",
78
+ enum: ["radio", "checkbox", "text"],
79
+ description: "Question type: radio (single-select), checkbox (multi-select), or text (free input)",
80
+ }),
81
+ prompt: Type.String({ description: "The question text to display" }),
82
+ label: Type.Optional(Type.String({ description: "Short label for tab bar (defaults to Q1, Q2...)" })),
83
+ options: Type.Optional(Type.Array(OptionSchema, { description: "Options for radio/checkbox types" })),
84
+ allowOther: Type.Optional(
85
+ Type.Boolean({ description: "Add an 'Other...' option with text input (default: true for radio/checkbox)" }),
86
+ ),
87
+ required: Type.Optional(Type.Boolean({ description: "Whether an answer is required (default: true)" })),
88
+ placeholder: Type.Optional(Type.String({ description: "Placeholder for text inputs" })),
89
+ default: Type.Optional(
90
+ Type.Union([Type.String(), Type.Array(Type.String())], {
91
+ description: "Default value(s). String for radio/text, string[] for checkbox",
92
+ }),
93
+ ),
94
+ });
95
+
96
+ const AskUserQuestionParams = Type.Object({
97
+ title: Type.Optional(Type.String({ description: "Form title displayed at the top" })),
98
+ description: Type.Optional(Type.String({ description: "Brief context or instructions shown under the title" })),
99
+ questions: Type.Array(QuestionSchema, {
100
+ description: "One or more questions to ask. Use radio for single-select, checkbox for multi-select, text for free input",
101
+ }),
102
+ });
103
+
104
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
105
+
106
+ function normalize(questions: Question[]): NormalizedQuestion[] {
107
+ return questions.map((q, i) => ({
108
+ ...q,
109
+ label: q.label || `Q${i + 1}`,
110
+ options: q.options || [],
111
+ allowOther: q.type === "text" ? false : q.allowOther !== false,
112
+ required: q.required !== false,
113
+ }));
114
+ }
115
+
116
+ function wrapText(text: string, maxWidth: number): string[] {
117
+ const words = text.split(" ");
118
+ const lines: string[] = [];
119
+ let current = "";
120
+ for (const word of words) {
121
+ if (!current) {
122
+ current = word;
123
+ } else if (current.length + 1 + word.length <= maxWidth) {
124
+ current += ` ${word}`;
125
+ } else {
126
+ lines.push(current);
127
+ current = word;
128
+ }
129
+ }
130
+ if (current) lines.push(current);
131
+ return lines.length ? lines : [""];
132
+ }
133
+
134
+ function errorResult(msg: string): {
135
+ content: { type: "text"; text: string }[];
136
+ details: FormResult;
137
+ } {
138
+ return {
139
+ content: [{ type: "text", text: msg }],
140
+ details: { questions: [], answers: [], cancelled: true },
141
+ };
142
+ }
143
+
144
+ // ─── Symbols ─────────────────────────────────────────────────────────────────
145
+
146
+ const SYM = {
147
+ radioOn: "◉",
148
+ radioOff: "○",
149
+ checkOn: "☑",
150
+ checkOff: "☐",
151
+ pointer: "❯",
152
+ dot: "·",
153
+ check: "✓",
154
+ pencil: "✎",
155
+ submit: "✓",
156
+ };
157
+
158
+ // ─── Extension ───────────────────────────────────────────────────────────────
159
+
160
+ export default function askUserQuestion(pi: ExtensionAPI) {
161
+ pi.registerTool({
162
+ name: "ask_user_question",
163
+ label: "Ask User",
164
+ description: `Ask the user one or more questions using an interactive form. Supports three question types:
165
+ - **radio**: Single-select from predefined options (like multiple choice)
166
+ - **checkbox**: Multi-select from options (pick all that apply)
167
+ - **text**: Free-form text input
168
+
169
+ Each radio/checkbox question can include an "Other..." option that lets the user type a custom answer.
170
+
171
+ Use this tool when you need user input to proceed — for clarifying requirements, getting preferences, confirming decisions, or choosing between alternatives. Prefer this over asking plain-text questions in your response.`,
172
+ promptSnippet: "Ask the user interactive questions with radio, checkbox, or text inputs",
173
+ promptGuidelines: [
174
+ "Use ask_user_question instead of asking questions in plain text when you need structured user input.",
175
+ "Prefer radio for single-choice, checkbox for multi-choice, text for open-ended answers.",
176
+ "Always include an 'Other' escape hatch (allowOther: true) unless the options are exhaustive.",
177
+ "Group related questions in a single call rather than making multiple separate calls.",
178
+ ],
179
+ parameters: AskUserQuestionParams as any,
180
+
181
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
182
+ if (!ctx.hasUI) {
183
+ return errorResult("Error: UI not available (running in non-interactive mode)");
184
+ }
185
+ if (!params.questions.length) {
186
+ return errorResult("Error: No questions provided");
187
+ }
188
+
189
+ const questions = normalize(params.questions as Question[]);
190
+ const isMulti = questions.length > 1;
191
+ const totalTabs = questions.length + (isMulti ? 1 : 0); // +1 for Submit tab
192
+
193
+ const result = await ctx.ui.custom<FormResult>((tui, theme, _kb, done) => {
194
+ // ── State ────────────────────────────────────────────────
195
+ let currentTab = 0;
196
+ let cursorIdx = 0; // cursor within current question's options
197
+ let otherMode = false; // typing into "Other..." editor
198
+ let otherQuestionId: string | null = null;
199
+ let cachedLines: string[] | undefined;
200
+
201
+ // Answers store
202
+ const radioAnswers = new Map<string, { value: string; label: string; wasCustom: boolean }>();
203
+ const checkAnswers = new Map<string, Set<string>>(); // id -> set of selected values
204
+ const checkCustom = new Map<string, string>(); // id -> custom "other" text
205
+ const textAnswers = new Map<string, string>();
206
+
207
+ // Initialize defaults
208
+ for (const q of questions) {
209
+ if (q.type === "checkbox") {
210
+ const defaults = new Set<string>();
211
+ if (Array.isArray(q.default)) {
212
+ for (const v of q.default) defaults.add(v);
213
+ }
214
+ checkAnswers.set(q.id, defaults);
215
+ } else if (q.type === "text" && typeof q.default === "string") {
216
+ textAnswers.set(q.id, q.default);
217
+ } else if (q.type === "radio" && typeof q.default === "string") {
218
+ const opt = q.options.find((o) => o.value === q.default);
219
+ if (opt) radioAnswers.set(q.id, { value: opt.value, label: opt.label, wasCustom: false });
220
+ }
221
+ }
222
+
223
+ // Editor for "Other" and "text" fields
224
+ const editorTheme: EditorTheme = {
225
+ borderColor: (s) => theme.fg("accent", s),
226
+ selectList: {
227
+ selectedPrefix: (t) => theme.fg("accent", t),
228
+ selectedText: (t) => theme.fg("accent", t),
229
+ description: (t) => theme.fg("muted", t),
230
+ scrollInfo: (t) => theme.fg("dim", t),
231
+ noMatch: (t) => theme.fg("warning", t),
232
+ },
233
+ };
234
+ const editor = new Editor(tui, editorTheme);
235
+
236
+ function getNextTab(): number {
237
+ if (currentTab < questions.length - 1) {
238
+ return currentTab + 1;
239
+ }
240
+ return questions.length; // Submit tab
241
+ }
242
+
243
+ function advanceTab() {
244
+ if (!(questions.length > 1)) {
245
+ finishSubmit(false);
246
+ } else {
247
+ switchTab(getNextTab());
248
+ }
249
+ }
250
+
251
+ function refresh() {
252
+ cachedLines = undefined;
253
+ tui.requestRender();
254
+ }
255
+
256
+ function curQ(): NormalizedQuestion | undefined {
257
+ return questions[currentTab];
258
+ }
259
+
260
+ /** Save "Other" editor text to the appropriate answer store and exit otherMode. */
261
+ function saveOtherModeText() {
262
+ if (!otherMode || !otherQuestionId) return;
263
+ const t = editor.getText().trim();
264
+ const oq = questions.find((q) => q.id === otherQuestionId);
265
+ if (oq?.type === "radio" && t) {
266
+ radioAnswers.set(oq.id, { value: t, label: t, wasCustom: true });
267
+ } else if (oq?.type === "checkbox" && t) {
268
+ checkCustom.set(oq.id, t);
269
+ }
270
+ otherMode = false;
271
+ otherQuestionId = null;
272
+ editor.setText("");
273
+ }
274
+
275
+ /** Total selectable rows for the current question */
276
+ function optionCount(q: NormalizedQuestion): number {
277
+ if (q.type === "text") return 0;
278
+ return q.options.length + (q.allowOther ? 1 : 0);
279
+ }
280
+
281
+ function isAnswered(q: NormalizedQuestion): boolean {
282
+ if (q.type === "radio") return radioAnswers.has(q.id);
283
+ if (q.type === "checkbox") {
284
+ const set = checkAnswers.get(q.id);
285
+ const custom = checkCustom.get(q.id);
286
+ return (set != null && set.size > 0) || (custom != null && custom.trim().length > 0);
287
+ }
288
+ if (q.type === "text") {
289
+ return (textAnswers.get(q.id)?.trim() ?? "").length > 0;
290
+ }
291
+ return false;
292
+ }
293
+
294
+ function allRequired(): boolean {
295
+ return questions.every((q) => !q.required || isAnswered(q));
296
+ }
297
+
298
+ function switchTab(idx: number) {
299
+ // Save text editor state
300
+ saveEditorText();
301
+ currentTab = ((idx % totalTabs) + totalTabs) % totalTabs;
302
+ cursorIdx = 0;
303
+ otherMode = false;
304
+ otherQuestionId = null;
305
+
306
+ // If switching to a text question, load its value
307
+ const q = curQ();
308
+ if (q?.type === "text") {
309
+ editor.setText(textAnswers.get(q.id) ?? "");
310
+ }
311
+ refresh();
312
+ }
313
+
314
+ function saveEditorText() {
315
+ const q = curQ();
316
+ if (!q) return;
317
+ if (q.type === "text") {
318
+ const t = editor.getText().trim();
319
+ if (t) textAnswers.set(q.id, t);
320
+ else textAnswers.delete(q.id);
321
+ }
322
+ }
323
+
324
+ function finishSubmit(cancelled: boolean) {
325
+ saveEditorText();
326
+ const answers: Answer[] = [];
327
+ for (const q of questions) {
328
+ if (q.type === "radio") {
329
+ const a = radioAnswers.get(q.id);
330
+ answers.push({
331
+ id: q.id,
332
+ type: "radio",
333
+ value: a?.value ?? "",
334
+ wasCustom: a?.wasCustom ?? false,
335
+ });
336
+ } else if (q.type === "checkbox") {
337
+ const set = checkAnswers.get(q.id) ?? new Set();
338
+ const custom = checkCustom.get(q.id)?.trim();
339
+ const values = [...set];
340
+ if (custom) values.push(custom);
341
+ answers.push({ id: q.id, type: "checkbox", value: values, wasCustom: !!custom });
342
+ } else {
343
+ const t = textAnswers.get(q.id) ?? "";
344
+ answers.push({ id: q.id, type: "text", value: t, wasCustom: true });
345
+ }
346
+ }
347
+ done({ title: params.title, questions, answers, cancelled });
348
+ }
349
+
350
+ // ── Editor submit (for "Other" mode) ────────────────────
351
+ editor.onSubmit = (value) => {
352
+ const trimmed = value.trim();
353
+ if (otherMode && otherQuestionId) {
354
+ const q = questions.find((q) => q.id === otherQuestionId);
355
+ if (q?.type === "radio" && trimmed) {
356
+ radioAnswers.set(q.id, { value: trimmed, label: trimmed, wasCustom: true });
357
+ } else if (q?.type === "checkbox" && trimmed) {
358
+ checkCustom.set(q.id, trimmed);
359
+ }
360
+ otherMode = false;
361
+ otherQuestionId = null;
362
+ editor.setText("");
363
+
364
+ // Auto-advance
365
+ advanceTab();
366
+ return;
367
+ }
368
+
369
+ // Text question submit (fallback — Enter is normally intercepted in handleInput
370
+ // before reaching the editor, but handle it here defensively using `value`
371
+ // since editor state is already cleared by the time onSubmit fires)
372
+ const q = curQ();
373
+ if (q?.type === "text") {
374
+ const trimmedValue = value.trim();
375
+ if (trimmedValue) {
376
+ textAnswers.set(q.id, trimmedValue);
377
+ } else {
378
+ textAnswers.delete(q.id);
379
+ }
380
+ advanceTab();
381
+ }
382
+ };
383
+
384
+ // ── Input handling ───────────────────────────────────────
385
+
386
+ function handleInput(data: string) {
387
+ // "Other" editor mode
388
+ if (otherMode) {
389
+ if (matchesKey(data, Key.escape)) {
390
+ otherMode = false;
391
+ otherQuestionId = null;
392
+ editor.setText("");
393
+ refresh();
394
+ return;
395
+ }
396
+ // Enter: capture text directly from editor (before it clears itself) and advance
397
+ if (matchesKey(data, Key.enter)) {
398
+ saveOtherModeText();
399
+ advanceTab();
400
+ return;
401
+ }
402
+ // Tab navigation in multi-question forms: save text and switch tab
403
+ if (isMulti && (matchesKey(data, Key.tab) || matchesKey(data, Key.shift("tab")))) {
404
+ saveOtherModeText();
405
+ switchTab(currentTab + (matchesKey(data, Key.shift("tab")) ? -1 : 1));
406
+ return;
407
+ }
408
+ editor.handleInput(data);
409
+ refresh();
410
+ return;
411
+ }
412
+
413
+ // Text question — route most input to editor
414
+ const q = curQ();
415
+ if (q?.type === "text") {
416
+ // Enter: save text (editor still has content here) and advance
417
+ if (matchesKey(data, Key.enter)) {
418
+ saveEditorText();
419
+ advanceTab();
420
+ return;
421
+ }
422
+ // Tab navigation still works
423
+ if (isMulti && (matchesKey(data, Key.tab) || matchesKey(data, Key.shift("tab")))) {
424
+ saveEditorText();
425
+ switchTab(currentTab + (matchesKey(data, Key.shift("tab")) ? -1 : 1));
426
+ return;
427
+ }
428
+ if (matchesKey(data, Key.escape)) {
429
+ finishSubmit(true);
430
+ return;
431
+ }
432
+ editor.handleInput(data);
433
+ refresh();
434
+ return;
435
+ }
436
+
437
+ // Submit tab (multi-question only)
438
+ if (isMulti && currentTab === questions.length) {
439
+ if (matchesKey(data, Key.enter) && allRequired()) {
440
+ finishSubmit(false);
441
+ return;
442
+ }
443
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
444
+ switchTab(0);
445
+ return;
446
+ }
447
+ if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
448
+ switchTab(currentTab - 1);
449
+ return;
450
+ }
451
+ if (matchesKey(data, Key.escape)) {
452
+ finishSubmit(true);
453
+ return;
454
+ }
455
+ return;
456
+ }
457
+
458
+ if (!q) return;
459
+
460
+ // Tab navigation (multi)
461
+ if (isMulti) {
462
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
463
+ switchTab(currentTab + 1);
464
+ return;
465
+ }
466
+ if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
467
+ switchTab(currentTab - 1);
468
+ return;
469
+ }
470
+ }
471
+
472
+ // Arrow navigation
473
+ const total = optionCount(q);
474
+ if (matchesKey(data, Key.up)) {
475
+ cursorIdx = Math.max(0, cursorIdx - 1);
476
+ refresh();
477
+ return;
478
+ }
479
+ if (matchesKey(data, Key.down)) {
480
+ cursorIdx = Math.min(total - 1, cursorIdx + 1);
481
+ refresh();
482
+ return;
483
+ }
484
+
485
+ // Escape
486
+ if (matchesKey(data, Key.escape)) {
487
+ finishSubmit(true);
488
+ return;
489
+ }
490
+
491
+ // Radio select
492
+ if (q.type === "radio" && matchesKey(data, Key.enter)) {
493
+ const isOther = q.allowOther && cursorIdx === q.options.length;
494
+ if (isOther) {
495
+ otherMode = true;
496
+ otherQuestionId = q.id;
497
+ // Pre-fill with existing custom answer
498
+ const existing = radioAnswers.get(q.id);
499
+ editor.setText(existing?.wasCustom ? existing.label : "");
500
+ refresh();
501
+ return;
502
+ }
503
+ const opt = q.options[cursorIdx];
504
+ if (opt) {
505
+ radioAnswers.set(q.id, { value: opt.value, label: opt.label, wasCustom: false });
506
+ advanceTab();
507
+ }
508
+ return;
509
+ }
510
+
511
+ // Checkbox toggle (space only)
512
+ if (q.type === "checkbox" && matchesKey(data, Key.space)) {
513
+ const isOther = q.allowOther && cursorIdx === q.options.length;
514
+ if (isOther) {
515
+ otherMode = true;
516
+ otherQuestionId = q.id;
517
+ editor.setText(checkCustom.get(q.id) ?? "");
518
+ refresh();
519
+ return;
520
+ }
521
+ const opt = q.options[cursorIdx];
522
+ if (opt) {
523
+ const set = checkAnswers.get(q.id) ?? new Set();
524
+ if (set.has(opt.value)) set.delete(opt.value);
525
+ else set.add(opt.value);
526
+ checkAnswers.set(q.id, set);
527
+ refresh();
528
+ }
529
+ return;
530
+ }
531
+
532
+ // Checkbox: Enter submits (single) or advances (multi)
533
+ if (q.type === "checkbox" && matchesKey(data, Key.enter)) {
534
+ advanceTab();
535
+ return;
536
+ }
537
+ }
538
+
539
+ // ── Render ───────────────────────────────────────────────
540
+
541
+ function render(width: number): string[] {
542
+ if (cachedLines) return cachedLines;
543
+
544
+ const lines: string[] = [];
545
+ const maxW = Math.min(width, 120);
546
+ const add = (s: string) => lines.push(truncateToWidth(s, maxW));
547
+ const hr = () => add(theme.fg("accent", "─".repeat(maxW)));
548
+
549
+ hr();
550
+
551
+ // Title & description
552
+ if (params.title) {
553
+ add(` ${theme.fg("accent", theme.bold(params.title))}`);
554
+ }
555
+ if (params.description) {
556
+ add(` ${theme.fg("muted", params.description)}`);
557
+ }
558
+ if (params.title || params.description) lines.push("");
559
+
560
+ // Tab bar (multi-question)
561
+ if (isMulti) {
562
+ const dividerVisible = visibleWidth("│");
563
+ const tabCount = totalTabs;
564
+
565
+ // Determine each tab's state
566
+ interface TabState {
567
+ isActive: boolean;
568
+ answered: boolean;
569
+ label: string;
570
+ }
571
+ const tabStates: TabState[] = [];
572
+ for (let i = 0; i < questions.length; i++) {
573
+ tabStates.push({
574
+ isActive: i === currentTab,
575
+ answered: isAnswered(questions[i]),
576
+ label: `Q${i + 1}`,
577
+ });
578
+ }
579
+ tabStates.push({
580
+ isActive: currentTab === questions.length,
581
+ answered: allRequired(),
582
+ label: "Submit",
583
+ });
584
+
585
+ // Prefix widths: "▸ " = 2, "✓ " = 2
586
+ const prefixWidths = tabStates.map((s) => {
587
+ let w = 0;
588
+ if (s.isActive) w += visibleWidth(`${SYM.pointer} `);
589
+ if (s.answered) w += visibleWidth(`${SYM.check} `);
590
+ return w;
591
+ });
592
+
593
+ const totalDividers = tabCount - 1;
594
+ const dividerSpace = totalDividers * dividerVisible;
595
+ const paddingSpace = tabCount * 2; // " " around each tab
596
+ const prefixSpace = prefixWidths.reduce((a, b) => a + b, 0);
597
+ const availableForLabels = maxW - dividerSpace - paddingSpace - prefixSpace;
598
+ const minLabelPerTab = 6;
599
+ let maxLabelLen =
600
+ availableForLabels > tabCount * minLabelPerTab
601
+ ? Math.floor(availableForLabels / tabCount)
602
+ : minLabelPerTab;
603
+ if (maxLabelLen < minLabelPerTab) maxLabelLen = minLabelPerTab;
604
+
605
+ const tabs: string[] = [];
606
+ for (let i = 0; i < tabStates.length; i++) {
607
+ const s = tabStates[i];
608
+ const rawParts: string[] = [];
609
+ if (s.isActive) rawParts.push(SYM.pointer);
610
+ if (s.answered) rawParts.push(SYM.check);
611
+ const prefix = rawParts.join(" ") + (rawParts.length > 0 ? " " : "");
612
+ const label = truncateToWidth(s.label, Math.max(1, maxLabelLen));
613
+ const rawText = prefix + label;
614
+ let styledText;
615
+ if (s.isActive) {
616
+ styledText = theme.fg("accent", theme.bold(rawText));
617
+ } else {
618
+ const color = s.answered ? "success" : "muted";
619
+ styledText = theme.fg(color, rawText);
620
+ }
621
+ tabs.push(` ${styledText} `);
622
+ }
623
+
624
+ add(theme.fg("dim", ` ${tabs.join(theme.fg("dim", "│"))}`));
625
+ lines.push("");
626
+ }
627
+
628
+ const q = curQ();
629
+
630
+ // ── Submit tab ───────────────────────────────────────
631
+ if (isMulti && currentTab === questions.length) {
632
+ add(` ${theme.fg("accent", theme.bold("Review & Submit"))}`);
633
+ lines.push("");
634
+
635
+ for (const question of questions) {
636
+ const label = theme.fg("muted", `${question.label}:`);
637
+ if (question.type === "radio") {
638
+ const a = radioAnswers.get(question.id);
639
+ if (a) {
640
+ const prefix = a.wasCustom ? theme.fg("dim", "(wrote) ") : "";
641
+ add(` ${label} ${prefix}${a.label}`);
642
+ } else {
643
+ add(` ${label} ${theme.fg("warning", "(unanswered)")}`);
644
+ }
645
+ } else if (question.type === "checkbox") {
646
+ const set = checkAnswers.get(question.id) ?? new Set();
647
+ const custom = checkCustom.get(question.id)?.trim();
648
+ const all = [...set];
649
+ if (custom) all.push(`${theme.fg("dim", "(wrote)")} ${custom}`);
650
+ if (all.length) {
651
+ add(` ${label} ${all.join(", ")}`);
652
+ } else {
653
+ add(` ${label} ${theme.fg("warning", "(unanswered)")}`);
654
+ }
655
+ } else {
656
+ const t = textAnswers.get(question.id)?.trim();
657
+ if (t) {
658
+ add(` ${label} ${truncateToWidth(t, maxW - visibleWidth(question.label) - 5)}`);
659
+ } else {
660
+ add(` ${label} ${theme.fg("warning", "(unanswered)")}`);
661
+ }
662
+ }
663
+ }
664
+
665
+ lines.push("");
666
+ if (allRequired()) {
667
+ add(` ${theme.fg("success", "Press Enter to submit")}`);
668
+ } else {
669
+ const missing = questions
670
+ .filter((q) => q.required && !isAnswered(q))
671
+ .map((q) => q.label)
672
+ .join(", ");
673
+ add(` ${theme.fg("warning", `Required: ${missing}`)}`);
674
+ }
675
+
676
+ lines.push("");
677
+ add(theme.fg("dim", " Tab/←→ navigate questions • Enter submit • Esc cancel"));
678
+ hr();
679
+ cachedLines = lines;
680
+ return lines;
681
+ }
682
+
683
+ if (!q) {
684
+ hr();
685
+ cachedLines = lines;
686
+ return lines;
687
+ }
688
+
689
+ // ── Question prompt ──────────────────────────────────
690
+ const typeTag =
691
+ q.type === "radio"
692
+ ? theme.fg("dim", "[single-select]")
693
+ : q.type === "checkbox"
694
+ ? theme.fg("dim", "[multi-select]")
695
+ : theme.fg("dim", "[text]");
696
+
697
+ const promptLines = wrapText(q.prompt, maxW - 2);
698
+ for (let i = 0; i < promptLines.length; i++) {
699
+ const isLast = i === promptLines.length - 1;
700
+ add(` ${theme.fg("text", theme.bold(promptLines[i]))}${isLast ? ` ${typeTag}` : ""}`);
701
+ }
702
+ if (q.required) {
703
+ add(` ${theme.fg("warning", "*required")}`);
704
+ }
705
+ lines.push("");
706
+
707
+ // ── Radio options ────────────────────────────────────
708
+ if (q.type === "radio") {
709
+ const selected = radioAnswers.get(q.id);
710
+ for (let i = 0; i < q.options.length; i++) {
711
+ const opt = q.options[i];
712
+ const isCursor = i === cursorIdx;
713
+ const isSelected = selected?.value === opt.value && !selected.wasCustom;
714
+ const bullet = isSelected ? theme.fg("accent", SYM.radioOn) : theme.fg("dim", SYM.radioOff);
715
+ const pointer = isCursor ? theme.fg("accent", SYM.pointer) : " ";
716
+ const color = isCursor ? "accent" : isSelected ? "text" : "muted";
717
+ const prefix = ` ${pointer} ${bullet} `;
718
+ const prefixWidth = visibleWidth(prefix);
719
+ const labelLines = wrapText(opt.label, Math.max(1, maxW - prefixWidth));
720
+ for (let li = 0; li < labelLines.length; li++) {
721
+ const linePrefix = li === 0 ? prefix : " ".repeat(prefixWidth);
722
+ add(`${linePrefix}${theme.fg(color, labelLines[li])}`);
723
+ }
724
+ if (opt.description) {
725
+ const descLines = wrapText(opt.description, Math.max(1, maxW - 6));
726
+ for (const dl of descLines) {
727
+ add(` ${theme.fg("dim", dl)}`);
728
+ }
729
+ }
730
+ }
731
+ if (q.allowOther) {
732
+ const isCursor = cursorIdx === q.options.length;
733
+ const isSelected = selected?.wasCustom === true;
734
+ const bullet = isSelected ? theme.fg("accent", SYM.radioOn) : theme.fg("dim", SYM.radioOff);
735
+ const pointer = isCursor ? theme.fg("accent", SYM.pointer) : " ";
736
+ const label = isSelected ? `Other: ${selected.label}` : "Other...";
737
+ const prefix = ` ${pointer} ${bullet} `;
738
+ const prefixWidth = visibleWidth(prefix);
739
+ const labelLines = wrapText(label, Math.max(1, maxW - prefixWidth));
740
+ const color = isCursor ? "accent" : "muted";
741
+ for (let li = 0; li < labelLines.length; li++) {
742
+ const linePrefix = li === 0 ? prefix : " ".repeat(prefixWidth);
743
+ add(`${linePrefix}${theme.fg(color, labelLines[li])}`);
744
+ }
745
+
746
+ if (otherMode) {
747
+ lines.push("");
748
+ add(` ${theme.fg("muted", " Your answer:")}`);
749
+ for (const line of editor.render(maxW - 6)) {
750
+ add(` ${line}`);
751
+ }
752
+ }
753
+ }
754
+ }
755
+
756
+ // ── Checkbox options ─────────────────────────────────
757
+ if (q.type === "checkbox") {
758
+ const set = checkAnswers.get(q.id) ?? new Set();
759
+ for (let i = 0; i < q.options.length; i++) {
760
+ const opt = q.options[i];
761
+ const isCursor = i === cursorIdx;
762
+ const isChecked = set.has(opt.value);
763
+ const box = isChecked ? theme.fg("accent", SYM.checkOn) : theme.fg("dim", SYM.checkOff);
764
+ const pointer = isCursor ? theme.fg("accent", SYM.pointer) : " ";
765
+ const color = isCursor ? "accent" : isChecked ? "text" : "muted";
766
+ const prefix = ` ${pointer} ${box} `;
767
+ const prefixWidth = visibleWidth(prefix);
768
+ const labelLines = wrapText(opt.label, Math.max(1, maxW - prefixWidth));
769
+ for (let li = 0; li < labelLines.length; li++) {
770
+ const linePrefix = li === 0 ? prefix : " ".repeat(prefixWidth);
771
+ add(`${linePrefix}${theme.fg(color, labelLines[li])}`);
772
+ }
773
+ if (opt.description) {
774
+ const descLines = wrapText(opt.description, Math.max(1, maxW - 6));
775
+ for (const dl of descLines) {
776
+ add(` ${theme.fg("dim", dl)}`);
777
+ }
778
+ }
779
+ }
780
+ if (q.allowOther) {
781
+ const isCursor = cursorIdx === q.options.length;
782
+ const custom = checkCustom.get(q.id)?.trim();
783
+ const box = custom ? theme.fg("accent", SYM.checkOn) : theme.fg("dim", SYM.checkOff);
784
+ const pointer = isCursor ? theme.fg("accent", SYM.pointer) : " ";
785
+ const label = custom ? `Other: ${custom}` : "Other...";
786
+ const prefix = ` ${pointer} ${box} `;
787
+ const prefixWidth = visibleWidth(prefix);
788
+ const labelLines = wrapText(label, Math.max(1, maxW - prefixWidth));
789
+ const color = isCursor ? "accent" : "muted";
790
+ for (let li = 0; li < labelLines.length; li++) {
791
+ const linePrefix = li === 0 ? prefix : " ".repeat(prefixWidth);
792
+ add(`${linePrefix}${theme.fg(color, labelLines[li])}`);
793
+ }
794
+ }
795
+ }
796
+
797
+ // ── Text input ───────────────────────────────────────
798
+ if (q.type === "text") {
799
+ if (q.placeholder && !editor.getText()) {
800
+ add(` ${theme.fg("dim", q.placeholder)}`);
801
+ }
802
+ for (const line of editor.render(maxW - 4)) {
803
+ add(` ${line}`);
804
+ }
805
+ }
806
+
807
+ // ── Footer ───────────────────────────────────────────
808
+ lines.push("");
809
+ if (otherMode) {
810
+ add(theme.fg("dim", " Enter submit • Esc go back"));
811
+ } else if (q.type === "text") {
812
+ const nav = isMulti ? "Tab/←→ navigate • " : "";
813
+ add(theme.fg("dim", ` ${nav}Enter submit • Esc cancel`));
814
+ } else if (q.type === "checkbox") {
815
+ const nav = isMulti ? "Tab/←→ navigate • " : "";
816
+ add(theme.fg("dim", ` ↑↓ navigate • Space toggle • ${nav}Enter ${isMulti ? "next" : "submit"} • Esc cancel`));
817
+ } else {
818
+ const nav = isMulti ? "Tab/←→ navigate • " : "";
819
+ add(theme.fg("dim", ` ↑↓ navigate • ${nav}Enter select • Esc cancel`));
820
+ }
821
+ hr();
822
+
823
+ cachedLines = lines;
824
+ return lines;
825
+ }
826
+
827
+ // Initialize: if first question is text, load editor
828
+ const firstQ = questions[0];
829
+ if (firstQ?.type === "text") {
830
+ editor.setText(textAnswers.get(firstQ.id) ?? "");
831
+ }
832
+
833
+ return {
834
+ render,
835
+ invalidate: () => {
836
+ cachedLines = undefined;
837
+ },
838
+ handleInput,
839
+ };
840
+ });
841
+
842
+ // ── Format result ────────────────────────────────────────────
843
+
844
+ if (result.cancelled) {
845
+ return {
846
+ content: [{ type: "text", text: "User cancelled the form" }],
847
+ details: result,
848
+ };
849
+ }
850
+
851
+ const answerLines: string[] = [];
852
+ for (const a of result.answers) {
853
+ const q = questions.find((q) => q.id === a.id);
854
+ const label = q?.label || a.id;
855
+ if (a.type === "radio") {
856
+ const prefix = a.wasCustom ? "(wrote) " : "";
857
+ answerLines.push(`${label}: ${prefix}${a.value}`);
858
+ } else if (a.type === "checkbox") {
859
+ const values = Array.isArray(a.value) ? a.value : [a.value];
860
+ if (values.length === 0) {
861
+ answerLines.push(`${label}: (none selected)`);
862
+ } else {
863
+ answerLines.push(`${label}: ${values.join(", ")}`);
864
+ }
865
+ } else {
866
+ answerLines.push(`${label}: ${a.value || "(empty)"}`);
867
+ }
868
+ }
869
+
870
+ return {
871
+ content: [{ type: "text", text: answerLines.join("\n") }],
872
+ details: result,
873
+ };
874
+ },
875
+
876
+ // ── Custom rendering ─────────────────────────────────────────────
877
+
878
+ renderCall(args, theme, _context) {
879
+ const qs = (args.questions as Question[]) || [];
880
+ const title = args.title as string | undefined;
881
+ let text = theme.fg("toolTitle", theme.bold("ask_user_question "));
882
+ if (title) {
883
+ text += theme.fg("accent", title) + " ";
884
+ }
885
+ text += theme.fg("muted", `${qs.length} question${qs.length !== 1 ? "s" : ""}`);
886
+ const types = [...new Set(qs.map((q) => q.type))].join(", ");
887
+ if (types) {
888
+ text += theme.fg("dim", ` (${types})`);
889
+ }
890
+ return new Text(text, 0, 0);
891
+ },
892
+
893
+ renderResult(result, _options, theme, _context) {
894
+ const details = result.details as FormResult | undefined;
895
+ if (!details) {
896
+ const text = result.content[0];
897
+ return new Text(text?.type === "text" ? text.text : "", 0, 0);
898
+ }
899
+
900
+ if (details.cancelled) {
901
+ return new Text(theme.fg("warning", "Cancelled"), 0, 0);
902
+ }
903
+
904
+ const lines = details.answers.map((a) => {
905
+ const q = details.questions.find((q) => q.id === a.id);
906
+ const label = q?.label || a.id;
907
+
908
+ if (a.type === "radio") {
909
+ const prefix = a.wasCustom ? theme.fg("dim", "(wrote) ") : "";
910
+ return `${theme.fg("success", SYM.check)} ${theme.fg("accent", label)}: ${prefix}${a.value}`;
911
+ }
912
+ if (a.type === "checkbox") {
913
+ const values = Array.isArray(a.value) ? a.value : [a.value];
914
+ const display = values.length ? values.join(", ") : theme.fg("dim", "(none)");
915
+ return `${theme.fg("success", SYM.check)} ${theme.fg("accent", label)}: ${display}`;
916
+ }
917
+ return `${theme.fg("success", SYM.check)} ${theme.fg("accent", label)}: ${a.value || theme.fg("dim", "(empty)")}`;
918
+ });
919
+
920
+ return new Text(lines.join("\n"), 0, 0);
921
+ },
922
+ });
923
+ }