@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.
- package/README.md +1 -1
- package/dist/core/agent-session-skill-block.test.d.ts +2 -0
- package/dist/core/agent-session-skill-block.test.d.ts.map +1 -0
- package/dist/core/agent-session-skill-block.test.js +20 -0
- package/dist/core/agent-session-skill-block.test.js.map +1 -0
- package/dist/core/agent-session.d.ts +7 -4
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +22 -12
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/skills.d.ts.map +1 -1
- package/dist/core/skills.js +1 -1
- package/dist/core/skills.js.map +1 -1
- package/dist/data/capabilities.d.ts +48 -0
- package/dist/data/capabilities.d.ts.map +1 -0
- package/dist/data/capabilities.js +282 -0
- package/dist/data/capabilities.js.map +1 -0
- package/dist/data/navigation.d.ts +3 -0
- package/dist/data/navigation.d.ts.map +1 -0
- package/dist/data/navigation.js +90 -0
- package/dist/data/navigation.js.map +1 -0
- package/dist/extensions/inline-skills.test.ts +96 -0
- package/dist/extensions/inline-skills.ts +92 -0
- package/dist/extensions/package.json +1 -0
- package/dist/extensions/pi-powerline-footer/tests/tps.test.ts +39 -0
- package/dist/extensions/pi-powerline-footer/tps.ts +172 -113
- package/dist/extensions/pi-subagents/src/tui/fleet.ts +0 -4
- package/dist/extensions/pi-subagents/test/integration/slash-commands.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/fleet.test.ts +1 -1
- package/dist/extensions/question/batch.ts +89 -25
- package/dist/extensions/question/constants.ts +1 -1
- package/dist/extensions/question/helpers.ts +34 -78
- package/dist/extensions/question/index.ts +327 -564
- package/dist/extensions/question/package.json +2 -1
- package/dist/extensions/question/question-list.ts +21 -14
- package/dist/extensions/question/row-layout.ts +2 -2
- package/dist/extensions/question/schemas.ts +33 -47
- package/dist/extensions/question/selection-mode.ts +5 -7
- package/dist/extensions/question/tests/batch.test.ts +65 -16
- package/dist/extensions/question/tests/helpers.test.ts +31 -156
- package/dist/extensions/question/tests/question-list.test.ts +17 -3
- package/dist/extensions/question/tests/row-layout.test.ts +4 -4
- package/dist/extensions/question/tests/schemas.test.ts +30 -0
- package/dist/extensions/question/tests/wizard.test.ts +99 -0
- package/dist/extensions/question/types.ts +65 -66
- package/dist/extensions/workflow/adapter.ts +20 -77
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +10 -10
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/skills/workflow-creation/SKILL.md +2 -2
- package/docs/skills.md +8 -0
- package/docs/workflows.md +4 -4
- package/package.json +1 -1
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
//
|
|
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 {
|
|
21
|
-
import { BOX_BORDER_LEFT, BOX_BORDER_OVERHEAD, BOX_BORDER_RIGHT, QUESTION_VERSION } from "./constants.ts";
|
|
22
|
-
import {
|
|
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 {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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: (
|
|
39
|
-
private title
|
|
40
|
-
private titleColor
|
|
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
|
-
|
|
50
|
-
|
|
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: (
|
|
58
|
-
private label
|
|
59
|
-
private labelColor
|
|
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
|
-
|
|
65
|
-
|
|
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
|
|
64
|
+
function editorTheme(theme: Theme): EditorTheme {
|
|
75
65
|
return {
|
|
76
|
-
borderColor: (
|
|
66
|
+
borderColor: (value) => theme.fg("accent", value),
|
|
77
67
|
selectList: {
|
|
78
|
-
selectedPrefix: (
|
|
79
|
-
selectedText: (
|
|
80
|
-
description: (
|
|
81
|
-
scrollInfo: (
|
|
82
|
-
noMatch: (
|
|
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
|
|
93
|
-
private
|
|
94
|
-
private
|
|
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
|
|
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:
|
|
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
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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((
|
|
161
|
-
|
|
162
|
-
this.
|
|
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.
|
|
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.
|
|
175
|
-
if (this.editor
|
|
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
|
-
|
|
181
|
-
|
|
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
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (index ===
|
|
193
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
this.
|
|
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
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
this.
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
266
|
-
this.
|
|
267
|
-
|
|
167
|
+
this.question.options,
|
|
168
|
+
multi ? MultiSelect : SingleSelect,
|
|
169
|
+
this.question.allowOther,
|
|
170
|
+
false,
|
|
268
171
|
this.theme,
|
|
269
172
|
this.keybindings,
|
|
270
|
-
|
|
173
|
+
DISABLED_SHORTCUT,
|
|
271
174
|
);
|
|
272
|
-
list.onSubmit = (
|
|
273
|
-
|
|
274
|
-
|
|
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,
|
|
282
|
-
editor.
|
|
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
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
|
298
|
-
if (this.mode === "
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
|
310
|
-
this.mode = "select";
|
|
311
|
-
this.pendingSelections = [];
|
|
218
|
+
private rebuildMode(): void {
|
|
312
219
|
this.modeContainer.clear();
|
|
313
|
-
|
|
314
|
-
|
|
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
|
|
320
|
-
|
|
321
|
-
this.
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
|
334
|
-
this.mode
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
this.
|
|
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 === "
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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:
|
|
363
|
+
private questions: PreparedQuestion[],
|
|
383
364
|
private tui: TUI,
|
|
384
365
|
private theme: Theme,
|
|
385
366
|
private keybindings: KeybindingsManager,
|
|
386
|
-
private onDone: (
|
|
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
|
|
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.
|
|
422
|
-
this.
|
|
423
|
-
|
|
424
|
-
|
|
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.
|
|
434
|
-
|
|
435
|
-
|
|
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.
|
|
446
|
-
|
|
415
|
+
this.reviewComponent = undefined;
|
|
447
416
|
if (this.isReviewPage) {
|
|
448
|
-
this.
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
|
479
|
-
if (
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
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
|
-
|
|
521
|
-
|
|
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
|
-
|
|
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
|
|
543
|
-
promptSnippet: "Ask the user
|
|
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
|
|
546
|
-
"
|
|
547
|
-
"
|
|
548
|
-
"Use
|
|
549
|
-
"
|
|
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
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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:
|
|
730
|
-
details:
|
|
530
|
+
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
531
|
+
details: result,
|
|
731
532
|
};
|
|
732
533
|
},
|
|
733
534
|
|
|
734
|
-
renderCall(args, theme
|
|
735
|
-
const
|
|
736
|
-
|
|
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(
|
|
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,
|
|
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
|
|
768
|
-
|
|
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
|
-
|
|
782
|
-
|
|
783
|
-
const
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
if (options.expanded
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
const
|
|
792
|
-
text += `\n${theme.fg("
|
|
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);
|