@dieulc/workflow 0.6.1 → 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.
@@ -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
+ }