@selesai/code 0.5.21 → 0.5.23

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 (56) hide show
  1. package/README.md +1 -1
  2. package/dist/core/agent-session-skill-block.test.d.ts +2 -0
  3. package/dist/core/agent-session-skill-block.test.d.ts.map +1 -0
  4. package/dist/core/agent-session-skill-block.test.js +20 -0
  5. package/dist/core/agent-session-skill-block.test.js.map +1 -0
  6. package/dist/core/agent-session.d.ts +7 -4
  7. package/dist/core/agent-session.d.ts.map +1 -1
  8. package/dist/core/agent-session.js +22 -12
  9. package/dist/core/agent-session.js.map +1 -1
  10. package/dist/core/skills.d.ts.map +1 -1
  11. package/dist/core/skills.js +1 -1
  12. package/dist/core/skills.js.map +1 -1
  13. package/dist/data/capabilities.d.ts +48 -0
  14. package/dist/data/capabilities.d.ts.map +1 -0
  15. package/dist/data/capabilities.js +282 -0
  16. package/dist/data/capabilities.js.map +1 -0
  17. package/dist/data/navigation.d.ts +3 -0
  18. package/dist/data/navigation.d.ts.map +1 -0
  19. package/dist/data/navigation.js +90 -0
  20. package/dist/data/navigation.js.map +1 -0
  21. package/dist/extensions/inline-skills.test.ts +96 -0
  22. package/dist/extensions/inline-skills.ts +92 -0
  23. package/dist/extensions/package.json +1 -0
  24. package/dist/extensions/pi-powerline-footer/tests/tps.test.ts +39 -0
  25. package/dist/extensions/pi-powerline-footer/tps.ts +172 -113
  26. package/dist/extensions/pi-subagents/src/tui/fleet.ts +0 -4
  27. package/dist/extensions/pi-subagents/test/integration/slash-commands.test.ts +1 -1
  28. package/dist/extensions/pi-subagents/test/unit/fleet.test.ts +1 -1
  29. package/dist/extensions/question/batch.ts +89 -25
  30. package/dist/extensions/question/constants.ts +1 -1
  31. package/dist/extensions/question/helpers.ts +34 -78
  32. package/dist/extensions/question/index.ts +327 -564
  33. package/dist/extensions/question/package.json +2 -1
  34. package/dist/extensions/question/question-list.ts +21 -14
  35. package/dist/extensions/question/row-layout.ts +2 -2
  36. package/dist/extensions/question/schemas.ts +33 -47
  37. package/dist/extensions/question/selection-mode.ts +5 -7
  38. package/dist/extensions/question/tests/batch.test.ts +65 -16
  39. package/dist/extensions/question/tests/helpers.test.ts +31 -156
  40. package/dist/extensions/question/tests/question-list.test.ts +17 -3
  41. package/dist/extensions/question/tests/row-layout.test.ts +4 -4
  42. package/dist/extensions/question/tests/schemas.test.ts +30 -0
  43. package/dist/extensions/question/tests/wizard.test.ts +99 -0
  44. package/dist/extensions/question/types.ts +65 -66
  45. package/dist/extensions/workflow/adapter.ts +20 -77
  46. package/dist/index.d.ts +1 -1
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +1 -1
  49. package/dist/index.js.map +1 -1
  50. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  51. package/dist/modes/interactive/interactive-mode.js +10 -10
  52. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  53. package/dist/skills/workflow-creation/SKILL.md +2 -2
  54. package/docs/skills.md +8 -0
  55. package/docs/workflows.md +4 -4
  56. package/package.json +1 -1
@@ -1,6 +1,4 @@
1
- // Question extension entry point.
2
- // Owns: QuestionComponent (mode-switching + editor lifecycle + box borders),
3
- // tool registration, execute() orchestration via UIProtocol.
1
+ // Batch-first interactive question tool.
4
2
 
5
3
  import type { ExtensionAPI, KeybindingsManager, Theme } from "@selesai/code";
6
4
  import {
@@ -9,381 +7,364 @@ import {
9
7
  Editor,
10
8
  type EditorTheme,
11
9
  Key,
12
- Markdown,
13
10
  matchesKey,
14
11
  Spacer,
15
12
  Text,
16
13
  type TUI,
17
14
  truncateToWidth,
15
+ wrapTextWithAnsi,
18
16
  } from "@earendil-works/pi-tui";
19
17
 
20
- import { prepareBatchQuestions } from "./batch.ts";
21
- import { BOX_BORDER_LEFT, BOX_BORDER_OVERHEAD, BOX_BORDER_RIGHT, QUESTION_VERSION } from "./constants.ts";
22
- import { askBatchViaDialogs, DialogFallback } from "./dialog-adapter.ts";
23
- import { createFreeformResponse, createSelectionResponse, formatOptionsForMessage, formatResponseSummary, getOptionsFormatError, normalizeOptions, oneLine, safeMarkdownTheme } from "./helpers.ts";
18
+ import { buildQuestionAnswers, prepareQuestions, saveDraftQuestion } from "./batch.ts";
19
+ import { BOX_BORDER_LEFT, BOX_BORDER_OVERHEAD, BOX_BORDER_RIGHT, DISABLED_SHORTCUT, QUESTION_VERSION } from "./constants.ts";
20
+ import { createSelectionResponse, createTextResponse, formatQuestionResponse, formatResponseSummary, oneLine, previewText, trimText } from "./helpers.ts";
24
21
  import { QuestionList } from "./question-list.ts";
25
22
  import { MultiSelect, SingleSelect } from "./selection-mode.ts";
26
- import { resolveShortcuts } from "./shortcuts.ts";
27
23
  import { QuestionParamsSchema } from "./schemas.ts";
28
- import type { BatchAnswer, BatchQuestionDetails, BatchedQuestion, QuestionDetails, QuestionMode, QuestionOption, QuestionResponse, RawOption, ResolvedShortcuts } from "./types.ts";
29
- import { createTUIProtocol } from "./tui-adapter.ts";
30
- import type { CustomFactory, UIProtocol } from "./ui-protocol.ts";
31
-
32
- // ---------------------------------------------------------------------------
33
- // Box borders (manual ANSI components, self-contained)
34
- // ---------------------------------------------------------------------------
24
+ import type {
25
+ DraftQuestionState,
26
+ PreparedQuestion,
27
+ QuestionAnswer,
28
+ QuestionMode,
29
+ QuestionResponse,
30
+ QuestionToolResult,
31
+ RawQuestion,
32
+ } from "./types.ts";
35
33
 
36
34
  class BoxBorderTop implements Component {
37
35
  constructor(
38
- private color: (s: string) => string,
39
- private title?: string,
40
- private titleColor?: (s: string) => string,
36
+ private color: (value: string) => string,
37
+ private title: string,
38
+ private titleColor: (value: string) => string,
41
39
  ) {}
42
40
  invalidate(): void {}
43
41
  render(width: number): string[] {
44
42
  const inner = Math.max(0, width - 2);
45
- if (!this.title || inner < this.title.length + 4) {
46
- return [this.color(`╭${"─".repeat(inner)}╮`)];
47
- }
48
43
  const label = ` ${this.title} `;
49
- const remaining = inner - 1 - label.length;
50
- const titleStyle = this.titleColor ?? this.color;
51
- return [this.color("╭─") + titleStyle(label) + this.color("─".repeat(Math.max(0, remaining)) + "╮")];
44
+ if (inner < label.length + 2) return [this.color(`╭${"─".repeat(inner)}╮`)];
45
+ return [this.color("╭─") + this.titleColor(label) + this.color(`${"─".repeat(Math.max(0, inner - label.length - 1))}╮`)];
52
46
  }
53
47
  }
54
48
 
55
49
  class BoxBorderBottom implements Component {
56
50
  constructor(
57
- private color: (s: string) => string,
58
- private label?: string,
59
- private labelColor?: (s: string) => string,
51
+ private color: (value: string) => string,
52
+ private label: string,
53
+ private labelColor: (value: string) => string,
60
54
  ) {}
61
55
  invalidate(): void {}
62
56
  render(width: number): string[] {
63
57
  const inner = Math.max(0, width - 2);
64
- if (!this.label || inner < this.label.length + 4) {
65
- return [this.color(`╰${"─".repeat(inner)}╯`)];
66
- }
67
- const tag = ` ${this.label} `;
68
- const leftDashes = inner - tag.length - 1;
69
- const style = this.labelColor ?? this.color;
70
- return [this.color("╰" + "─".repeat(Math.max(0, leftDashes))) + style(tag) + this.color("─╯")];
58
+ const label = ` ${this.label} `;
59
+ if (inner < label.length + 2) return [this.color(`╰${"─".repeat(inner)}╯`)];
60
+ return [this.color(`╰${"─".repeat(Math.max(0, inner - label.length - 1))}`) + this.labelColor(label) + this.color("─╯")];
71
61
  }
72
62
  }
73
63
 
74
- function createEditorTheme(theme: Theme): EditorTheme {
64
+ function editorTheme(theme: Theme): EditorTheme {
75
65
  return {
76
- borderColor: (s) => theme.fg("accent", s),
66
+ borderColor: (value) => theme.fg("accent", value),
77
67
  selectList: {
78
- selectedPrefix: (t) => theme.fg("accent", t),
79
- selectedText: (t) => theme.fg("accent", t),
80
- description: (t) => theme.fg("muted", t),
81
- scrollInfo: (t) => theme.fg("dim", t),
82
- noMatch: (t) => theme.fg("warning", t),
68
+ selectedPrefix: (value) => theme.fg("accent", value),
69
+ selectedText: (value) => theme.fg("accent", value),
70
+ description: (value) => theme.fg("muted", value),
71
+ scrollInfo: (value) => theme.fg("dim", value),
72
+ noMatch: (value) => theme.fg("warning", value),
83
73
  },
84
74
  };
85
75
  }
86
76
 
87
- // ---------------------------------------------------------------------------
88
- // Question component (Container-based layout)
89
- // ---------------------------------------------------------------------------
90
-
91
77
  class QuestionComponent extends Container implements Component {
92
- private mode: QuestionMode = "select";
93
- private pendingSelections: string[] = [];
94
- private freeformDraft = "";
95
- private commentDraft = "";
96
-
97
- private titleText: Text;
98
- private questionText: Text;
99
- private contextComponent?: Component;
100
- private modeContainer: Container;
101
- private helpText: Text;
102
-
78
+ private mode: QuestionMode;
79
+ private selectionValues: string[] = [];
80
+ private otherDraft = "";
103
81
  private list?: QuestionList;
104
82
  private editor?: Editor;
105
-
106
- private _focused = false;
83
+ private editorActive = true;
84
+ private focusedState = false;
85
+ private answerText = new Text("", 1, 0);
86
+ private modeContainer = new Container();
87
+ private helpText = new Text("", 1, 0);
107
88
 
108
89
  constructor(
109
- private question: string,
110
- private context: string | undefined,
111
- private options: QuestionOption[],
112
- private allowMultiple: boolean,
113
- private allowFreeform: boolean,
114
- private allowComment: boolean,
90
+ private question: PreparedQuestion,
115
91
  private tui: TUI,
116
92
  private theme: Theme,
117
93
  private keybindings: KeybindingsManager,
118
- private shortcuts: ResolvedShortcuts,
119
- private onDone: (result: QuestionResponse | null) => void,
94
+ private onCancel: () => void,
95
+ initialResponse?: QuestionResponse,
120
96
  ) {
121
97
  super();
122
-
123
- this.addChild(
124
- new BoxBorderTop(
125
- (s: string) => theme.fg("accent", s),
126
- "question",
127
- (s: string) => theme.fg("dim", theme.bold(s)),
128
- ),
129
- );
130
- this.addChild(new Spacer(1));
131
-
132
- this.titleText = new Text("", 1, 0);
133
- this.addChild(this.titleText);
98
+ this.mode = question.type === "text" ? "text" : "select";
99
+ this.addChild(new BoxBorderTop((value) => theme.fg("accent", value), "question", (value) => theme.fg("dim", theme.bold(value))));
134
100
  this.addChild(new Spacer(1));
135
-
136
- this.questionText = new Text("", 1, 0);
137
- this.addChild(this.questionText);
138
-
139
- if (this.context) {
101
+ this.addChild(new Text(theme.fg("text", theme.bold(question.question)), 1, 0));
102
+ if (question.context) {
140
103
  this.addChild(new Spacer(1));
141
- const mdTheme = safeMarkdownTheme();
142
- if (mdTheme) {
143
- this.contextComponent = new Markdown(`**Context:**\n${this.context}`, 1, 0, mdTheme);
144
- } else {
145
- this.contextComponent = new Text(`${theme.fg("accent", theme.bold("Context:"))}\n${theme.fg("dim", this.context)}`, 1, 0);
146
- }
147
- this.addChild(this.contextComponent);
104
+ this.addChild(new Text(`${theme.fg("accent", theme.bold("Context:"))}\n${theme.fg("dim", question.context)}`, 1, 0));
148
105
  }
149
-
150
106
  this.addChild(new Spacer(1));
151
-
152
- this.modeContainer = new Container();
107
+ this.addChild(this.answerText);
108
+ this.addChild(new Spacer(1));
153
109
  this.addChild(this.modeContainer);
154
-
155
110
  this.addChild(new Spacer(1));
156
- this.helpText = new Text("", 1, 0);
157
111
  this.addChild(this.helpText);
158
-
159
112
  this.addChild(new Spacer(1));
160
- this.addChild(new BoxBorderBottom((s: string) => theme.fg("accent", s), `v${QUESTION_VERSION}`, (s: string) => theme.fg("dim", s)));
161
-
162
- this.updateStaticText();
163
- this.showSelectMode();
113
+ this.addChild(new BoxBorderBottom((value) => theme.fg("accent", value), `v${QUESTION_VERSION}`, (value) => theme.fg("dim", value)));
114
+ this.restore(initialResponse);
115
+ this.rebuildMode();
164
116
  }
165
117
 
166
118
  get focused(): boolean {
167
- return this._focused;
168
- }
169
-
170
- get isEditing(): boolean {
171
- return this.mode === "freeform" || this.mode === "comment";
119
+ return this.focusedState;
172
120
  }
173
121
  set focused(value: boolean) {
174
- this._focused = value;
175
- if (this.editor && (this.mode === "freeform" || this.mode === "comment")) {
176
- (this.editor as any).focused = value;
177
- }
122
+ this.focusedState = value;
123
+ if (this.editor) this.editor.focused = value && this.editorActive;
178
124
  }
179
125
 
180
- override invalidate(): void {
181
- super.invalidate();
182
- this.updateStaticText();
183
- this.updateHelpText();
126
+ get isEditing(): boolean {
127
+ return (this.mode === "text" || this.mode === "other") && this.editorActive;
184
128
  }
185
129
 
186
130
  override render(width: number): string[] {
187
131
  const innerWidth = Math.max(1, width - BOX_BORDER_OVERHEAD);
188
- const rawLines = super.render(innerWidth);
189
- const borderColor = (s: string) => this.theme.fg("accent", s);
190
- const titleColor = (s: string) => this.theme.fg("dim", this.theme.bold(s));
191
- return rawLines.map((line, index) => {
192
- if (index === 0) return new BoxBorderTop(borderColor, "question", titleColor).render(width)[0];
193
- if (index === rawLines.length - 1)
194
- return new BoxBorderBottom(borderColor, `v${QUESTION_VERSION}`, (s: string) => this.theme.fg("dim", s)).render(width)[0];
195
- const padded = truncateToWidth(line, innerWidth, "", true);
196
- return `${borderColor(BOX_BORDER_LEFT)}${padded}${borderColor(BOX_BORDER_RIGHT)}`;
132
+ const lines = super.render(innerWidth);
133
+ const border = (value: string) => this.theme.fg("accent", value);
134
+ return lines.map((line, index) => {
135
+ if (index === 0) return new BoxBorderTop(border, "question", (value) => this.theme.fg("dim", this.theme.bold(value))).render(width)[0]!;
136
+ if (index === lines.length - 1) return new BoxBorderBottom(border, `v${QUESTION_VERSION}`, (value) => this.theme.fg("dim", value)).render(width)[0]!;
137
+ return `${border(BOX_BORDER_LEFT)}${truncateToWidth(line, innerWidth, "", true)}${border(BOX_BORDER_RIGHT)}`;
197
138
  });
198
139
  }
199
140
 
200
- private updateStaticText(): void {
201
- const theme = this.theme;
202
- const title = this.mode === "comment" ? "Optional comment" : "Question";
203
- this.titleText.setText(theme.fg("accent", theme.bold(title)));
204
- this.questionText.setText(theme.fg("text", theme.bold(this.question)));
205
- if (this.contextComponent && this.context) {
206
- const mdTheme = safeMarkdownTheme();
207
- if (mdTheme && this.contextComponent instanceof Markdown) {
208
- (this.contextComponent as Markdown).setText(`**Context:**\n${this.context}`);
209
- } else {
210
- (this.contextComponent as Text).setText(`${theme.fg("accent", theme.bold("Context:"))}\n${theme.fg("dim", this.context)}`);
211
- }
212
- }
141
+ override invalidate(): void {
142
+ super.invalidate();
143
+ this.updateAnswerText();
144
+ this.updateHelp();
213
145
  }
214
146
 
215
- private updateHelpText(): void {
216
- const theme = this.theme;
217
- const kb = this.keybindings;
218
- const commentHint =
219
- this.allowComment && !this.shortcuts.commentToggle.disabled
220
- ? `${theme.fg("dim", this.shortcuts.commentToggle.spec)}${theme.fg("muted", " toggle context")}`
221
- : null;
222
-
223
- if (this.mode === "freeform" || this.mode === "comment") {
224
- const hints = [
225
- `${theme.fg("dim", kb.getKeys("tui.input.submit").join("/"))}${theme.fg("muted", this.mode === "comment" ? " submit/skip" : " submit")}`,
226
- `${theme.fg("dim", kb.getKeys("tui.input.newLine").join("/"))}${theme.fg("muted", " newline")}`,
227
- `${theme.fg("dim", "esc")}${theme.fg("muted", " back")}`,
228
- ]
229
- .filter((h): h is string => !!h)
230
- .join(" • ");
231
- this.helpText.setText(theme.fg("dim", hints));
147
+ private restore(response?: QuestionResponse): void {
148
+ if (!response) return;
149
+ if (response.kind === "text") {
150
+ this.mode = "text";
151
+ this.ensureEditor().setText(response.text);
232
152
  return;
233
153
  }
234
-
235
- if (this.allowMultiple) {
236
- const hints = [
237
- `${theme.fg("dim", "↑↓")}${theme.fg("muted", " navigate")}`,
238
- `${theme.fg("dim", "space")}${theme.fg("muted", " toggle")}`,
239
- commentHint,
240
- `${theme.fg("dim", kb.getKeys("tui.select.confirm").join("/"))}${theme.fg("muted", " submit")}`,
241
- `${theme.fg("dim", kb.getKeys("tui.select.cancel").join("/"))}${theme.fg("muted", " cancel")}`,
242
- ]
243
- .filter((h): h is string => !!h)
244
- .join(" • ");
245
- this.helpText.setText(theme.fg("dim", hints));
246
- } else {
247
- const hints = [
248
- `${theme.fg("dim", "type")}${theme.fg("muted", " filter")}`,
249
- `${theme.fg("dim", kb.getKeys("tui.editor.deleteCharBackward").join("/"))}${theme.fg("muted", " erase")}`,
250
- `${theme.fg("dim", "↑↓")}${theme.fg("muted", " navigate")}`,
251
- commentHint,
252
- `${theme.fg("dim", kb.getKeys("tui.select.confirm").join("/"))}${theme.fg("muted", " select")}`,
253
- `${theme.fg("dim", "esc")}${theme.fg("muted", " clear/cancel")}`,
254
- ]
255
- .filter((h): h is string => !!h)
256
- .join(" • ");
257
- this.helpText.setText(theme.fg("dim", hints));
154
+ this.selectionValues = [...response.values];
155
+ this.otherDraft = response.otherText ?? "";
156
+ this.ensureList().restoreSelection(response.values);
157
+ if (response.otherText) {
158
+ this.mode = "other";
159
+ this.ensureEditor().setText(response.otherText);
258
160
  }
259
161
  }
260
162
 
261
163
  private ensureList(): QuestionList {
262
164
  if (this.list) return this.list;
165
+ const multi = this.question.type === "multiselect";
263
166
  const list = new QuestionList(
264
- this.options,
265
- this.allowMultiple ? MultiSelect : SingleSelect,
266
- this.allowFreeform,
267
- this.allowComment,
167
+ this.question.options,
168
+ multi ? MultiSelect : SingleSelect,
169
+ this.question.allowOther,
170
+ false,
268
171
  this.theme,
269
172
  this.keybindings,
270
- this.shortcuts.commentToggle,
173
+ DISABLED_SHORTCUT,
271
174
  );
272
- list.onSubmit = (selections, commentEnabled) => this.handleSelectionSubmit(selections, commentEnabled);
273
- list.onCancel = () => this.onDone(null);
274
- list.onEnterFreeform = () => this.showFreeformMode();
175
+ list.onSubmit = (values) => {
176
+ this.selectionValues = values;
177
+ if (!multi) this.otherDraft = "";
178
+ this.updateAnswerText();
179
+ this.tui.requestRender();
180
+ };
181
+ list.onEnterFreeform = () => this.showOther();
182
+ list.onCancel = this.onCancel;
275
183
  this.list = list;
276
184
  return list;
277
185
  }
278
186
 
279
187
  private ensureEditor(): Editor {
280
188
  if (this.editor) return this.editor;
281
- const editor = new Editor(this.tui, createEditorTheme(this.theme));
282
- editor.onSubmit = (text: string) => this.handleEditorSubmit(text);
189
+ const editor = new Editor(this.tui, editorTheme(this.theme));
190
+ editor.onChange = () => {
191
+ this.updateAnswerText();
192
+ this.tui.requestRender();
193
+ };
194
+ editor.onSubmit = (text) => {
195
+ editor.setText(text.trim());
196
+ this.updateAnswerText();
197
+ this.tui.requestRender();
198
+ };
283
199
  this.editor = editor;
284
200
  return editor;
285
201
  }
286
202
 
287
- private handleSelectionSubmit(selections: string[], wantsComment: boolean): void {
288
- if (this.allowComment && wantsComment) {
289
- this.pendingSelections = selections;
290
- this.commentDraft = "";
291
- this.showCommentMode();
292
- return;
293
- }
294
- this.onDone(createSelectionResponse(selections) ?? null);
203
+ private showOther(): void {
204
+ this.mode = "other";
205
+ this.editorActive = true;
206
+ const editor = this.ensureEditor();
207
+ editor.setText(this.otherDraft);
208
+ this.rebuildMode();
295
209
  }
296
210
 
297
- private handleEditorSubmit(text: string): void {
298
- if (this.mode === "freeform") {
299
- const r = createFreeformResponse(text);
300
- this.onDone(r ?? null);
301
- return;
302
- }
303
- if (this.mode === "comment") {
304
- this.commentDraft = text;
305
- this.onDone(createSelectionResponse(this.pendingSelections, text) ?? null);
306
- }
211
+ private showSelect(): void {
212
+ if (this.mode === "other") this.otherDraft = this.ensureEditor().getText();
213
+ this.mode = "select";
214
+ this.editorActive = false;
215
+ this.rebuildMode();
307
216
  }
308
217
 
309
- private showSelectMode(): void {
310
- this.mode = "select";
311
- this.pendingSelections = [];
218
+ private rebuildMode(): void {
312
219
  this.modeContainer.clear();
313
- this.modeContainer.addChild(this.ensureList());
314
- this.updateHelpText();
220
+ if (this.mode === "select") {
221
+ this.modeContainer.addChild(this.ensureList());
222
+ } else {
223
+ const editor = this.ensureEditor();
224
+ editor.focused = this.focusedState && this.editorActive;
225
+ this.modeContainer.addChild(new Text(this.theme.fg("accent", this.theme.bold(this.mode === "text" ? "Answer" : "Other answer")), 1, 0));
226
+ this.modeContainer.addChild(new Spacer(1));
227
+ this.modeContainer.addChild(editor);
228
+ }
229
+ this.updateAnswerText();
230
+ this.updateHelp();
315
231
  this.invalidate();
316
232
  this.tui.requestRender();
317
233
  }
318
234
 
319
- private showFreeformMode(): void {
320
- this.mode = "freeform";
321
- this.modeContainer.clear();
322
- const editor = this.ensureEditor();
323
- (editor as any).setText?.(this.freeformDraft);
324
- (editor as any).focused = this._focused;
325
- this.modeContainer.addChild(new Text(this.theme.fg("accent", this.theme.bold("Custom response")), 1, 0));
326
- this.modeContainer.addChild(new Spacer(1));
327
- this.modeContainer.addChild(editor);
328
- this.updateHelpText();
329
- this.invalidate();
330
- this.tui.requestRender();
235
+ private updateAnswerText(): void {
236
+ const response = this.getResponse();
237
+ this.answerText.setText(
238
+ response
239
+ ? `${this.theme.fg("success", "Draft:")} ${this.theme.fg("text", oneLine(formatQuestionResponse(this.question, response), 160))}`
240
+ : this.theme.fg("dim", "No answer yet"),
241
+ );
331
242
  }
332
243
 
333
- private showCommentMode(): void {
334
- this.mode = "comment";
335
- this.modeContainer.clear();
336
- const editor = this.ensureEditor();
337
- (editor as any).setText?.(this.commentDraft);
338
- (editor as any).focused = this._focused;
339
- const label = this.pendingSelections.length === 1 ? "Selected option:" : "Selected options:";
340
- this.modeContainer.addChild(new Text(this.theme.fg("accent", this.theme.bold(label)), 1, 0));
341
- this.modeContainer.addChild(new Text(this.theme.fg("text", this.pendingSelections.join(", ")), 1, 0));
342
- this.modeContainer.addChild(new Spacer(1));
343
- this.modeContainer.addChild(editor);
344
- this.updateHelpText();
345
- this.invalidate();
346
- this.tui.requestRender();
244
+ private updateHelp(): void {
245
+ if (this.mode === "select") {
246
+ const multi = this.question.type === "multiselect";
247
+ this.helpText.setText(
248
+ this.theme.fg(
249
+ "dim",
250
+ multi
251
+ ? "↑↓ navigate • space toggle • enter save selection • Tab/→ next • Shift+Tab/← previous • esc cancel"
252
+ : "type filter • ↑↓ navigate • enter save choice • Tab/→ next • Shift+Tab/← previous • esc cancel",
253
+ ),
254
+ );
255
+ return;
256
+ }
257
+ this.helpText.setText(
258
+ this.theme.fg(
259
+ "dim",
260
+ `${this.keybindings.getKeys("tui.input.submit").join("/")} save • ${this.keybindings.getKeys("tui.input.newLine").join("/")} newline • Tab next • Shift+Tab previous • esc ${this.mode === "other" ? "choices" : "blur"}`,
261
+ ),
262
+ );
263
+ }
264
+
265
+ getResponse(): QuestionResponse | null {
266
+ if (this.question.type === "text") return createTextResponse(this.ensureEditor().getText());
267
+ const values = this.question.type === "multiselect" ? this.ensureList().getCheckedValues() : this.selectionValues;
268
+ const otherText = this.mode === "other" ? this.ensureEditor().getText() : this.otherDraft;
269
+ return createSelectionResponse(this.question.type === "select" && trimText(otherText) ? [] : values, otherText);
347
270
  }
348
271
 
349
272
  handleInput(data: string): void {
350
- if (this.mode === "freeform" || this.mode === "comment") {
351
- if (matchesKey(data, Key.escape)) {
352
- this.showSelectMode();
353
- return;
354
- }
355
- if (this.keybindings.matches(data, "tui.select.cancel")) {
356
- this.onDone(null);
357
- return;
358
- }
359
- this.ensureEditor().handleInput(data);
273
+ if (this.mode === "other" && matchesKey(data, Key.escape)) {
274
+ this.showSelect();
275
+ return;
276
+ }
277
+ if (this.mode === "text" && matchesKey(data, Key.escape) && this.editorActive) {
278
+ this.editorActive = false;
279
+ this.ensureEditor().focused = false;
280
+ this.updateHelp();
360
281
  this.tui.requestRender();
361
282
  return;
362
283
  }
363
- this.ensureList().handleInput(data);
284
+ if ((this.mode === "text" || this.mode === "other") && !this.editorActive) {
285
+ if (matchesKey(data, Key.enter)) {
286
+ this.editorActive = true;
287
+ this.ensureEditor().focused = this.focusedState;
288
+ this.tui.requestRender();
289
+ }
290
+ return;
291
+ }
292
+ if (this.mode === "text" || this.mode === "other") this.ensureEditor().handleInput(data);
293
+ else this.ensureList().handleInput(data);
364
294
  this.tui.requestRender();
365
295
  }
366
296
  }
367
297
 
368
- // ---------------------------------------------------------------------------
369
- // Batched-question component
370
- // ---------------------------------------------------------------------------
298
+ class ReviewComponent implements Component {
299
+ private focusedState = false;
300
+
301
+ constructor(
302
+ private questions: PreparedQuestion[],
303
+ private states: ReadonlyMap<string, DraftQuestionState>,
304
+ private theme: Theme,
305
+ private onBack: () => void,
306
+ private onCancel: () => void,
307
+ private onSubmit: () => void,
308
+ ) {}
309
+
310
+ get focused(): boolean {
311
+ return this.focusedState;
312
+ }
313
+ set focused(value: boolean) {
314
+ this.focusedState = value;
315
+ }
316
+
317
+ invalidate(): void {}
318
+
319
+ render(width: number): string[] {
320
+ const safeWidth = Math.max(12, width);
321
+ const lines: string[] = [];
322
+ for (const question of this.questions) {
323
+ const state = this.states.get(question.id);
324
+ const status = state?.status ?? "unanswered";
325
+ const marker = status === "answered" ? this.theme.fg("success", "✓") : status === "skipped" ? this.theme.fg("warning", "−") : this.theme.fg("dim", "○");
326
+ lines.push(truncateToWidth(`${marker} ${this.theme.fg("accent", this.theme.bold(question.label))} · ${this.theme.fg(status === "answered" ? "success" : status === "skipped" ? "warning" : "dim", status)}`, safeWidth, ""));
327
+ lines.push(...wrapTextWithAnsi(this.theme.fg("dim", question.question), safeWidth));
328
+ if (state?.status === "answered" && state.response) {
329
+ const preview = previewText(formatQuestionResponse(question, state.response));
330
+ lines.push(...wrapTextWithAnsi(`${this.theme.fg("dim", "Answer:")} ${this.theme.fg("text", preview)}`, safeWidth));
331
+ }
332
+ lines.push("");
333
+ }
334
+
335
+ const bodyBudget = Math.max(7, (process.stdout.rows || 24) - 9);
336
+ let body = lines;
337
+ if (lines.length > bodyBudget) {
338
+ body = lines.slice(-bodyBudget);
339
+ body[0] = this.theme.fg("dim", "↑ earlier answers");
340
+ }
341
+ const submit = this.theme.bg("selectedBg", this.theme.fg("text", " Submit "));
342
+ return [...body, truncateToWidth(submit, safeWidth, "")];
343
+ }
344
+
345
+ handleInput(data: string): void {
346
+ if (matchesKey(data, Key.enter)) this.onSubmit();
347
+ else if (matchesKey(data, Key.left)) this.onBack();
348
+ else if (matchesKey(data, Key.escape)) this.onCancel();
349
+ }
350
+ }
371
351
 
372
352
  class BatchQuestionComponent extends Container implements Component {
373
353
  private currentPage = 0;
374
- private readonly answers = new Map<string, QuestionResponse>();
354
+ private states = new Map<string, DraftQuestionState>();
375
355
  private questionComponent?: QuestionComponent;
356
+ private reviewComponent?: ReviewComponent;
376
357
  private focusedState = false;
377
358
  private header = new Text("", 1, 0);
378
359
  private content = new Container();
379
360
  private legend = new Text("", 1, 0);
380
361
 
381
362
  constructor(
382
- private questions: BatchedQuestion[],
363
+ private questions: PreparedQuestion[],
383
364
  private tui: TUI,
384
365
  private theme: Theme,
385
366
  private keybindings: KeybindingsManager,
386
- private onDone: (answers: BatchAnswer[] | null) => void,
367
+ private onDone: (result: QuestionToolResult) => void,
387
368
  ) {
388
369
  super();
389
370
  this.addChild(this.header);
@@ -400,6 +381,7 @@ class BatchQuestionComponent extends Container implements Component {
400
381
  set focused(value: boolean) {
401
382
  this.focusedState = value;
402
383
  if (this.questionComponent) this.questionComponent.focused = value;
384
+ if (this.reviewComponent) this.reviewComponent.focused = value;
403
385
  }
404
386
 
405
387
  override invalidate(): void {
@@ -411,391 +393,172 @@ class BatchQuestionComponent extends Container implements Component {
411
393
  return this.currentPage === this.questions.length;
412
394
  }
413
395
 
414
- private allAnswered(): boolean {
415
- return this.questions.every((question) => this.answers.has(question.id));
416
- }
396
+ private cancel = (): void => this.onDone({ status: "cancelled", reason: "user" });
417
397
 
418
398
  private updateChrome(): void {
419
- const answered = this.answers.size;
420
399
  if (this.isReviewPage) {
421
- this.header.setText(this.theme.fg("accent", this.theme.bold(`Review answers · ${answered}/${this.questions.length} answered`)));
422
- this.legend.setText(
423
- this.theme.fg(
424
- "dim",
425
- this.allAnswered()
426
- ? "Shift+Tab/← previous • Enter submit all answers • Esc cancel"
427
- : "Shift+Tab/← previous • Answer every question before submitting • Esc cancel",
428
- ),
429
- );
400
+ const answered = [...this.states.values()].filter((state) => state.status === "answered").length;
401
+ const skipped = [...this.states.values()].filter((state) => state.status === "skipped").length;
402
+ this.header.setText(this.theme.fg("accent", this.theme.bold(`Review · ${answered} answered · ${skipped} skipped · ${this.questions.length - this.states.size} unanswered`)));
403
+ this.legend.setText(this.theme.fg("dim", "Enter submit • ← edit answers • Esc cancel"));
430
404
  return;
431
405
  }
432
- const question = this.questions[this.currentPage];
433
- this.header.setText(
434
- this.theme.fg(
435
- "accent",
436
- this.theme.bold(`${question.label} · ${this.currentPage + 1}/${this.questions.length}${this.answers.has(question.id) ? " · answered" : ""}`),
437
- ),
438
- );
439
- this.legend.setText(this.theme.fg("dim", "Tab next • Shift+Tab previous • ←→ navigate outside text entry • Answers save when selected"));
406
+ const question = this.questions[this.currentPage]!;
407
+ const status = this.states.get(question.id)?.status ?? "unanswered";
408
+ this.header.setText(this.theme.fg("accent", this.theme.bold(`${question.label} · Question ${this.currentPage + 1} of ${this.questions.length} · ${status}`)));
409
+ this.legend.setText(this.theme.fg("dim", "Tab/→ next • Shift+Tab/← previous • blank Next skips • answers commit only on final Submit"));
440
410
  }
441
411
 
442
412
  private showPage(): void {
443
413
  this.content.clear();
444
414
  this.questionComponent = undefined;
445
- this.updateChrome();
446
-
415
+ this.reviewComponent = undefined;
447
416
  if (this.isReviewPage) {
448
- this.content.addChild(new Text(this.theme.fg("accent", this.theme.bold("Ready to submit")), 1, 0));
449
- this.content.addChild(new Spacer(1));
450
- for (const question of this.questions) {
451
- const response = this.answers.get(question.id);
452
- const marker = response ? this.theme.fg("success", "✓") : this.theme.fg("warning", "○");
453
- const answer = response ? formatResponseSummary(response) : "Unanswered";
454
- this.content.addChild(new Text(`${marker} ${this.theme.fg("accent", question.label)} ${this.theme.fg("dim", "—")} ${this.theme.fg(response ? "text" : "warning", answer)}`, 1, 0));
455
- }
456
- } else {
457
- const question = this.questions[this.currentPage];
458
- this.questionComponent = new QuestionComponent(
459
- question.question,
460
- question.context,
461
- question.options,
462
- question.allowMultiple,
463
- question.allowFreeform,
464
- question.allowComment,
465
- this.tui,
417
+ this.reviewComponent = new ReviewComponent(
418
+ this.questions,
419
+ this.states,
466
420
  this.theme,
467
- this.keybindings,
468
- question.shortcuts,
469
- (response) => this.saveAnswer(question, response),
421
+ () => {
422
+ this.currentPage = this.questions.length - 1;
423
+ this.showPage();
424
+ },
425
+ this.cancel,
426
+ () => this.onDone({ status: "submitted", answers: buildQuestionAnswers(this.questions, this.states) }),
470
427
  );
428
+ this.reviewComponent.focused = this.focusedState;
429
+ this.content.addChild(this.reviewComponent);
430
+ } else {
431
+ const question = this.questions[this.currentPage]!;
432
+ const state = this.states.get(question.id);
433
+ this.questionComponent = new QuestionComponent(question, this.tui, this.theme, this.keybindings, this.cancel, state?.response);
471
434
  this.questionComponent.focused = this.focusedState;
472
435
  this.content.addChild(this.questionComponent);
473
436
  }
437
+ this.updateChrome();
474
438
  this.invalidate();
475
439
  this.tui.requestRender();
476
440
  }
477
441
 
478
- private saveAnswer(question: BatchedQuestion, response: QuestionResponse | null): void {
479
- if (response === null) {
480
- this.onDone(null);
481
- return;
482
- }
483
- this.answers.set(question.id, response);
484
- this.goNext();
442
+ private saveCurrent(markBlankSkipped: boolean): void {
443
+ if (this.isReviewPage || !this.questionComponent) return;
444
+ const question = this.questions[this.currentPage]!;
445
+ const response = this.questionComponent.getResponse();
446
+ saveDraftQuestion(this.states, question.id, response, markBlankSkipped);
485
447
  }
486
448
 
487
449
  private goNext(): void {
450
+ this.saveCurrent(true);
488
451
  if (this.currentPage < this.questions.length) this.currentPage++;
489
452
  this.showPage();
490
453
  }
491
454
 
492
455
  private goPrevious(): void {
456
+ this.saveCurrent(false);
493
457
  if (this.currentPage > 0) this.currentPage--;
494
458
  this.showPage();
495
459
  }
496
460
 
497
461
  handleInput(data: string): void {
498
462
  if (this.isReviewPage) {
499
- if (matchesKey(data, Key.enter) && this.allAnswered()) {
500
- this.onDone(this.questions.map((question) => ({ id: question.id, question: question.question, response: this.answers.get(question.id)! })));
501
- return;
502
- }
503
- if (matchesKey(data, Key.escape) || this.keybindings.matches(data, "tui.select.cancel")) {
504
- this.onDone(null);
505
- return;
506
- }
507
- if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) this.goPrevious();
463
+ this.reviewComponent?.handleInput(data);
508
464
  return;
509
465
  }
510
-
511
- if (matchesKey(data, Key.tab)) {
466
+ if (matchesKey(data, Key.tab) || (!this.questionComponent?.isEditing && matchesKey(data, Key.right))) {
512
467
  this.goNext();
513
468
  return;
514
469
  }
515
- if (matchesKey(data, Key.shift("tab"))) {
470
+ if (matchesKey(data, Key.shift("tab")) || (!this.questionComponent?.isEditing && matchesKey(data, Key.left))) {
516
471
  this.goPrevious();
517
472
  return;
518
473
  }
519
- if (!this.questionComponent?.isEditing) {
520
- if (matchesKey(data, Key.right)) {
521
- this.goNext();
522
- return;
523
- }
524
- if (matchesKey(data, Key.left)) {
525
- this.goPrevious();
526
- return;
527
- }
474
+ if (!this.questionComponent?.isEditing && matchesKey(data, Key.escape) && this.questions[this.currentPage]?.type === "text") {
475
+ this.cancel();
476
+ return;
528
477
  }
529
478
  this.questionComponent?.handleInput(data);
530
479
  }
531
480
  }
532
481
 
533
- // ---------------------------------------------------------------------------
534
- // Tool registration
535
- // ---------------------------------------------------------------------------
482
+ function answerText(answer: QuestionAnswer): string {
483
+ if (answer.status !== "answered") return answer.status;
484
+ return formatResponseSummary(answer.response);
485
+ }
486
+
487
+ function rawQuestionLabel(question: RawQuestion | undefined, answer: QuestionAnswer): string {
488
+ if (!question || answer.status !== "answered" || answer.response.kind === "text") return answerText(answer);
489
+ const labels = answer.response.values.map((value) => question.options.find((option) => option.value === value)?.label ?? value);
490
+ if (answer.response.otherText) labels.push(`Other: ${answer.response.otherText}`);
491
+ return labels.join(", ");
492
+ }
536
493
 
537
494
  export default function questionExtension(pi: ExtensionAPI) {
538
495
  pi.registerTool({
539
496
  name: "question",
540
497
  label: "Question",
541
498
  description:
542
- "Ask the user one question or a paged batch through the Pi UI and return the answer(s). Supports multiple-choice options, multi-select, freeform answers, optional context, comments, and a final batch review/submit page. Use when you need user input to proceed.",
543
- promptSnippet: "Ask the user a question when required information is missing.",
499
+ "Ask one or more typed questions in a terminal wizard. Supports select, multiselect, text, stable option values, Other answers, and partial atomic submission.",
500
+ promptSnippet: "Ask the user one or more typed questions when a decision is required.",
544
501
  promptGuidelines: [
545
- "Use question only when you cannot proceed safely or accurately without user input.",
546
- "Ask concise questions. Prefer options when choices are known.",
547
- "Set allowFreeform=true when user may need to provide a custom answer.",
548
- "Use allowMultiple=true when more than one option may apply.",
549
- "Pass a short summary via context when the question depends on prior findings.",
550
- "After the user answers, continue the task immediately using that answer; do not stop and ask the user to type continue unless the task is complete.",
551
- "Do not use question for information you can infer, inspect, or compute with other tools.",
552
- "For independent related decisions, send one question call with a questions array. The user can use Tab/Shift+Tab or Left/Right to revisit pages, then presses Enter on the review page to submit all answers.",
502
+ "Use question only for decisions the user must make; inspect facts yourself.",
503
+ "Send one questions array containing independent decisions that can be answered together.",
504
+ "Use stable option values separate from human-readable labels.",
505
+ "Use text questions only when known choices cannot represent the answer.",
506
+ "After question returns, continue immediately using submitted answers; skipped and unanswered are distinct states.",
553
507
  ],
554
- // Block other tool calls in the same turn until the user answers, so the
555
- // model can't batch question with side-effecting tools and run them first.
556
508
  executionMode: "sequential",
557
509
  parameters: QuestionParamsSchema,
558
510
 
559
511
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
560
- if (Array.isArray(params.questions)) {
561
- const prepared = prepareBatchQuestions(params.questions);
562
- if ("error" in prepared) {
563
- return { content: [{ type: "text", text: prepared.error }], isError: true, details: { questions: [], answers: [], cancelled: true } satisfies BatchQuestionDetails };
564
- }
565
- const questions = prepared.questions;
566
- const detailsBase = {
567
- questions: questions.map(({ id, label, question, context, options }) => ({ id, label, question, context, options })),
568
- };
569
- if (signal?.aborted) {
570
- return { content: [{ type: "text", text: "Question batch cancelled" }], details: { ...detailsBase, answers: [], cancelled: true } satisfies BatchQuestionDetails };
571
- }
572
-
573
- const protocol = createTUIProtocol(ctx);
574
- if (!protocol.hasUI) {
575
- return {
576
- content: [{ type: "text", text: "Question batch requires interactive mode." }],
577
- isError: true,
578
- details: { ...detailsBase, answers: [], cancelled: true } satisfies BatchQuestionDetails,
579
- };
580
- }
581
- onUpdate?.({ content: [{ type: "text", text: "Waiting for answers to question batch..." }], details: { ...detailsBase, answers: [], cancelled: false } satisfies BatchQuestionDetails });
582
-
583
- let answers: BatchAnswer[] | null | undefined;
584
- try {
585
- const customFactory: CustomFactory<BatchAnswer[] | null> = (tui, theme, keybindings, done) => {
586
- if (signal) signal.addEventListener("abort", () => done(null), { once: true });
587
- const timeout = params.timeout as number | undefined;
588
- if (timeout && timeout > 0) setTimeout(() => done(null), timeout);
589
- return new BatchQuestionComponent(questions, tui, theme, keybindings, done);
590
- };
591
- answers = await protocol.custom(customFactory);
592
- if (answers === undefined) answers = await askBatchViaDialogs(questions, protocol, params.timeout as number | undefined);
593
- } catch (error) {
594
- const message = error instanceof Error ? `${error.message}\n${error.stack ?? ""}` : String(error);
595
- return { content: [{ type: "text", text: `Question batch failed: ${message}` }], isError: true, details: { ...detailsBase, answers: [], cancelled: true } satisfies BatchQuestionDetails };
596
- }
597
-
598
- if (!answers) {
599
- return { content: [{ type: "text", text: "User cancelled the question batch" }], details: { ...detailsBase, answers: [], cancelled: true } satisfies BatchQuestionDetails };
600
- }
601
- return {
602
- content: [{ type: "text", text: answers.map((answer) => `${answer.id}: ${formatResponseSummary(answer.response)}`).join("\n") }],
603
- details: { ...detailsBase, answers, cancelled: false } satisfies BatchQuestionDetails,
604
- };
605
- }
606
-
607
- if (signal?.aborted) {
608
- return {
609
- content: [{ type: "text", text: "Question cancelled" }],
610
- details: { question: params.question, options: [], response: null, cancelled: true } satisfies QuestionDetails,
611
- };
612
- }
613
-
614
- const rawQuestion = params.question as string;
615
- // `title` is a deprecated alias for context; merge if only title is given.
616
- const rawContext = (params.context as string | undefined)?.trim() || (params.title as string | undefined)?.trim() || undefined;
617
- const allowMultiple = params.allowMultiple ?? false;
618
- const allowFreeform = params.allowFreeform ?? !((params.options ?? []).length > 0);
619
- const allowComment = params.allowComment ?? false;
620
- const timeout = params.timeout as number | undefined;
621
-
622
- const shortcuts: ResolvedShortcuts = resolveShortcuts(
623
- params.commentToggleKey as string | null | undefined,
624
- process.env.PI_QUESTION_COMMENT_TOGGLE_KEY,
625
- );
626
-
627
- const optionsFormatError = getOptionsFormatError(params.options as RawOption[] | undefined);
628
- if (optionsFormatError) {
629
- return {
630
- content: [{ type: "text", text: optionsFormatError }],
631
- isError: true,
632
- details: { question: rawQuestion, context: rawContext, options: [], response: null, cancelled: true } satisfies QuestionDetails,
633
- };
634
- }
635
-
636
- const options = normalizeOptions(params.options as RawOption[] | undefined);
637
- const normalizedContext = rawContext || undefined;
638
- const detailsBase = { question: rawQuestion, context: normalizedContext, options };
639
-
640
- const protocol: UIProtocol = createTUIProtocol(ctx);
641
-
642
- if (!protocol.hasUI) {
643
- const optionText = options.length > 0 ? `\n\nOptions:\n${formatOptionsForMessage(options)}` : "";
644
- const freeformHint = allowFreeform ? "\n\nYou can also answer freely." : "";
645
- const commentHint = allowComment ? "\n\nAfter choosing, you may add an optional comment." : "";
646
- const contextText = normalizedContext ? `\n\nContext:\n${normalizedContext}` : "";
647
- return {
648
- content: [
649
- { type: "text", text: `Question requires interactive mode. Please answer:\n\n${rawQuestion}${contextText}${optionText}${freeformHint}${commentHint}` },
650
- ],
651
- isError: true,
652
- details: { ...detailsBase, response: null, cancelled: true } satisfies QuestionDetails,
653
- };
654
- }
655
-
656
- if (options.length === 0 && !allowFreeform) {
657
- return {
658
- content: [{ type: "text", text: "Question options are empty and allowFreeform is false." }],
659
- isError: true,
660
- details: { ...detailsBase, response: null, cancelled: true } satisfies QuestionDetails,
661
- };
662
- }
663
-
664
- onUpdate?.({
665
- content: [{ type: "text", text: "Waiting for user input..." }],
666
- details: { ...detailsBase, response: null, cancelled: false } satisfies QuestionDetails,
512
+ const prepared = prepareQuestions(params.questions);
513
+ if ("error" in prepared) throw new Error(prepared.error);
514
+ if (ctx.mode !== "tui") throw new Error("Question requires terminal TUI mode.");
515
+ if (signal?.aborted) return { content: [{ type: "text", text: "Question cancelled" }], details: { status: "cancelled", reason: "user" } satisfies QuestionToolResult };
516
+
517
+ onUpdate?.({ content: [{ type: "text", text: `Waiting for answers to ${prepared.questions.length} question${prepared.questions.length === 1 ? "" : "s"}...` }] });
518
+ const result = await ctx.ui.custom<QuestionToolResult>((tui, theme, keybindings, done) => {
519
+ if (signal) signal.addEventListener("abort", () => done({ status: "cancelled", reason: "user" }), { once: true });
520
+ return new BatchQuestionComponent(prepared.questions, tui, theme, keybindings, done);
667
521
  });
522
+ if (!result) throw new Error("Question TUI did not return a result.");
668
523
 
669
- let result: QuestionResponse | null;
670
-
671
- try {
672
- const customFactory: CustomFactory<QuestionResponse | null> = (
673
- tui: TUI,
674
- theme: Theme,
675
- keybindings: KeybindingsManager,
676
- done: (result: QuestionResponse | null) => void,
677
- ) => {
678
- if (signal) {
679
- const onAbort = () => done(null);
680
- signal.addEventListener("abort", onAbort, { once: true });
681
- }
682
- if (timeout && timeout > 0) setTimeout(() => done(null), timeout);
683
-
684
- return new QuestionComponent(
685
- rawQuestion,
686
- normalizedContext,
687
- options,
688
- allowMultiple,
689
- allowFreeform,
690
- allowComment,
691
- tui,
692
- theme,
693
- keybindings,
694
- shortcuts,
695
- done,
696
- );
697
- };
698
-
699
- const customResult = await protocol.custom(customFactory);
700
-
701
- if (customResult !== undefined) {
702
- result = customResult;
703
- } else {
704
- // RPC/headless: custom() returns undefined — degrade to dialog protocol.
705
- result = await DialogFallback.ask(
706
- { question: rawQuestion, context: normalizedContext, options, allowMultiple, allowFreeform, allowComment, shortcuts, timeout },
707
- protocol,
708
- );
709
- }
710
- } catch (error) {
711
- const message = error instanceof Error ? `${error.message}\n${error.stack ?? ""}` : String(error);
712
- return {
713
- content: [{ type: "text", text: `Question tool failed: ${message}` }],
714
- isError: true,
715
- details: { error: message } as unknown as QuestionDetails,
716
- };
524
+ if (result.status === "cancelled") {
525
+ pi.events.emit("question:cancelled", { reason: result.reason });
526
+ return { content: [{ type: "text", text: "User cancelled the question batch" }], details: result };
717
527
  }
718
-
719
- if (result === null) {
720
- pi.events.emit("question:cancelled", { question: rawQuestion, context: normalizedContext, options });
721
- return {
722
- content: [{ type: "text", text: "User cancelled the question" }],
723
- details: { ...detailsBase, response: null, cancelled: true } satisfies QuestionDetails,
724
- };
725
- }
726
-
727
- pi.events.emit("question:answered", { question: rawQuestion, context: normalizedContext, response: result });
528
+ pi.events.emit("question:submitted", { answers: result.answers });
728
529
  return {
729
- content: [{ type: "text", text: `User answered: ${formatResponseSummary(result)}` }],
730
- details: { ...detailsBase, response: result, cancelled: false } satisfies QuestionDetails,
530
+ content: [{ type: "text", text: JSON.stringify(result) }],
531
+ details: result,
731
532
  };
732
533
  },
733
534
 
734
- renderCall(args, theme, _context) {
735
- const batch = Array.isArray(args.questions) ? args.questions : undefined;
736
- if (batch) {
737
- const labels = batch.map((item: { label?: string; id?: string }, index: number) => item.label || item.id || `Q${index + 1}`).join(", ");
738
- return new Text(
739
- `${theme.fg("toolTitle", theme.bold("QUESTION"))} ${theme.fg("accent", theme.bold(`${batch.length} question${batch.length === 1 ? "" : "s"}`))}${labels ? theme.fg("dim", ` · ${oneLine(labels, 100)}`) : ""}`,
740
- 0,
741
- 0,
742
- );
743
- }
744
- const question = (args.question as string) || "";
745
- const rawOptions = Array.isArray(args.options) ? args.options : [];
746
- const title = args.context ? `${args.context}: ` : args.title ? `${args.title}: ` : "";
747
- const optionCount = rawOptions.length;
748
- const flags: string[] = [];
749
- if (args.allowMultiple) flags.push("multi");
750
- if (args.allowComment) flags.push("comment");
751
- const suffix = optionCount
752
- ? theme.fg("dim", ` · ${optionCount} option${optionCount === 1 ? "" : "s"}${flags.length ? ` · ${flags.join("/")}` : ""}`)
753
- : theme.fg("dim", ` · freeform${flags.length ? ` · ${flags.join("/")}` : ""}`);
535
+ renderCall(args, theme) {
536
+ const questions = Array.isArray(args.questions) ? (args.questions as RawQuestion[]) : [];
537
+ const labels = questions.map((question, index) => question.label || question.id || `Q${index + 1}`).join(", ");
754
538
  return new Text(
755
- `${theme.fg("toolTitle", theme.bold("QUESTION"))} ${theme.fg("accent", theme.bold(oneLine(title + question, 120)))}${suffix}`,
539
+ `${theme.fg("toolTitle", theme.bold("QUESTION"))} ${theme.fg("accent", theme.bold(`${questions.length} question${questions.length === 1 ? "" : "s"}`))}${labels ? theme.fg("dim", ` · ${oneLine(labels, 100)}`) : ""}`,
756
540
  0,
757
541
  0,
758
542
  );
759
543
  },
760
544
 
761
- renderResult(result, options, theme, _context) {
762
- const details = result.details as (QuestionDetails | BatchQuestionDetails) & { error?: string } | undefined;
763
-
764
- if (details?.error) return new Text(theme.fg("error", details.error), 0, 0);
765
-
545
+ renderResult(result, options, theme, context) {
766
546
  if (options.isPartial) {
767
- const waitingText =
768
- result.content
769
- ?.filter((part: { type?: string; text?: string }) => part?.type === "text")
770
- .map((part: { text?: string }) => part.text ?? "")
771
- .join("\n")
772
- .trim() || "Waiting for user input...";
773
- return new Text(theme.fg("muted", waitingText), 0, 0);
774
- }
775
-
776
- if (!details || details.cancelled) return new Text(theme.fg("warning", "question cancelled"), 0, 0);
777
-
778
- if ("answers" in details) {
779
- return new Text(details.answers.map((answer) => `${theme.fg("success", "✓")} ${theme.fg("accent", answer.id)}: ${theme.fg("text", oneLine(formatResponseSummary(answer.response), 120))}`).join("\n"), 0, 0);
547
+ const text = result.content.find((part: { type?: string }) => part.type === "text") as { text?: string } | undefined;
548
+ return new Text(theme.fg("muted", text?.text ?? "Waiting for user input..."), 0, 0);
780
549
  }
781
- if (!details.response) return new Text(theme.fg("warning", "question cancelled"), 0, 0);
782
-
783
- const response = details.response;
784
- let text = theme.fg("success", theme.bold("answered: "));
785
- text += theme.fg("accent", theme.bold(oneLine(formatResponseSummary(response), 120)));
786
-
787
- if (options.expanded && details.question) {
788
- text += `\n${theme.fg("dim", `Q: ${oneLine(details.question, 200)}`)}`;
789
- if (details.context) text += `\n${theme.fg("dim", oneLine(details.context, 200))}`;
790
- if (response.kind === "selection" && details.options.length > 0) {
791
- const selected = new Set(response.selections);
792
- text += `\n${theme.fg("dim", "Options:")}`;
793
- for (const opt of details.options) {
794
- const desc = opt.description ? ` — ${opt.description}` : "";
795
- const marker = selected.has(opt.label) ? theme.fg("success", "●") : theme.fg("dim", "○");
796
- text += `\n ${marker} ${theme.fg("dim", opt.label)}${theme.fg("dim", desc)}`;
797
- }
798
- if (response.comment) text += `\n${theme.fg("dim", "Comment:")} ${theme.fg("dim", response.comment)}`;
550
+ const details = result.details as QuestionToolResult | undefined;
551
+ if (!details || details.status === "cancelled") return new Text(theme.fg("warning", "question cancelled"), 0, 0);
552
+ const answered = details.answers.filter((answer) => answer.status === "answered").length;
553
+ const skipped = details.answers.filter((answer) => answer.status === "skipped").length;
554
+ const unanswered = details.answers.length - answered - skipped;
555
+ let text = `${theme.fg("success", "submitted")} ${theme.fg("dim", `· ${answered} answered · ${skipped} skipped · ${unanswered} unanswered`)}`;
556
+ if (options.expanded) {
557
+ const questions = (((context as { args?: { questions?: RawQuestion[] } }).args?.questions ?? []) as RawQuestion[]);
558
+ for (const answer of details.answers) {
559
+ const question = questions.find((candidate, index) => (candidate.id?.trim() || `q${index + 1}`) === answer.id);
560
+ const marker = answer.status === "answered" ? theme.fg("success", "✓") : answer.status === "skipped" ? theme.fg("warning", "−") : theme.fg("dim", "○");
561
+ text += `\n${marker} ${theme.fg("accent", answer.id)}: ${theme.fg(answer.status === "answered" ? "text" : "dim", oneLine(rawQuestionLabel(question, answer), 200))}`;
799
562
  }
800
563
  }
801
564
  return new Text(text, 0, 0);