surgent 0.7.0-alpha.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 (132) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +407 -0
  3. package/bin/surgent.js +211 -0
  4. package/dist/optimizers/LICENSE +21 -0
  5. package/dist/optimizers/index.js +1984 -0
  6. package/dist/optimizers/index.js.map +7 -0
  7. package/dist/optimizers/package.json +31 -0
  8. package/package.json +45 -0
  9. package/src/agent/built-in/documenter.md +58 -0
  10. package/src/agent/built-in/general.md +107 -0
  11. package/src/agent/built-in/planner.md +73 -0
  12. package/src/agent/built-in/scout.md +97 -0
  13. package/src/agent/command.ts +140 -0
  14. package/src/agent/helpers.ts +95 -0
  15. package/src/agent/index.ts +9 -0
  16. package/src/agent/storage.ts +287 -0
  17. package/src/agent/types.ts +28 -0
  18. package/src/checkpoint/git.ts +173 -0
  19. package/src/checkpoint/index.ts +117 -0
  20. package/src/checkpoint/snapshot.ts +28 -0
  21. package/src/checkpoint/stage.ts +59 -0
  22. package/src/checkpoint/store.ts +108 -0
  23. package/src/cleanup/checkpoint.ts +31 -0
  24. package/src/cleanup/helpers.ts +24 -0
  25. package/src/cleanup/index.ts +21 -0
  26. package/src/cleanup/permission.ts +74 -0
  27. package/src/cleanup/subsession.ts +46 -0
  28. package/src/commands/helpers.ts +217 -0
  29. package/src/commands/index.ts +79 -0
  30. package/src/commands/render.ts +95 -0
  31. package/src/commands/types.ts +11 -0
  32. package/src/mcp-client/call-tool.ts +143 -0
  33. package/src/mcp-client/client.ts +90 -0
  34. package/src/mcp-client/command.ts +257 -0
  35. package/src/mcp-client/helpers.ts +153 -0
  36. package/src/mcp-client/index.ts +21 -0
  37. package/src/mcp-client/list-tools.ts +84 -0
  38. package/src/mcp-client/storage.ts +190 -0
  39. package/src/mcp-client/types.ts +34 -0
  40. package/src/mcp-client/validation.ts +115 -0
  41. package/src/optimizers/compactor/bash.ts +159 -0
  42. package/src/optimizers/compactor/grep.ts +141 -0
  43. package/src/optimizers/compactor/index.ts +132 -0
  44. package/src/optimizers/deduplicator/helpers.ts +75 -0
  45. package/src/optimizers/deduplicator/index.ts +23 -0
  46. package/src/optimizers/deduplicator/resources.ts +77 -0
  47. package/src/optimizers/deduplicator/state.ts +119 -0
  48. package/src/optimizers/deduplicator/types.ts +14 -0
  49. package/src/optimizers/entries.ts +104 -0
  50. package/src/optimizers/index.ts +17 -0
  51. package/src/optimizers/inspector/helpers.ts +60 -0
  52. package/src/optimizers/inspector/index.ts +89 -0
  53. package/src/optimizers/inspector/inspect.ts +88 -0
  54. package/src/optimizers/inspector/types.ts +7 -0
  55. package/src/optimizers/languages/go.ts +79 -0
  56. package/src/optimizers/languages/grammar.ts +200 -0
  57. package/src/optimizers/languages/index.ts +75 -0
  58. package/src/optimizers/languages/java.ts +64 -0
  59. package/src/optimizers/languages/python.ts +63 -0
  60. package/src/optimizers/languages/rust.ts +71 -0
  61. package/src/optimizers/languages/symbols.ts +95 -0
  62. package/src/optimizers/languages/tree-sitter-languages.d.ts +23 -0
  63. package/src/optimizers/languages/types.ts +134 -0
  64. package/src/optimizers/languages/typescript.ts +116 -0
  65. package/src/optimizers/mapper/files.ts +94 -0
  66. package/src/optimizers/mapper/index.ts +133 -0
  67. package/src/optimizers/mapper/types.ts +6 -0
  68. package/src/optimizers/pruner/cleanup.ts +121 -0
  69. package/src/optimizers/pruner/context.ts +46 -0
  70. package/src/optimizers/pruner/index.ts +45 -0
  71. package/src/optimizers/pruner/session.ts +34 -0
  72. package/src/optimizers/pruner/types.ts +18 -0
  73. package/src/permission/bash.ts +124 -0
  74. package/src/permission/command.ts +111 -0
  75. package/src/permission/components/prompt.ts +255 -0
  76. package/src/permission/components/rules-list.ts +342 -0
  77. package/src/permission/constants.ts +48 -0
  78. package/src/permission/helpers.ts +156 -0
  79. package/src/permission/index.ts +134 -0
  80. package/src/permission/pattern.ts +51 -0
  81. package/src/permission/piignore.ts +148 -0
  82. package/src/permission/precedence.ts +54 -0
  83. package/src/permission/resolution.ts +116 -0
  84. package/src/permission/storage.ts +142 -0
  85. package/src/permission/types.ts +57 -0
  86. package/src/questionnaire/component.ts +357 -0
  87. package/src/questionnaire/helpers.ts +220 -0
  88. package/src/questionnaire/index.ts +67 -0
  89. package/src/questionnaire/schemas.ts +50 -0
  90. package/src/questionnaire/types.ts +47 -0
  91. package/src/redactor/index.ts +34 -0
  92. package/src/redactor/patterns.ts +234 -0
  93. package/src/redactor/secrets.ts +113 -0
  94. package/src/subagent/helpers.ts +93 -0
  95. package/src/subagent/index.ts +81 -0
  96. package/src/subagent/storage.ts +100 -0
  97. package/src/subagent/subsession.ts +266 -0
  98. package/src/subagent/types.ts +83 -0
  99. package/src/subagent/validation.ts +100 -0
  100. package/src/ui/components/action-select-list.ts +165 -0
  101. package/src/ui/components/bash-mode.ts +281 -0
  102. package/src/ui/components/extended-select-list.ts +166 -0
  103. package/src/ui/components/form-field.ts +184 -0
  104. package/src/ui/components/form.ts +179 -0
  105. package/src/ui/components/frame.ts +60 -0
  106. package/src/ui/components/input-mode-indicator.ts +64 -0
  107. package/src/ui/components/keybound.ts +150 -0
  108. package/src/ui/components/lines.ts +27 -0
  109. package/src/ui/components/placeholder-input.ts +59 -0
  110. package/src/ui/components/scoped-input.ts +78 -0
  111. package/src/ui/components/scrollable-view.ts +155 -0
  112. package/src/ui/index.ts +40 -0
  113. package/src/utils.ts +206 -0
  114. package/src/web-tools/index.ts +15 -0
  115. package/src/web-tools/providers/brave.ts +55 -0
  116. package/src/web-tools/providers/firecrawl.ts +66 -0
  117. package/src/web-tools/providers/index.ts +50 -0
  118. package/src/web-tools/providers/jina.ts +48 -0
  119. package/src/web-tools/providers/native.ts +57 -0
  120. package/src/web-tools/providers/tavily.ts +56 -0
  121. package/src/web-tools/settings.ts +15 -0
  122. package/src/web-tools/web-fetch/helpers.ts +66 -0
  123. package/src/web-tools/web-fetch/index.ts +91 -0
  124. package/src/web-tools/web-fetch/parser.ts +51 -0
  125. package/src/web-tools/web-fetch/storage.ts +65 -0
  126. package/src/web-tools/web-fetch/types.ts +8 -0
  127. package/src/web-tools/web-login/helpers.ts +79 -0
  128. package/src/web-tools/web-login/index.ts +100 -0
  129. package/src/web-tools/web-login/types.ts +4 -0
  130. package/src/web-tools/web-search/helpers.ts +36 -0
  131. package/src/web-tools/web-search/index.ts +98 -0
  132. package/src/web-tools/web-search/types.ts +15 -0
@@ -0,0 +1,357 @@
1
+ import {
2
+ Editor,
3
+ Key,
4
+ matchesKey,
5
+ parseKey,
6
+ wrapTextWithAnsi,
7
+ type EditorTheme,
8
+ type Focusable,
9
+ type TUI,
10
+ } from "@earendil-works/pi-tui";
11
+ import { type Theme } from "@earendil-works/pi-coding-agent";
12
+ import { Frame } from "../ui/components/frame.js";
13
+ import { Lines } from "../ui/components/lines.js";
14
+ import {
15
+ createInitialDraft,
16
+ ensureSingleSelection,
17
+ getValidationMessage,
18
+ moveCursor,
19
+ serializeQuestionAnswer,
20
+ summarizeAnswer,
21
+ toggleSuggestion,
22
+ } from "./helpers.js";
23
+ import type { NormalizedQuestion, QuestionDraft, QuestionnaireResult } from "./types.js";
24
+
25
+ export default class Questionnaire extends Frame implements Focusable {
26
+ onDone?: (result: QuestionnaireResult) => void;
27
+
28
+ private readonly drafts: QuestionDraft[];
29
+ private readonly editors: Editor[] = [];
30
+
31
+ private tab = 0;
32
+ private _focused = false;
33
+
34
+ constructor(
35
+ private readonly tui: TUI,
36
+ protected theme: Theme,
37
+ private readonly questions: NormalizedQuestion[],
38
+ ) {
39
+ super(theme);
40
+ this.drafts = questions.map((question) => createInitialDraft(question));
41
+ this.questions.forEach((_, index) => {
42
+ const editor = this.createEditor();
43
+ editor.onChange = () => {
44
+ const draft = this.drafts[index]!;
45
+ draft.text = editor.getText();
46
+ };
47
+ this.editors.push(editor);
48
+ });
49
+
50
+ this.registerKeybindings([
51
+ {
52
+ key: Key.escape,
53
+ hint: "cancel",
54
+ handler: () => this.onDone?.({ cancelled: true, questions: [], answers: [] }),
55
+ },
56
+ {
57
+ key: { navigation: "horizontal", metakey: Key.alt },
58
+ hint: "switch tabs",
59
+ navigate: (keyId) => this.moveQuestion(keyId === Key.left ? -1 : 1),
60
+ },
61
+ {
62
+ key: { navigation: "vertical" },
63
+ hint: "focus options",
64
+ navigate: (keyId) => this.handleVerticalNav(keyId),
65
+ },
66
+ { key: Key.tab, hint: "switch focus", handler: () => this.toggleFocusMode() },
67
+ { key: Key.space, hint: "toggle option", handler: () => this.toggleOption() },
68
+ { key: Key.enter, hint: "continue", handler: () => this.handleEnterKey() },
69
+ ]);
70
+ this.syncInteractionState();
71
+ }
72
+
73
+ get focused(): boolean {
74
+ return this._focused;
75
+ }
76
+
77
+ set focused(value: boolean) {
78
+ this._focused = value;
79
+ this.syncEditorFocus();
80
+ }
81
+
82
+ invalidate() {
83
+ for (const editor of this.editors) editor.invalidate();
84
+ }
85
+
86
+ handleInput(data: string) {
87
+ if (this.handleKb(data)) return;
88
+ if (this.tab === this.questions.length) return;
89
+ if (!this.drafts[this.tab]!.editing) {
90
+ const parsedKey = parseKey(data);
91
+ const shouldRouteToEditor =
92
+ (parsedKey !== undefined && parsedKey.length === 1) ||
93
+ matchesKey(data, Key.backspace) ||
94
+ matchesKey(data, Key.delete) ||
95
+ matchesKey(data, Key.space);
96
+ if (!shouldRouteToEditor) return;
97
+ this.setEditing(true);
98
+ }
99
+ this.editors[this.tab]!.handleInput(data);
100
+ }
101
+
102
+ protected override children(width: number) {
103
+ const lines = new Lines(width);
104
+
105
+ lines.add(this.renderTabs());
106
+ lines.space();
107
+
108
+ if (this.tab === this.questions.length) {
109
+ lines.add(this.theme.bold("Review answers"));
110
+ lines.space();
111
+ for (const [index, question] of this.questions.entries()) {
112
+ const answer = serializeQuestionAnswer(question, this.drafts[index]!);
113
+ for (const line of wrapTextWithAnsi(`Q${index + 1}: ${question.prompt}`, width)) {
114
+ lines.add(line);
115
+ }
116
+ for (const line of wrapTextWithAnsi(`A${index + 1}: ${answer || "(incomplete)"}`, width)) {
117
+ lines.add(this.theme.fg("accent", line));
118
+ }
119
+ if (index < this.questions.length - 1) lines.space();
120
+ }
121
+ lines.space();
122
+ lines.add(
123
+ this.theme.fg(
124
+ this.allQuestionsAnswered() ? "success" : "warning",
125
+ this.allQuestionsAnswered()
126
+ ? "Press Enter to submit."
127
+ : "Complete required answers before submitting.",
128
+ ),
129
+ );
130
+ return lines.get();
131
+ }
132
+
133
+ const question = this.questions[this.tab]!;
134
+ const draft = this.drafts[this.tab]!;
135
+ const editor = this.editors[this.tab]!;
136
+
137
+ for (const wrappedPromptLine of wrapTextWithAnsi(this.theme.bold(question.prompt), width)) {
138
+ lines.add(wrappedPromptLine);
139
+ }
140
+ if (question.reason) {
141
+ for (const wrappedLine of wrapTextWithAnsi(this.theme.fg("muted", question.reason), width)) {
142
+ lines.add(wrappedLine);
143
+ }
144
+ }
145
+
146
+ if (question.options.length > 0) {
147
+ lines.space();
148
+ lines.add(
149
+ this.theme.fg(
150
+ draft.editing ? "muted" : "accent",
151
+ `Options ${draft.editing ? "[press Tab or ↑↓]" : "[selecting]"}`,
152
+ ),
153
+ );
154
+ lines.space();
155
+ for (const [index, option] of question.options.entries()) {
156
+ const selected = draft.selectedIndexes.includes(index);
157
+ const cursor = draft.cursor === index && !draft.editing;
158
+ const marker = question.multi ? (selected ? "[x]" : "[ ]") : selected ? "(*)" : "( )";
159
+ const prefix = cursor ? this.theme.fg("accent", "→") : " ";
160
+ const recommendation =
161
+ question.recommendedCount !== undefined && index < question.recommendedCount
162
+ ? this.theme.fg("success", " [recommended]")
163
+ : "";
164
+ const exclusive = option.exclusive ? this.theme.fg("dim", " [exclusive]") : "";
165
+ const optionText = `${marker} ${option.text}${recommendation}${exclusive}`;
166
+ lines.add(`${prefix} ${cursor ? this.theme.fg("accent", optionText) : optionText}`);
167
+ if (option.description) lines.add(this.theme.fg("muted", option.description), 6);
168
+ }
169
+ }
170
+
171
+ lines.space();
172
+ lines.add(
173
+ this.theme.fg(
174
+ draft.editing ? "accent" : "muted",
175
+ `${question.placeholder} ${draft.editing ? "[editing]" : "[press Tab to edit]"}`,
176
+ ),
177
+ );
178
+ for (const line of editor.render(Math.max(12, width - 2))) lines.add(line);
179
+
180
+ const currentAnswer = serializeQuestionAnswer(question, draft);
181
+ lines.space();
182
+ lines.add(`${this.theme.fg("success", "Answer:")} ${summarizeAnswer(currentAnswer)}`);
183
+
184
+ return lines.get();
185
+ }
186
+
187
+ private createEditor(): Editor {
188
+ const editorTheme: EditorTheme = {
189
+ borderColor: (text) => this.theme.fg("dim", text),
190
+ selectList: {
191
+ selectedPrefix: (text) => this.theme.fg("accent", text),
192
+ selectedText: (text) => this.theme.fg("accent", text),
193
+ description: (text) => this.theme.fg("muted", text),
194
+ scrollInfo: (text) => this.theme.fg("dim", text),
195
+ noMatch: (text) => this.theme.fg("warning", text),
196
+ },
197
+ };
198
+ return new Editor(this.tui, editorTheme);
199
+ }
200
+
201
+ private moveQuestion(delta: number) {
202
+ this.tab = Math.max(0, Math.min(this.questions.length, this.tab + delta));
203
+ this.syncInteractionState();
204
+ }
205
+
206
+ private setEditing(editing: boolean) {
207
+ const draft = this.drafts[this.tab]!;
208
+ if (draft.editing === editing) return;
209
+ draft.editing = editing;
210
+ this.syncInteractionState();
211
+ }
212
+
213
+ private toggleFocusMode() {
214
+ if (this.questions[this.tab]!.options.length === 0) return;
215
+ this.setEditing(!this.drafts[this.tab]!.editing);
216
+ }
217
+
218
+ private handleVerticalNav(keyId: string) {
219
+ const question = this.questions[this.tab]!;
220
+ if (question.options.length === 0) return;
221
+
222
+ const draft = this.drafts[this.tab]!;
223
+ const oldCursor = draft.cursor;
224
+
225
+ if (keyId === Key.down) {
226
+ moveCursor(question, draft, 1);
227
+ if (draft.cursor === oldCursor) {
228
+ this.setEditing(true);
229
+ }
230
+ return;
231
+ }
232
+
233
+ moveCursor(question, draft, draft.editing ? 0 : -1);
234
+ this.setEditing(false);
235
+ }
236
+
237
+ private toggleOption() {
238
+ const question = this.questions[this.tab]!;
239
+ const draft = this.drafts[this.tab]!;
240
+ if (!question.multi || draft.editing) return;
241
+
242
+ const result = toggleSuggestion(question, draft, draft.cursor);
243
+ this.drafts[this.tab] = { ...draft, selectedIndexes: result.selectedIndexes };
244
+ }
245
+
246
+ private handleEnterKey() {
247
+ if (this.tab === this.questions.length) {
248
+ this.submitAnswer();
249
+ return;
250
+ }
251
+
252
+ const question = this.questions[this.tab]!;
253
+ const draft = this.drafts[this.tab]!;
254
+ if (question.options.length > 0 && !draft.editing && !question.multi) {
255
+ this.drafts[this.tab] = { ...draft, selectedIndexes: [draft.cursor] };
256
+ }
257
+ this.submitAnswer();
258
+ }
259
+
260
+ private submitAnswer() {
261
+ if (this.tab === this.questions.length) {
262
+ if (!this.allQuestionsAnswered()) {
263
+ this.tab = this.firstIncompleteIndex();
264
+ this.syncInteractionState();
265
+ return;
266
+ }
267
+
268
+ this.onDone?.({
269
+ cancelled: false,
270
+ questions: this.questions.map((entry) => entry.prompt),
271
+ answers: this.questions.map((entry, index) =>
272
+ serializeQuestionAnswer(entry, this.drafts[index]!),
273
+ ),
274
+ });
275
+ return;
276
+ }
277
+
278
+ const question = this.questions[this.tab]!;
279
+ const draft = this.drafts[this.tab]!;
280
+ ensureSingleSelection(question, this.drafts[this.tab]!);
281
+
282
+ const message = getValidationMessage(question, draft);
283
+ if (message) {
284
+ return;
285
+ }
286
+ this.tab += 1;
287
+ this.syncInteractionState();
288
+ }
289
+
290
+ private allQuestionsAnswered(): boolean {
291
+ return this.questions.every(
292
+ (question, index) => !getValidationMessage(question, this.drafts[index]!),
293
+ );
294
+ }
295
+
296
+ private firstIncompleteIndex(): number {
297
+ const index = this.questions.findIndex(
298
+ (question, index) => !!getValidationMessage(question, this.drafts[index]!),
299
+ );
300
+ return index >= 0 ? index : this.questions.length - 1;
301
+ }
302
+
303
+ private renderTabs(): string {
304
+ const tabs = this.questions.map((question, index) => {
305
+ const answered = !getValidationMessage(question, this.drafts[index]!);
306
+ const label = ` Q${index + 1}${answered ? "*" : ""} `;
307
+ if (index === this.tab) {
308
+ return this.theme.bg("selectedBg", this.theme.fg("text", label));
309
+ }
310
+ return this.theme.fg(answered ? "success" : "muted", label);
311
+ });
312
+ const submitLabel = " Submit ";
313
+ tabs.push(
314
+ this.tab === this.questions.length
315
+ ? this.theme.bg("selectedBg", this.theme.fg("text", submitLabel))
316
+ : this.theme.fg(this.allQuestionsAnswered() ? "success" : "muted", submitLabel),
317
+ );
318
+ return tabs.join(" ");
319
+ }
320
+
321
+ private syncInteractionState() {
322
+ this.syncEditorFocus();
323
+ this.syncKeybindingState();
324
+ }
325
+
326
+ private syncKeybindingState() {
327
+ this.setArrowKeyAccess({ navigation: "horizontal", metakey: Key.alt }, true);
328
+
329
+ if (this.tab === this.questions.length) {
330
+ this.setArrowKeyAccess({ navigation: "vertical" }, false);
331
+ this.setKeyAccess(Key.tab, false);
332
+ this.setKeyAccess(Key.space, false);
333
+ this.setHint(Key.enter, "submit");
334
+ return;
335
+ }
336
+
337
+ const question = this.questions[this.tab]!;
338
+ const draft = this.drafts[this.tab]!;
339
+ const hasOptions = question.options.length > 0;
340
+ const optionsFocused = hasOptions && !draft.editing;
341
+
342
+ this.setArrowKeyAccess({ navigation: "vertical" }, hasOptions);
343
+ this.setKeyAccess(Key.tab, hasOptions);
344
+ this.setKeyAccess(Key.space, optionsFocused && question.multi);
345
+
346
+ this.setHint(Key.tab, optionsFocused ? "edit answer" : "select options");
347
+ this.setHint(Key.up, optionsFocused ? "move options" : "focus options");
348
+ this.setHint(Key.enter, optionsFocused && !question.multi ? "select + continue" : "continue");
349
+ }
350
+
351
+ private syncEditorFocus() {
352
+ for (const [index, editor] of this.editors.entries()) {
353
+ const draft = this.drafts[index]!;
354
+ editor.focused = this.focused && index === this.tab && draft.editing;
355
+ }
356
+ }
357
+ }
@@ -0,0 +1,220 @@
1
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
+ import Questionnaire from "./component.js";
3
+ import type {
4
+ NormalizedQuestion,
5
+ Question,
6
+ QuestionDraft,
7
+ QuestionnaireResult,
8
+ QuestionOption,
9
+ ToggleSelectionResult,
10
+ } from "./types.js";
11
+
12
+ export function createInitialDraft(question: NormalizedQuestion): QuestionDraft {
13
+ const selectedIndexes = question.multi
14
+ ? question.options.slice(0, question.recommendedCount).map((_option, index) => index)
15
+ : [];
16
+
17
+ return {
18
+ text: "",
19
+ selectedIndexes,
20
+ cursor: selectedIndexes[0] ?? 0,
21
+ editing: question.options.length === 0,
22
+ };
23
+ }
24
+
25
+ export function getValidationMessage(
26
+ question: NormalizedQuestion,
27
+ draft: QuestionDraft,
28
+ ): string | undefined {
29
+ const trimmedText = draft.text.trim();
30
+ const selectedCount = draft.selectedIndexes.length;
31
+
32
+ if (question.multi && selectedCount > question.maxSelections) {
33
+ return `Select at most ${question.maxSelections} option${question.maxSelections === 1 ? "" : "s"}, or type your answer.`;
34
+ }
35
+ if (trimmedText) {
36
+ return;
37
+ }
38
+ if (question.options.length === 0) {
39
+ return "Type an answer to continue.";
40
+ }
41
+ if (!question.multi) {
42
+ return selectedCount > 0 ? undefined : "Select a option or type an answer to continue.";
43
+ }
44
+ if (selectedCount === 0) {
45
+ return "Select one or more options, or type an answer to continue.";
46
+ }
47
+ if (selectedCount < question.minSelections) {
48
+ return `Select at least ${question.minSelections} option${question.minSelections === 1 ? "" : "s"}, or type an answer.`;
49
+ }
50
+ return;
51
+ }
52
+
53
+ export function serializeQuestionAnswer(
54
+ question: NormalizedQuestion,
55
+ draft: QuestionDraft,
56
+ ): string {
57
+ const trimmedText = draft.text.trim();
58
+ const selectedTexts = draft.selectedIndexes
59
+ .map((index) => question.options[index]?.text)
60
+ .filter((value): value is string => Boolean(value));
61
+
62
+ if (!question.multi) {
63
+ return trimmedText || selectedTexts[0] || "";
64
+ }
65
+
66
+ const lines: string[] = [];
67
+
68
+ for (const text of selectedTexts) {
69
+ if (!lines.includes(text)) {
70
+ lines.push(text);
71
+ }
72
+ }
73
+
74
+ if (trimmedText && !lines.includes(trimmedText)) {
75
+ lines.push(trimmedText);
76
+ }
77
+
78
+ return lines.join("\n");
79
+ }
80
+
81
+ export function summarizeAnswer(answer: string): string {
82
+ return answer.replace(/\s*\n\s*/g, " / ").trim();
83
+ }
84
+
85
+ export function toggleSuggestion(
86
+ question: NormalizedQuestion,
87
+ draft: QuestionDraft,
88
+ optionIndex: number,
89
+ ): ToggleSelectionResult {
90
+ if (!question.multi) {
91
+ return { selectedIndexes: [optionIndex] };
92
+ }
93
+
94
+ const option = question.options[optionIndex];
95
+ if (!option) {
96
+ return { selectedIndexes: draft.selectedIndexes };
97
+ }
98
+
99
+ const currentlySelected = draft.selectedIndexes.includes(optionIndex);
100
+ if (currentlySelected) {
101
+ return {
102
+ selectedIndexes: draft.selectedIndexes.filter((index) => index !== optionIndex),
103
+ };
104
+ }
105
+
106
+ if (option.exclusive) {
107
+ return { selectedIndexes: [optionIndex] };
108
+ }
109
+
110
+ const withoutExclusive = draft.selectedIndexes.filter(
111
+ (index) => !question.options[index]?.exclusive,
112
+ );
113
+ if (withoutExclusive.length >= question.maxSelections) {
114
+ return {
115
+ selectedIndexes: draft.selectedIndexes,
116
+ message: `Select at most ${question.maxSelections} option${question.maxSelections === 1 ? "" : "s"}.`,
117
+ };
118
+ }
119
+
120
+ return { selectedIndexes: [...withoutExclusive, optionIndex] };
121
+ }
122
+
123
+ export function ensureSingleSelection(question: NormalizedQuestion, draft: QuestionDraft) {
124
+ if (question.multi || question.options.length === 0) return draft;
125
+ draft.selectedIndexes = [draft.cursor];
126
+ }
127
+
128
+ export function moveCursor(question: NormalizedQuestion, draft: QuestionDraft, delta: number) {
129
+ if (question.options.length === 0) return draft;
130
+ draft.cursor = Math.max(0, Math.min(question.options.length - 1, draft.cursor + delta));
131
+ }
132
+
133
+ export async function askQuestions(questions: Question[], ui: ExtensionUIContext) {
134
+ if (questions.length === 0) {
135
+ throw new Error("At least one question is required.");
136
+ }
137
+
138
+ const normalized = questions.map(normalizeQuestion);
139
+ return ui.custom<QuestionnaireResult>((tui, theme, _keybindings, done) => {
140
+ const component = new Questionnaire(tui, theme, normalized);
141
+ component.onDone = done;
142
+ return component;
143
+ });
144
+ }
145
+
146
+ function normalizeQuestion(question: Question): NormalizedQuestion {
147
+ const prompt = question.prompt.trim();
148
+ if (!prompt) {
149
+ throw new Error("Question prompt must not be empty.");
150
+ }
151
+
152
+ const options = question.options ?? [];
153
+ const multi = question.multi === true && options.length > 0;
154
+ const minSelections = multi ? (question.minSelections ?? 1) : 1;
155
+ const maxSelections = multi ? (question.maxSelections ?? options.length) : 1;
156
+ const recommendedCount = getRecommendedCount({
157
+ options,
158
+ multi,
159
+ minSelections,
160
+ recommendedCount: question.recommendedCount,
161
+ });
162
+
163
+ if (multi && options.length === 0) {
164
+ throw new Error(`Question "${prompt}" enables multi-select but does not provide any options.`);
165
+ }
166
+ if (multi && maxSelections < minSelections) {
167
+ throw new Error(`Question "${prompt}" has minSelections greater than maxSelections.`);
168
+ }
169
+ if (multi && maxSelections > options.length) {
170
+ throw new Error(`Question "${prompt}" has maxSelections larger than the number of options.`);
171
+ }
172
+ if (
173
+ !multi &&
174
+ options.length > 0 &&
175
+ question.recommendedCount !== undefined &&
176
+ question.recommendedCount !== 1
177
+ ) {
178
+ throw new Error(`Question "${prompt}" must use recommendedCount: 1 for single-select options.`);
179
+ }
180
+ if (multi && recommendedCount !== undefined && recommendedCount < minSelections) {
181
+ throw new Error(
182
+ `Question "${prompt}" must have recommendedCount greater than or equal to minSelections.`,
183
+ );
184
+ }
185
+ if (recommendedCount !== undefined && recommendedCount > options.length) {
186
+ throw new Error(`Question "${prompt}" has recommendedCount larger than the number of options.`);
187
+ }
188
+ if (options.length > 0 && recommendedCount === undefined) {
189
+ throw new Error(
190
+ `Question "${prompt}" must provide recommended options at the top of the list.`,
191
+ );
192
+ }
193
+ if (options.length > 0 && recommendedCount !== undefined && recommendedCount < 1) {
194
+ throw new Error(
195
+ `Question "${prompt}" must recommend at least one option when options are provided.`,
196
+ );
197
+ }
198
+
199
+ return {
200
+ prompt,
201
+ reason: question.reason?.trim(),
202
+ options,
203
+ placeholder: question.placeholder.trim(),
204
+ multi,
205
+ recommendedCount,
206
+ minSelections,
207
+ maxSelections,
208
+ };
209
+ }
210
+
211
+ function getRecommendedCount(question: {
212
+ options: QuestionOption[];
213
+ multi: boolean;
214
+ minSelections: number;
215
+ recommendedCount?: number;
216
+ }): number | undefined {
217
+ if (question.options.length === 0) return;
218
+ if (!question.multi) return 1;
219
+ return question.recommendedCount ?? question.minSelections;
220
+ }
@@ -0,0 +1,67 @@
1
+ import {
2
+ defineTool,
3
+ type ExtensionAPI,
4
+ type ExtensionContext,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { askQuestions } from "./helpers.js";
7
+ import type { Question, QuestionnaireResult } from "./types.js";
8
+ import { renderCallText } from "../utils.js";
9
+ import { QuestionnaireParamsSchema } from "./schemas.js";
10
+
11
+ export function createQuestionnaireTool(parentCtx?: ExtensionContext) {
12
+ return defineTool({
13
+ name: "questionnaire",
14
+ label: "Questionnaire",
15
+ description:
16
+ "Ask user focused clarifying question(s) when answer changes next step. Prefer over guessing.",
17
+ promptSnippet:
18
+ "Clarify material unknowns with user. Ask 1 question or a small batch before committing.",
19
+ promptGuidelines: [
20
+ "Use when answer changes design, safety, scope, or next step.",
21
+ "If multiple viable approaches remain, ask before choosing.",
22
+ "If user invites questions, lower threshold.",
23
+ "Ask 1 focused question or a small related batch.",
24
+ "Prefer questionnaire over plain-text follow-up when UI is available.",
25
+ "Do not ask what repo or prior answers already provide.",
26
+ ],
27
+ parameters: QuestionnaireParamsSchema,
28
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
29
+ const interactionCtx = parentCtx ?? ctx;
30
+ if (!interactionCtx.hasUI) {
31
+ return {
32
+ content: [{ type: "text", text: "Questionnaire requires an interactive UI." }],
33
+ details: { cancelled: true, questions: [], answers: [] } satisfies QuestionnaireResult,
34
+ };
35
+ }
36
+
37
+ const result = await askQuestions(params.questions, interactionCtx.ui);
38
+ if (result.cancelled) {
39
+ return {
40
+ content: [{ type: "text", text: "User cancelled the questionnaire." }],
41
+ details: result,
42
+ };
43
+ }
44
+
45
+ const content = result.questions
46
+ .map(
47
+ (question, idx) => `Q${idx + 1}: ${question}\nA${idx + 1}: ${result.answers[idx] ?? ""}`,
48
+ )
49
+ .join("\n\n");
50
+
51
+ return {
52
+ content: [{ type: "text", text: content }],
53
+ details: result,
54
+ };
55
+ },
56
+ renderCall(args, theme, { isPartial }) {
57
+ const questions = ((args.questions as Question[] | undefined) ?? []).filter(Boolean);
58
+ const count = questions.length;
59
+ let text = `${theme.fg("toolTitle", "questionnaire")} ${theme.fg("muted", `${count} question${count === 1 ? "" : "s"}`)}`;
60
+ return renderCallText(text, isPartial);
61
+ },
62
+ });
63
+ }
64
+
65
+ export default function (pi: ExtensionAPI) {
66
+ pi.registerTool(createQuestionnaireTool());
67
+ }
@@ -0,0 +1,50 @@
1
+ import Type from "typebox";
2
+
3
+ export const QuestionOptionSchema = Type.Object({
4
+ text: Type.String({ description: "Option text" }),
5
+ description: Type.Optional(Type.String({ description: "Option helper text" })),
6
+ exclusive: Type.Optional(
7
+ Type.Boolean({
8
+ description: "If selected, clears other options",
9
+ }),
10
+ ),
11
+ });
12
+
13
+ export const QuestionSchema = Type.Object({
14
+ prompt: Type.String({ description: "Question text" }),
15
+ reason: Type.Optional(Type.String({ description: "Why answer matters" })),
16
+ options: Type.Optional(Type.Array(QuestionOptionSchema, { description: "Suggested options" })),
17
+ placeholder: Type.String({
18
+ description: "Freeform input placeholder",
19
+ default: "Type your answer",
20
+ }),
21
+ multi: Type.Optional(Type.Boolean({ description: "Allow multiple options" })),
22
+ recommendedCount: Type.Optional(
23
+ Type.Integer({
24
+ minimum: 1,
25
+ description: "Recommended options count",
26
+ }),
27
+ ),
28
+ minSelections: Type.Optional(
29
+ Type.Integer({ minimum: 0, description: "Min options to select (if multi=true)" }),
30
+ ),
31
+ maxSelections: Type.Optional(
32
+ Type.Integer({ minimum: 1, description: "Max options to select (if multi=true)" }),
33
+ ),
34
+ });
35
+
36
+ export const QuestionnaireParamsSchema = Type.Object({
37
+ questions: Type.Array(QuestionSchema, {
38
+ minItems: 1,
39
+ description: "Questions to ask",
40
+ }),
41
+ });
42
+
43
+ export const QuestionnaireResultSchema = Type.Object({
44
+ cancelled: Type.Boolean({
45
+ description: "True if user cancelled questionnaire tool",
46
+ }),
47
+ answers: Type.Array(Type.String(), {
48
+ description: "Final answers in questions order",
49
+ }),
50
+ });