@xynogen/pix-core 0.1.0 → 0.1.1
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 +49 -40
- package/src/commands/clear/clear.ts +1 -1
- package/src/commands/copy-all/copy-all.ts +21 -6
- package/src/commands/diff/diff.ts +45 -13
- package/src/commands/models/models.test.ts +2 -2
- package/src/commands/models/models.ts +1 -1
- package/src/commands/update/update.test.ts +8 -8
- package/src/commands/update/update.ts +4 -4
- package/src/index.ts +9 -9
- package/src/lib/data.ts +1 -1
- package/src/nudge/capability.test.ts +5 -8
- package/src/nudge/capability.ts +3 -1
- package/src/nudge/index.ts +1 -1
- package/src/nudge/tools.test.ts +21 -9
- package/src/nudge/tools.ts +0 -2
- package/src/tool/ask/ask.test.ts +29 -18
- package/src/tool/ask/ask.ts +1004 -979
- package/src/tool/ask/single-select-layout.test.ts +21 -5
- package/src/tool/ask/single-select-layout.ts +48 -14
- package/src/tool/todo/todo.ts +24 -37
- package/src/tool/toolbox/toolbox.test.ts +2 -2
- package/src/tool/toolbox/toolbox.ts +0 -1
- package/src/ui/footer.ts +3 -4
- package/src/ui/welcome.test.ts +6 -6
package/src/tool/ask/ask.ts
CHANGED
|
@@ -9,27 +9,24 @@
|
|
|
9
9
|
|
|
10
10
|
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
12
|
-
import { Type, type Static } from "typebox";
|
|
13
12
|
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
decodeKittyPrintable,
|
|
30
|
-
wrapTextWithAnsi,
|
|
13
|
+
type Component,
|
|
14
|
+
Container,
|
|
15
|
+
decodeKittyPrintable,
|
|
16
|
+
Editor,
|
|
17
|
+
fuzzyFilter,
|
|
18
|
+
Key,
|
|
19
|
+
type KeybindingsManager,
|
|
20
|
+
Markdown,
|
|
21
|
+
type MarkdownTheme,
|
|
22
|
+
matchesKey,
|
|
23
|
+
Spacer,
|
|
24
|
+
Text,
|
|
25
|
+
type TUI,
|
|
26
|
+
truncateToWidth,
|
|
27
|
+
wrapTextWithAnsi,
|
|
31
28
|
} from "@earendil-works/pi-tui";
|
|
32
|
-
import {
|
|
29
|
+
import { type Static, Type } from "typebox";
|
|
33
30
|
|
|
34
31
|
// ── Constants ──────────────────────────────────────────────────────────
|
|
35
32
|
|
|
@@ -49,54 +46,54 @@ const SEPARATOR = " │ ";
|
|
|
49
46
|
// ── Schema ─────────────────────────────────────────────────────────────
|
|
50
47
|
|
|
51
48
|
const OptionSchema = Type.Object({
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
+
),
|
|
65
62
|
});
|
|
66
63
|
|
|
67
64
|
const QuestionSchema = Type.Object({
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
+
),
|
|
88
85
|
});
|
|
89
86
|
|
|
90
87
|
const QuestionsSchema = Type.Array(QuestionSchema, {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
88
|
+
minItems: 1,
|
|
89
|
+
maxItems: MAX_QUESTIONS,
|
|
90
|
+
description: "1-4 questions",
|
|
94
91
|
});
|
|
95
92
|
|
|
96
93
|
const ParamsSchema = Type.Object({ questions: QuestionsSchema });
|
|
97
94
|
|
|
98
|
-
type OptionData = Static<typeof OptionSchema>;
|
|
99
|
-
type QuestionData = Static<typeof QuestionSchema>;
|
|
95
|
+
export type OptionData = Static<typeof OptionSchema>;
|
|
96
|
+
export type QuestionData = Static<typeof QuestionSchema>;
|
|
100
97
|
type Params = Static<typeof ParamsSchema>;
|
|
101
98
|
|
|
102
99
|
// ── Answer types ───────────────────────────────────────────────────────
|
|
@@ -104,978 +101,1006 @@ type Params = Static<typeof ParamsSchema>;
|
|
|
104
101
|
type AnswerKind = "option" | "custom" | "chat" | "multi";
|
|
105
102
|
|
|
106
103
|
interface QuestionAnswer {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
104
|
+
questionIndex: number;
|
|
105
|
+
question: string;
|
|
106
|
+
kind: AnswerKind;
|
|
107
|
+
answer: string | null;
|
|
108
|
+
selected?: string[];
|
|
109
|
+
preview?: string;
|
|
113
110
|
}
|
|
114
111
|
|
|
115
112
|
interface QuestionnaireResult {
|
|
116
|
-
|
|
117
|
-
|
|
113
|
+
answers: QuestionAnswer[];
|
|
114
|
+
cancelled: boolean;
|
|
118
115
|
}
|
|
119
116
|
|
|
120
117
|
// ── Helpers ────────────────────────────────────────────────────────────
|
|
121
118
|
|
|
122
119
|
function safeMarkdownTheme(): MarkdownTheme | undefined {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
120
|
+
try {
|
|
121
|
+
const md = getMarkdownTheme();
|
|
122
|
+
if (!md) return undefined;
|
|
123
|
+
md.bold("");
|
|
124
|
+
return md;
|
|
125
|
+
} catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
131
128
|
}
|
|
132
129
|
|
|
133
130
|
export function hasAnyPreview(q: QuestionData): boolean {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
131
|
+
return q.options.some(
|
|
132
|
+
(o) => typeof o.preview === "string" && o.preview.length > 0,
|
|
133
|
+
);
|
|
137
134
|
}
|
|
138
135
|
|
|
139
136
|
/** Which sentinel rows are auto-appended for a question. */
|
|
140
137
|
export function sentinelsFor(
|
|
141
|
-
|
|
138
|
+
q: QuestionData,
|
|
142
139
|
): Array<{ kind: string; label: string }> {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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;
|
|
151
148
|
}
|
|
152
149
|
|
|
153
150
|
export function formatAnswerScalar(a: QuestionAnswer): string {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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)";
|
|
158
155
|
}
|
|
159
156
|
|
|
160
157
|
export function buildResponseText(
|
|
161
|
-
|
|
162
|
-
|
|
158
|
+
answers: QuestionAnswer[],
|
|
159
|
+
questions: QuestionData[],
|
|
163
160
|
): string {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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.";
|
|
174
171
|
}
|
|
175
172
|
|
|
176
173
|
// ── Scrollbar helper ───────────────────────────────────────────────────
|
|
177
174
|
|
|
178
175
|
function scrollIndicator(index: number, total: number): string {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
176
|
+
if (total <= 1) return "";
|
|
177
|
+
const pos = Math.round((index / (total - 1)) * 6);
|
|
178
|
+
const bar = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦"][pos] ?? "·";
|
|
179
|
+
return ` ${bar} ${index + 1}/${total}`;
|
|
183
180
|
}
|
|
184
181
|
|
|
185
182
|
// ── TUI Components ─────────────────────────────────────────────────────
|
|
186
183
|
|
|
187
|
-
function
|
|
188
|
-
|
|
184
|
+
function _borderColor(theme: Theme): (s: string) => string {
|
|
185
|
+
return (s: string) => theme.fg("accent", s);
|
|
189
186
|
}
|
|
190
187
|
|
|
191
188
|
function dim(theme: Theme): (s: string) => string {
|
|
192
|
-
|
|
189
|
+
return (s: string) => theme.fg("dim", s);
|
|
193
190
|
}
|
|
194
191
|
|
|
195
192
|
class TabBar implements Component {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
193
|
+
private questions: QuestionData[];
|
|
194
|
+
private activeIndex: number;
|
|
195
|
+
private theme: Theme;
|
|
196
|
+
|
|
197
|
+
constructor(questions: QuestionData[], activeIndex: number, theme: Theme) {
|
|
198
|
+
this.questions = questions;
|
|
199
|
+
this.activeIndex = activeIndex;
|
|
200
|
+
this.theme = theme;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
invalidate(): void {}
|
|
204
|
+
|
|
205
|
+
render(width: number): string[] {
|
|
206
|
+
const t = this.theme;
|
|
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
|
+
}
|
|
237
234
|
}
|
|
238
235
|
|
|
239
236
|
class AskQuestionnaire extends Container {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
237
|
+
private params: Params;
|
|
238
|
+
private tui: TUI;
|
|
239
|
+
private theme: Theme;
|
|
240
|
+
private keybindings: KeybindingsManager;
|
|
241
|
+
private onDone: (result: QuestionnaireResult | null) => void;
|
|
242
|
+
|
|
243
|
+
private currentIndex = 0;
|
|
244
|
+
private answers: QuestionAnswer[] = [];
|
|
245
|
+
private searchQuery = "";
|
|
246
|
+
private selectedOptionIndex = 0;
|
|
247
|
+
private multiChecked = new Set<number>();
|
|
248
|
+
private inputMode = false; // typing freeform text
|
|
249
|
+
private freeformText = "";
|
|
250
|
+
private editor?: Editor;
|
|
251
|
+
private mdTheme = safeMarkdownTheme();
|
|
252
|
+
// Resolve panel width once
|
|
253
|
+
private _splitWidth: number | null = null;
|
|
254
|
+
|
|
255
|
+
constructor(
|
|
256
|
+
params: Params,
|
|
257
|
+
tui: TUI,
|
|
258
|
+
theme: Theme,
|
|
259
|
+
keybindings: KeybindingsManager,
|
|
260
|
+
onDone: (result: QuestionnaireResult | null) => void,
|
|
261
|
+
) {
|
|
262
|
+
super();
|
|
263
|
+
this.params = params;
|
|
264
|
+
this.tui = tui;
|
|
265
|
+
this.theme = theme;
|
|
266
|
+
this.keybindings = keybindings;
|
|
267
|
+
this.onDone = onDone;
|
|
268
|
+
this.renderLayout();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private get currentQ(): QuestionData {
|
|
272
|
+
return this.params.questions[this.currentIndex]!;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private get filteredOptions(): OptionData[] {
|
|
276
|
+
if (!this.searchQuery) return this.currentQ.options;
|
|
277
|
+
return fuzzyFilter(
|
|
278
|
+
this.currentQ.options,
|
|
279
|
+
this.searchQuery,
|
|
280
|
+
(o) => `${o.label} ${o.description}`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private get mainListItems(): Array<{
|
|
285
|
+
kind: string;
|
|
286
|
+
label?: string;
|
|
287
|
+
option?: OptionData;
|
|
288
|
+
}> {
|
|
289
|
+
const items: Array<{ kind: string; label?: string; option?: OptionData }> =
|
|
290
|
+
[];
|
|
291
|
+
for (const o of this.filteredOptions) {
|
|
292
|
+
items.push({ kind: "option", option: o });
|
|
293
|
+
}
|
|
294
|
+
for (const s of sentinelsFor(this.currentQ)) {
|
|
295
|
+
items.push({ kind: s.kind, label: s.label });
|
|
296
|
+
}
|
|
297
|
+
return items;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private get totalItems(): number {
|
|
301
|
+
return this.mainListItems.length;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private get selectedItem(): (typeof this.mainListItems)[0] | undefined {
|
|
305
|
+
return this.mainListItems[this.selectedOptionIndex];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
invalidate(): void {
|
|
309
|
+
super.invalidate();
|
|
310
|
+
this._splitWidth = null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
renderLayout(): void {
|
|
314
|
+
this.clear();
|
|
315
|
+
const t = this.theme;
|
|
316
|
+
// Border top
|
|
317
|
+
this.addChild(new Text("", 0, 0)); // placeholder, re-rendered
|
|
318
|
+
|
|
319
|
+
// Tab bar
|
|
320
|
+
if (this.params.questions.length > 1) {
|
|
321
|
+
this.addChild(new TabBar(this.params.questions, this.currentIndex, t));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Question header chip
|
|
325
|
+
const q = this.currentQ;
|
|
326
|
+
const chip = t.fg("accent", t.bold(q.header));
|
|
327
|
+
const prog =
|
|
328
|
+
this.params.questions.length > 1
|
|
329
|
+
? dim(t)(
|
|
330
|
+
scrollIndicator(this.currentIndex, this.params.questions.length),
|
|
331
|
+
)
|
|
332
|
+
: "";
|
|
333
|
+
this.addChild(new Text(`${chip}${prog}`, 1, 0));
|
|
334
|
+
this.addChild(new Spacer(1));
|
|
335
|
+
|
|
336
|
+
// Question text
|
|
337
|
+
this.addChild(new Text(t.fg("text", t.bold(q.question)), 1, 0));
|
|
338
|
+
this.addChild(new Spacer(1));
|
|
339
|
+
|
|
340
|
+
// Search bar for single-select
|
|
341
|
+
if (!q.multiSelect && !this.inputMode) {
|
|
342
|
+
const searchVal = this.searchQuery
|
|
343
|
+
? t.fg("text", this.searchQuery)
|
|
344
|
+
: t.fg("dim", "type to filter");
|
|
345
|
+
this.addChild(
|
|
346
|
+
new Text(`${t.fg("accent", "Filter:")} ${searchVal}`, 1, 0),
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Options area (filled on render)
|
|
351
|
+
this.addChild(new Spacer(1));
|
|
352
|
+
|
|
353
|
+
// Input mode editor
|
|
354
|
+
if (this.inputMode) {
|
|
355
|
+
this.addChild(this.ensureEditor());
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Footer hints
|
|
359
|
+
this.addChild(new Spacer(1));
|
|
360
|
+
this.addChild(this.buildHintText());
|
|
361
|
+
|
|
362
|
+
// Border bottom
|
|
363
|
+
this.addChild(new Text("", 0, 0));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private ensureEditor(): Editor {
|
|
367
|
+
if (this.editor) return this.editor;
|
|
368
|
+
const editor = new Editor(this.tui, {
|
|
369
|
+
borderColor: (s: string) => this.theme.fg("accent", s),
|
|
370
|
+
selectList: {
|
|
371
|
+
selectedPrefix: (s: string) => this.theme.fg("accent", s),
|
|
372
|
+
selectedText: (s: string) => this.theme.fg("accent", s),
|
|
373
|
+
description: (s: string) => this.theme.fg("muted", s),
|
|
374
|
+
scrollInfo: (s: string) => this.theme.fg("dim", s),
|
|
375
|
+
noMatch: (s: string) => this.theme.fg("warning", s),
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
editor.disableSubmit = false;
|
|
379
|
+
editor.onSubmit = (text: string) => this.handleFreeformSubmit(text);
|
|
380
|
+
(editor as any).focused = true;
|
|
381
|
+
this.editor = editor;
|
|
382
|
+
return editor;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private buildHintText(): Text {
|
|
386
|
+
const t = this.theme;
|
|
387
|
+
const isMulti = !!this.currentQ.multiSelect;
|
|
388
|
+
const hints: string[] = [];
|
|
389
|
+
if (this.inputMode) {
|
|
390
|
+
hints.push(dim(t)("enter=submit • esc=back • ^c=cancel"));
|
|
391
|
+
} else {
|
|
392
|
+
const multiQ = this.params.questions.length > 1;
|
|
393
|
+
const nav = multiQ ? "↑↓=nav • ←→=question" : "↑↓=nav";
|
|
394
|
+
if (isMulti) {
|
|
395
|
+
hints.push(
|
|
396
|
+
dim(t)(
|
|
397
|
+
`${nav} • space=toggle • enter=commit & next • esc=clear • ^c=cancel`,
|
|
398
|
+
),
|
|
399
|
+
);
|
|
400
|
+
} else {
|
|
401
|
+
hints.push(
|
|
402
|
+
dim(t)(`${nav} • type=filter • enter=select • esc=clear • ^c=cancel`),
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return new Text(hints.join("\n"), 1, 0);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
private recordAnswer(
|
|
410
|
+
kind: AnswerKind,
|
|
411
|
+
answer: string | null,
|
|
412
|
+
selected?: string[],
|
|
413
|
+
preview?: string,
|
|
414
|
+
): void {
|
|
415
|
+
// Remove any previous answer for this question
|
|
416
|
+
this.answers = this.answers.filter(
|
|
417
|
+
(a) => a.questionIndex !== this.currentIndex,
|
|
418
|
+
);
|
|
419
|
+
this.answers.push({
|
|
420
|
+
questionIndex: this.currentIndex,
|
|
421
|
+
question: this.currentQ.question,
|
|
422
|
+
kind,
|
|
423
|
+
answer,
|
|
424
|
+
selected,
|
|
425
|
+
preview,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private commitAnswer(): void {
|
|
430
|
+
const item = this.selectedItem;
|
|
431
|
+
if (!item) {
|
|
432
|
+
this.cancel();
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (item.kind === "option" && item.option) {
|
|
437
|
+
this.recordAnswer(
|
|
438
|
+
"option",
|
|
439
|
+
item.option.label,
|
|
440
|
+
undefined,
|
|
441
|
+
item.option.preview,
|
|
442
|
+
);
|
|
443
|
+
this.nextQuestion();
|
|
444
|
+
} else if (item.kind === "other") {
|
|
445
|
+
this.inputMode = true;
|
|
446
|
+
this.freeformText = "";
|
|
447
|
+
(this.ensureEditor() as any).focused = true;
|
|
448
|
+
this.invalidate();
|
|
449
|
+
this.renderLayout();
|
|
450
|
+
this.tui.requestRender();
|
|
451
|
+
} else if (item.kind === "next") {
|
|
452
|
+
// multi-select commit
|
|
453
|
+
const selected = Array.from(this.multiChecked)
|
|
454
|
+
.sort((a, b) => a - b)
|
|
455
|
+
.map((i) => this.currentQ.options[i]?.label);
|
|
456
|
+
if (selected.length === 0) {
|
|
457
|
+
this.cancel();
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
this.recordAnswer("multi", null, selected);
|
|
461
|
+
this.nextQuestion();
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
private handleFreeformSubmit(text: string): void {
|
|
466
|
+
if (!text.trim()) {
|
|
467
|
+
this.cancel();
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
this.recordAnswer("custom", text.trim());
|
|
471
|
+
this.nextQuestion();
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
private gotoQuestion(index: number): void {
|
|
475
|
+
if (index < 0 || index >= this.params.questions.length) return;
|
|
476
|
+
this.currentIndex = index;
|
|
477
|
+
this.searchQuery = "";
|
|
478
|
+
this.multiChecked.clear();
|
|
479
|
+
this.inputMode = false;
|
|
480
|
+
this.selectedOptionIndex = 0;
|
|
481
|
+
this.freeformText = "";
|
|
482
|
+
this.editor = undefined;
|
|
483
|
+
this.restoreAnswerState();
|
|
484
|
+
this.invalidate();
|
|
485
|
+
this.renderLayout();
|
|
486
|
+
this.tui.requestRender();
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Re-select the previously recorded answer when revisiting a question. */
|
|
490
|
+
private restoreAnswerState(): void {
|
|
491
|
+
const prev = this.answers.find(
|
|
492
|
+
(a) => a.questionIndex === this.currentIndex,
|
|
493
|
+
);
|
|
494
|
+
if (!prev) return;
|
|
495
|
+
const q = this.currentQ;
|
|
496
|
+
if (prev.kind === "multi") {
|
|
497
|
+
for (let i = 0; i < q.options.length; i++) {
|
|
498
|
+
if (prev.selected?.includes(q.options[i]!.label)) {
|
|
499
|
+
this.multiChecked.add(i);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
} else if (prev.kind === "option" && prev.answer) {
|
|
503
|
+
const idx = this.mainListItems.findIndex(
|
|
504
|
+
(it) => it.kind === "option" && it.option?.label === prev.answer,
|
|
505
|
+
);
|
|
506
|
+
if (idx >= 0) this.selectedOptionIndex = idx;
|
|
507
|
+
} else if (prev.kind === "custom") {
|
|
508
|
+
const idx = this.mainListItems.findIndex((it) => it.kind === "other");
|
|
509
|
+
if (idx >= 0) this.selectedOptionIndex = idx;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
private nextQuestion(): void {
|
|
514
|
+
const total = this.params.questions.length;
|
|
515
|
+
const answered = new Set(this.answers.map((a) => a.questionIndex));
|
|
516
|
+
// Advance to the next unanswered question (wrapping), finish when none left.
|
|
517
|
+
for (let step = 1; step <= total; step++) {
|
|
518
|
+
const idx = (this.currentIndex + step) % total;
|
|
519
|
+
if (!answered.has(idx)) {
|
|
520
|
+
this.gotoQuestion(idx);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
this.answers.sort((a, b) => a.questionIndex - b.questionIndex);
|
|
525
|
+
this.onDone({ answers: this.answers, cancelled: false });
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
private cancel(): void {
|
|
529
|
+
this.onDone({ answers: this.answers, cancelled: true });
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
private toggleMulti(index: number): void {
|
|
533
|
+
if (index < 0 || index >= this.currentQ.options.length) return;
|
|
534
|
+
if (this.multiChecked.has(index)) this.multiChecked.delete(index);
|
|
535
|
+
else this.multiChecked.add(index);
|
|
536
|
+
this.invalidate();
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
handleInput(data: string): void {
|
|
540
|
+
// Global: cancel
|
|
541
|
+
if (this.keybindings.matches(data, "tui.select.cancel")) {
|
|
542
|
+
this.cancel();
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Input mode: handle editor keys
|
|
547
|
+
if (this.inputMode) {
|
|
548
|
+
if (matchesKey(data, Key.escape)) {
|
|
549
|
+
this.inputMode = false;
|
|
550
|
+
this.editor = undefined;
|
|
551
|
+
this.invalidate();
|
|
552
|
+
this.renderLayout();
|
|
553
|
+
this.tui.requestRender();
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
// Forward all other keys to the Editor (typing, enter=submit, etc.)
|
|
557
|
+
this.ensureEditor().handleInput(data);
|
|
558
|
+
this.tui.requestRender();
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const isMulti = !!this.currentQ.multiSelect;
|
|
563
|
+
const total = this.totalItems;
|
|
564
|
+
|
|
565
|
+
// Navigation
|
|
566
|
+
if (
|
|
567
|
+
this.keybindings.matches(data, "tui.select.up") ||
|
|
568
|
+
matchesKey(data, Key.shift("tab")) ||
|
|
569
|
+
matchesKey(data, Key.ctrl("k"))
|
|
570
|
+
) {
|
|
571
|
+
if (total > 0) {
|
|
572
|
+
this.selectedOptionIndex =
|
|
573
|
+
(this.selectedOptionIndex - 1 + total) % total;
|
|
574
|
+
this.invalidate();
|
|
575
|
+
this.tui.requestRender();
|
|
576
|
+
}
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (
|
|
581
|
+
this.keybindings.matches(data, "tui.select.down") ||
|
|
582
|
+
matchesKey(data, Key.tab) ||
|
|
583
|
+
matchesKey(data, Key.ctrl("j"))
|
|
584
|
+
) {
|
|
585
|
+
if (total > 0) {
|
|
586
|
+
this.selectedOptionIndex = (this.selectedOptionIndex + 1) % total;
|
|
587
|
+
this.invalidate();
|
|
588
|
+
this.tui.requestRender();
|
|
589
|
+
}
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Left/Right: move between questions (answers preserved)
|
|
594
|
+
if (matchesKey(data, Key.left)) {
|
|
595
|
+
this.gotoQuestion(this.currentIndex - 1);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
if (matchesKey(data, Key.right)) {
|
|
599
|
+
this.gotoQuestion(this.currentIndex + 1);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Backspace: pop search
|
|
604
|
+
if (
|
|
605
|
+
this.keybindings.matches(data, "tui.editor.deleteCharBackward") ||
|
|
606
|
+
matchesKey(data, Key.backspace)
|
|
607
|
+
) {
|
|
608
|
+
if (this.searchQuery) {
|
|
609
|
+
const chars = [...this.searchQuery];
|
|
610
|
+
chars.pop();
|
|
611
|
+
this.searchQuery = chars.join("");
|
|
612
|
+
this.selectedOptionIndex = 0;
|
|
613
|
+
this.invalidate();
|
|
614
|
+
this.tui.requestRender();
|
|
615
|
+
}
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Escape: clear search
|
|
620
|
+
if (matchesKey(data, Key.escape)) {
|
|
621
|
+
if (this.searchQuery) {
|
|
622
|
+
this.searchQuery = "";
|
|
623
|
+
this.selectedOptionIndex = 0;
|
|
624
|
+
this.invalidate();
|
|
625
|
+
this.tui.requestRender();
|
|
626
|
+
}
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Space: toggle multi-select
|
|
631
|
+
if (matchesKey(data, Key.space) && isMulti) {
|
|
632
|
+
if (this.selectedItem?.kind === "option" && this.selectedItem.option) {
|
|
633
|
+
const idx = this.filteredOptions.indexOf(this.selectedItem.option);
|
|
634
|
+
if (idx >= 0) this.toggleMulti(idx);
|
|
635
|
+
this.invalidate();
|
|
636
|
+
this.tui.requestRender();
|
|
637
|
+
}
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Number shortcut
|
|
642
|
+
const numMatch = data.match(/^[1-9]$/);
|
|
643
|
+
if (numMatch && this.filteredOptions.length > 0) {
|
|
644
|
+
const idx = Number(numMatch[0]) - 1;
|
|
645
|
+
if (idx >= 0 && idx < this.filteredOptions.length) {
|
|
646
|
+
if (isMulti) {
|
|
647
|
+
this.toggleMulti(idx);
|
|
648
|
+
this.selectedOptionIndex = Math.min(idx, this.totalItems - 1);
|
|
649
|
+
this.invalidate();
|
|
650
|
+
this.tui.requestRender();
|
|
651
|
+
} else {
|
|
652
|
+
// Direct select
|
|
653
|
+
const opt = this.filteredOptions[idx]!;
|
|
654
|
+
this.recordAnswer("option", opt.label, undefined, opt.preview);
|
|
655
|
+
this.nextQuestion();
|
|
656
|
+
}
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Submit / select
|
|
662
|
+
if (this.keybindings.matches(data, "tui.select.confirm")) {
|
|
663
|
+
this.commitAnswer();
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Search input: type to filter
|
|
668
|
+
if (!isMulti) {
|
|
669
|
+
const printable = decodeKittyPrintable(data);
|
|
670
|
+
if (printable !== undefined) {
|
|
671
|
+
this.searchQuery += printable;
|
|
672
|
+
this.selectedOptionIndex = 0;
|
|
673
|
+
this.invalidate();
|
|
674
|
+
this.tui.requestRender();
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
const chars = [...data];
|
|
678
|
+
if (
|
|
679
|
+
chars.length === 1 &&
|
|
680
|
+
chars[0] &&
|
|
681
|
+
chars[0].charCodeAt(0) >= 32 &&
|
|
682
|
+
chars[0].charCodeAt(0) < 127
|
|
683
|
+
) {
|
|
684
|
+
this.searchQuery += chars[0];
|
|
685
|
+
this.selectedOptionIndex = 0;
|
|
686
|
+
this.invalidate();
|
|
687
|
+
this.tui.requestRender();
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
private renderOptions(width: number): string[] {
|
|
693
|
+
const t = this.theme;
|
|
694
|
+
const inner = Math.max(20, width - 6);
|
|
695
|
+
const isMulti = !!this.currentQ.multiSelect;
|
|
696
|
+
const items = this.mainListItems;
|
|
697
|
+
const total = items.length;
|
|
698
|
+
const chk = (i: number) =>
|
|
699
|
+
isMulti
|
|
700
|
+
? this.multiChecked.has(i)
|
|
701
|
+
? t.fg("success", "✓")
|
|
702
|
+
: t.fg("dim", "○")
|
|
703
|
+
: "";
|
|
704
|
+
|
|
705
|
+
if (total === 0) return [t.fg("warning", "No options")];
|
|
706
|
+
|
|
707
|
+
const maxVisible = Math.min(total, 12);
|
|
708
|
+
const start = Math.max(
|
|
709
|
+
0,
|
|
710
|
+
Math.min(
|
|
711
|
+
this.selectedOptionIndex - Math.floor(maxVisible / 2),
|
|
712
|
+
total - maxVisible,
|
|
713
|
+
),
|
|
714
|
+
);
|
|
715
|
+
const end = Math.min(start + maxVisible, total);
|
|
716
|
+
|
|
717
|
+
const lines: string[] = [];
|
|
718
|
+
const pad = " ";
|
|
719
|
+
|
|
720
|
+
for (let i = start; i < end; i++) {
|
|
721
|
+
const item = items[i]!;
|
|
722
|
+
const sel = i === this.selectedOptionIndex;
|
|
723
|
+
const ptr = sel ? t.fg("accent", "→") : " ";
|
|
724
|
+
|
|
725
|
+
if (item.kind === "option" && item.option) {
|
|
726
|
+
const optIdx = this.filteredOptions.indexOf(item.option);
|
|
727
|
+
const checkbox = isMulti ? ` ${chk(optIdx)}` : "";
|
|
728
|
+
const num = t.fg("dim", `${optIdx + 1}.`);
|
|
729
|
+
const label = sel
|
|
730
|
+
? t.fg("accent", t.bold(item.option.label))
|
|
731
|
+
: t.fg("text", t.bold(item.option.label));
|
|
732
|
+
lines.push(
|
|
733
|
+
truncateToWidth(`${ptr} ${num}${checkbox} ${label}`, inner, ""),
|
|
734
|
+
);
|
|
735
|
+
if (item.option.description) {
|
|
736
|
+
const wrapped = wrapTextWithAnsi(
|
|
737
|
+
item.option.description,
|
|
738
|
+
Math.max(10, inner - 6),
|
|
739
|
+
);
|
|
740
|
+
for (const w of wrapped) {
|
|
741
|
+
lines.push(truncateToWidth(`${pad}${t.fg("muted", w)}`, inner, ""));
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
} else if (item.kind === "other") {
|
|
745
|
+
const label = sel
|
|
746
|
+
? t.fg("accent", t.bold(SENTINEL_FREEFORM))
|
|
747
|
+
: t.fg("text", t.bold(SENTINEL_FREEFORM));
|
|
748
|
+
lines.push(
|
|
749
|
+
truncateToWidth(`${ptr} ${t.fg("dim", "✎")} ${label}`, inner, ""),
|
|
750
|
+
);
|
|
751
|
+
} else if (item.kind === "next") {
|
|
752
|
+
const label = sel
|
|
753
|
+
? t.fg("accent", t.bold(SENTINEL_NEXT))
|
|
754
|
+
: t.fg("text", t.bold(SENTINEL_NEXT));
|
|
755
|
+
lines.push(
|
|
756
|
+
truncateToWidth(`${ptr} ${t.fg("dim", "→")} ${label}`, inner, ""),
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (start > 0 || end < total) {
|
|
762
|
+
const count =
|
|
763
|
+
this.filteredOptions.length > 0
|
|
764
|
+
? `${this.selectedOptionIndex + 1}/${total}`
|
|
765
|
+
: `${total}`;
|
|
766
|
+
lines.push(t.fg("dim", truncateToWidth(` ${count}`, inner, "")));
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
return lines;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
private renderPreview(width: number): string[] {
|
|
773
|
+
const item = this.selectedItem;
|
|
774
|
+
if (item?.kind !== "option" || !item.option?.preview) {
|
|
775
|
+
return [this.theme.fg("dim", "No preview")];
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const mdText = item.option.preview;
|
|
779
|
+
const mdWidth = Math.max(10, width);
|
|
780
|
+
|
|
781
|
+
if (this.mdTheme) {
|
|
782
|
+
const md = new Markdown(
|
|
783
|
+
`## ${item.option.label}\n\n${mdText}`,
|
|
784
|
+
0,
|
|
785
|
+
0,
|
|
786
|
+
this.mdTheme,
|
|
787
|
+
);
|
|
788
|
+
return md.render(mdWidth);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const lines = wrapTextWithAnsi(mdText, mdWidth);
|
|
792
|
+
return lines.map((l) =>
|
|
793
|
+
truncateToWidth(this.theme.fg("muted", l), mdWidth, ""),
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
override render(width: number): string[] {
|
|
798
|
+
const inner = Math.max(20, width - 4);
|
|
799
|
+
const t = this.theme;
|
|
800
|
+
const isMulti = !!this.currentQ.multiSelect;
|
|
801
|
+
const hasPreview =
|
|
802
|
+
!isMulti &&
|
|
803
|
+
this.selectedItem?.kind === "option" &&
|
|
804
|
+
!!this.selectedItem?.option?.preview;
|
|
805
|
+
|
|
806
|
+
// Decide layout: split pane if preview and wide enough
|
|
807
|
+
const useSplit = hasPreview && width >= SPLIT_PANE_MIN_WIDTH;
|
|
808
|
+
const leftWidth = useSplit ? Math.floor((width - 6) * 0.45) : inner;
|
|
809
|
+
const previewWidth = useSplit ? Math.max(20, width - leftWidth - 10) : 0;
|
|
810
|
+
|
|
811
|
+
const lines: string[] = [];
|
|
812
|
+
|
|
813
|
+
// Build a bordered body row: pad/truncate the (ANSI-containing) content to
|
|
814
|
+
// exactly `inner` visible columns, then wrap in side borders. Using
|
|
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
|
+
};
|
|
819
|
+
|
|
820
|
+
// Tab bar
|
|
821
|
+
if (this.params.questions.length > 1) {
|
|
822
|
+
const tabParts: string[] = [];
|
|
823
|
+
for (let i = 0; i < this.params.questions.length; i++) {
|
|
824
|
+
const active = i === this.currentIndex;
|
|
825
|
+
const tag = `${i + 1}.${this.params.questions[i]?.header}`;
|
|
826
|
+
tabParts.push(active ? t.fg("accent", t.bold(tag)) : t.fg("dim", tag));
|
|
827
|
+
}
|
|
828
|
+
const tabLine = tabParts.join(t.fg("dim", " "));
|
|
829
|
+
lines.push(row(tabLine));
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// Header chip
|
|
833
|
+
const chip = t.fg("accent", t.bold(this.currentQ.header));
|
|
834
|
+
const prog =
|
|
835
|
+
this.params.questions.length > 1
|
|
836
|
+
? dim(t)(` ${this.currentIndex + 1}/${this.params.questions.length}`)
|
|
837
|
+
: "";
|
|
838
|
+
lines.push(row(`${chip}${prog}`));
|
|
839
|
+
|
|
840
|
+
// Question text
|
|
841
|
+
const questionWrapped = wrapTextWithAnsi(
|
|
842
|
+
this.currentQ.question,
|
|
843
|
+
Math.max(10, inner),
|
|
844
|
+
);
|
|
845
|
+
for (const w of questionWrapped) {
|
|
846
|
+
lines.push(row(t.fg("text", t.bold(w))));
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// Input mode: render the freeform editor instead of the options list.
|
|
850
|
+
if (this.inputMode) {
|
|
851
|
+
lines.push("");
|
|
852
|
+
lines.push(row(t.fg("accent", t.bold("Type your response:"))));
|
|
853
|
+
lines.push("");
|
|
854
|
+
const editorLines = this.ensureEditor().render(Math.max(0, width - 1));
|
|
855
|
+
for (const el of editorLines)
|
|
856
|
+
lines.push(` ${truncateToWidth(el, Math.max(0, width - 1), "")}`);
|
|
857
|
+
lines.push("");
|
|
858
|
+
lines.push(row(dim(t)("enter submit • esc back • ctrl+c cancel")));
|
|
859
|
+
lines.push("");
|
|
860
|
+
return lines.map((l) => truncateToWidth(l, width, ""));
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// Search bar
|
|
864
|
+
if (!isMulti) {
|
|
865
|
+
const searchVal = this.searchQuery
|
|
866
|
+
? t.fg("text", this.searchQuery)
|
|
867
|
+
: t.fg("dim", "type to filter");
|
|
868
|
+
lines.push(row(`${t.fg("accent", "Filter:")} ${searchVal}`));
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// Chat sentinel row (above options for single-select, always visible)
|
|
872
|
+
const chatLabel =
|
|
873
|
+
this.selectedOptionIndex === -999
|
|
874
|
+
? t.fg("accent", t.bold(SENTINEL_CHAT))
|
|
875
|
+
: t.fg("dim", SENTINEL_CHAT);
|
|
876
|
+
lines.push(row(` ${t.fg("dim", "💬")} ${chatLabel}`));
|
|
877
|
+
|
|
878
|
+
// Options (with optional preview pane)
|
|
879
|
+
const optionLines = this.renderOptions(useSplit ? leftWidth : width - 4);
|
|
880
|
+
const previewLines = useSplit ? this.renderPreview(previewWidth) : [];
|
|
881
|
+
const maxOptLines = Math.max(optionLines.length, previewLines.length);
|
|
882
|
+
|
|
883
|
+
if (useSplit) {
|
|
884
|
+
const sep = t.fg("dim", SEPARATOR);
|
|
885
|
+
for (let i = 0; i < maxOptLines; i++) {
|
|
886
|
+
const left = truncateToWidth(
|
|
887
|
+
optionLines[i] ?? "",
|
|
888
|
+
leftWidth - 1,
|
|
889
|
+
"",
|
|
890
|
+
true,
|
|
891
|
+
);
|
|
892
|
+
const right = truncateToWidth(
|
|
893
|
+
previewLines[i] ?? "",
|
|
894
|
+
previewWidth - 2,
|
|
895
|
+
"",
|
|
896
|
+
);
|
|
897
|
+
const paintedLeft = left || " ".repeat(leftWidth - 1);
|
|
898
|
+
const paintedRight = right || " ".repeat(previewWidth - 2);
|
|
899
|
+
const body = `${paintedLeft}${sep}${paintedRight}`;
|
|
900
|
+
lines.push(` ${truncateToWidth(body, Math.max(0, width - 1), "")}`);
|
|
901
|
+
}
|
|
902
|
+
} else {
|
|
903
|
+
for (const line of optionLines) {
|
|
904
|
+
lines.push(row(line));
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// Footer hints
|
|
909
|
+
const hintTexts: string[] = [];
|
|
910
|
+
const navHint =
|
|
911
|
+
this.params.questions.length > 1 ? "↑↓ nav • ←→ question" : "↑↓ nav";
|
|
912
|
+
if (isMulti) {
|
|
913
|
+
hintTexts.push(`${navHint} • space toggle • enter commit • esc clear`);
|
|
914
|
+
} else {
|
|
915
|
+
hintTexts.push(`${navHint} • type filter • enter select • esc clear`);
|
|
916
|
+
}
|
|
917
|
+
hintTexts.push("ctrl+c cancel");
|
|
918
|
+
const hint = dim(t)(hintTexts.join(" • "));
|
|
919
|
+
lines.push(row(hint));
|
|
920
|
+
lines.push("");
|
|
921
|
+
|
|
922
|
+
// Final safety net: never emit a line wider than the terminal.
|
|
923
|
+
return lines.map((l) => truncateToWidth(l, width, ""));
|
|
924
|
+
}
|
|
900
925
|
}
|
|
901
926
|
|
|
902
927
|
// ── RPC fallback ───────────────────────────────────────────────────────
|
|
903
928
|
|
|
904
929
|
async function rpcFallback(
|
|
905
|
-
|
|
906
|
-
|
|
930
|
+
ui: { select: Function; input: Function },
|
|
931
|
+
params: Params,
|
|
907
932
|
): Promise<QuestionnaireResult> {
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
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 };
|
|
980
1005
|
}
|
|
981
1006
|
|
|
982
1007
|
// ── Tool registration ──────────────────────────────────────────────────
|
|
983
1008
|
|
|
984
1009
|
export default function registerAsk(pi: ExtensionAPI): void {
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
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
|
+
});
|
|
1081
1106
|
}
|