@dieulc/workflow 0.7.0 → 0.8.0
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 +110 -28
- package/commit-policy.ts +43 -0
- package/index.ts +2725 -1100
- package/model-default.ts +535 -0
- package/package.json +8 -2
- package/picker.ts +246 -0
- package/profiles.ts +73 -0
- package/questionnaire-layout.ts +340 -0
- package/questionnaire-view.ts +909 -0
- package/review-runtime.ts +260 -74
- package/review-ui.ts +27 -4
- package/review.ts +516 -16
- package/tab-editor.ts +147 -0
- package/tab-policy.ts +42 -0
- package/utils.ts +372 -69
|
@@ -0,0 +1,909 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Questionnaire view.
|
|
3
|
+
*
|
|
4
|
+
* The questionnaire is hosted as a full-width, bottom-anchored overlay. The
|
|
5
|
+
* host renders the component as a flat list of lines (the fullscreen input
|
|
6
|
+
* dock treats its editor container as a leaf), so this view must bound its own
|
|
7
|
+
* height and slice its panes manually:
|
|
8
|
+
*
|
|
9
|
+
* top border / tabs
|
|
10
|
+
* pinned short question
|
|
11
|
+
* scrollable question body (context / long question / answers summary)
|
|
12
|
+
* pinned option list (or the editor pane while typing)
|
|
13
|
+
* blank or submit status / help / bottom border
|
|
14
|
+
*
|
|
15
|
+
* The option rows and the help line are never part of the scroll region, so a
|
|
16
|
+
* long proposed commit message can never push the choices off-screen. The view
|
|
17
|
+
* never renders more than `questionnaireMaxRows(terminal.rows)` lines and stays
|
|
18
|
+
* inside the host overlay's 80% max height.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
Editor,
|
|
23
|
+
Key,
|
|
24
|
+
matchesKey,
|
|
25
|
+
visibleWidth,
|
|
26
|
+
wrapTextWithAnsi,
|
|
27
|
+
truncateToWidth,
|
|
28
|
+
type Component,
|
|
29
|
+
type EditorTheme,
|
|
30
|
+
type Focusable,
|
|
31
|
+
type TUI,
|
|
32
|
+
} from "@earendil-works/pi-tui";
|
|
33
|
+
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
34
|
+
import {
|
|
35
|
+
QUESTIONNAIRE_MAX_ANSWER_PREVIEW_LINES,
|
|
36
|
+
QUESTIONNAIRE_MAX_PINNED_QUESTION_LINES,
|
|
37
|
+
computeQuestionnaireBudget,
|
|
38
|
+
createScrollPane,
|
|
39
|
+
keepRangeVisible,
|
|
40
|
+
parseWheelScroll,
|
|
41
|
+
previewTextLines,
|
|
42
|
+
questionnaireMaxRows,
|
|
43
|
+
scrollPaneBy,
|
|
44
|
+
scrollPaneTo,
|
|
45
|
+
updateScrollPane,
|
|
46
|
+
type LineRange,
|
|
47
|
+
type ScrollPane,
|
|
48
|
+
} from "./questionnaire-layout.ts";
|
|
49
|
+
|
|
50
|
+
// ── Public types ─────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
export interface QuestionnaireOption {
|
|
53
|
+
label: string;
|
|
54
|
+
description?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface QuestionnaireQuestion {
|
|
58
|
+
question: string;
|
|
59
|
+
header?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Long supporting text (e.g. the proposed commit message). Rendered in the
|
|
62
|
+
* scrollable body while `question` stays pinned and short when possible.
|
|
63
|
+
*/
|
|
64
|
+
context?: string;
|
|
65
|
+
options: QuestionnaireOption[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface QuestionnaireAnswer {
|
|
69
|
+
question: string;
|
|
70
|
+
header?: string;
|
|
71
|
+
answer: string;
|
|
72
|
+
wasCustom: boolean;
|
|
73
|
+
index?: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface QuestionnaireResult {
|
|
77
|
+
answers: QuestionnaireAnswer[];
|
|
78
|
+
cancelled: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface QuestionnaireViewOptions {
|
|
82
|
+
questions: QuestionnaireQuestion[];
|
|
83
|
+
/** Resolve the surrounding `ctx.ui.custom()` call. */
|
|
84
|
+
done: (result: QuestionnaireResult | null) => void;
|
|
85
|
+
signal?: AbortSignal;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Minimal theme surface. Structural, so Pi's real `Theme` satisfies it and
|
|
90
|
+
* tests can pass a fake with identity styling.
|
|
91
|
+
*/
|
|
92
|
+
export interface QuestionnaireTheme {
|
|
93
|
+
fg(color: ThemeColor, text: string): string;
|
|
94
|
+
/** The package root exports ThemeColor but not ThemeBg; the view only uses this key. */
|
|
95
|
+
bg(color: "selectedBg", text: string): string;
|
|
96
|
+
bold(text: string): string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface QuestionnaireView extends Component, Focusable {
|
|
100
|
+
/** Always present in this view (unlike the optional Component method). */
|
|
101
|
+
handleInput(data: string): void;
|
|
102
|
+
dispose(): void;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Freeform options (same aliases as the original questionnaire) ────
|
|
106
|
+
|
|
107
|
+
export interface DisplayOption {
|
|
108
|
+
label: string;
|
|
109
|
+
description?: string;
|
|
110
|
+
isOther?: boolean;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const FREEFORM_ALIASES = new Set([
|
|
114
|
+
"type something",
|
|
115
|
+
"type something else",
|
|
116
|
+
"other",
|
|
117
|
+
"other (please specify)",
|
|
118
|
+
"other please specify",
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
export function normalizeFreeform(value: string): string {
|
|
122
|
+
return value.toLowerCase().trim().replace(/\.$/, "").replace(/\s+/g, " ");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Build the rows shown for a question: its options plus the freeform row. */
|
|
126
|
+
export function buildDisplayOptions(
|
|
127
|
+
raw: readonly QuestionnaireOption[],
|
|
128
|
+
): DisplayOption[] {
|
|
129
|
+
const filtered: DisplayOption[] = raw
|
|
130
|
+
.filter((option) => !FREEFORM_ALIASES.has(normalizeFreeform(option.label)))
|
|
131
|
+
.map((option) => ({
|
|
132
|
+
label: option.label,
|
|
133
|
+
...(option.description === undefined
|
|
134
|
+
? {}
|
|
135
|
+
: { description: option.description }),
|
|
136
|
+
}));
|
|
137
|
+
filtered.push({ label: "Type something.", isOther: true });
|
|
138
|
+
return filtered;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Was this option the previously selected answer (not the moving cursor)? */
|
|
142
|
+
export function isCustomSelected(
|
|
143
|
+
prev: { wasCustom: boolean; index?: number } | undefined,
|
|
144
|
+
opt: DisplayOption | undefined,
|
|
145
|
+
index: number,
|
|
146
|
+
): boolean {
|
|
147
|
+
if (!prev) return false;
|
|
148
|
+
if (prev.wasCustom && opt?.isOther) return true;
|
|
149
|
+
return prev.index === index + 1;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Small rendering helpers ──────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
function wrapWithPrefix(
|
|
155
|
+
prefix: string,
|
|
156
|
+
text: string,
|
|
157
|
+
width: number,
|
|
158
|
+
): string[] {
|
|
159
|
+
const W = Math.max(1, Math.floor(width) || 1);
|
|
160
|
+
const prefixWidth = visibleWidth(prefix);
|
|
161
|
+
if (prefixWidth >= W) {
|
|
162
|
+
const combined = wrapTextWithAnsi(`${prefix}${text}`, W);
|
|
163
|
+
return combined.length > 0 ? combined : [prefix];
|
|
164
|
+
}
|
|
165
|
+
const wrapped = wrapTextWithAnsi(text, Math.max(1, W - prefixWidth));
|
|
166
|
+
if (wrapped.length === 0) return [prefix];
|
|
167
|
+
const continuation = " ".repeat(prefixWidth);
|
|
168
|
+
return wrapped.map(
|
|
169
|
+
(line, index) => `${index === 0 ? prefix : continuation}${line}`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function repeatRule(theme: QuestionnaireTheme, width: number): string {
|
|
174
|
+
return theme.fg("accent", "─".repeat(Math.max(1, width)));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function truncateOne(
|
|
178
|
+
theme: QuestionnaireTheme,
|
|
179
|
+
text: string,
|
|
180
|
+
width: number,
|
|
181
|
+
color: ThemeColor = "dim",
|
|
182
|
+
): string {
|
|
183
|
+
return truncateToWidth(` ${theme.fg(color, text)}`, Math.max(1, width));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── View factory ─────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
interface AnswerState {
|
|
189
|
+
answer: string;
|
|
190
|
+
wasCustom: boolean;
|
|
191
|
+
index?: number;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
interface OptionBody {
|
|
195
|
+
lines: string[];
|
|
196
|
+
selectedRange?: LineRange;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function createQuestionnaireView(
|
|
200
|
+
tui: TUI,
|
|
201
|
+
theme: QuestionnaireTheme,
|
|
202
|
+
opts: QuestionnaireViewOptions,
|
|
203
|
+
): QuestionnaireView {
|
|
204
|
+
const questions = opts.questions;
|
|
205
|
+
const isMulti = questions.length > 1;
|
|
206
|
+
const done = opts.done;
|
|
207
|
+
const signal = opts.signal;
|
|
208
|
+
|
|
209
|
+
// ── state ──
|
|
210
|
+
let currentTab = 0;
|
|
211
|
+
let optionIndex = 0;
|
|
212
|
+
let inputMode = false;
|
|
213
|
+
let inputQuestionIdx: number | null = null;
|
|
214
|
+
let closed = false;
|
|
215
|
+
let focused = false;
|
|
216
|
+
|
|
217
|
+
const answers = new Map<number, AnswerState>();
|
|
218
|
+
const questionPane: ScrollPane = createScrollPane();
|
|
219
|
+
const optionPane: ScrollPane = createScrollPane();
|
|
220
|
+
const questionScrollByTab = new Map<number, number>();
|
|
221
|
+
const optionScrollByTab = new Map<number, number>();
|
|
222
|
+
|
|
223
|
+
const editorTheme: EditorTheme = {
|
|
224
|
+
borderColor: (text) => theme.fg("accent", text),
|
|
225
|
+
selectList: {
|
|
226
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
227
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
228
|
+
description: (text) => theme.fg("muted", text),
|
|
229
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
230
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
const editor = new Editor(tui, editorTheme);
|
|
234
|
+
|
|
235
|
+
// ── helpers ──
|
|
236
|
+
|
|
237
|
+
function refresh(): void {
|
|
238
|
+
tui.requestRender();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function currentQ(): QuestionnaireQuestion | undefined {
|
|
242
|
+
return questions[currentTab];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function optsForTab(tab: number): DisplayOption[] {
|
|
246
|
+
const q = questions[tab];
|
|
247
|
+
return q ? buildDisplayOptions(q.options) : [];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function currentOpts(): DisplayOption[] {
|
|
251
|
+
return optsForTab(currentTab);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function allAnswered(): boolean {
|
|
255
|
+
return questions.every((_, index) => answers.has(index));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function restoreOptionIndex(tab: number): void {
|
|
259
|
+
if (tab >= questions.length) {
|
|
260
|
+
optionIndex = 0;
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const previous = answers.get(tab);
|
|
264
|
+
const opts = optsForTab(tab);
|
|
265
|
+
if (previous?.wasCustom) {
|
|
266
|
+
optionIndex = Math.max(0, opts.length - 1);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (previous?.index) {
|
|
270
|
+
optionIndex = Math.max(0, Math.min(previous.index - 1, opts.length - 1));
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
optionIndex = 0;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function saveTabScroll(): void {
|
|
277
|
+
questionScrollByTab.set(currentTab, questionPane.scrollTop);
|
|
278
|
+
optionScrollByTab.set(currentTab, optionPane.scrollTop);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function restoreTabScroll(tab: number): void {
|
|
282
|
+
questionPane.scrollTop = questionScrollByTab.get(tab) ?? 0;
|
|
283
|
+
optionPane.scrollTop = optionScrollByTab.get(tab) ?? 0;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function goToTab(tab: number): void {
|
|
287
|
+
const next = Math.max(0, Math.min(questions.length, tab));
|
|
288
|
+
if (next === currentTab) return;
|
|
289
|
+
saveTabScroll();
|
|
290
|
+
currentTab = next;
|
|
291
|
+
inputMode = false;
|
|
292
|
+
inputQuestionIdx = null;
|
|
293
|
+
editor.focused = false;
|
|
294
|
+
restoreOptionIndex(currentTab);
|
|
295
|
+
restoreTabScroll(currentTab);
|
|
296
|
+
refresh();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function finish(result: QuestionnaireResult | null): void {
|
|
300
|
+
if (closed) return;
|
|
301
|
+
closed = true;
|
|
302
|
+
done(result);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function submit(cancelled: boolean): void {
|
|
306
|
+
if (closed) return;
|
|
307
|
+
if (cancelled) {
|
|
308
|
+
finish({ answers: [], cancelled: true });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const result: QuestionnaireAnswer[] = questions.map((question, index) => {
|
|
312
|
+
const answer = answers.get(index);
|
|
313
|
+
return {
|
|
314
|
+
question: question.question,
|
|
315
|
+
header: question.header,
|
|
316
|
+
answer: answer ? answer.answer : "",
|
|
317
|
+
wasCustom: answer ? answer.wasCustom : false,
|
|
318
|
+
index: answer?.index,
|
|
319
|
+
};
|
|
320
|
+
});
|
|
321
|
+
finish({ answers: result, cancelled: false });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function advanceAfter(): void {
|
|
325
|
+
if (!isMulti) {
|
|
326
|
+
submit(false);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (currentTab < questions.length - 1) {
|
|
330
|
+
goToTab(currentTab + 1);
|
|
331
|
+
} else {
|
|
332
|
+
goToTab(questions.length);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function leaveInputMode(): void {
|
|
337
|
+
inputMode = false;
|
|
338
|
+
inputQuestionIdx = null;
|
|
339
|
+
editor.setText("");
|
|
340
|
+
editor.focused = false;
|
|
341
|
+
refresh();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function beginInputMode(): void {
|
|
345
|
+
const opts = currentOpts();
|
|
346
|
+
if (!opts[optionIndex]?.isOther) return;
|
|
347
|
+
inputMode = true;
|
|
348
|
+
inputQuestionIdx = currentTab;
|
|
349
|
+
const previous = answers.get(currentTab);
|
|
350
|
+
editor.setText(previous?.wasCustom ? previous.answer : "");
|
|
351
|
+
editor.focused = focused;
|
|
352
|
+
refresh();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
editor.onSubmit = (value) => {
|
|
356
|
+
if (!inputMode || inputQuestionIdx === null) return;
|
|
357
|
+
const index = inputQuestionIdx;
|
|
358
|
+
const trimmed = value.trim() || "(no response)";
|
|
359
|
+
const freeformIndex = optsForTab(index).length; // 1-based last row
|
|
360
|
+
answers.set(index, {
|
|
361
|
+
answer: trimmed,
|
|
362
|
+
wasCustom: true,
|
|
363
|
+
index: freeformIndex,
|
|
364
|
+
});
|
|
365
|
+
inputMode = false;
|
|
366
|
+
inputQuestionIdx = null;
|
|
367
|
+
editor.setText("");
|
|
368
|
+
editor.focused = false;
|
|
369
|
+
advanceAfter();
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
// ── body builders ──
|
|
373
|
+
|
|
374
|
+
function buildQuestionLines(
|
|
375
|
+
question: QuestionnaireQuestion,
|
|
376
|
+
width: number,
|
|
377
|
+
): string[] {
|
|
378
|
+
const lines: string[] = [];
|
|
379
|
+
for (const raw of String(question.question ?? "").split(/\r?\n/)) {
|
|
380
|
+
lines.push(...wrapWithPrefix(" ", theme.fg("text", raw), width));
|
|
381
|
+
}
|
|
382
|
+
return lines.length > 0 ? lines : [""];
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function buildContextLines(
|
|
386
|
+
question: QuestionnaireQuestion | undefined,
|
|
387
|
+
width: number,
|
|
388
|
+
): string[] {
|
|
389
|
+
const context = String(question?.context ?? "");
|
|
390
|
+
if (!context.trim()) return [];
|
|
391
|
+
const lines: string[] = [];
|
|
392
|
+
for (const raw of context.split(/\r?\n/)) {
|
|
393
|
+
lines.push(...wrapWithPrefix(" ", theme.fg("text", raw), width));
|
|
394
|
+
}
|
|
395
|
+
return lines;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function buildPinnedQuestion(
|
|
399
|
+
question: QuestionnaireQuestion | undefined,
|
|
400
|
+
questionLines: string[],
|
|
401
|
+
maxRows: number,
|
|
402
|
+
): string[] {
|
|
403
|
+
if (!question || currentTab === questions.length) return [];
|
|
404
|
+
if (!String(question.context ?? "").trim()) return [];
|
|
405
|
+
if (maxRows < 12) return [];
|
|
406
|
+
if (questionLines.length > QUESTIONNAIRE_MAX_PINNED_QUESTION_LINES) return [];
|
|
407
|
+
return [...questionLines, ""];
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function buildAnswersSummary(width: number): string[] {
|
|
411
|
+
const lines: string[] = [];
|
|
412
|
+
lines.push(
|
|
413
|
+
...wrapWithPrefix(
|
|
414
|
+
" ",
|
|
415
|
+
theme.fg("accent", theme.bold("Ready to submit")),
|
|
416
|
+
width,
|
|
417
|
+
),
|
|
418
|
+
);
|
|
419
|
+
lines.push("");
|
|
420
|
+
for (let index = 0; index < questions.length; index++) {
|
|
421
|
+
const answer = answers.get(index);
|
|
422
|
+
if (!answer) continue;
|
|
423
|
+
const header = questions[index].header || `Q${index + 1}`;
|
|
424
|
+
const prefix = answer.wasCustom ? "(wrote) " : "";
|
|
425
|
+
lines.push(
|
|
426
|
+
...wrapWithPrefix(
|
|
427
|
+
" ",
|
|
428
|
+
theme.fg("muted", `${header}: `) +
|
|
429
|
+
theme.fg("text", prefix + answer.answer),
|
|
430
|
+
width,
|
|
431
|
+
),
|
|
432
|
+
);
|
|
433
|
+
lines.push("");
|
|
434
|
+
}
|
|
435
|
+
return lines;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function buildQuestionBody(
|
|
439
|
+
width: number,
|
|
440
|
+
question: QuestionnaireQuestion | undefined,
|
|
441
|
+
questionLines: string[],
|
|
442
|
+
pinned: boolean,
|
|
443
|
+
): string[] {
|
|
444
|
+
if (currentTab === questions.length) return buildAnswersSummary(width);
|
|
445
|
+
if (!question) return [];
|
|
446
|
+
const contextLines = buildContextLines(question, width);
|
|
447
|
+
if (pinned) return contextLines;
|
|
448
|
+
return [...questionLines, "", ...contextLines];
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function buildEditorPane(width: number): string[] {
|
|
452
|
+
const lines: string[] = [];
|
|
453
|
+
lines.push("");
|
|
454
|
+
lines.push(
|
|
455
|
+
...wrapWithPrefix(" ", theme.fg("muted", "Your answer:"), width),
|
|
456
|
+
);
|
|
457
|
+
const inner = Math.max(1, width - 2);
|
|
458
|
+
for (const line of editor.render(inner)) {
|
|
459
|
+
lines.push(...wrapWithPrefix(" ", line, width));
|
|
460
|
+
}
|
|
461
|
+
lines.push("");
|
|
462
|
+
lines.push(
|
|
463
|
+
...wrapWithPrefix(
|
|
464
|
+
" ",
|
|
465
|
+
theme.fg("dim", "Enter to submit • Esc to go back"),
|
|
466
|
+
width,
|
|
467
|
+
),
|
|
468
|
+
);
|
|
469
|
+
return lines;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function buildOptionBody(width: number): OptionBody {
|
|
473
|
+
if (currentTab === questions.length) {
|
|
474
|
+
return { lines: [] };
|
|
475
|
+
}
|
|
476
|
+
if (inputMode) {
|
|
477
|
+
return { lines: buildEditorPane(width) };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const options = currentOpts();
|
|
481
|
+
const previous = answers.get(currentTab);
|
|
482
|
+
const lines: string[] = [];
|
|
483
|
+
let selectedRange: LineRange | undefined;
|
|
484
|
+
const selectedIndex = Math.max(
|
|
485
|
+
0,
|
|
486
|
+
Math.min(optionIndex, Math.max(0, options.length - 1)),
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
for (let index = 0; index < options.length; index++) {
|
|
490
|
+
const option = options[index];
|
|
491
|
+
const selected = index === selectedIndex;
|
|
492
|
+
const previouslySelected = isCustomSelected(previous, option, index);
|
|
493
|
+
const isOther = !!option.isOther;
|
|
494
|
+
|
|
495
|
+
let prefix: string;
|
|
496
|
+
let color: ThemeColor;
|
|
497
|
+
if (selected) {
|
|
498
|
+
prefix = theme.fg("accent", "> ");
|
|
499
|
+
color = "accent";
|
|
500
|
+
} else if (previouslySelected) {
|
|
501
|
+
prefix = theme.fg("success", "✓ ");
|
|
502
|
+
color = "success";
|
|
503
|
+
} else {
|
|
504
|
+
prefix = " ";
|
|
505
|
+
color = "text";
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const start = lines.length;
|
|
509
|
+
lines.push(
|
|
510
|
+
...wrapWithPrefix(
|
|
511
|
+
prefix,
|
|
512
|
+
theme.fg(color, `${index + 1}. ${option.label}`),
|
|
513
|
+
width,
|
|
514
|
+
),
|
|
515
|
+
);
|
|
516
|
+
if (option.description) {
|
|
517
|
+
lines.push(
|
|
518
|
+
...wrapWithPrefix(
|
|
519
|
+
" ",
|
|
520
|
+
theme.fg("muted", option.description),
|
|
521
|
+
width,
|
|
522
|
+
),
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Show the previously typed freeform answer inline under its row, but
|
|
527
|
+
// never let a multi-paragraph answer take over the option viewport.
|
|
528
|
+
if (isOther && previous?.wasCustom) {
|
|
529
|
+
const preview = previewTextLines(
|
|
530
|
+
previous.answer,
|
|
531
|
+
Math.max(1, width - 22),
|
|
532
|
+
QUESTIONNAIRE_MAX_ANSWER_PREVIEW_LINES,
|
|
533
|
+
);
|
|
534
|
+
const firstLine = preview.lines[0] ?? "";
|
|
535
|
+
const moreSuffix =
|
|
536
|
+
preview.hidden > 0 ? ` … +${preview.hidden} more` : "";
|
|
537
|
+
const closingFirst = preview.lines.length === 1 ? '"' : "";
|
|
538
|
+
lines.push(
|
|
539
|
+
...wrapWithPrefix(
|
|
540
|
+
" ",
|
|
541
|
+
theme.fg("muted", "↳ Your answer: ") +
|
|
542
|
+
theme.fg("text", `"${firstLine}${closingFirst}${moreSuffix}`),
|
|
543
|
+
width,
|
|
544
|
+
),
|
|
545
|
+
);
|
|
546
|
+
for (let previewIndex = 1; previewIndex < preview.lines.length; previewIndex++) {
|
|
547
|
+
const closing =
|
|
548
|
+
previewIndex === preview.lines.length - 1 ? '"' : "";
|
|
549
|
+
lines.push(
|
|
550
|
+
...wrapWithPrefix(
|
|
551
|
+
" ",
|
|
552
|
+
theme.fg("text", `${preview.lines[previewIndex]}${closing}`),
|
|
553
|
+
width,
|
|
554
|
+
),
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (selected) {
|
|
560
|
+
selectedRange = { start, end: lines.length };
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
return { lines, selectedRange };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ── chrome ──
|
|
568
|
+
|
|
569
|
+
function isCompact(maxRows: number): boolean {
|
|
570
|
+
return maxRows < 12;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function isTiny(maxRows: number): boolean {
|
|
574
|
+
return maxRows < 6;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function bottomHeightFor(maxRows: number): number {
|
|
578
|
+
return isTiny(maxRows) ? 1 : isCompact(maxRows) ? 2 : 3;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function buildTabsLine(width: number): string {
|
|
582
|
+
const tabs: string[] = [];
|
|
583
|
+
const labelBudget = Math.max(
|
|
584
|
+
4,
|
|
585
|
+
Math.floor((width - 2) / (questions.length + 1)) - 4,
|
|
586
|
+
);
|
|
587
|
+
for (let index = 0; index < questions.length; index++) {
|
|
588
|
+
const active = index === currentTab;
|
|
589
|
+
const answered = answers.has(index);
|
|
590
|
+
const label = truncateToWidth(
|
|
591
|
+
questions[index].header || `Q${index + 1}`,
|
|
592
|
+
labelBudget,
|
|
593
|
+
);
|
|
594
|
+
const box = answered ? "■" : "□";
|
|
595
|
+
const color: ThemeColor = answered ? "success" : "muted";
|
|
596
|
+
const text = ` ${box} ${label} `;
|
|
597
|
+
const styled = active
|
|
598
|
+
? theme.bg(
|
|
599
|
+
"selectedBg",
|
|
600
|
+
`${theme.fg("accent", "→")} ${theme.fg("text", text)}`,
|
|
601
|
+
)
|
|
602
|
+
: theme.fg(color, ` ${text}`);
|
|
603
|
+
tabs.push(`${styled} `);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const canSubmit = allAnswered();
|
|
607
|
+
const isSubmit = currentTab === questions.length;
|
|
608
|
+
const submitText = " ✓ Submit ";
|
|
609
|
+
const styledSubmit = isSubmit
|
|
610
|
+
? theme.bg(
|
|
611
|
+
"selectedBg",
|
|
612
|
+
`${theme.fg("accent", "→")} ${theme.fg("text", submitText)}`,
|
|
613
|
+
)
|
|
614
|
+
: theme.fg(canSubmit ? "success" : "dim", ` ${submitText}`);
|
|
615
|
+
tabs.push(styledSubmit);
|
|
616
|
+
return truncateToWidth(` ${tabs.join("")}`, Math.max(1, width));
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function buildTopBar(width: number, maxRows: number): string[] {
|
|
620
|
+
const lines: string[] = [];
|
|
621
|
+
// A one-line terminal cannot hold both a tab row and the help line; keep
|
|
622
|
+
// the controls visible and let the tab row go rather than exceed the cap.
|
|
623
|
+
if (maxRows <= 1) return lines;
|
|
624
|
+
if (!isCompact(maxRows)) {
|
|
625
|
+
lines.push(repeatRule(theme, width));
|
|
626
|
+
}
|
|
627
|
+
if (isMulti) {
|
|
628
|
+
lines.push(buildTabsLine(width));
|
|
629
|
+
if (!isCompact(maxRows)) lines.push("");
|
|
630
|
+
}
|
|
631
|
+
return lines;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function buildBottom(
|
|
635
|
+
width: number,
|
|
636
|
+
maxRows: number,
|
|
637
|
+
overflow: { question: boolean; options: boolean },
|
|
638
|
+
): string[] {
|
|
639
|
+
const submitTab = currentTab === questions.length;
|
|
640
|
+
const anyOverflow = overflow.question || overflow.options;
|
|
641
|
+
const scrollHint = anyOverflow ? " • PgUp/PgDn scroll" : "";
|
|
642
|
+
const status = submitTab
|
|
643
|
+
? allAnswered()
|
|
644
|
+
? "Press Enter to submit"
|
|
645
|
+
: `Unanswered: ${questions
|
|
646
|
+
.filter((_, index) => !answers.has(index))
|
|
647
|
+
.map((question, index) => question.header || `Q${index + 1}`)
|
|
648
|
+
.join(", ")}`
|
|
649
|
+
: "";
|
|
650
|
+
const help = submitTab
|
|
651
|
+
? `Enter submit • Esc cancel${scrollHint}`
|
|
652
|
+
: inputMode
|
|
653
|
+
? `Enter submit • Esc back${scrollHint}`
|
|
654
|
+
: `${
|
|
655
|
+
anyOverflow ? "PgUp/PgDn scroll • " : ""
|
|
656
|
+
}${isMulti ? "Tab/←→ tabs • " : ""}↑↓ select • Enter confirm • Esc cancel`;
|
|
657
|
+
|
|
658
|
+
const lines: string[] = [];
|
|
659
|
+
if (!isCompact(maxRows)) {
|
|
660
|
+
lines.push(
|
|
661
|
+
submitTab
|
|
662
|
+
? truncateOne(
|
|
663
|
+
theme,
|
|
664
|
+
status,
|
|
665
|
+
width,
|
|
666
|
+
allAnswered() ? "success" : "warning",
|
|
667
|
+
)
|
|
668
|
+
: "",
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
if (isTiny(maxRows)) {
|
|
672
|
+
lines.push(truncateOne(theme, help, width));
|
|
673
|
+
} else {
|
|
674
|
+
lines.push(truncateOne(theme, help, width));
|
|
675
|
+
lines.push(repeatRule(theme, width));
|
|
676
|
+
}
|
|
677
|
+
return lines;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// ── render ──
|
|
681
|
+
|
|
682
|
+
function render(width: number): string[] {
|
|
683
|
+
const W = Math.max(1, Math.floor(width) || 1);
|
|
684
|
+
const rows = Math.max(
|
|
685
|
+
1,
|
|
686
|
+
Math.floor(Number.isFinite(tui.terminal?.rows) && tui.terminal.rows > 0
|
|
687
|
+
? tui.terminal.rows
|
|
688
|
+
: 24),
|
|
689
|
+
);
|
|
690
|
+
const maxRows = questionnaireMaxRows(rows);
|
|
691
|
+
|
|
692
|
+
const top = buildTopBar(W, maxRows);
|
|
693
|
+
const submitTab = currentTab === questions.length;
|
|
694
|
+
const question = submitTab ? undefined : currentQ();
|
|
695
|
+
const questionLines = question
|
|
696
|
+
? buildQuestionLines(question, W)
|
|
697
|
+
: [];
|
|
698
|
+
const pinned = buildPinnedQuestion(question, questionLines, maxRows);
|
|
699
|
+
const questionBody = buildQuestionBody(
|
|
700
|
+
W,
|
|
701
|
+
question,
|
|
702
|
+
questionLines,
|
|
703
|
+
pinned.length > 0,
|
|
704
|
+
);
|
|
705
|
+
const optionBody = buildOptionBody(W);
|
|
706
|
+
|
|
707
|
+
const budget = computeQuestionnaireBudget({
|
|
708
|
+
termRows: rows,
|
|
709
|
+
topHeight: top.length,
|
|
710
|
+
pinnedHeight: pinned.length,
|
|
711
|
+
bottomHeight: bottomHeightFor(maxRows),
|
|
712
|
+
questionContentHeight: questionBody.length,
|
|
713
|
+
optionsContentHeight: optionBody.lines.length,
|
|
714
|
+
editorPriority: inputMode,
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
updateScrollPane(questionPane, questionBody.length, budget.question);
|
|
718
|
+
updateScrollPane(optionPane, optionBody.lines.length, budget.options);
|
|
719
|
+
|
|
720
|
+
if (optionBody.selectedRange) {
|
|
721
|
+
const target = keepRangeVisible(optionPane, optionBody.selectedRange);
|
|
722
|
+
scrollPaneTo(optionPane, target);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const bottom = buildBottom(W, maxRows, {
|
|
726
|
+
question: budget.questionOverflow,
|
|
727
|
+
options: budget.optionsOverflow,
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
const questionSlice = questionBody.slice(
|
|
731
|
+
questionPane.scrollTop,
|
|
732
|
+
questionPane.scrollTop + budget.question,
|
|
733
|
+
);
|
|
734
|
+
const optionSlice = optionBody.lines.slice(
|
|
735
|
+
optionPane.scrollTop,
|
|
736
|
+
optionPane.scrollTop + budget.options,
|
|
737
|
+
);
|
|
738
|
+
|
|
739
|
+
return [...top, ...pinned, ...questionSlice, ...optionSlice, ...bottom];
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// ── input ──
|
|
743
|
+
|
|
744
|
+
function pageSize(pane: ScrollPane): number {
|
|
745
|
+
return Math.max(1, pane.viewportHeight - 2);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function scrollOverflowing(delta: number, questionOnly = false): void {
|
|
749
|
+
if (questionPane.contentHeight > questionPane.viewportHeight) {
|
|
750
|
+
scrollPaneBy(questionPane, delta);
|
|
751
|
+
refresh();
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
if (
|
|
755
|
+
!questionOnly &&
|
|
756
|
+
optionPane.contentHeight > optionPane.viewportHeight
|
|
757
|
+
) {
|
|
758
|
+
scrollPaneBy(optionPane, delta);
|
|
759
|
+
refresh();
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function handleScrollKey(data: string, questionOnly = false): boolean {
|
|
764
|
+
const wheel = parseWheelScroll(data);
|
|
765
|
+
if (wheel !== 0) {
|
|
766
|
+
scrollOverflowing(wheel, questionOnly);
|
|
767
|
+
return true;
|
|
768
|
+
}
|
|
769
|
+
if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.pageDown)) {
|
|
770
|
+
const size = pageSize(
|
|
771
|
+
questionPane.contentHeight > questionPane.viewportHeight
|
|
772
|
+
? questionPane
|
|
773
|
+
: optionPane,
|
|
774
|
+
);
|
|
775
|
+
scrollOverflowing(
|
|
776
|
+
matchesKey(data, Key.pageUp) ? -size : size,
|
|
777
|
+
questionOnly,
|
|
778
|
+
);
|
|
779
|
+
return true;
|
|
780
|
+
}
|
|
781
|
+
if (!questionOnly && matchesKey(data, Key.ctrl("u"))) {
|
|
782
|
+
scrollOverflowing(
|
|
783
|
+
-Math.max(1, Math.floor(questionPane.viewportHeight / 2)),
|
|
784
|
+
);
|
|
785
|
+
return true;
|
|
786
|
+
}
|
|
787
|
+
if (!questionOnly && matchesKey(data, Key.ctrl("d"))) {
|
|
788
|
+
scrollOverflowing(
|
|
789
|
+
Math.max(1, Math.floor(questionPane.viewportHeight / 2)),
|
|
790
|
+
);
|
|
791
|
+
return true;
|
|
792
|
+
}
|
|
793
|
+
return false;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function handleInput(data: string): void {
|
|
797
|
+
if (closed) return;
|
|
798
|
+
if (signal?.aborted) {
|
|
799
|
+
finish(null);
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
if (inputMode) {
|
|
804
|
+
if (matchesKey(data, Key.escape)) {
|
|
805
|
+
leaveInputMode();
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
if (handleScrollKey(data, true)) return;
|
|
809
|
+
editor.handleInput(data);
|
|
810
|
+
refresh();
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
if (isMulti) {
|
|
815
|
+
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
|
|
816
|
+
goToTab((currentTab + 1) % (questions.length + 1));
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
if (
|
|
820
|
+
matchesKey(data, Key.shift("tab")) ||
|
|
821
|
+
matchesKey(data, Key.left)
|
|
822
|
+
) {
|
|
823
|
+
goToTab(
|
|
824
|
+
(currentTab - 1 + questions.length + 1) % (questions.length + 1),
|
|
825
|
+
);
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if (currentTab === questions.length) {
|
|
831
|
+
if (matchesKey(data, Key.enter)) {
|
|
832
|
+
if (allAnswered()) submit(false);
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
if (matchesKey(data, Key.escape)) {
|
|
836
|
+
submit(true);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
handleScrollKey(data);
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
if (matchesKey(data, Key.up)) {
|
|
844
|
+
optionIndex = Math.max(0, optionIndex - 1);
|
|
845
|
+
refresh();
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
if (matchesKey(data, Key.down)) {
|
|
849
|
+
optionIndex = Math.min(
|
|
850
|
+
Math.max(0, currentOpts().length - 1),
|
|
851
|
+
optionIndex + 1,
|
|
852
|
+
);
|
|
853
|
+
refresh();
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
if (matchesKey(data, Key.enter)) {
|
|
857
|
+
const option = currentOpts()[optionIndex];
|
|
858
|
+
if (!option) return;
|
|
859
|
+
if (option.isOther) {
|
|
860
|
+
beginInputMode();
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
answers.set(currentTab, {
|
|
864
|
+
answer: option.label,
|
|
865
|
+
wasCustom: false,
|
|
866
|
+
index: optionIndex + 1,
|
|
867
|
+
});
|
|
868
|
+
advanceAfter();
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
if (matchesKey(data, Key.escape)) {
|
|
872
|
+
submit(true);
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
handleScrollKey(data);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
const onAbort = () => {
|
|
880
|
+
finish(null);
|
|
881
|
+
};
|
|
882
|
+
if (signal) {
|
|
883
|
+
if (signal.aborted) {
|
|
884
|
+
onAbort();
|
|
885
|
+
} else {
|
|
886
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
return {
|
|
891
|
+
get focused(): boolean {
|
|
892
|
+
return focused;
|
|
893
|
+
},
|
|
894
|
+
set focused(value: boolean) {
|
|
895
|
+
focused = !!value;
|
|
896
|
+
editor.focused = focused && inputMode;
|
|
897
|
+
},
|
|
898
|
+
render,
|
|
899
|
+
handleInput,
|
|
900
|
+
invalidate(): void {
|
|
901
|
+
editor.invalidate();
|
|
902
|
+
tui.requestRender();
|
|
903
|
+
},
|
|
904
|
+
dispose(): void {
|
|
905
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
906
|
+
editor.focused = false;
|
|
907
|
+
},
|
|
908
|
+
};
|
|
909
|
+
}
|