@xynogen/pix-core 0.1.1 → 0.1.3
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/package.json +3 -1
- package/src/commands/agent-sop/agent-sop.ts +58 -0
- package/src/index.ts +3 -1
- package/src/lib/data.ts +21 -229
- package/src/nudge/capability.test.ts +45 -5
- package/src/nudge/capability.ts +45 -9
- package/src/tool/ask/ask.test.ts +2 -2
- package/src/tool/ask/components.ts +55 -0
- package/src/tool/ask/helpers.ts +77 -0
- package/src/tool/ask/index.ts +130 -0
- package/src/tool/ask/{ask.ts → questionnaire.ts} +60 -473
- package/src/tool/ask/rpc.ts +84 -0
- package/src/tool/ask/schema.ts +69 -0
- package/src/tool/ask/types.ts +17 -0
- package/src/tool/toolbox/toolbox.ts +9 -1
- package/src/ui/diagnostics.ts +3 -6
- package/src/ui/welcome.ts +5 -2
|
@@ -1,16 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
* Ask Tool — structured questionnaire for pi-coding-agent
|
|
3
|
-
*
|
|
4
|
-
* Single file. Single tool. `ask_user_question` API style:
|
|
5
|
-
* multiple questions, options w/ label/description/preview, multiSelect.
|
|
6
|
-
*
|
|
7
|
-
* Replaces both the old `ask` and the external `ask_user_question` package.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
11
|
-
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
12
2
|
import {
|
|
13
|
-
type Component,
|
|
14
3
|
Container,
|
|
15
4
|
decodeKittyPrintable,
|
|
16
5
|
Editor,
|
|
@@ -18,7 +7,6 @@ import {
|
|
|
18
7
|
Key,
|
|
19
8
|
type KeybindingsManager,
|
|
20
9
|
Markdown,
|
|
21
|
-
type MarkdownTheme,
|
|
22
10
|
matchesKey,
|
|
23
11
|
Spacer,
|
|
24
12
|
Text,
|
|
@@ -26,214 +14,26 @@ import {
|
|
|
26
14
|
truncateToWidth,
|
|
27
15
|
wrapTextWithAnsi,
|
|
28
16
|
} from "@earendil-works/pi-tui";
|
|
29
|
-
import { type Static, Type } from "typebox";
|
|
30
|
-
|
|
31
|
-
// ── Constants ──────────────────────────────────────────────────────────
|
|
32
|
-
|
|
33
|
-
const MAX_QUESTIONS = 4;
|
|
34
|
-
const MIN_OPTIONS = 2;
|
|
35
|
-
const MAX_OPTIONS = 4;
|
|
36
|
-
const MAX_HEADER_LENGTH = 16;
|
|
37
|
-
const MAX_LABEL_LENGTH = 60;
|
|
38
|
-
|
|
39
|
-
const SENTINEL_FREEFORM = "Type something.";
|
|
40
|
-
const SENTINEL_CHAT = "Chat about this";
|
|
41
|
-
const SENTINEL_NEXT = "Next";
|
|
42
|
-
|
|
43
|
-
const SPLIT_PANE_MIN_WIDTH = 84;
|
|
44
|
-
const SEPARATOR = " │ ";
|
|
45
|
-
|
|
46
|
-
// ── Schema ─────────────────────────────────────────────────────────────
|
|
47
|
-
|
|
48
|
-
const OptionSchema = Type.Object({
|
|
49
|
-
label: Type.String({
|
|
50
|
-
maxLength: MAX_LABEL_LENGTH,
|
|
51
|
-
description: `MAX ${MAX_LABEL_LENGTH} CHARACTERS. Display text for this option. Concise (1-5 words).`,
|
|
52
|
-
}),
|
|
53
|
-
description: Type.String({
|
|
54
|
-
description: "Explanation of what this option means or trade-offs.",
|
|
55
|
-
}),
|
|
56
|
-
preview: Type.Optional(
|
|
57
|
-
Type.String({
|
|
58
|
-
description:
|
|
59
|
-
"Optional markdown preview for side-by-side layout (single-select only).",
|
|
60
|
-
}),
|
|
61
|
-
),
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
const QuestionSchema = Type.Object({
|
|
65
|
-
question: Type.String({
|
|
66
|
-
description: "Clear, specific question ending with ?",
|
|
67
|
-
}),
|
|
68
|
-
header: Type.String({
|
|
69
|
-
maxLength: MAX_HEADER_LENGTH,
|
|
70
|
-
description: `MAX ${MAX_HEADER_LENGTH} CHARS — short chip/tag. E.g. "Auth method", "Approach".`,
|
|
71
|
-
}),
|
|
72
|
-
options: Type.Array(OptionSchema, {
|
|
73
|
-
minItems: MIN_OPTIONS,
|
|
74
|
-
maxItems: MAX_OPTIONS,
|
|
75
|
-
description:
|
|
76
|
-
"2-4 options. 'Type something.' and 'Chat about this' are auto-appended.",
|
|
77
|
-
}),
|
|
78
|
-
multiSelect: Type.Optional(
|
|
79
|
-
Type.Boolean({
|
|
80
|
-
default: false,
|
|
81
|
-
description:
|
|
82
|
-
"Allow multiple selections. Suppresses 'Type something.' row.",
|
|
83
|
-
}),
|
|
84
|
-
),
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
const QuestionsSchema = Type.Array(QuestionSchema, {
|
|
88
|
-
minItems: 1,
|
|
89
|
-
maxItems: MAX_QUESTIONS,
|
|
90
|
-
description: "1-4 questions",
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
const ParamsSchema = Type.Object({ questions: QuestionsSchema });
|
|
94
|
-
|
|
95
|
-
export type OptionData = Static<typeof OptionSchema>;
|
|
96
|
-
export type QuestionData = Static<typeof QuestionSchema>;
|
|
97
|
-
type Params = Static<typeof ParamsSchema>;
|
|
98
|
-
|
|
99
|
-
// ── Answer types ───────────────────────────────────────────────────────
|
|
100
|
-
|
|
101
|
-
type AnswerKind = "option" | "custom" | "chat" | "multi";
|
|
102
|
-
|
|
103
|
-
interface QuestionAnswer {
|
|
104
|
-
questionIndex: number;
|
|
105
|
-
question: string;
|
|
106
|
-
kind: AnswerKind;
|
|
107
|
-
answer: string | null;
|
|
108
|
-
selected?: string[];
|
|
109
|
-
preview?: string;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
interface QuestionnaireResult {
|
|
113
|
-
answers: QuestionAnswer[];
|
|
114
|
-
cancelled: boolean;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// ── Helpers ────────────────────────────────────────────────────────────
|
|
118
|
-
|
|
119
|
-
function safeMarkdownTheme(): MarkdownTheme | undefined {
|
|
120
|
-
try {
|
|
121
|
-
const md = getMarkdownTheme();
|
|
122
|
-
if (!md) return undefined;
|
|
123
|
-
md.bold("");
|
|
124
|
-
return md;
|
|
125
|
-
} catch {
|
|
126
|
-
return undefined;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
export function hasAnyPreview(q: QuestionData): boolean {
|
|
131
|
-
return q.options.some(
|
|
132
|
-
(o) => typeof o.preview === "string" && o.preview.length > 0,
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/** Which sentinel rows are auto-appended for a question. */
|
|
137
|
-
export function sentinelsFor(
|
|
138
|
-
q: QuestionData,
|
|
139
|
-
): Array<{ kind: string; label: string }> {
|
|
140
|
-
const out: Array<{ kind: string; label: string }> = [];
|
|
141
|
-
if (q.multiSelect) {
|
|
142
|
-
out.push({ kind: "next", label: SENTINEL_NEXT });
|
|
143
|
-
} else if (!hasAnyPreview(q)) {
|
|
144
|
-
out.push({ kind: "other", label: SENTINEL_FREEFORM });
|
|
145
|
-
}
|
|
146
|
-
// Chat sentinel is always last (in its own row list, not in main list)
|
|
147
|
-
return out;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
export function formatAnswerScalar(a: QuestionAnswer): string {
|
|
151
|
-
if (a.kind === "multi") return (a.selected ?? []).join(", ");
|
|
152
|
-
if (a.kind === "custom") return a.answer ?? "(custom)";
|
|
153
|
-
if (a.kind === "chat") return "(chat)";
|
|
154
|
-
return a.answer ?? "(selected)";
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
export function buildResponseText(
|
|
158
|
-
answers: QuestionAnswer[],
|
|
159
|
-
questions: QuestionData[],
|
|
160
|
-
): string {
|
|
161
|
-
const segs: string[] = [];
|
|
162
|
-
for (const a of answers) {
|
|
163
|
-
const q = questions[a.questionIndex]?.question ?? `Q${a.questionIndex + 1}`;
|
|
164
|
-
let s = `"${q}"="${formatAnswerScalar(a)}"`;
|
|
165
|
-
if (a.preview) s += `. selected preview: ${a.preview}`;
|
|
166
|
-
segs.push(s);
|
|
167
|
-
}
|
|
168
|
-
return segs.length
|
|
169
|
-
? `User answered: ${segs.join(". ")}.`
|
|
170
|
-
: "User declined to answer questions.";
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// ── Scrollbar helper ───────────────────────────────────────────────────
|
|
174
|
-
|
|
175
|
-
function scrollIndicator(index: number, total: number): string {
|
|
176
|
-
if (total <= 1) return "";
|
|
177
|
-
const pos = Math.round((index / (total - 1)) * 6);
|
|
178
|
-
const bar = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦"][pos] ?? "·";
|
|
179
|
-
return ` ${bar} ${index + 1}/${total}`;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// ── TUI Components ─────────────────────────────────────────────────────
|
|
183
|
-
|
|
184
|
-
function _borderColor(theme: Theme): (s: string) => string {
|
|
185
|
-
return (s: string) => theme.fg("accent", s);
|
|
186
|
-
}
|
|
187
17
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const inner = Math.max(10, width - 2);
|
|
208
|
-
// Build tab labels: "1.Approach 2.Auth 3.Database"
|
|
209
|
-
const parts: string[] = [];
|
|
210
|
-
for (let i = 0; i < this.questions.length; i++) {
|
|
211
|
-
const active = i === this.activeIndex;
|
|
212
|
-
const num = `${i + 1}`;
|
|
213
|
-
const tag = `${num}.${this.questions[i]?.header}`;
|
|
214
|
-
if (active) {
|
|
215
|
-
parts.push(t.fg("accent", t.bold(tag)));
|
|
216
|
-
} else {
|
|
217
|
-
parts.push(t.fg("dim", tag));
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
const line = parts.join(t.fg("dim", " "));
|
|
221
|
-
return [
|
|
222
|
-
truncateToWidth(
|
|
223
|
-
t.fg("accent", "╭─") +
|
|
224
|
-
line +
|
|
225
|
-
t.fg(
|
|
226
|
-
"accent",
|
|
227
|
-
`${"─".repeat(Math.max(0, inner - line.length - 1))}╮`,
|
|
228
|
-
),
|
|
229
|
-
width,
|
|
230
|
-
"",
|
|
231
|
-
),
|
|
232
|
-
].filter(Boolean);
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
class AskQuestionnaire extends Container {
|
|
18
|
+
import { dim, TabBar } from "./components.js";
|
|
19
|
+
import { safeMarkdownTheme, scrollIndicator, sentinelsFor } from "./helpers.js";
|
|
20
|
+
import type { OptionData, Params, QuestionData } from "./schema.js";
|
|
21
|
+
import {
|
|
22
|
+
SENTINEL_CHAT,
|
|
23
|
+
SENTINEL_FREEFORM,
|
|
24
|
+
SENTINEL_NEXT,
|
|
25
|
+
SEPARATOR,
|
|
26
|
+
SPLIT_PANE_MIN_WIDTH,
|
|
27
|
+
} from "./schema.js";
|
|
28
|
+
import type {
|
|
29
|
+
AnswerKind,
|
|
30
|
+
QuestionAnswer,
|
|
31
|
+
QuestionnaireResult,
|
|
32
|
+
} from "./types.js";
|
|
33
|
+
|
|
34
|
+
// ── AskQuestionnaire ───────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
export class AskQuestionnaire extends Container {
|
|
237
37
|
private params: Params;
|
|
238
38
|
private tui: TUI;
|
|
239
39
|
private theme: Theme;
|
|
@@ -245,12 +45,9 @@ class AskQuestionnaire extends Container {
|
|
|
245
45
|
private searchQuery = "";
|
|
246
46
|
private selectedOptionIndex = 0;
|
|
247
47
|
private multiChecked = new Set<number>();
|
|
248
|
-
private inputMode = false;
|
|
249
|
-
private freeformText = "";
|
|
48
|
+
private inputMode = false;
|
|
250
49
|
private editor?: Editor;
|
|
251
50
|
private mdTheme = safeMarkdownTheme();
|
|
252
|
-
// Resolve panel width once
|
|
253
|
-
private _splitWidth: number | null = null;
|
|
254
51
|
|
|
255
52
|
constructor(
|
|
256
53
|
params: Params,
|
|
@@ -268,6 +65,8 @@ class AskQuestionnaire extends Container {
|
|
|
268
65
|
this.renderLayout();
|
|
269
66
|
}
|
|
270
67
|
|
|
68
|
+
// ── Accessors ──────────────────────────────────────────────────────
|
|
69
|
+
|
|
271
70
|
private get currentQ(): QuestionData {
|
|
272
71
|
return this.params.questions[this.currentIndex]!;
|
|
273
72
|
}
|
|
@@ -305,23 +104,22 @@ class AskQuestionnaire extends Container {
|
|
|
305
104
|
return this.mainListItems[this.selectedOptionIndex];
|
|
306
105
|
}
|
|
307
106
|
|
|
308
|
-
|
|
107
|
+
// ── Layout ─────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
override invalidate(): void {
|
|
309
110
|
super.invalidate();
|
|
310
|
-
this._splitWidth = null;
|
|
311
111
|
}
|
|
312
112
|
|
|
313
113
|
renderLayout(): void {
|
|
314
114
|
this.clear();
|
|
315
115
|
const t = this.theme;
|
|
316
|
-
// Border top
|
|
317
|
-
this.addChild(new Text("", 0, 0)); // placeholder, re-rendered
|
|
318
116
|
|
|
319
|
-
|
|
117
|
+
this.addChild(new Text("", 0, 0));
|
|
118
|
+
|
|
320
119
|
if (this.params.questions.length > 1) {
|
|
321
120
|
this.addChild(new TabBar(this.params.questions, this.currentIndex, t));
|
|
322
121
|
}
|
|
323
122
|
|
|
324
|
-
// Question header chip
|
|
325
123
|
const q = this.currentQ;
|
|
326
124
|
const chip = t.fg("accent", t.bold(q.header));
|
|
327
125
|
const prog =
|
|
@@ -332,12 +130,9 @@ class AskQuestionnaire extends Container {
|
|
|
332
130
|
: "";
|
|
333
131
|
this.addChild(new Text(`${chip}${prog}`, 1, 0));
|
|
334
132
|
this.addChild(new Spacer(1));
|
|
335
|
-
|
|
336
|
-
// Question text
|
|
337
133
|
this.addChild(new Text(t.fg("text", t.bold(q.question)), 1, 0));
|
|
338
134
|
this.addChild(new Spacer(1));
|
|
339
135
|
|
|
340
|
-
// Search bar for single-select
|
|
341
136
|
if (!q.multiSelect && !this.inputMode) {
|
|
342
137
|
const searchVal = this.searchQuery
|
|
343
138
|
? t.fg("text", this.searchQuery)
|
|
@@ -347,19 +142,14 @@ class AskQuestionnaire extends Container {
|
|
|
347
142
|
);
|
|
348
143
|
}
|
|
349
144
|
|
|
350
|
-
// Options area (filled on render)
|
|
351
145
|
this.addChild(new Spacer(1));
|
|
352
146
|
|
|
353
|
-
// Input mode editor
|
|
354
147
|
if (this.inputMode) {
|
|
355
148
|
this.addChild(this.ensureEditor());
|
|
356
149
|
}
|
|
357
150
|
|
|
358
|
-
// Footer hints
|
|
359
151
|
this.addChild(new Spacer(1));
|
|
360
|
-
this.addChild(this.
|
|
361
|
-
|
|
362
|
-
// Border bottom
|
|
152
|
+
this.addChild(this._buildHintText());
|
|
363
153
|
this.addChild(new Text("", 0, 0));
|
|
364
154
|
}
|
|
365
155
|
|
|
@@ -377,12 +167,12 @@ class AskQuestionnaire extends Container {
|
|
|
377
167
|
});
|
|
378
168
|
editor.disableSubmit = false;
|
|
379
169
|
editor.onSubmit = (text: string) => this.handleFreeformSubmit(text);
|
|
380
|
-
|
|
170
|
+
editor.focused = true;
|
|
381
171
|
this.editor = editor;
|
|
382
172
|
return editor;
|
|
383
173
|
}
|
|
384
174
|
|
|
385
|
-
private
|
|
175
|
+
private _buildHintText(): Text {
|
|
386
176
|
const t = this.theme;
|
|
387
177
|
const isMulti = !!this.currentQ.multiSelect;
|
|
388
178
|
const hints: string[] = [];
|
|
@@ -406,13 +196,14 @@ class AskQuestionnaire extends Container {
|
|
|
406
196
|
return new Text(hints.join("\n"), 1, 0);
|
|
407
197
|
}
|
|
408
198
|
|
|
199
|
+
// ── Answer management ──────────────────────────────────────────────
|
|
200
|
+
|
|
409
201
|
private recordAnswer(
|
|
410
202
|
kind: AnswerKind,
|
|
411
203
|
answer: string | null,
|
|
412
204
|
selected?: string[],
|
|
413
205
|
preview?: string,
|
|
414
206
|
): void {
|
|
415
|
-
// Remove any previous answer for this question
|
|
416
207
|
this.answers = this.answers.filter(
|
|
417
208
|
(a) => a.questionIndex !== this.currentIndex,
|
|
418
209
|
);
|
|
@@ -443,13 +234,11 @@ class AskQuestionnaire extends Container {
|
|
|
443
234
|
this.nextQuestion();
|
|
444
235
|
} else if (item.kind === "other") {
|
|
445
236
|
this.inputMode = true;
|
|
446
|
-
this.
|
|
447
|
-
(this.ensureEditor() as any).focused = true;
|
|
237
|
+
this.ensureEditor().focused = true;
|
|
448
238
|
this.invalidate();
|
|
449
239
|
this.renderLayout();
|
|
450
240
|
this.tui.requestRender();
|
|
451
241
|
} else if (item.kind === "next") {
|
|
452
|
-
// multi-select commit
|
|
453
242
|
const selected = Array.from(this.multiChecked)
|
|
454
243
|
.sort((a, b) => a - b)
|
|
455
244
|
.map((i) => this.currentQ.options[i]?.label);
|
|
@@ -478,7 +267,6 @@ class AskQuestionnaire extends Container {
|
|
|
478
267
|
this.multiChecked.clear();
|
|
479
268
|
this.inputMode = false;
|
|
480
269
|
this.selectedOptionIndex = 0;
|
|
481
|
-
this.freeformText = "";
|
|
482
270
|
this.editor = undefined;
|
|
483
271
|
this.restoreAnswerState();
|
|
484
272
|
this.invalidate();
|
|
@@ -486,7 +274,6 @@ class AskQuestionnaire extends Container {
|
|
|
486
274
|
this.tui.requestRender();
|
|
487
275
|
}
|
|
488
276
|
|
|
489
|
-
/** Re-select the previously recorded answer when revisiting a question. */
|
|
490
277
|
private restoreAnswerState(): void {
|
|
491
278
|
const prev = this.answers.find(
|
|
492
279
|
(a) => a.questionIndex === this.currentIndex,
|
|
@@ -513,7 +300,6 @@ class AskQuestionnaire extends Container {
|
|
|
513
300
|
private nextQuestion(): void {
|
|
514
301
|
const total = this.params.questions.length;
|
|
515
302
|
const answered = new Set(this.answers.map((a) => a.questionIndex));
|
|
516
|
-
// Advance to the next unanswered question (wrapping), finish when none left.
|
|
517
303
|
for (let step = 1; step <= total; step++) {
|
|
518
304
|
const idx = (this.currentIndex + step) % total;
|
|
519
305
|
if (!answered.has(idx)) {
|
|
@@ -536,14 +322,14 @@ class AskQuestionnaire extends Container {
|
|
|
536
322
|
this.invalidate();
|
|
537
323
|
}
|
|
538
324
|
|
|
325
|
+
// ── Input handling ─────────────────────────────────────────────────
|
|
326
|
+
|
|
539
327
|
handleInput(data: string): void {
|
|
540
|
-
// Global: cancel
|
|
541
328
|
if (this.keybindings.matches(data, "tui.select.cancel")) {
|
|
542
329
|
this.cancel();
|
|
543
330
|
return;
|
|
544
331
|
}
|
|
545
332
|
|
|
546
|
-
// Input mode: handle editor keys
|
|
547
333
|
if (this.inputMode) {
|
|
548
334
|
if (matchesKey(data, Key.escape)) {
|
|
549
335
|
this.inputMode = false;
|
|
@@ -553,7 +339,6 @@ class AskQuestionnaire extends Container {
|
|
|
553
339
|
this.tui.requestRender();
|
|
554
340
|
return;
|
|
555
341
|
}
|
|
556
|
-
// Forward all other keys to the Editor (typing, enter=submit, etc.)
|
|
557
342
|
this.ensureEditor().handleInput(data);
|
|
558
343
|
this.tui.requestRender();
|
|
559
344
|
return;
|
|
@@ -562,7 +347,6 @@ class AskQuestionnaire extends Container {
|
|
|
562
347
|
const isMulti = !!this.currentQ.multiSelect;
|
|
563
348
|
const total = this.totalItems;
|
|
564
349
|
|
|
565
|
-
// Navigation
|
|
566
350
|
if (
|
|
567
351
|
this.keybindings.matches(data, "tui.select.up") ||
|
|
568
352
|
matchesKey(data, Key.shift("tab")) ||
|
|
@@ -590,7 +374,6 @@ class AskQuestionnaire extends Container {
|
|
|
590
374
|
return;
|
|
591
375
|
}
|
|
592
376
|
|
|
593
|
-
// Left/Right: move between questions (answers preserved)
|
|
594
377
|
if (matchesKey(data, Key.left)) {
|
|
595
378
|
this.gotoQuestion(this.currentIndex - 1);
|
|
596
379
|
return;
|
|
@@ -600,7 +383,6 @@ class AskQuestionnaire extends Container {
|
|
|
600
383
|
return;
|
|
601
384
|
}
|
|
602
385
|
|
|
603
|
-
// Backspace: pop search
|
|
604
386
|
if (
|
|
605
387
|
this.keybindings.matches(data, "tui.editor.deleteCharBackward") ||
|
|
606
388
|
matchesKey(data, Key.backspace)
|
|
@@ -616,7 +398,6 @@ class AskQuestionnaire extends Container {
|
|
|
616
398
|
return;
|
|
617
399
|
}
|
|
618
400
|
|
|
619
|
-
// Escape: clear search
|
|
620
401
|
if (matchesKey(data, Key.escape)) {
|
|
621
402
|
if (this.searchQuery) {
|
|
622
403
|
this.searchQuery = "";
|
|
@@ -627,7 +408,6 @@ class AskQuestionnaire extends Container {
|
|
|
627
408
|
return;
|
|
628
409
|
}
|
|
629
410
|
|
|
630
|
-
// Space: toggle multi-select
|
|
631
411
|
if (matchesKey(data, Key.space) && isMulti) {
|
|
632
412
|
if (this.selectedItem?.kind === "option" && this.selectedItem.option) {
|
|
633
413
|
const idx = this.filteredOptions.indexOf(this.selectedItem.option);
|
|
@@ -638,7 +418,6 @@ class AskQuestionnaire extends Container {
|
|
|
638
418
|
return;
|
|
639
419
|
}
|
|
640
420
|
|
|
641
|
-
// Number shortcut
|
|
642
421
|
const numMatch = data.match(/^[1-9]$/);
|
|
643
422
|
if (numMatch && this.filteredOptions.length > 0) {
|
|
644
423
|
const idx = Number(numMatch[0]) - 1;
|
|
@@ -649,7 +428,6 @@ class AskQuestionnaire extends Container {
|
|
|
649
428
|
this.invalidate();
|
|
650
429
|
this.tui.requestRender();
|
|
651
430
|
} else {
|
|
652
|
-
// Direct select
|
|
653
431
|
const opt = this.filteredOptions[idx]!;
|
|
654
432
|
this.recordAnswer("option", opt.label, undefined, opt.preview);
|
|
655
433
|
this.nextQuestion();
|
|
@@ -658,13 +436,11 @@ class AskQuestionnaire extends Container {
|
|
|
658
436
|
}
|
|
659
437
|
}
|
|
660
438
|
|
|
661
|
-
// Submit / select
|
|
662
439
|
if (this.keybindings.matches(data, "tui.select.confirm")) {
|
|
663
440
|
this.commitAnswer();
|
|
664
441
|
return;
|
|
665
442
|
}
|
|
666
443
|
|
|
667
|
-
// Search input: type to filter
|
|
668
444
|
if (!isMulti) {
|
|
669
445
|
const printable = decodeKittyPrintable(data);
|
|
670
446
|
if (printable !== undefined) {
|
|
@@ -689,6 +465,8 @@ class AskQuestionnaire extends Container {
|
|
|
689
465
|
}
|
|
690
466
|
}
|
|
691
467
|
|
|
468
|
+
// ── Rendering ──────────────────────────────────────────────────────
|
|
469
|
+
|
|
692
470
|
private renderOptions(width: number): string[] {
|
|
693
471
|
const t = this.theme;
|
|
694
472
|
const inner = Math.max(20, width - 6);
|
|
@@ -803,19 +581,14 @@ class AskQuestionnaire extends Container {
|
|
|
803
581
|
this.selectedItem?.kind === "option" &&
|
|
804
582
|
!!this.selectedItem?.option?.preview;
|
|
805
583
|
|
|
806
|
-
// Decide layout: split pane if preview and wide enough
|
|
807
584
|
const useSplit = hasPreview && width >= SPLIT_PANE_MIN_WIDTH;
|
|
808
585
|
const leftWidth = useSplit ? Math.floor((width - 6) * 0.45) : inner;
|
|
809
586
|
const previewWidth = useSplit ? Math.max(20, width - leftWidth - 10) : 0;
|
|
810
587
|
|
|
811
588
|
const lines: string[] = [];
|
|
812
589
|
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
// visibleWidth() (not String.length) keeps ANSI codes + wide glyphs honest.
|
|
816
|
-
const row = (content: string): string => {
|
|
817
|
-
return ` ${truncateToWidth(content, Math.max(0, width - 1), "")}`;
|
|
818
|
-
};
|
|
590
|
+
const row = (content: string): string =>
|
|
591
|
+
` ${truncateToWidth(content, Math.max(0, width - 1), "")}`;
|
|
819
592
|
|
|
820
593
|
// Tab bar
|
|
821
594
|
if (this.params.questions.length > 1) {
|
|
@@ -825,8 +598,7 @@ class AskQuestionnaire extends Container {
|
|
|
825
598
|
const tag = `${i + 1}.${this.params.questions[i]?.header}`;
|
|
826
599
|
tabParts.push(active ? t.fg("accent", t.bold(tag)) : t.fg("dim", tag));
|
|
827
600
|
}
|
|
828
|
-
|
|
829
|
-
lines.push(row(tabLine));
|
|
601
|
+
lines.push(row(tabParts.join(t.fg("dim", " "))));
|
|
830
602
|
}
|
|
831
603
|
|
|
832
604
|
// Header chip
|
|
@@ -838,22 +610,22 @@ class AskQuestionnaire extends Container {
|
|
|
838
610
|
lines.push(row(`${chip}${prog}`));
|
|
839
611
|
|
|
840
612
|
// Question text
|
|
841
|
-
const
|
|
613
|
+
for (const w of wrapTextWithAnsi(
|
|
842
614
|
this.currentQ.question,
|
|
843
615
|
Math.max(10, inner),
|
|
844
|
-
)
|
|
845
|
-
for (const w of questionWrapped) {
|
|
616
|
+
)) {
|
|
846
617
|
lines.push(row(t.fg("text", t.bold(w))));
|
|
847
618
|
}
|
|
848
619
|
|
|
849
|
-
// Input mode
|
|
620
|
+
// Input mode
|
|
850
621
|
if (this.inputMode) {
|
|
851
622
|
lines.push("");
|
|
852
623
|
lines.push(row(t.fg("accent", t.bold("Type your response:"))));
|
|
853
624
|
lines.push("");
|
|
854
625
|
const editorLines = this.ensureEditor().render(Math.max(0, width - 1));
|
|
855
|
-
for (const el of editorLines)
|
|
626
|
+
for (const el of editorLines) {
|
|
856
627
|
lines.push(` ${truncateToWidth(el, Math.max(0, width - 1), "")}`);
|
|
628
|
+
}
|
|
857
629
|
lines.push("");
|
|
858
630
|
lines.push(row(dim(t)("enter submit • esc back • ctrl+c cancel")));
|
|
859
631
|
lines.push("");
|
|
@@ -868,14 +640,14 @@ class AskQuestionnaire extends Container {
|
|
|
868
640
|
lines.push(row(`${t.fg("accent", "Filter:")} ${searchVal}`));
|
|
869
641
|
}
|
|
870
642
|
|
|
871
|
-
// Chat sentinel
|
|
643
|
+
// Chat sentinel
|
|
872
644
|
const chatLabel =
|
|
873
645
|
this.selectedOptionIndex === -999
|
|
874
646
|
? t.fg("accent", t.bold(SENTINEL_CHAT))
|
|
875
647
|
: t.fg("dim", SENTINEL_CHAT);
|
|
876
648
|
lines.push(row(` ${t.fg("dim", "💬")} ${chatLabel}`));
|
|
877
649
|
|
|
878
|
-
// Options (with optional preview
|
|
650
|
+
// Options (with optional split-pane preview)
|
|
879
651
|
const optionLines = this.renderOptions(useSplit ? leftWidth : width - 4);
|
|
880
652
|
const previewLines = useSplit ? this.renderPreview(previewWidth) : [];
|
|
881
653
|
const maxOptLines = Math.max(optionLines.length, previewLines.length);
|
|
@@ -894,213 +666,28 @@ class AskQuestionnaire extends Container {
|
|
|
894
666
|
previewWidth - 2,
|
|
895
667
|
"",
|
|
896
668
|
);
|
|
897
|
-
const
|
|
898
|
-
const paintedRight = right || " ".repeat(previewWidth - 2);
|
|
899
|
-
const body = `${paintedLeft}${sep}${paintedRight}`;
|
|
669
|
+
const body = `${left || " ".repeat(leftWidth - 1)}${sep}${right || " ".repeat(previewWidth - 2)}`;
|
|
900
670
|
lines.push(` ${truncateToWidth(body, Math.max(0, width - 1), "")}`);
|
|
901
671
|
}
|
|
902
672
|
} else {
|
|
903
|
-
for (const line of optionLines)
|
|
904
|
-
lines.push(row(line));
|
|
905
|
-
}
|
|
673
|
+
for (const line of optionLines) lines.push(row(line));
|
|
906
674
|
}
|
|
907
675
|
|
|
908
676
|
// Footer hints
|
|
909
|
-
const hintTexts: string[] = [];
|
|
910
677
|
const navHint =
|
|
911
678
|
this.params.questions.length > 1 ? "↑↓ nav • ←→ question" : "↑↓ nav";
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
679
|
+
const hintParts = isMulti
|
|
680
|
+
? [
|
|
681
|
+
`${navHint} • space toggle • enter commit • esc clear`,
|
|
682
|
+
"ctrl+c cancel",
|
|
683
|
+
]
|
|
684
|
+
: [
|
|
685
|
+
`${navHint} • type filter • enter select • esc clear`,
|
|
686
|
+
"ctrl+c cancel",
|
|
687
|
+
];
|
|
688
|
+
lines.push(row(dim(t)(hintParts.join(" • "))));
|
|
920
689
|
lines.push("");
|
|
921
690
|
|
|
922
|
-
// Final safety net: never emit a line wider than the terminal.
|
|
923
691
|
return lines.map((l) => truncateToWidth(l, width, ""));
|
|
924
692
|
}
|
|
925
693
|
}
|
|
926
|
-
|
|
927
|
-
// ── RPC fallback ───────────────────────────────────────────────────────
|
|
928
|
-
|
|
929
|
-
async function rpcFallback(
|
|
930
|
-
ui: { select: Function; input: Function },
|
|
931
|
-
params: Params,
|
|
932
|
-
): Promise<QuestionnaireResult> {
|
|
933
|
-
const answers: QuestionAnswer[] = [];
|
|
934
|
-
let cancelled = false;
|
|
935
|
-
|
|
936
|
-
for (let i = 0; i < params.questions.length; i++) {
|
|
937
|
-
const q = params.questions[i]!;
|
|
938
|
-
const header = q.header;
|
|
939
|
-
|
|
940
|
-
if (q.multiSelect) {
|
|
941
|
-
const lines = q.options.map(
|
|
942
|
-
(o, idx) => `${idx + 1}. ${o.label} — ${o.description}`,
|
|
943
|
-
);
|
|
944
|
-
const raw = await ui.input(
|
|
945
|
-
`${header}: ${q.question}\n\n${lines.join("\n")}\n\nEnter numbers separated by commas:`,
|
|
946
|
-
"e.g. 1,3",
|
|
947
|
-
);
|
|
948
|
-
if (raw == null) {
|
|
949
|
-
cancelled = true;
|
|
950
|
-
break;
|
|
951
|
-
}
|
|
952
|
-
const indices = String(raw)
|
|
953
|
-
.split(",")
|
|
954
|
-
.map((s) => Number(s.trim()))
|
|
955
|
-
.filter((n) => n >= 1 && n <= q.options.length);
|
|
956
|
-
const selected = indices.map((n) => q.options[n - 1]?.label);
|
|
957
|
-
if (selected.length > 0) {
|
|
958
|
-
answers.push({
|
|
959
|
-
questionIndex: i,
|
|
960
|
-
question: q.question,
|
|
961
|
-
kind: "multi",
|
|
962
|
-
answer: null,
|
|
963
|
-
selected,
|
|
964
|
-
});
|
|
965
|
-
} else {
|
|
966
|
-
cancelled = true;
|
|
967
|
-
break;
|
|
968
|
-
}
|
|
969
|
-
} else {
|
|
970
|
-
const items = q.options.map((o) => `${o.label} — ${o.description}`);
|
|
971
|
-
items.push(SENTINEL_FREEFORM);
|
|
972
|
-
const chosen = await ui.select(`${header}: ${q.question}`, items);
|
|
973
|
-
if (chosen == null) {
|
|
974
|
-
cancelled = true;
|
|
975
|
-
break;
|
|
976
|
-
}
|
|
977
|
-
if (chosen === SENTINEL_FREEFORM) {
|
|
978
|
-
const text = await ui.input(q.question, "Type your answer...");
|
|
979
|
-
if (text == null) {
|
|
980
|
-
cancelled = true;
|
|
981
|
-
break;
|
|
982
|
-
}
|
|
983
|
-
answers.push({
|
|
984
|
-
questionIndex: i,
|
|
985
|
-
question: q.question,
|
|
986
|
-
kind: "custom",
|
|
987
|
-
answer: String(text),
|
|
988
|
-
});
|
|
989
|
-
} else {
|
|
990
|
-
const opt = q.options.find(
|
|
991
|
-
(o) =>
|
|
992
|
-
chosen === o.label || `${o.label} — ${o.description}` === chosen,
|
|
993
|
-
)!;
|
|
994
|
-
answers.push({
|
|
995
|
-
questionIndex: i,
|
|
996
|
-
question: q.question,
|
|
997
|
-
kind: "option",
|
|
998
|
-
answer: opt?.label ?? String(chosen),
|
|
999
|
-
});
|
|
1000
|
-
}
|
|
1001
|
-
}
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
return { answers, cancelled };
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
// ── Tool registration ──────────────────────────────────────────────────
|
|
1008
|
-
|
|
1009
|
-
export default function registerAsk(pi: ExtensionAPI): void {
|
|
1010
|
-
pi.registerTool({
|
|
1011
|
-
name: "ask_user",
|
|
1012
|
-
label: "Ask",
|
|
1013
|
-
description: `Ask the user up to ${MAX_QUESTIONS} structured questions (${MIN_OPTIONS}-${MAX_OPTIONS} options each) when requirements are ambiguous.`,
|
|
1014
|
-
promptSnippet: `Ask the user up to ${MAX_QUESTIONS} structured questions (${MIN_OPTIONS}-${MAX_OPTIONS} options each) when requirements are ambiguous`,
|
|
1015
|
-
promptGuidelines: [
|
|
1016
|
-
`Use ask whenever the user's request is underspecified and you cannot proceed without concrete decisions — you can ask up to ${MAX_QUESTIONS} questions per invocation.`,
|
|
1017
|
-
`Each question MUST have ${MIN_OPTIONS}-${MAX_OPTIONS} options. Every option requires a concise label (1-5 words) and a description explaining what the choice means or its trade-offs. The user can additionally type a custom answer ("${SENTINEL_FREEFORM}" row is appended automatically to single-select questions) or pick "${SENTINEL_CHAT}" to abandon the questionnaire.`,
|
|
1018
|
-
`Set multiSelect: true when multiple answers are valid; this suppresses the "${SENTINEL_FREEFORM}" row. Provide an options[].preview markdown string when an option benefits from richer side-by-side context (mockups, code snippets, diagrams, configs) — single-select only. NOTE: any non-empty preview on a single-select question ALSO suppresses the "${SENTINEL_FREEFORM}" row (no room in the side-by-side layout); "${SENTINEL_CHAT}" remains the escape hatch. If you recommend a specific option, make it the first option and append "(Recommended)" to its label.`,
|
|
1019
|
-
"Do not stack multiple ask calls back-to-back — group all clarifying questions into one invocation.",
|
|
1020
|
-
],
|
|
1021
|
-
executionMode: "sequential",
|
|
1022
|
-
parameters: ParamsSchema,
|
|
1023
|
-
|
|
1024
|
-
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
1025
|
-
if (signal?.aborted) {
|
|
1026
|
-
return {
|
|
1027
|
-
content: [{ type: "text", text: "Cancelled" }],
|
|
1028
|
-
details: { answers: [], cancelled: true },
|
|
1029
|
-
};
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
const typed = params as unknown as Params;
|
|
1033
|
-
|
|
1034
|
-
// Validate
|
|
1035
|
-
if (!Array.isArray(typed.questions) || typed.questions.length === 0) {
|
|
1036
|
-
return {
|
|
1037
|
-
content: [
|
|
1038
|
-
{ type: "text", text: "At least one question is required." },
|
|
1039
|
-
],
|
|
1040
|
-
isError: true,
|
|
1041
|
-
details: { answers: [], cancelled: true },
|
|
1042
|
-
};
|
|
1043
|
-
}
|
|
1044
|
-
|
|
1045
|
-
if (!ctx.hasUI) {
|
|
1046
|
-
const result = await rpcFallback(ctx.ui, typed);
|
|
1047
|
-
const text = result.cancelled
|
|
1048
|
-
? "User cancelled the questionnaire"
|
|
1049
|
-
: buildResponseText(result.answers, typed.questions);
|
|
1050
|
-
return { content: [{ type: "text", text }], details: result };
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
|
-
// Render inline in the conversation thread (no floating overlay).
|
|
1054
|
-
const result = await ctx.ui.custom<QuestionnaireResult | null>(
|
|
1055
|
-
(tui, theme, keybindings, done) => {
|
|
1056
|
-
if (signal) {
|
|
1057
|
-
signal.addEventListener(
|
|
1058
|
-
"abort",
|
|
1059
|
-
() => done({ answers: [], cancelled: true }),
|
|
1060
|
-
{ once: true },
|
|
1061
|
-
);
|
|
1062
|
-
}
|
|
1063
|
-
return new AskQuestionnaire(typed, tui, theme, keybindings, done);
|
|
1064
|
-
},
|
|
1065
|
-
);
|
|
1066
|
-
|
|
1067
|
-
if (!result || result.cancelled) {
|
|
1068
|
-
return {
|
|
1069
|
-
content: [{ type: "text", text: "User cancelled the questionnaire" }],
|
|
1070
|
-
details: result ?? { answers: [], cancelled: true },
|
|
1071
|
-
};
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
const text = buildResponseText(result.answers, typed.questions);
|
|
1075
|
-
return { content: [{ type: "text", text }], details: result };
|
|
1076
|
-
},
|
|
1077
|
-
|
|
1078
|
-
renderCall(args, theme) {
|
|
1079
|
-
const questions = Array.isArray(args.questions) ? args.questions : [];
|
|
1080
|
-
const count = questions.length;
|
|
1081
|
-
const firstQ = (questions[0]?.question ?? "") as string;
|
|
1082
|
-
let text = theme.fg("toolTitle", theme.bold(`ask (${count}) `));
|
|
1083
|
-
text += theme.fg("muted", firstQ);
|
|
1084
|
-
if (count > 1) text += theme.fg("dim", ` +${count - 1} more`);
|
|
1085
|
-
return new Text(text, 0, 0);
|
|
1086
|
-
},
|
|
1087
|
-
|
|
1088
|
-
renderResult(result, options, theme) {
|
|
1089
|
-
const details = result.details as
|
|
1090
|
-
| { answers?: QuestionAnswer[]; cancelled?: boolean }
|
|
1091
|
-
| undefined;
|
|
1092
|
-
if (options.isPartial) {
|
|
1093
|
-
return new Text(theme.fg("muted", "Waiting for user input..."), 0, 0);
|
|
1094
|
-
}
|
|
1095
|
-
if (!details || details.cancelled || !details.answers?.length) {
|
|
1096
|
-
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
|
1097
|
-
}
|
|
1098
|
-
const texts = details.answers.map((a) => {
|
|
1099
|
-
const v =
|
|
1100
|
-
a.kind === "multi" ? (a.selected ?? []).join(", ") : (a.answer ?? "");
|
|
1101
|
-
return `${a.questionIndex + 1}: ${v}`;
|
|
1102
|
-
});
|
|
1103
|
-
return new Text(theme.fg("success", `✓ ${texts.join(" • ")}`), 0, 0);
|
|
1104
|
-
},
|
|
1105
|
-
});
|
|
1106
|
-
}
|