@d3ara1n/pi-ask-user 2.1.1 → 2.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/README.md +39 -31
- package/package.json +1 -1
- package/src/index.ts +1386 -1267
package/src/index.ts
CHANGED
|
@@ -30,15 +30,15 @@
|
|
|
30
30
|
|
|
31
31
|
import type { ExtensionAPI, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
32
32
|
import {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
33
|
+
type Component,
|
|
34
|
+
Editor,
|
|
35
|
+
type EditorTheme,
|
|
36
|
+
type Focusable,
|
|
37
|
+
Key,
|
|
38
|
+
matchesKey,
|
|
39
|
+
truncateToWidth,
|
|
40
|
+
visibleWidth,
|
|
41
|
+
wrapTextWithAnsi,
|
|
42
42
|
} from "@earendil-works/pi-tui";
|
|
43
43
|
import { Type } from "typebox";
|
|
44
44
|
|
|
@@ -60,23 +60,23 @@ const ICON_ANSWER = "›"; // lead glyph on option-pick answers in the result ca
|
|
|
60
60
|
// ────────────────────────────────────────────────────────────────────────────
|
|
61
61
|
|
|
62
62
|
interface QuestionOption {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
label: string;
|
|
64
|
+
description?: string;
|
|
65
|
+
/** Rich preview shown in the right column when this option is focused. */
|
|
66
|
+
preview?: string;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
interface RenderOption extends QuestionOption {
|
|
70
|
-
|
|
70
|
+
isOther?: boolean;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
interface Question {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
74
|
+
tab: string;
|
|
75
|
+
header: string;
|
|
76
|
+
prompt?: string;
|
|
77
|
+
options: QuestionOption[];
|
|
78
|
+
multiSelect?: boolean;
|
|
79
|
+
allowSkip?: boolean;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
/** A committed answer. Discriminated by `kind` so every state carries exactly
|
|
@@ -93,35 +93,35 @@ interface Question {
|
|
|
93
93
|
* - `skipped`: the user navigated past without answering (Tab/arrows).
|
|
94
94
|
*/
|
|
95
95
|
type Answer =
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
96
|
+
| { tab: string; kind: "single"; option: string }
|
|
97
|
+
| { tab: string; kind: "custom"; text: string }
|
|
98
|
+
| { tab: string; kind: "multi"; options: string[]; custom?: string }
|
|
99
|
+
| { tab: string; kind: "skipped" };
|
|
100
100
|
|
|
101
101
|
interface AskUserResult {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
102
|
+
questions: Question[];
|
|
103
|
+
answers: Answer[];
|
|
104
|
+
cancelled: boolean;
|
|
105
|
+
/** Free-form note the user can attach on the review screen. Absent when empty. */
|
|
106
|
+
message?: string;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
/** Per-tab ephemeral UI state. Preserved across tab switches. */
|
|
110
110
|
interface TabState {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
111
|
+
/** Cursor position (where ▸ is). */
|
|
112
|
+
cursor: number;
|
|
113
|
+
/** Vertical scroll offset for the options viewport. */
|
|
114
|
+
scrollOffset: number;
|
|
115
|
+
/** Whether "Type something." input mode is active for this tab. */
|
|
116
|
+
inputMode: boolean;
|
|
117
|
+
/** This tab's own editor instance (its draft lives inside; no cross-tab sync needed). */
|
|
118
|
+
editor: Editor;
|
|
119
|
+
/** Indices of committed options (multi-select). */
|
|
120
|
+
multiChecked: Set<number>;
|
|
121
|
+
/** Committed custom text for multi-select mode (kept alongside multiChecked, never overwriting it). Null if none. */
|
|
122
|
+
customText: string | null;
|
|
123
|
+
/** Committed single-select index (or -1 if none yet, -2 = answered via type-something). */
|
|
124
|
+
selectedSingle: number;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
// ────────────────────────────────────────────────────────────────────────────
|
|
@@ -129,48 +129,54 @@ interface TabState {
|
|
|
129
129
|
// ────────────────────────────────────────────────────────────────────────────
|
|
130
130
|
|
|
131
131
|
const QuestionOptionSchema = Type.Object({
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
132
|
+
label: Type.String({
|
|
133
|
+
description: "Short display label for the option (shown on the selection row)",
|
|
134
|
+
}),
|
|
135
|
+
description: Type.Optional(
|
|
136
|
+
Type.String({
|
|
137
|
+
description:
|
|
138
|
+
"Short explanation shown under the label (wraps). Add one when the label alone isn't self-explanatory.",
|
|
139
|
+
}),
|
|
140
|
+
),
|
|
141
|
+
preview: Type.Optional(
|
|
142
|
+
Type.String({
|
|
143
|
+
description:
|
|
144
|
+
"Use this when `description` (a short one-liner) is not enough and the user genuinely benefits from seeing more detail in a side column — e.g. an ASCII layout demo, a code skeleton, a Pro/Cons breakdown, or the reasoning behind why this option is offered and what choosing it entails. Rendered verbatim in a side column (spaces/newlines preserved). Do NOT treat preview as extra text capacity. Every line competes for the user's attention against the option list; only add a preview when the content is worth reading, not just because there's room for more words. If a short `description` already conveys the option, leave preview empty. Most options need only `description`.",
|
|
145
|
+
}),
|
|
146
|
+
),
|
|
144
147
|
});
|
|
145
148
|
|
|
146
149
|
const QuestionSchema = Type.Object({
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
150
|
+
header: Type.String({
|
|
151
|
+
description: "Short question title shown in the panel header, e.g. 'Which layout?'",
|
|
152
|
+
}),
|
|
153
|
+
tab: Type.String({
|
|
154
|
+
description:
|
|
155
|
+
'Short keyword that identifies this question. Shown on the tab bar when there are multiple questions, and returned in the result as the answer\'s prefix. Write it in the user\'s language (e.g. "数据库" or "布局" in a Chinese conversation, "Database" or "Layout" in English), not as a programmatic identifier like "db_choice". Must be unique across questions in one call.',
|
|
156
|
+
}),
|
|
157
|
+
prompt: Type.Optional(
|
|
158
|
+
Type.String({ description: "Optional longer body text shown under the header" }),
|
|
159
|
+
),
|
|
160
|
+
options: Type.Array(QuestionOptionSchema, {
|
|
161
|
+
description:
|
|
162
|
+
"Available options. Pass 2-4; each needs a short `label` + a `description`, and a `preview` only when a description can't fully convey the option.",
|
|
163
|
+
}),
|
|
164
|
+
multiSelect: Type.Optional(
|
|
165
|
+
Type.Boolean({
|
|
166
|
+
description:
|
|
167
|
+
"If true, the user may check multiple options (space toggles, enter commits). Default false.",
|
|
168
|
+
}),
|
|
169
|
+
),
|
|
170
|
+
allowSkip: Type.Optional(
|
|
171
|
+
Type.Boolean({
|
|
172
|
+
description:
|
|
173
|
+
"If false, the user MUST answer before proceeding (Tab/Enter with no selection is blocked). Default true. Use false for required questions.",
|
|
174
|
+
}),
|
|
175
|
+
),
|
|
170
176
|
});
|
|
171
177
|
|
|
172
178
|
const AskUserParams = Type.Object({
|
|
173
|
-
|
|
179
|
+
questions: Type.Array(QuestionSchema, { description: "One or more questions to ask" }),
|
|
174
180
|
});
|
|
175
181
|
|
|
176
182
|
// ────────────────────────────────────────────────────────────────────────────
|
|
@@ -190,63 +196,79 @@ const TOGGLE_HINT = "Ctrl+\\";
|
|
|
190
196
|
// ────────────────────────────────────────────────────────────────────────────
|
|
191
197
|
|
|
192
198
|
function wrapTab(index: number, total: number): number {
|
|
193
|
-
|
|
194
|
-
|
|
199
|
+
if (total <= 0) return 0;
|
|
200
|
+
return ((index % total) + total) % total;
|
|
195
201
|
}
|
|
196
202
|
|
|
197
203
|
/** Build the full option list for a question, always appending the "Type something." custom-input row. */
|
|
198
204
|
function buildOptions(q: Question): RenderOption[] {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
205
|
+
const opts: RenderOption[] = [...q.options];
|
|
206
|
+
opts.push({ label: "Type something.", isOther: true });
|
|
207
|
+
return opts;
|
|
202
208
|
}
|
|
203
209
|
|
|
204
210
|
function isMulti(q: Question | undefined): boolean {
|
|
205
|
-
|
|
211
|
+
return !!q?.multiSelect;
|
|
206
212
|
}
|
|
207
213
|
|
|
208
214
|
/** Whether the user is allowed to skip this question (default true). */
|
|
209
215
|
function canSkip(q: Question | undefined): boolean {
|
|
210
|
-
|
|
216
|
+
return q?.allowSkip !== false;
|
|
211
217
|
}
|
|
212
218
|
|
|
213
219
|
/** Does this question use the two-column (options | preview) layout? */
|
|
214
220
|
function isDualColumn(q: Question | undefined): boolean {
|
|
215
|
-
|
|
216
|
-
|
|
221
|
+
if (!q) return false;
|
|
222
|
+
return q.options.some((o) => o.preview);
|
|
217
223
|
}
|
|
218
224
|
|
|
219
|
-
function newTabState(
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
225
|
+
function newTabState(
|
|
226
|
+
tui: TuiLike,
|
|
227
|
+
theme: EditorTheme,
|
|
228
|
+
tabIndex: number,
|
|
229
|
+
onSubmit: (tabIndex: number, value: string) => void,
|
|
230
|
+
): TabState {
|
|
231
|
+
const editor = new Editor(tui as never, theme);
|
|
232
|
+
editor.onSubmit = (value) => onSubmit(tabIndex, value);
|
|
233
|
+
return {
|
|
234
|
+
cursor: 0,
|
|
235
|
+
scrollOffset: 0,
|
|
236
|
+
inputMode: false,
|
|
237
|
+
editor,
|
|
238
|
+
multiChecked: new Set(),
|
|
239
|
+
customText: null,
|
|
240
|
+
selectedSingle: -1,
|
|
241
|
+
};
|
|
223
242
|
}
|
|
224
243
|
|
|
225
|
-
function errorResult(
|
|
226
|
-
|
|
227
|
-
|
|
244
|
+
function errorResult(
|
|
245
|
+
message: string,
|
|
246
|
+
questions: Question[] = [],
|
|
247
|
+
): {
|
|
248
|
+
content: { type: "text"; text: string }[];
|
|
249
|
+
details: AskUserResult;
|
|
228
250
|
} {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
251
|
+
return {
|
|
252
|
+
content: [{ type: "text", text: message }],
|
|
253
|
+
details: { questions, answers: [], cancelled: true },
|
|
254
|
+
};
|
|
233
255
|
}
|
|
234
256
|
|
|
235
257
|
/** Pad a string with trailing spaces to a visible width (left-justified). */
|
|
236
258
|
function padRight(s: string, width: number): string {
|
|
237
|
-
|
|
238
|
-
|
|
259
|
+
const v = visibleWidth(s);
|
|
260
|
+
return v >= width ? s : s + " ".repeat(width - v);
|
|
239
261
|
}
|
|
240
262
|
|
|
241
263
|
/** Truncate to a visible width, appending “…” only when the text actually
|
|
242
264
|
* overflows. (truncateToWidth's third arg is a fill, not a suffix, so we
|
|
243
265
|
* reserve one column and append the ellipsis ourselves when needed.) */
|
|
244
266
|
function truncForDisplay(text: string, maxW: number): string {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
267
|
+
if (maxW <= 0) return "";
|
|
268
|
+
if (maxW === 1) return "…";
|
|
269
|
+
const vw = visibleWidth(text);
|
|
270
|
+
if (vw <= maxW) return text;
|
|
271
|
+
return truncateToWidth(text, maxW - 1, "") + "…";
|
|
250
272
|
}
|
|
251
273
|
|
|
252
274
|
/** Structured interpretation of an Answer for display/serialization.
|
|
@@ -254,31 +276,31 @@ function truncForDisplay(text: string, maxW: number): string {
|
|
|
254
276
|
* formatAnswerText, result card's formatAnswer, execute's JSON payload)
|
|
255
277
|
* derive from this, so they can never drift apart. */
|
|
256
278
|
interface AnswerView {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
279
|
+
/** Human-readable text WITHOUT ANSI — e.g. "Sidebar" / "甲, 乙" /
|
|
280
|
+
* "✎ 自定义文本" / "(none)" / "(skipped)". Consumers wrap it in color. */
|
|
281
|
+
text: string;
|
|
282
|
+
/** Theme color name for the whole text. */
|
|
283
|
+
color: ThemeColor;
|
|
262
284
|
}
|
|
263
285
|
|
|
264
286
|
/** Interpret an Answer into display form. `customGlyph` (default "✎") prefixes
|
|
265
287
|
* any custom text. Returns `(no answer)` / dim for an absent answer. */
|
|
266
288
|
function describeAnswer(ans: Answer | undefined, customGlyph = ICON_OTHER): AnswerView {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
289
|
+
if (!ans) return { text: "(no answer)", color: "dim" };
|
|
290
|
+
switch (ans.kind) {
|
|
291
|
+
case "skipped":
|
|
292
|
+
return { text: "(skipped)", color: "warning" };
|
|
293
|
+
case "multi": {
|
|
294
|
+
if (ans.options.length === 0 && !ans.custom) return { text: "(none)", color: "dim" };
|
|
295
|
+
const parts = [...ans.options];
|
|
296
|
+
if (ans.custom) parts.push(`${customGlyph} ${ans.custom}`);
|
|
297
|
+
return { text: parts.join(", "), color: "text" };
|
|
298
|
+
}
|
|
299
|
+
case "custom":
|
|
300
|
+
return { text: `${customGlyph} ${ans.text}`, color: "text" };
|
|
301
|
+
case "single":
|
|
302
|
+
return { text: ans.option, color: "text" };
|
|
303
|
+
}
|
|
282
304
|
}
|
|
283
305
|
|
|
284
306
|
// ────────────────────────────────────────────────────────────────────────────
|
|
@@ -286,925 +308,1004 @@ function describeAnswer(ans: Answer | undefined, customGlyph = ICON_OTHER): Answ
|
|
|
286
308
|
// ────────────────────────────────────────────────────────────────────────────
|
|
287
309
|
|
|
288
310
|
interface TuiLike {
|
|
289
|
-
|
|
311
|
+
requestRender(): void;
|
|
290
312
|
}
|
|
291
313
|
|
|
292
314
|
interface PanelCallbacks {
|
|
293
|
-
|
|
315
|
+
onResult: (result: AskUserResult) => void;
|
|
294
316
|
}
|
|
295
317
|
|
|
296
318
|
class AskUserPanel implements Component, Focusable {
|
|
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
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
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
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
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
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
319
|
+
focused = false;
|
|
320
|
+
|
|
321
|
+
private questions: Question[];
|
|
322
|
+
private theme: Theme;
|
|
323
|
+
private tui: TuiLike;
|
|
324
|
+
private cb: PanelCallbacks;
|
|
325
|
+
|
|
326
|
+
// ── state ──
|
|
327
|
+
private currentTab = 0;
|
|
328
|
+
private answers = new Map<string, Answer>();
|
|
329
|
+
private collapsed = false;
|
|
330
|
+
private tabs: TabState[];
|
|
331
|
+
/** Visible option rows (recomputed each render). */
|
|
332
|
+
private optionViewportH = 8;
|
|
333
|
+
/** Cursor row in the review summary (shown on the review tab). */
|
|
334
|
+
private reviewCursor = 0;
|
|
335
|
+
/** Vertical scroll offset for the review viewport. */
|
|
336
|
+
private reviewScrollOffset = 0;
|
|
337
|
+
/** Visible review rows (recomputed each render). */
|
|
338
|
+
private reviewViewportH = 8;
|
|
339
|
+
/** True while the user is editing the free-form "note to assistant" on the
|
|
340
|
+
* review tab. While true, all input goes to messageEditor. */
|
|
341
|
+
private messageEditing = false;
|
|
342
|
+
/** Committed note text (trimmed). Empty string = no note. Lives only on the
|
|
343
|
+
* review screen; the LLM cannot set it. */
|
|
344
|
+
private messageText = "";
|
|
345
|
+
/** Dedicated editor for the note. Single-line semantics: Enter saves (like
|
|
346
|
+
* the per-question "Type something." editor). */
|
|
347
|
+
private messageEditor: Editor;
|
|
348
|
+
|
|
349
|
+
// ── render cache ──
|
|
350
|
+
private cachedWidth?: number;
|
|
351
|
+
private cachedLines?: string[];
|
|
352
|
+
|
|
353
|
+
constructor(questions: Question[], tui: TuiLike, theme: Theme, cb: PanelCallbacks) {
|
|
354
|
+
this.questions = questions;
|
|
355
|
+
this.tui = tui;
|
|
356
|
+
this.theme = theme;
|
|
357
|
+
this.cb = cb;
|
|
358
|
+
|
|
359
|
+
const editorTheme: EditorTheme = {
|
|
360
|
+
borderColor: (s) => theme.fg("accent", s),
|
|
361
|
+
selectList: {
|
|
362
|
+
selectedPrefix: (t) => theme.fg("accent", t),
|
|
363
|
+
selectedText: (t) => theme.fg("accent", t),
|
|
364
|
+
description: (t) => theme.fg("muted", t),
|
|
365
|
+
scrollInfo: (t) => theme.fg("dim", t),
|
|
366
|
+
noMatch: (t) => theme.fg("warning", t),
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
// Each tab owns its own Editor instance — its internal state IS that tab's
|
|
370
|
+
// draft, so tab switching needs no text shuttling. This is the fix for the
|
|
371
|
+
// draft-loss bug (previously a single shared editor was swapped in/out and
|
|
372
|
+
// the swap was lossy across the input-mode / tab-switch boundary).
|
|
373
|
+
this.tabs = questions.map((_, i) =>
|
|
374
|
+
newTabState(tui, editorTheme, i, (ti, v) => this.handleSubmit(ti, v)),
|
|
375
|
+
);
|
|
376
|
+
// Dedicated editor for the review-screen note. Enter saves (single-line),
|
|
377
|
+
// Esc returns to the review without saving — mirroring the per-question
|
|
378
|
+
// "Type something." editor's semantics.
|
|
379
|
+
this.messageEditor = new Editor(tui as never, editorTheme);
|
|
380
|
+
this.messageEditor.onSubmit = (value) => this.handleMessageSubmit(value);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Shared submit logic bound to each tab's editor. */
|
|
384
|
+
private handleSubmit(tabIndex: number, value: string): void {
|
|
385
|
+
const q = this.questions[tabIndex];
|
|
386
|
+
const st = this.tabs[tabIndex];
|
|
387
|
+
if (!q || !st) return;
|
|
388
|
+
const trimmed = value.trim();
|
|
389
|
+
if (!trimmed) {
|
|
390
|
+
// empty → back to options. In multi-select mode, an empty submit also
|
|
391
|
+
// clears any previously committed custom text (blank = "remove my custom
|
|
392
|
+
// answer"), then re-commits the remaining checked options.
|
|
393
|
+
st.inputMode = false;
|
|
394
|
+
st.editor.setText("");
|
|
395
|
+
if (isMulti(q)) {
|
|
396
|
+
st.customText = null;
|
|
397
|
+
if (!this.commitMultiAnswer(q, st)) this.answers.delete(q.tab);
|
|
398
|
+
}
|
|
399
|
+
if (tabIndex === this.currentTab) this.invalidate();
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (isMulti(q)) {
|
|
403
|
+
// Multi-select: the custom text is an extra entry kept ALONGSIDE the
|
|
404
|
+
// checked options — it must NOT overwrite them. (Previously this path
|
|
405
|
+
// did answers.set with only the custom text, dropping every check.)
|
|
406
|
+
// Committing custom text only records it — we return to the OPTION LIST
|
|
407
|
+
// (not advance) so the user can keep checking options and then press
|
|
408
|
+
// Enter on an option to confirm the whole question. Advancing here used
|
|
409
|
+
// to jump straight to review the moment the custom editor closed.
|
|
410
|
+
st.customText = trimmed;
|
|
411
|
+
this.commitMultiAnswer(q, st);
|
|
412
|
+
st.inputMode = false;
|
|
413
|
+
if (tabIndex === this.currentTab) this.invalidate();
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
this.answers.set(q.tab, {
|
|
417
|
+
tab: q.tab,
|
|
418
|
+
kind: "custom",
|
|
419
|
+
text: trimmed,
|
|
420
|
+
});
|
|
421
|
+
st.selectedSingle = -1; // clear any prior option pick — answer is now custom
|
|
422
|
+
st.inputMode = false;
|
|
423
|
+
if (tabIndex === this.currentTab) {
|
|
424
|
+
this.advanceAfterAnswer();
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Save the review-tab note: trim, store, return to the review tab.
|
|
429
|
+
* currentTab already points at the review tab (note editing is only
|
|
430
|
+
* entered from there), so we just clear the editing flag. Empty = no note. */
|
|
431
|
+
private handleMessageSubmit(value: string): void {
|
|
432
|
+
this.messageText = value.trim();
|
|
433
|
+
this.messageEditing = false;
|
|
434
|
+
this.invalidate();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Multi-select commit: merge the checked options (st.multiChecked) together
|
|
439
|
+
* with the committed custom text (st.customText) into one multi-select
|
|
440
|
+
* answer. Returns false when there is nothing to commit (no checks and no
|
|
441
|
+
* custom text), so the caller can delete the stale answer if desired.
|
|
442
|
+
*/
|
|
443
|
+
private commitMultiAnswer(q: Question, st: TabState): boolean {
|
|
444
|
+
const opts = buildOptions(q);
|
|
445
|
+
const picked = Array.from(st.multiChecked)
|
|
446
|
+
.sort((a, b) => a - b)
|
|
447
|
+
.map((i) => opts[i])
|
|
448
|
+
.filter((o): o is RenderOption => !!o && !o.isOther);
|
|
449
|
+
const labels = picked.map((o) => o.label);
|
|
450
|
+
const customText = st.customText;
|
|
451
|
+
// Empty commit = no option picks AND no custom text. (A skippable
|
|
452
|
+
// multi-select still records an explicit empty answer via the Enter
|
|
453
|
+
// path — see handleInput — so this function returning false just means
|
|
454
|
+
// "nothing to record here".)
|
|
455
|
+
if (labels.length === 0 && !customText) return false;
|
|
456
|
+
const ans: Answer = {
|
|
457
|
+
tab: q.tab,
|
|
458
|
+
kind: "multi",
|
|
459
|
+
options: labels,
|
|
460
|
+
};
|
|
461
|
+
if (customText) ans.custom = customText;
|
|
462
|
+
this.answers.set(q.tab, ans);
|
|
463
|
+
return true;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ── accessors ──
|
|
467
|
+
|
|
468
|
+
/** Total number of tabs: one per question, plus the trailing review tab. */
|
|
469
|
+
private get totalTabs(): number {
|
|
470
|
+
return this.questions.length + 1;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** The review tab sits at index === questions.length (the last tab).
|
|
474
|
+
* While true, the panel renders the review summary instead of a question. */
|
|
475
|
+
private get isReviewTab(): boolean {
|
|
476
|
+
return this.currentTab === this.questions.length;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
private currentQuestion(): Question | undefined {
|
|
480
|
+
return this.questions[this.currentTab];
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
private currentTabState(): TabState {
|
|
484
|
+
return this.tabs[this.currentTab]!;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
private currentOptions(): RenderOption[] {
|
|
488
|
+
const q = this.currentQuestion();
|
|
489
|
+
return q ? buildOptions(q) : [];
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
private advanceAfterAnswer(): void {
|
|
493
|
+
// Advance to the next tab. The review tab is the last tab, so answering
|
|
494
|
+
// the final question lands the user on the review tab (where Enter
|
|
495
|
+
// submits). Navigation is now uniform: review is just the next tab,
|
|
496
|
+
// reached by the same Tab/→ keys as any question — no special "enter
|
|
497
|
+
// review" step. Safe because this is only called from question tabs
|
|
498
|
+
// (currentTab < questions.length), so currentTab + 1 <= reviewTabIndex.
|
|
499
|
+
this.switchTab(this.currentTab + 1);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Called before navigating AWAY from a question tab (Tab/→/←/Shift+Tab).
|
|
504
|
+
* Resolves the current question's state so it can be left cleanly:
|
|
505
|
+
*
|
|
506
|
+
* - Already committed (answers.has): leave freely.
|
|
507
|
+
* - Multi-select with UNCOMMITTED checks (or a typed custom text): a check
|
|
508
|
+
* IS an answer — commit it first, then leave. Navigating away with
|
|
509
|
+
* pending checks must submit them (not skip, not block), regardless of
|
|
510
|
+
* allowSkip, because the user has already expressed a choice. (Single-
|
|
511
|
+
* select commits on Space, so it never has pending uncommitted state.)
|
|
512
|
+
* - Nothing selected at all:
|
|
513
|
+
* allowSkip true → record a skipped answer, allow leaving.
|
|
514
|
+
* allowSkip false → block (a required question must be answered).
|
|
515
|
+
*
|
|
516
|
+
* Returns true when navigation may proceed.
|
|
517
|
+
*/
|
|
518
|
+
private prepareQuestionForLeave(): boolean {
|
|
519
|
+
const q = this.currentQuestion();
|
|
520
|
+
if (!q) return true;
|
|
521
|
+
if (this.answers.has(q.tab)) return true;
|
|
522
|
+
const st = this.currentTabState();
|
|
523
|
+
// Multi-select: uncommitted checks count as an answer — commit them,
|
|
524
|
+
// then leave. commitMultiAnswer only returns false when there's nothing
|
|
525
|
+
// to commit (no checks, no custom), which the guard already rules out.
|
|
526
|
+
if (isMulti(q) && (st.multiChecked.size > 0 || !!st.customText)) {
|
|
527
|
+
this.commitMultiAnswer(q, st);
|
|
528
|
+
return true;
|
|
529
|
+
}
|
|
530
|
+
if (!canSkip(q)) return false; // required question, nothing chosen: block
|
|
531
|
+
this.answers.set(q.tab, {
|
|
532
|
+
tab: q.tab,
|
|
533
|
+
kind: "skipped",
|
|
534
|
+
});
|
|
535
|
+
return true;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
private submit(cancelled: boolean): void {
|
|
539
|
+
this.cb.onResult({
|
|
540
|
+
questions: this.questions,
|
|
541
|
+
answers: Array.from(this.answers.values()),
|
|
542
|
+
cancelled,
|
|
543
|
+
// Only attach the note when non-empty. A cancelled submit still carries
|
|
544
|
+
// the note if the user wrote one (it may explain why they cancelled).
|
|
545
|
+
message: this.messageText || undefined,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
private setCollapsed(next: boolean): void {
|
|
550
|
+
if (this.collapsed === next) return;
|
|
551
|
+
this.collapsed = next;
|
|
552
|
+
this.invalidate();
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private clampScrollToCursor(): void {
|
|
556
|
+
const opts = this.currentOptions();
|
|
557
|
+
if (opts.length === 0) return;
|
|
558
|
+
const viewH = this.optionViewportH;
|
|
559
|
+
const st = this.currentTabState();
|
|
560
|
+
if (st.cursor < st.scrollOffset) st.scrollOffset = st.cursor;
|
|
561
|
+
else if (st.cursor >= st.scrollOffset + viewH) st.scrollOffset = st.cursor - viewH + 1;
|
|
562
|
+
if (st.scrollOffset < 0) st.scrollOffset = 0;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ── input ──
|
|
566
|
+
|
|
567
|
+
handleInput(data: string): void {
|
|
568
|
+
// 1. Note editor (messageEditing): owns all input while active. Esc
|
|
569
|
+
// returns to the review tab (currentTab already points there — note
|
|
570
|
+
// editing is only entered from the review tab).
|
|
571
|
+
if (this.messageEditing) {
|
|
572
|
+
if (matchesKey(data, Key.escape)) {
|
|
573
|
+
this.messageEditing = false;
|
|
574
|
+
this.invalidate();
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
this.messageEditor.handleInput(data);
|
|
578
|
+
this.invalidate();
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// 2. Collapse toggle (global, any tab).
|
|
583
|
+
if (matchesKey(data, TOGGLE_KEY)) {
|
|
584
|
+
this.setCollapsed(!this.collapsed);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// 3. Collapsed: only Esc (cancel) is meaningful.
|
|
589
|
+
if (this.collapsed) {
|
|
590
|
+
if (matchesKey(data, Key.escape)) this.submit(true);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// 4. Question tab + "Type something." input mode: the editor owns ALL
|
|
595
|
+
// editing keys (Tab, arrows, etc.). Tab is NOT hijacked for tab
|
|
596
|
+
// switching here, because that would break indentation / cursor
|
|
597
|
+
// movement. Esc exits back to the option list. The review tab has no
|
|
598
|
+
// input mode (it never edits options), so it skips this branch — the
|
|
599
|
+
// `!this.isReviewTab` short-circuit also avoids indexing tabs[] OOB.
|
|
600
|
+
if (!this.isReviewTab && this.currentTabState().inputMode) {
|
|
601
|
+
if (matchesKey(data, Key.escape)) {
|
|
602
|
+
const st = this.currentTabState();
|
|
603
|
+
st.inputMode = false;
|
|
604
|
+
// Keep the editor content (per-tab editor preserves it as draft).
|
|
605
|
+
this.invalidate();
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
this.currentTabState().editor.handleInput(data);
|
|
609
|
+
this.invalidate();
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// 5. Esc = cancel submission (any tab, when not editing).
|
|
614
|
+
if (matchesKey(data, Key.escape)) {
|
|
615
|
+
this.submit(true);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// 6. Shared tab navigation — Tab/→ forward, Shift+Tab/← backward.
|
|
620
|
+
// Runs on BOTH question tabs and the review tab, which is what makes
|
|
621
|
+
// the review reachable by the same keys as any question. The skip
|
|
622
|
+
// check only applies when LEAVING a question tab (never the review).
|
|
623
|
+
if (this.handleTabNavigation(data)) return;
|
|
624
|
+
|
|
625
|
+
// 7. Review tab: ↑↓ move · Space edit · Enter submit. (Esc + tab
|
|
626
|
+
// navigation were already handled above.)
|
|
627
|
+
if (this.isReviewTab) {
|
|
628
|
+
this.handleReviewInput(data);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// 8. Question tab: ↑↓ move cursor · Space toggle/commit · Enter confirm.
|
|
633
|
+
const st = this.currentTabState();
|
|
634
|
+
const q = this.currentQuestion();
|
|
635
|
+
if (!q) return;
|
|
636
|
+
const opts = this.currentOptions();
|
|
637
|
+
const multi = isMulti(q);
|
|
638
|
+
|
|
639
|
+
// Up / Down — moves ONLY the cursor (▸), does not change selection
|
|
640
|
+
if (matchesKey(data, Key.up)) {
|
|
641
|
+
if (st.cursor > 0) {
|
|
642
|
+
st.cursor--;
|
|
643
|
+
this.clampScrollToCursor();
|
|
644
|
+
this.invalidate();
|
|
645
|
+
}
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
if (matchesKey(data, Key.down)) {
|
|
649
|
+
if (st.cursor < opts.length - 1) {
|
|
650
|
+
st.cursor++;
|
|
651
|
+
this.clampScrollToCursor();
|
|
652
|
+
this.invalidate();
|
|
653
|
+
}
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
if (matchesKey(data, Key.pageUp)) {
|
|
657
|
+
st.cursor = Math.max(0, st.cursor - Math.max(1, this.optionViewportH));
|
|
658
|
+
this.clampScrollToCursor();
|
|
659
|
+
this.invalidate();
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
if (matchesKey(data, Key.pageDown)) {
|
|
663
|
+
st.cursor = Math.min(opts.length - 1, st.cursor + Math.max(1, this.optionViewportH));
|
|
664
|
+
this.clampScrollToCursor();
|
|
665
|
+
this.invalidate();
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Space — the "interact" key: select (single), toggle (multi), or EDIT
|
|
670
|
+
// (the "Type something." row). It never advances — that's Enter's job.
|
|
671
|
+
// This mirrors the review tab (where Space opens an entry for editing),
|
|
672
|
+
// so "the key that modifies things" is the same on every screen.
|
|
673
|
+
if (matchesKey(data, Key.space)) {
|
|
674
|
+
const opt = opts[st.cursor];
|
|
675
|
+
if (!opt) return;
|
|
676
|
+
if (opt.isOther) {
|
|
677
|
+
// Enter edit mode for a custom answer. Prefill with any committed
|
|
678
|
+
// custom text so the user edits rather than retypes. Per-tab
|
|
679
|
+
// editor keeps the text for Esc-discard semantics automatically.
|
|
680
|
+
// - Single-select: custom text lives in the `custom` answer.
|
|
681
|
+
// - Multi-select: it lives in st.customText (kept alongside checks).
|
|
682
|
+
st.inputMode = true;
|
|
683
|
+
const existing = this.answers.get(q.tab);
|
|
684
|
+
const prefill = multi ? st.customText : existing?.kind === "custom" ? existing.text : null;
|
|
685
|
+
if (prefill) st.editor.setText(prefill);
|
|
686
|
+
this.invalidate();
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
if (multi) {
|
|
690
|
+
if (st.multiChecked.has(st.cursor)) st.multiChecked.delete(st.cursor);
|
|
691
|
+
else st.multiChecked.add(st.cursor);
|
|
692
|
+
this.invalidate();
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
// single-select: mark the selection WITHOUT advancing (stay on question)
|
|
696
|
+
st.selectedSingle = st.cursor;
|
|
697
|
+
this.answers.set(q.tab, {
|
|
698
|
+
tab: q.tab,
|
|
699
|
+
kind: "single",
|
|
700
|
+
option: opt.label,
|
|
701
|
+
});
|
|
702
|
+
this.invalidate();
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Enter — confirm + advance to the next tab. It does NOT enter edit mode
|
|
707
|
+
// (Space owns that now), keeping the two keys orthogonal: Space modifies,
|
|
708
|
+
// Enter commits. Single-select commits the cursor position and advances;
|
|
709
|
+
// multi-select commits the currently checked options as-is and advances
|
|
710
|
+
// (Space owns checking, so Enter no longer auto-checks the cursor option).
|
|
711
|
+
if (matchesKey(data, Key.enter)) {
|
|
712
|
+
const opt = opts[st.cursor];
|
|
713
|
+
if (!opt) return;
|
|
714
|
+
if (opt.isOther && !multi) {
|
|
715
|
+
// Single-select isOther: Enter never edits (Space owns that). Only
|
|
716
|
+
// advance if a custom answer was already committed; otherwise stay.
|
|
717
|
+
if (this.answers.get(q.tab)?.kind === "custom") {
|
|
718
|
+
this.advanceAfterAnswer();
|
|
719
|
+
}
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
if (multi) {
|
|
723
|
+
// Commit the current checks (+ any custom text) and advance. For a
|
|
724
|
+
// skippable multi-select, committing an EMPTY selection is still a
|
|
725
|
+
// commit — Enter means "submit (even if empty) and move on", not
|
|
726
|
+
// "skip" (Tab/arrows do skipping). So we record an explicit empty
|
|
727
|
+
// answer and advance; a required question (!canSkip) with an empty
|
|
728
|
+
// selection stays put, since it must have at least one pick.
|
|
729
|
+
if (this.commitMultiAnswer(q, st)) {
|
|
730
|
+
this.advanceAfterAnswer();
|
|
731
|
+
} else if (canSkip(q)) {
|
|
732
|
+
this.answers.set(q.tab, {
|
|
733
|
+
tab: q.tab,
|
|
734
|
+
kind: "multi",
|
|
735
|
+
options: [],
|
|
736
|
+
});
|
|
737
|
+
this.advanceAfterAnswer();
|
|
738
|
+
}
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
// single-select: commit cursor position as the selection, then advance
|
|
742
|
+
st.selectedSingle = st.cursor;
|
|
743
|
+
this.answers.set(q.tab, {
|
|
744
|
+
tab: q.tab,
|
|
745
|
+
kind: "single",
|
|
746
|
+
option: opt.label,
|
|
747
|
+
});
|
|
748
|
+
this.advanceAfterAnswer();
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** Switch tab. Each tab owns its own Editor instance, so draft preservation
|
|
754
|
+
* is automatic — no text shuttling required. */
|
|
755
|
+
private switchTab(next: number): void {
|
|
756
|
+
if (next === this.currentTab) return;
|
|
757
|
+
this.currentTab = next;
|
|
758
|
+
this.invalidate();
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** Shared tab navigation, invoked from handleInput for BOTH question tabs
|
|
762
|
+
* and the review tab. Returns true when the key was consumed.
|
|
763
|
+
*
|
|
764
|
+
* - Tab / → : forward. Tab WRAPS through every tab (questions → review →
|
|
765
|
+
* first question); → STOPS at the review tab (boundary).
|
|
766
|
+
* - Shift+Tab / ← : backward. Shift+Tab wraps; ← stops at the first
|
|
767
|
+
* question.
|
|
768
|
+
*
|
|
769
|
+
* Leaving a question tab may need to commit pending multi-select checks
|
|
770
|
+
* or record a skip (when it's unanswered and required) — that's handled
|
|
771
|
+
* by prepareQuestionForLeave. Leaving the review tab never needs that
|
|
772
|
+
* check (it isn't a question), so →/Tab work freely from review. */
|
|
773
|
+
private handleTabNavigation(data: string): boolean {
|
|
774
|
+
if (this.totalTabs <= 1) return false;
|
|
775
|
+
// Forward
|
|
776
|
+
if (matchesKey(data, Key.tab)) {
|
|
777
|
+
if (!this.isReviewTab && !this.prepareQuestionForLeave()) return true; // required: blocked
|
|
778
|
+
this.switchTab(wrapTab(this.currentTab + 1, this.totalTabs));
|
|
779
|
+
return true;
|
|
780
|
+
}
|
|
781
|
+
if (matchesKey(data, Key.right)) {
|
|
782
|
+
if (!this.isReviewTab && !this.prepareQuestionForLeave()) return true; // required: blocked
|
|
783
|
+
if (this.currentTab + 1 >= this.totalTabs) return true; // stop at review
|
|
784
|
+
this.switchTab(this.currentTab + 1);
|
|
785
|
+
return true;
|
|
786
|
+
}
|
|
787
|
+
// Backward
|
|
788
|
+
if (matchesKey(data, Key.shift("tab"))) {
|
|
789
|
+
this.switchTab(wrapTab(this.currentTab - 1, this.totalTabs));
|
|
790
|
+
return true;
|
|
791
|
+
}
|
|
792
|
+
if (matchesKey(data, Key.left)) {
|
|
793
|
+
if (this.currentTab - 1 < 0) return true; // stop at first question
|
|
794
|
+
this.switchTab(this.currentTab - 1);
|
|
795
|
+
return true;
|
|
796
|
+
}
|
|
797
|
+
return false;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** Handle input specific to the review tab. Esc and tab navigation
|
|
801
|
+
* (Tab/←/→) are already handled upstream in handleInput, so here we only
|
|
802
|
+
* deal with: ↑↓/PgUp/PgDn (move the review cursor), Space (open the entry
|
|
803
|
+
* under the cursor for editing), and Enter (submit the whole review).
|
|
804
|
+
*
|
|
805
|
+
* The review list has N question entries plus one trailing "note to
|
|
806
|
+
* assistant" entry (index N), so the cursor ranges over [0, N]. */
|
|
807
|
+
private handleReviewInput(data: string): void {
|
|
808
|
+
const n = this.questions.length;
|
|
809
|
+
const total = n + 1; // include the note entry
|
|
810
|
+
// ↑/↓/PgUp/PgDn — move the review cursor over [0, total-1]
|
|
811
|
+
if (matchesKey(data, Key.up)) {
|
|
812
|
+
if (this.reviewCursor > 0) {
|
|
813
|
+
this.reviewCursor--;
|
|
814
|
+
this.invalidate();
|
|
815
|
+
}
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
if (matchesKey(data, Key.down)) {
|
|
819
|
+
if (this.reviewCursor < total - 1) {
|
|
820
|
+
this.reviewCursor++;
|
|
821
|
+
this.invalidate();
|
|
822
|
+
}
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
if (matchesKey(data, Key.pageUp)) {
|
|
826
|
+
this.reviewCursor = Math.max(0, this.reviewCursor - Math.max(1, this.reviewViewportH));
|
|
827
|
+
this.invalidate();
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
if (matchesKey(data, Key.pageDown)) {
|
|
831
|
+
this.reviewCursor = Math.min(
|
|
832
|
+
total - 1,
|
|
833
|
+
this.reviewCursor + Math.max(1, this.reviewViewportH),
|
|
834
|
+
);
|
|
835
|
+
this.invalidate();
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
// Space — "select" the entry under the cursor: jump into editing it.
|
|
839
|
+
// (Mirrors the option screens, where Space = select/toggle.)
|
|
840
|
+
if (matchesKey(data, Key.space)) {
|
|
841
|
+
if (this.reviewCursor === n) {
|
|
842
|
+
// Note entry: open the note editor. Prefill with the committed note
|
|
843
|
+
// (if any) so the user can tweak rather than retype.
|
|
844
|
+
this.messageEditing = true;
|
|
845
|
+
if (this.messageText) this.messageEditor.setText(this.messageText);
|
|
846
|
+
this.invalidate();
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
// Question entry: switch to that question's tab for editing.
|
|
850
|
+
// switchTab early-returns when next === currentTab, which is fine — that
|
|
851
|
+
// only happens on a single-question call where we're already on the
|
|
852
|
+
// question; nothing to redraw.
|
|
853
|
+
this.switchTab(this.reviewCursor);
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
// Enter — submit the whole review, no matter where the cursor sits.
|
|
857
|
+
if (matchesKey(data, Key.enter)) {
|
|
858
|
+
this.submit(false);
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/** Build a bordered content row that ALWAYS fits innerW: truncateToWidth
|
|
864
|
+
* is the hard safety net (any content — LLM-generated headers, user-typed
|
|
865
|
+
* custom answers, tab names — is clamped so the TUI render can never crash
|
|
866
|
+
* on an over-wide line), padRight then fills short content to keep the
|
|
867
|
+
* right border aligned. Shared by every bordered screen so the width
|
|
868
|
+
* invariant holds uniformly. */
|
|
869
|
+
private makeRow(th: Theme, borderColor: ThemeColor, innerW: number) {
|
|
870
|
+
return (content: string) => {
|
|
871
|
+
const fitted = truncateToWidth(content, innerW);
|
|
872
|
+
return th.fg(borderColor, "│") + padRight(fitted, innerW) + th.fg(borderColor, "│");
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// ── render ──
|
|
877
|
+
|
|
878
|
+
render(width: number): string[] {
|
|
879
|
+
if (this.collapsed) {
|
|
880
|
+
this.cachedWidth = width;
|
|
881
|
+
this.cachedLines = this.renderCollapsed(width);
|
|
882
|
+
return this.cachedLines;
|
|
883
|
+
}
|
|
884
|
+
if (this.cachedLines && this.cachedWidth === width) {
|
|
885
|
+
return this.cachedLines;
|
|
886
|
+
}
|
|
887
|
+
this.cachedWidth = width;
|
|
888
|
+
this.cachedLines = this.renderExpanded(width);
|
|
889
|
+
return this.cachedLines;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
private renderCollapsed(width: number): string[] {
|
|
893
|
+
const th = this.theme;
|
|
894
|
+
const qParts = this.questions.map((q, i) => {
|
|
895
|
+
const done = this.answers.has(q.tab);
|
|
896
|
+
const active = i === this.currentTab && !this.isReviewTab;
|
|
897
|
+
const mark = active ? "▸" : done ? "✓" : "○";
|
|
898
|
+
const color = active ? "accent" : done ? "success" : "dim";
|
|
899
|
+
return th.fg(color, `${q.tab}${mark}`);
|
|
900
|
+
});
|
|
901
|
+
const reviewPart = this.isReviewTab ? th.fg("accent", "Review▸") : th.fg("dim", "Review○");
|
|
902
|
+
const tabsPart = [...qParts, reviewPart].join(th.fg("dim", " "));
|
|
903
|
+
const inner = `${tabsPart} ${th.fg("dim", ` ${TOGGLE_HINT} expand `)}${th.fg("dim", " Esc cancel ")}`;
|
|
904
|
+
const line =
|
|
905
|
+
th.fg("border", "│") +
|
|
906
|
+
inner +
|
|
907
|
+
" ".repeat(Math.max(0, width - 2 - visibleWidth(inner))) +
|
|
908
|
+
th.fg("border", "│");
|
|
909
|
+
return [truncateToWidth(line, width)];
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/** Build the tab-bar content line (without border wrapping). Shared by the
|
|
913
|
+
* question screen and the review screen so the bar is always visible —
|
|
914
|
+
* the review tab is a real tab, so it must highlight when active just like
|
|
915
|
+
* any question. Callers wrap the returned string in their own row() so the
|
|
916
|
+
* border color matches the surrounding screen. */
|
|
917
|
+
private renderTabBarContent(th: Theme): string {
|
|
918
|
+
const tabCells = this.questions.map((q, i) => {
|
|
919
|
+
const active = i === this.currentTab;
|
|
920
|
+
const ans = this.answers.get(q.tab);
|
|
921
|
+
let mark = " ";
|
|
922
|
+
let baseColor: import("@earendil-works/pi-coding-agent").ThemeColor = active
|
|
923
|
+
? "accent"
|
|
924
|
+
: "muted";
|
|
925
|
+
if (ans?.kind === "skipped") {
|
|
926
|
+
mark = "—";
|
|
927
|
+
baseColor = "warning";
|
|
928
|
+
} else if (ans) {
|
|
929
|
+
mark = "✓";
|
|
930
|
+
baseColor = "success";
|
|
931
|
+
} else if (active) mark = "▸";
|
|
932
|
+
const color = active ? "accent" : baseColor;
|
|
933
|
+
return th.fg(color, `${mark} ${q.tab}`);
|
|
934
|
+
});
|
|
935
|
+
const reviewActive = this.isReviewTab;
|
|
936
|
+
const reviewMark = reviewActive ? "▸" : " ";
|
|
937
|
+
const reviewColor: import("@earendil-works/pi-coding-agent").ThemeColor = reviewActive
|
|
938
|
+
? "accent"
|
|
939
|
+
: "muted";
|
|
940
|
+
const reviewCell = th.fg(reviewColor, `${reviewMark} [ Review ]`);
|
|
941
|
+
const sep = th.fg("dim", " │");
|
|
942
|
+
return ` ${tabCells.join(th.fg("dim", " "))}${sep}${reviewCell}`;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
private renderExpanded(width: number): string[] {
|
|
946
|
+
const th = this.theme;
|
|
947
|
+
const innerW = Math.max(20, width - 2);
|
|
948
|
+
const lines: string[] = [];
|
|
949
|
+
const row = this.makeRow(th, "border", innerW);
|
|
950
|
+
|
|
951
|
+
if (this.messageEditing) {
|
|
952
|
+
return this.renderMessageEditor(width, innerW, th);
|
|
953
|
+
}
|
|
954
|
+
if (this.isReviewTab) {
|
|
955
|
+
return this.renderReview(width, innerW, th);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
lines.push(th.fg("border", `╭${"─".repeat(innerW)}╮`));
|
|
959
|
+
|
|
960
|
+
// ── Tab bar ──
|
|
961
|
+
// Always shown: there is always at least one question tab plus the
|
|
962
|
+
// trailing review tab. renderTabBarContent is shared with the review
|
|
963
|
+
// screen so the active tab stays visible across every screen.
|
|
964
|
+
lines.push(row(this.renderTabBarContent(th)));
|
|
965
|
+
lines.push(row(""));
|
|
966
|
+
|
|
967
|
+
// ── Question header ──
|
|
968
|
+
const q = this.currentQuestion();
|
|
969
|
+
const multi = isMulti(q);
|
|
970
|
+
const dual = isDualColumn(q);
|
|
971
|
+
const progress =
|
|
972
|
+
this.questions.length > 1 ? ` [${this.currentTab + 1}/${this.questions.length}]` : "";
|
|
973
|
+
const tag = multi ? th.fg("dim", " (multi)") : "";
|
|
974
|
+
const headerText = truncateToWidth(
|
|
975
|
+
` ${th.fg("accent", th.bold(q?.header ?? ""))}${tag}${th.fg("dim", progress)}`,
|
|
976
|
+
innerW,
|
|
977
|
+
"",
|
|
978
|
+
);
|
|
979
|
+
lines.push(th.fg("border", "│") + padRight(headerText, innerW) + th.fg("border", "│"));
|
|
980
|
+
|
|
981
|
+
// ── Prompt body ──
|
|
982
|
+
if (q?.prompt) {
|
|
983
|
+
for (const w of wrapTextWithAnsi(th.fg("muted", q.prompt), innerW - 2))
|
|
984
|
+
lines.push(row(` ${w}`));
|
|
985
|
+
}
|
|
986
|
+
// 空行分隔 header/prompt 与 options。原来只在有 prompt 时才加,导致无
|
|
987
|
+
// prompt 的问题其标题与选项紧贴("粘在一起")。现在无条件加。
|
|
988
|
+
lines.push(row(""));
|
|
989
|
+
|
|
990
|
+
// ── Body: options / preview / input editor ──
|
|
991
|
+
const st = this.currentTabState();
|
|
992
|
+
if (st.inputMode) {
|
|
993
|
+
for (const el of st.editor.render(innerW - 2)) lines.push(row(` ${el}`));
|
|
994
|
+
lines.push(row(th.fg("dim", " Esc back to options · Enter submit")));
|
|
995
|
+
} else if (dual && q) {
|
|
996
|
+
lines.push(...this.renderDualColumn(q, st, innerW, row, th));
|
|
997
|
+
} else {
|
|
998
|
+
lines.push(...this.renderSingleColumn(st, innerW, row, th));
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// ── Footer ──
|
|
1002
|
+
lines.push(th.fg("border", `├${"─".repeat(innerW)}┤`));
|
|
1003
|
+
const doneCount = Array.from(this.answers.keys()).length;
|
|
1004
|
+
const left =
|
|
1005
|
+
this.questions.length > 1
|
|
1006
|
+
? th.fg("dim", ` ${doneCount}/${this.questions.length} answered · `)
|
|
1007
|
+
: th.fg("dim", " ");
|
|
1008
|
+
const hint = multi
|
|
1009
|
+
? `${TOGGLE_HINT} collapse · ↑↓ move · Space toggle · Enter confirm · Esc cancel`
|
|
1010
|
+
: `${TOGGLE_HINT} collapse · ↑↓ move · Space select · Enter confirm · Esc cancel`;
|
|
1011
|
+
lines.push(row(`${left}${th.fg("dim", hint)}`));
|
|
1012
|
+
lines.push(th.fg("border", `╰${"─".repeat(innerW)}╯`));
|
|
1013
|
+
return lines;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/** Render the option row glyph. The cursor (▸) is independent of selection. */
|
|
1017
|
+
private optionGlyph(
|
|
1018
|
+
opt: RenderOption,
|
|
1019
|
+
index: number,
|
|
1020
|
+
st: TabState,
|
|
1021
|
+
multi: boolean,
|
|
1022
|
+
th: Theme,
|
|
1023
|
+
_isCursor: boolean,
|
|
1024
|
+
customAnswered: boolean,
|
|
1025
|
+
): string {
|
|
1026
|
+
if (opt.isOther) {
|
|
1027
|
+
// "Type something." is filled when a custom answer was committed.
|
|
1028
|
+
return customAnswered ? th.fg("success", ICON_RADIO_FILLED) : th.fg("dim", ICON_OTHER);
|
|
1029
|
+
}
|
|
1030
|
+
if (multi) {
|
|
1031
|
+
const checked = st.multiChecked.has(index);
|
|
1032
|
+
return checked ? th.fg("success", ICON_CHECK_FILLED) : th.fg("dim", ICON_CHECK_EMPTY);
|
|
1033
|
+
}
|
|
1034
|
+
// single: filled only when committed (selectedSingle), not on cursor hover
|
|
1035
|
+
const filled = st.selectedSingle === index;
|
|
1036
|
+
return filled ? th.fg("success", ICON_RADIO_FILLED) : th.fg("dim", ICON_RADIO_EMPTY);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/** Format an answer for the review summary. Delegates to describeAnswer
|
|
1040
|
+
* (single source of truth), then wraps in the theme color + truncates. */
|
|
1041
|
+
private formatAnswerText(ans: Answer | undefined, maxW: number, th: Theme): string {
|
|
1042
|
+
const view = describeAnswer(ans);
|
|
1043
|
+
const text = truncForDisplay(view.text, maxW);
|
|
1044
|
+
return th.fg(view.color, text);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/** Clamp the review scroll offset so the cursor stays visible. The review
|
|
1048
|
+
* list has N questions + 1 note entry, so the cursor may equal N. */
|
|
1049
|
+
private clampReviewScroll(): void {
|
|
1050
|
+
const total = this.questions.length + 1;
|
|
1051
|
+
if (total === 0) return;
|
|
1052
|
+
const viewH = this.reviewViewportH;
|
|
1053
|
+
if (this.reviewCursor < this.reviewScrollOffset) this.reviewScrollOffset = this.reviewCursor;
|
|
1054
|
+
else if (this.reviewCursor >= this.reviewScrollOffset + viewH)
|
|
1055
|
+
this.reviewScrollOffset = this.reviewCursor - viewH + 1;
|
|
1056
|
+
if (this.reviewScrollOffset < 0) this.reviewScrollOffset = 0;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/** Review summary: one question per entry (header + answer), plus a trailing
|
|
1060
|
+
* "note to assistant" entry. Viewport scrolling reuses the option-screen
|
|
1061
|
+
* layout primitives. */
|
|
1062
|
+
private renderReview(_width: number, innerW: number, th: Theme): string[] {
|
|
1063
|
+
const lines: string[] = [];
|
|
1064
|
+
// Review uses a distinct border color (success/green) so it's visually
|
|
1065
|
+
// unmistakable as the review/confirm screen — not another question. The
|
|
1066
|
+
// question screen keeps the default "border" color.
|
|
1067
|
+
const bc: import("@earendil-works/pi-coding-agent").ThemeColor = "success";
|
|
1068
|
+
const row = this.makeRow(th, bc, innerW);
|
|
1069
|
+
lines.push(th.fg(bc, `╭${"─".repeat(innerW)}╮`));
|
|
1070
|
+
lines.push(row(this.renderTabBarContent(th)));
|
|
1071
|
+
lines.push(row(""));
|
|
1072
|
+
lines.push(row(` ${th.fg("accent", th.bold("Review your answers"))}`));
|
|
1073
|
+
lines.push(th.fg(bc, `├${"─".repeat(innerW)}┤`));
|
|
1074
|
+
const n = this.questions.length;
|
|
1075
|
+
const total = n + 1; // +1 for the note entry
|
|
1076
|
+
// Body indent (6 cols): questions and the note carry a 2-visible-col marker
|
|
1077
|
+
// (`1.` / `2.` … or `✎ ` for the note) right after the cursor, plus a
|
|
1078
|
+
// separator space, so every title starts at the same column. The body is
|
|
1079
|
+
// indented one past that so header vs content stay visually distinct —
|
|
1080
|
+
// previously the body sat at 5 cols and the note's icon pushed its title
|
|
1081
|
+
// out of alignment with the question titles.
|
|
1082
|
+
const bodyIndent = " "; // 6 spaces
|
|
1083
|
+
const maxW = innerW - 2 - bodyIndent.length;
|
|
1084
|
+
this.reviewViewportH = Math.max(3, Math.min(total, 10));
|
|
1085
|
+
this.clampReviewScroll();
|
|
1086
|
+
const start = this.reviewScrollOffset;
|
|
1087
|
+
const end = Math.min(total, start + this.reviewViewportH);
|
|
1088
|
+
for (let i = start; i < end; i++) {
|
|
1089
|
+
const isCursor = i === this.reviewCursor;
|
|
1090
|
+
const prefix = isCursor ? `${th.fg("accent", ICON_CURSOR)} ` : " ";
|
|
1091
|
+
const headerColor: import("@earendil-works/pi-coding-agent").ThemeColor = isCursor
|
|
1092
|
+
? "accent"
|
|
1093
|
+
: "muted";
|
|
1094
|
+
// marker: a fixed 2-visible-col slot + 1 separator space, so every title
|
|
1095
|
+
// (questions + note) aligns regardless of icon width. `1.` is 2 cols;
|
|
1096
|
+
// the note's ✎ is 1 col, padded to `✎ ` (hence one extra space between
|
|
1097
|
+
// ✎ and its title — the deliberate tradeoff of this layout).
|
|
1098
|
+
// ── Note entry (index n): always last, two rows like a question. ──
|
|
1099
|
+
if (i === n) {
|
|
1100
|
+
// 空行分隔:note 是异类条目(附加留言,非问答),用空行和上方
|
|
1101
|
+
// 问答列表隔开。保持简单,不用点线/装饰。
|
|
1102
|
+
lines.push(row(""));
|
|
1103
|
+
const marker = th.fg(headerColor, `${ICON_OTHER} `);
|
|
1104
|
+
lines.push(row(` ${prefix}${marker} ${th.fg(headerColor, "Note to assistant")}`));
|
|
1105
|
+
const msg = this.messageText;
|
|
1106
|
+
if (msg) {
|
|
1107
|
+
const vw = visibleWidth(msg);
|
|
1108
|
+
const body = vw <= maxW ? msg : `${truncateToWidth(msg, maxW - 1, "")}…`;
|
|
1109
|
+
lines.push(row(`${bodyIndent}${th.fg("text", body)}`));
|
|
1110
|
+
} else {
|
|
1111
|
+
lines.push(row(`${bodyIndent}${th.fg("dim", "(optional — Space to add a note)")}`));
|
|
1112
|
+
}
|
|
1113
|
+
continue;
|
|
1114
|
+
}
|
|
1115
|
+
const q = this.questions[i]!;
|
|
1116
|
+
const ans = this.answers.get(q.tab);
|
|
1117
|
+
// Header row: cursor + marker + title.
|
|
1118
|
+
const marker = th.fg(headerColor, `${i + 1}.`);
|
|
1119
|
+
lines.push(row(` ${prefix}${marker} ${th.fg(headerColor, q.header)}`));
|
|
1120
|
+
// Answer row: reuse the description renderer's indent/wrap, fed the
|
|
1121
|
+
// formatted answer text. Skipped/custom/multi-select all flow through
|
|
1122
|
+
// formatAnswerText, so the coloring matches the option screen.
|
|
1123
|
+
const ansText = this.formatAnswerText(ans, maxW, th);
|
|
1124
|
+
lines.push(row(`${bodyIndent}${ansText}`));
|
|
1125
|
+
}
|
|
1126
|
+
if (total > this.reviewViewportH) {
|
|
1127
|
+
lines.push(
|
|
1128
|
+
row(th.fg("dim", `${bodyIndent}↑↓/PgUp/PgDn scroll · ${start + 1}-${end}/${total}`)),
|
|
1129
|
+
);
|
|
1130
|
+
}
|
|
1131
|
+
lines.push(th.fg(bc, `├${"─".repeat(innerW)}┤`));
|
|
1132
|
+
lines.push(row(th.fg("dim", " ↑↓ move · Space edit · Enter confirm · Esc cancel")));
|
|
1133
|
+
lines.push(th.fg(bc, `╰${"─".repeat(innerW)}╯`));
|
|
1134
|
+
return lines;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/** Note editor screen: reached from the review's note entry via Space. Uses
|
|
1138
|
+
* the same success-bordered look as the review screen to signal it's part
|
|
1139
|
+
* of the review flow, not a fresh question. */
|
|
1140
|
+
private renderMessageEditor(_width: number, innerW: number, th: Theme): string[] {
|
|
1141
|
+
const lines: string[] = [];
|
|
1142
|
+
const bc: import("@earendil-works/pi-coding-agent").ThemeColor = "success";
|
|
1143
|
+
const row = this.makeRow(th, bc, innerW);
|
|
1144
|
+
lines.push(th.fg(bc, `╭${"─".repeat(innerW)}╮`));
|
|
1145
|
+
lines.push(row(` ${th.fg("accent", th.bold(`${ICON_OTHER} Note to assistant`))}`));
|
|
1146
|
+
lines.push(th.fg(bc, `├${"─".repeat(innerW)}┤`));
|
|
1147
|
+
for (const el of this.messageEditor.render(innerW - 2)) lines.push(row(` ${el}`));
|
|
1148
|
+
lines.push(th.fg(bc, `├${"─".repeat(innerW)}┤`));
|
|
1149
|
+
lines.push(row(th.fg("dim", " Esc back to review · Enter save note")));
|
|
1150
|
+
lines.push(th.fg(bc, `╰${"─".repeat(innerW)}╯`));
|
|
1151
|
+
return lines;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/** Single-column layout: option label + wrapped description below. */
|
|
1155
|
+
private renderSingleColumn(
|
|
1156
|
+
st: TabState,
|
|
1157
|
+
innerW: number,
|
|
1158
|
+
row: (s: string) => string,
|
|
1159
|
+
th: Theme,
|
|
1160
|
+
): string[] {
|
|
1161
|
+
const q = this.currentQuestion()!;
|
|
1162
|
+
const multi = isMulti(q);
|
|
1163
|
+
const opts = this.currentOptions();
|
|
1164
|
+
const maxRows = Math.max(3, Math.min(opts.length, 10));
|
|
1165
|
+
this.optionViewportH = maxRows;
|
|
1166
|
+
this.clampScrollToCursor();
|
|
1167
|
+
const start = st.scrollOffset;
|
|
1168
|
+
const end = Math.min(opts.length, start + maxRows);
|
|
1169
|
+
const out: string[] = [];
|
|
1170
|
+
for (let i = start; i < end; i++) {
|
|
1171
|
+
const opt = opts[i]!;
|
|
1172
|
+
const isCursor = i === st.cursor;
|
|
1173
|
+
const prefix = isCursor ? `${th.fg("accent", ICON_CURSOR)} ` : " ";
|
|
1174
|
+
const ans = this.answers.get(q.tab);
|
|
1175
|
+
const committedCustom = multi ? st.customText : ans?.kind === "custom" ? ans.text : null;
|
|
1176
|
+
const customAnswered = !!committedCustom;
|
|
1177
|
+
const glyph = this.optionGlyph(opt, i, st, multi, th, isCursor, customAnswered);
|
|
1178
|
+
// For "Type something.", show the committed text instead of the placeholder.
|
|
1179
|
+
// Custom text is arbitrary-length user input — truncate to the label
|
|
1180
|
+
// column (the full text appears on the review screen and result card,
|
|
1181
|
+
// both of which wrap). Without this a long custom answer makes the row
|
|
1182
|
+
// exceed the terminal width and crashes the TUI render.
|
|
1183
|
+
const labelMaxW = Math.max(0, innerW - 5); // 1 lead + 2 prefix + 1 glyph + 1 space
|
|
1184
|
+
const displayLabel =
|
|
1185
|
+
opt.isOther && customAnswered ? truncForDisplay(committedCustom!, labelMaxW) : opt.label;
|
|
1186
|
+
const labelColor = isCursor
|
|
1187
|
+
? "accent"
|
|
1188
|
+
: opt.isOther
|
|
1189
|
+
? customAnswered
|
|
1190
|
+
? "text"
|
|
1191
|
+
: "dim"
|
|
1192
|
+
: "text";
|
|
1193
|
+
const labelText = th.fg(labelColor, displayLabel);
|
|
1194
|
+
out.push(row(` ${prefix}${glyph} ${labelText}`));
|
|
1195
|
+
if (opt.description) {
|
|
1196
|
+
out.push(...this.renderDescription(opt.description, isCursor, innerW, row, th));
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
if (opts.length > maxRows) {
|
|
1200
|
+
out.push(row(th.fg("dim", ` ↑↓/PgUp/PgDn scroll · ${start + 1}-${end}/${opts.length}`)));
|
|
1201
|
+
}
|
|
1202
|
+
return out;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/**
|
|
1206
|
+
* Two-column layout (options | preview), each half-width. Triggered when any
|
|
1207
|
+
* option of the question carries a `preview` field. The right pane shows the
|
|
1208
|
+
* preview of the option currently under the cursor.
|
|
1209
|
+
*/
|
|
1210
|
+
private renderDualColumn(
|
|
1211
|
+
q: Question,
|
|
1212
|
+
st: TabState,
|
|
1213
|
+
innerW: number,
|
|
1214
|
+
row: (s: string) => string,
|
|
1215
|
+
th: Theme,
|
|
1216
|
+
): string[] {
|
|
1217
|
+
const multi = isMulti(q);
|
|
1218
|
+
const opts = this.currentOptions();
|
|
1219
|
+
const halfW = Math.floor((innerW - 2) / 2); // 1-space gutter between columns
|
|
1220
|
+
const leftW = halfW;
|
|
1221
|
+
const rightW = innerW - 2 - halfW;
|
|
1222
|
+
const maxRows = Math.max(3, Math.min(opts.length, 10));
|
|
1223
|
+
this.optionViewportH = maxRows;
|
|
1224
|
+
this.clampScrollToCursor();
|
|
1225
|
+
const start = st.scrollOffset;
|
|
1226
|
+
const end = Math.min(opts.length, start + maxRows);
|
|
1227
|
+
|
|
1228
|
+
// ── build left column lines (options) ──
|
|
1229
|
+
const leftLines: string[] = [];
|
|
1230
|
+
const dualAns = this.answers.get(q.tab);
|
|
1231
|
+
const dualCommittedCustom = multi
|
|
1232
|
+
? st.customText
|
|
1233
|
+
: dualAns?.kind === "custom"
|
|
1234
|
+
? dualAns.text
|
|
1235
|
+
: null;
|
|
1236
|
+
const customAnswered = !!dualCommittedCustom;
|
|
1237
|
+
for (let i = start; i < end; i++) {
|
|
1238
|
+
const opt = opts[i]!;
|
|
1239
|
+
const isCursor = i === st.cursor;
|
|
1240
|
+
const prefix = isCursor ? `${th.fg("accent", ICON_CURSOR)} ` : " ";
|
|
1241
|
+
const glyph = this.optionGlyph(opt, i, st, multi, th, isCursor, customAnswered);
|
|
1242
|
+
const displayLabel = opt.isOther && customAnswered ? dualCommittedCustom! : opt.label;
|
|
1243
|
+
const labelColor = isCursor
|
|
1244
|
+
? "accent"
|
|
1245
|
+
: opt.isOther
|
|
1246
|
+
? customAnswered
|
|
1247
|
+
? "text"
|
|
1248
|
+
: "dim"
|
|
1249
|
+
: "text";
|
|
1250
|
+
const labelLine = `${prefix}${glyph} ${th.fg(labelColor, displayLabel)}`;
|
|
1251
|
+
leftLines.push(truncateToWidth(labelLine, leftW - 1, ""));
|
|
1252
|
+
}
|
|
1253
|
+
if (opts.length > maxRows) {
|
|
1254
|
+
leftLines.push(
|
|
1255
|
+
th.fg("dim", truncateToWidth(`${start + 1}-${end}/${opts.length}`, leftW - 1, "")),
|
|
1256
|
+
);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// ── build right column lines (preview of cursor option) ──
|
|
1260
|
+
const rightLines: string[] = [];
|
|
1261
|
+
const cursorOpt = opts[st.cursor];
|
|
1262
|
+
if (cursorOpt?.preview) {
|
|
1263
|
+
// Render preview verbatim (preserve ASCII layout), truncate to rightW.
|
|
1264
|
+
for (const ln of cursorOpt.preview.split("\n")) {
|
|
1265
|
+
rightLines.push(th.fg("muted", truncateToWidth(ln, rightW - 1, "")));
|
|
1266
|
+
}
|
|
1267
|
+
} else {
|
|
1268
|
+
rightLines.push(th.fg("dim", truncateToWidth("(no preview)", rightW - 1, "")));
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// ── merge columns side by side, padding the shorter one ──
|
|
1272
|
+
const rowCount = Math.max(leftLines.length, rightLines.length);
|
|
1273
|
+
const out: string[] = [];
|
|
1274
|
+
for (let r = 0; r < rowCount; r++) {
|
|
1275
|
+
const l = padRight(leftLines[r] ?? "", leftW);
|
|
1276
|
+
const rr = padRight(rightLines[r] ?? "", rightW);
|
|
1277
|
+
out.push(row(` ${l} ${rr}`));
|
|
1278
|
+
}
|
|
1279
|
+
return out;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* Render an option description in single-column mode. Multi-line (newline-
|
|
1284
|
+
* containing) descriptions render verbatim as a fixed-width block.
|
|
1285
|
+
*/
|
|
1286
|
+
private renderDescription(
|
|
1287
|
+
description: string,
|
|
1288
|
+
selected: boolean,
|
|
1289
|
+
innerW: number,
|
|
1290
|
+
row: (s: string) => string,
|
|
1291
|
+
th: Theme,
|
|
1292
|
+
): string[] {
|
|
1293
|
+
const indent = " ";
|
|
1294
|
+
const color = selected ? "muted" : "dim";
|
|
1295
|
+
if (description.includes("\n")) {
|
|
1296
|
+
const maxW = innerW - 2 - indent.length;
|
|
1297
|
+
return description
|
|
1298
|
+
.split("\n")
|
|
1299
|
+
.map((ln) => row(`${indent}${truncateToWidth(th.fg(color, ln), maxW, "")}`));
|
|
1300
|
+
}
|
|
1301
|
+
return wrapTextWithAnsi(`${indent}${th.fg(color, description)}`, innerW - 2).map((w) => row(w));
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
invalidate(): void {
|
|
1305
|
+
this.cachedWidth = undefined;
|
|
1306
|
+
this.cachedLines = undefined;
|
|
1307
|
+
this.tui.requestRender();
|
|
1308
|
+
}
|
|
1208
1309
|
}
|
|
1209
1310
|
|
|
1210
1311
|
// ────────────────────────────────────────────────────────────────────────────
|
|
@@ -1217,134 +1318,139 @@ class AskUserPanel implements Component, Focusable {
|
|
|
1217
1318
|
// ────────────────────────────────────────────────────────────────────────────
|
|
1218
1319
|
|
|
1219
1320
|
class AskUserResultView implements Component {
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1321
|
+
private questions: ReadonlyArray<Pick<Question, "header" | "tab">>;
|
|
1322
|
+
private result: AskUserResult;
|
|
1323
|
+
private theme: Theme;
|
|
1324
|
+
private expanded = false;
|
|
1325
|
+
private cachedWidth?: number;
|
|
1326
|
+
private cachedLines?: string[];
|
|
1327
|
+
|
|
1328
|
+
constructor(
|
|
1329
|
+
questions: ReadonlyArray<Pick<Question, "header" | "tab">>,
|
|
1330
|
+
result: AskUserResult,
|
|
1331
|
+
theme: Theme,
|
|
1332
|
+
) {
|
|
1333
|
+
this.questions = questions;
|
|
1334
|
+
this.result = result;
|
|
1335
|
+
this.theme = theme;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
setExpanded(expanded: boolean): void {
|
|
1339
|
+
if (this.expanded !== expanded) {
|
|
1340
|
+
this.expanded = expanded;
|
|
1341
|
+
this.cachedWidth = undefined;
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
invalidate(): void {
|
|
1346
|
+
this.cachedWidth = undefined;
|
|
1347
|
+
this.cachedLines = undefined;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
render(width: number): string[] {
|
|
1351
|
+
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
1352
|
+
this.cachedWidth = width;
|
|
1353
|
+
this.cachedLines = this.expanded ? this.renderCard(width) : this.renderCollapsed(width);
|
|
1354
|
+
return this.cachedLines;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
/** Overall status → icon, color, and a short status phrase (plain text,
|
|
1358
|
+
* no ANSI — callers wrap it in color). */
|
|
1359
|
+
private getStatus(): { icon: string; color: ThemeColor; phrase: string } {
|
|
1360
|
+
const total = this.questions.length;
|
|
1361
|
+
if (this.result.cancelled) {
|
|
1362
|
+
return {
|
|
1363
|
+
icon: "⊘",
|
|
1364
|
+
color: "warning",
|
|
1365
|
+
phrase: `Cancelled · ${this.result.answers.length}/${total} answered`,
|
|
1366
|
+
};
|
|
1367
|
+
}
|
|
1368
|
+
const anySkipped = this.result.answers.some((a) => a.kind === "skipped");
|
|
1369
|
+
return anySkipped
|
|
1370
|
+
? { icon: "○", color: "accent", phrase: "Answers (some skipped)" }
|
|
1371
|
+
: { icon: "✓", color: "success", phrase: "Answers submitted" };
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/** Format one answer for the card display. Delegates to describeAnswer. */
|
|
1375
|
+
private formatAnswer(ans: Answer | undefined): { text: string; color: ThemeColor } {
|
|
1376
|
+
return describeAnswer(ans);
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
private renderCollapsed(width: number): string[] {
|
|
1380
|
+
const th = this.theme;
|
|
1381
|
+
const { icon, color, phrase } = this.getStatus();
|
|
1382
|
+
const head = `${th.fg(color, icon)} ${th.fg(color, phrase)}`;
|
|
1383
|
+
const sep = th.fg("dim", ": ");
|
|
1384
|
+
const pairs = this.questions.map((q) => {
|
|
1385
|
+
const ans = this.result.answers.find((a) => a.tab === q.tab);
|
|
1386
|
+
return `${q.header}=${this.formatAnswer(ans).text}`;
|
|
1387
|
+
});
|
|
1388
|
+
const body = pairs.join(th.fg("dim", " · "));
|
|
1389
|
+
return [th.fg("dim", truncForDisplay(`${head}${sep}${body}`, width))];
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
private renderCard(width: number): string[] {
|
|
1393
|
+
const th = this.theme;
|
|
1394
|
+
const { icon, color, phrase } = this.getStatus();
|
|
1395
|
+
const lines: string[] = [];
|
|
1396
|
+
// Status line — icon + phrase in the status color. No border, no redundant
|
|
1397
|
+
// "Ask User" title (the tool-execution cell already renders the tool name
|
|
1398
|
+
// as its header above this component).
|
|
1399
|
+
lines.push(`${th.fg(color, icon)} ${th.fg(color, th.bold(phrase))}`);
|
|
1400
|
+
lines.push(""); // blank line separates status from the Q&A list
|
|
1401
|
+
|
|
1402
|
+
// Each question: header on its own row, then one or more answer rows.
|
|
1403
|
+
// Rows are prefixed by a glyph indicating the answer TYPE, not a uniform
|
|
1404
|
+
// marker: option picks get an arrow (›), custom text gets a pencil (✎).
|
|
1405
|
+
// A multi-select with BOTH options and custom renders as TWO rows. Long
|
|
1406
|
+
// content wraps (wrapTextWithAnsi) so nothing is ever truncated/lost.
|
|
1407
|
+
const indent = " "; // 4-space lead for answer rows
|
|
1408
|
+
const arrow = th.fg("dim", ICON_ANSWER);
|
|
1409
|
+
const pencil = th.fg("dim", ICON_OTHER);
|
|
1410
|
+
const answerRow = (glyph: string, text: string, textColor: ThemeColor) => {
|
|
1411
|
+
const lead = `${indent}${glyph} `;
|
|
1412
|
+
const w = Math.max(8, width - visibleWidth(lead));
|
|
1413
|
+
const wrapped = wrapTextWithAnsi(th.fg(textColor, text), w);
|
|
1414
|
+
const contIndent = " ".repeat(visibleWidth(lead));
|
|
1415
|
+
const out = [`${lead}${wrapped[0]}`];
|
|
1416
|
+
for (let i = 1; i < wrapped.length; i++) out.push(`${contIndent}${wrapped[i]}`);
|
|
1417
|
+
return out;
|
|
1418
|
+
};
|
|
1419
|
+
for (const q of this.questions) {
|
|
1420
|
+
const ans = this.result.answers.find((a) => a.tab === q.tab);
|
|
1421
|
+
lines.push(truncateToWidth(th.fg("muted", q.header), width));
|
|
1422
|
+
if (!ans) {
|
|
1423
|
+
lines.push(`${indent}${th.fg("dim", "(no answer)")}`);
|
|
1424
|
+
} else if (ans.kind === "skipped") {
|
|
1425
|
+
lines.push(`${indent}${th.fg("warning", "(skipped)")}`);
|
|
1426
|
+
} else if (ans.kind === "single") {
|
|
1427
|
+
lines.push(...answerRow(arrow, ans.option, "text"));
|
|
1428
|
+
} else if (ans.kind === "custom") {
|
|
1429
|
+
lines.push(...answerRow(pencil, ans.text, "text"));
|
|
1430
|
+
} else if (ans.kind === "multi") {
|
|
1431
|
+
if (ans.options.length === 0 && !ans.custom) {
|
|
1432
|
+
lines.push(`${indent}${th.fg("dim", "(none)")}`);
|
|
1433
|
+
} else {
|
|
1434
|
+
// Options row (arrow) + optional custom row (pencil) — two rows.
|
|
1435
|
+
if (ans.options.length > 0)
|
|
1436
|
+
lines.push(...answerRow(arrow, ans.options.join(", "), "text"));
|
|
1437
|
+
if (ans.custom) lines.push(...answerRow(pencil, ans.custom, "text"));
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
// Note — separated by a blank line, no border, no indent. A speech-bubble
|
|
1443
|
+
// glyph marks it as a free-form message, distinct from the Q&A answers.
|
|
1444
|
+
if (this.result.message) {
|
|
1445
|
+
lines.push("");
|
|
1446
|
+
const prefix = th.fg("accent", ICON_NOTE);
|
|
1447
|
+
const noteW = Math.max(8, width - visibleWidth(prefix) - 1);
|
|
1448
|
+
const wrapped = wrapTextWithAnsi(this.result.message, noteW);
|
|
1449
|
+
lines.push(`${prefix} ${wrapped[0]}`);
|
|
1450
|
+
for (let i = 1; i < wrapped.length; i++) lines.push(wrapped[i]);
|
|
1451
|
+
}
|
|
1452
|
+
return lines;
|
|
1453
|
+
}
|
|
1348
1454
|
}
|
|
1349
1455
|
|
|
1350
1456
|
// ────────────────────────────────────────────────────────────────────────────
|
|
@@ -1352,104 +1458,117 @@ class AskUserResultView implements Component {
|
|
|
1352
1458
|
// ────────────────────────────────────────────────────────────────────────────
|
|
1353
1459
|
|
|
1354
1460
|
export default function askUserExtension(pi: ExtensionAPI) {
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1461
|
+
pi.registerTool<typeof AskUserParams, AskUserResult>({
|
|
1462
|
+
name: "ask_user",
|
|
1463
|
+
label: "Ask User",
|
|
1464
|
+
description:
|
|
1465
|
+
'Ask the user one or more questions with options. Supports single-select (◎→◉) and multi-select (□→▣, space toggles). Every question always includes a \'Type something.\' row so the user can type a custom answer whenever none of the provided options fit — this is built in and cannot be disabled, so never assume the user is restricted to your listed options. The custom-input draft is preserved across tab switches, and a focused side panel shows extended detail (ASCII layouts, code, reasoning) when an option carries a `preview` field. Each option needs a short `label` + a `description` (shown beneath it); add a `preview` field only when a description can\'t fully convey the option. The panel is collapsible (Ctrl+\\). Use for clarifying requirements, getting preferences, or confirming decisions. Avoid using this to pick one item from a long list you just enumerated (e.g. "which of these 8 fixes should I start with?"): options are capped at a handful for a reason, and if the choice isn\'t a real either/or, present the list in a normal message and let the user reply freely, or just proceed with the highest-priority item — reserve ask_user for genuine decisions with a few distinct, mutually-exclusive paths. All displayed user-facing text should use the conversation\'s language. The result is returned as JSON: `{ "cancelled": bool, "answers": [{ "tab": <the question\'s tab>, ... }], "message"?: string }`. Each answer echoes the question\'s `tab` so you can correlate by key. Per answer only the relevant fields appear: single-select option pick → `answer`; single-select custom text → `custom`; multi-select option picks → `answers: [...]`; multi-select with custom → `answers` + `custom`; multi-select empty commit (skippable, submitted with nothing) → `answers: []`; Tab-skipped → `skipped: true`. `custom` is a sibling of `answer`/`answers`, never mixed in — it signals the user typed something outside the offered options. The optional top-level `message` is a free-form note the user can attach on the review screen (about overall direction, pacing, or anything beyond the specific questions); it is user-provided and out-of-band: you cannot set it via the parameters, and it may be absent; when present, treat it as high-priority context that can override or reframe the answers above it.',
|
|
1466
|
+
parameters: AskUserParams,
|
|
1467
|
+
|
|
1468
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1469
|
+
if (!ctx.hasUI) {
|
|
1470
|
+
return errorResult("Error: UI not available (running in non-interactive mode)");
|
|
1471
|
+
}
|
|
1472
|
+
if (params.questions.length === 0) {
|
|
1473
|
+
return errorResult("Error: No questions provided");
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
const questions: Question[] = params.questions.map((q) => ({
|
|
1477
|
+
...q,
|
|
1478
|
+
options: q.options.map((o) => ({ ...o })),
|
|
1479
|
+
}));
|
|
1480
|
+
|
|
1481
|
+
const result = await ctx.ui.custom<AskUserResult>(
|
|
1482
|
+
(tui, theme, _kb, done) => {
|
|
1483
|
+
return new AskUserPanel(questions, tui, theme, {
|
|
1484
|
+
onResult: (r) => done(r),
|
|
1485
|
+
});
|
|
1486
|
+
},
|
|
1487
|
+
{
|
|
1488
|
+
// overlay:false renders the panel into pi's bottom editorContainer slot
|
|
1489
|
+
// (the same path ctx.ui.select()/input() take) instead of compositing a
|
|
1490
|
+
// screen overlay over everything. The chat transcript stays visible above
|
|
1491
|
+
// the panel and is scrollable via the terminal's native scrollback. See
|
|
1492
|
+
// "Layout" note at the top of this file for why overlay:true breaks
|
|
1493
|
+
// transcript scrolling.
|
|
1494
|
+
overlay: false,
|
|
1495
|
+
},
|
|
1496
|
+
);
|
|
1497
|
+
|
|
1498
|
+
// ── Build the JSON payload returned to the LLM ──
|
|
1499
|
+
//
|
|
1500
|
+
// Structure is intentionally SYMMETRIC with the questions schema the LLM
|
|
1501
|
+
// authored (AskUserParams): each question carries a `tab` as its identity
|
|
1502
|
+
// key, and each answer echoes that same `tab`, so the LLM can correlate
|
|
1503
|
+
// answers back to its own questions by key with zero parsing effort. This
|
|
1504
|
+
// replaces the old `tab: answer` text format, which broke when a custom
|
|
1505
|
+
// answer contained a colon or newline (it could overwrite or collide with
|
|
1506
|
+
// adjacent lines).
|
|
1507
|
+
//
|
|
1508
|
+
// Field shape (only relevant fields are emitted — no noise):
|
|
1509
|
+
// single-select, option picked : { tab, answer }
|
|
1510
|
+
// single-select, custom typed : { tab, custom } // custom NOT in answer
|
|
1511
|
+
// multi-select, options picked : { tab, answers: [...] }
|
|
1512
|
+
// multi-select, options+custom : { tab, answers: [...], custom }
|
|
1513
|
+
// multi-select, custom only : { tab, custom }
|
|
1514
|
+
// multi-select, empty commit : { tab, answers: [] } // NOT skipped
|
|
1515
|
+
// any question, Tab-skipped : { tab, skipped: true }
|
|
1516
|
+
//
|
|
1517
|
+
// `custom` gets its own key (rather than being mixed into answer/answers)
|
|
1518
|
+
// so the LLM can tell the user stepped outside the offered options — a
|
|
1519
|
+
// useful signal, not an anti-spoofing measure. The empty-message note is
|
|
1520
|
+
// omitted entirely ("user left no note" is noise the LLM doesn't need).
|
|
1521
|
+
const jsonAnswers = result.answers.map((a): Record<string, unknown> => {
|
|
1522
|
+
const out: Record<string, unknown> = { tab: a.tab };
|
|
1523
|
+
switch (a.kind) {
|
|
1524
|
+
case "skipped":
|
|
1525
|
+
out.skipped = true;
|
|
1526
|
+
break;
|
|
1527
|
+
case "single":
|
|
1528
|
+
out.answer = a.option;
|
|
1529
|
+
break;
|
|
1530
|
+
case "custom":
|
|
1531
|
+
out.custom = a.text;
|
|
1532
|
+
break;
|
|
1533
|
+
case "multi":
|
|
1534
|
+
out.answers = a.options;
|
|
1535
|
+
if (a.custom) out.custom = a.custom;
|
|
1536
|
+
break;
|
|
1537
|
+
}
|
|
1538
|
+
return out;
|
|
1539
|
+
});
|
|
1540
|
+
const payload: Record<string, unknown> = {
|
|
1541
|
+
cancelled: result.cancelled,
|
|
1542
|
+
answers: jsonAnswers,
|
|
1543
|
+
};
|
|
1544
|
+
if (result.message) payload.message = result.message;
|
|
1545
|
+
|
|
1546
|
+
return {
|
|
1547
|
+
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
1548
|
+
details: result,
|
|
1549
|
+
};
|
|
1550
|
+
},
|
|
1551
|
+
renderResult(result, options, theme, context) {
|
|
1552
|
+
// context.args.questions is the schema-static type; AskUserResultView
|
|
1553
|
+
// only needs header+tab, so the structural subtype is compatible.
|
|
1554
|
+
const questions = context.args?.questions ?? [];
|
|
1555
|
+
// Field-level fallback: result.details may be a truthy partial object
|
|
1556
|
+
// (e.g. { cancelled: false }) whose .answers is undefined, which would
|
|
1557
|
+
// crash getStatus/renderCollapsed/renderCard with "Cannot read
|
|
1558
|
+
// properties of undefined (reading 'some')". Normalize every field.
|
|
1559
|
+
const raw = result.details ?? {};
|
|
1560
|
+
const details: AskUserResult = {
|
|
1561
|
+
questions: raw.questions ?? [],
|
|
1562
|
+
answers: raw.answers ?? [],
|
|
1563
|
+
cancelled: raw.cancelled ?? true,
|
|
1564
|
+
message: raw.message,
|
|
1565
|
+
};
|
|
1566
|
+
const comp =
|
|
1567
|
+
context.lastComponent instanceof AskUserResultView
|
|
1568
|
+
? context.lastComponent
|
|
1569
|
+
: new AskUserResultView(questions, details, theme);
|
|
1570
|
+
comp.setExpanded(options.expanded);
|
|
1571
|
+
return comp;
|
|
1572
|
+
},
|
|
1573
|
+
});
|
|
1455
1574
|
}
|