@black942026/pi-ask-user 0.1.1 → 0.1.2
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 +121 -25
- package/package.json +1 -1
- package/src/adapters/pi.ts +3 -1
- package/src/answers.ts +118 -33
- package/src/config.ts +11 -9
- package/src/core.ts +23 -6
- package/src/index.ts +17 -2
- package/src/parse.ts +7 -0
- package/src/schema.ts +188 -89
- package/src/types.ts +103 -13
- package/src/ui/custom.ts +393 -9
- package/src/ui/native.ts +162 -59
package/src/ui/custom.ts
CHANGED
|
@@ -9,8 +9,8 @@ import {
|
|
|
9
9
|
wrapTextWithAnsi,
|
|
10
10
|
} from "@earendil-works/pi-tui";
|
|
11
11
|
import type { Component, Focusable, MarkdownTheme } from "@earendil-works/pi-tui";
|
|
12
|
-
import { draftFromDefault, draftIsEmpty, emptyDraft, finalizeAnswers, type DraftAnswer } from "../answers.ts";
|
|
13
|
-
import type { AskUserAnswer, Deadline, NormalizedQuestion, NormalizedRequest } from "../types.ts";
|
|
12
|
+
import { draftFromDefault, draftIsEmpty, emptyDraft, finalizeAnswers, toAnswer, validateAnswer, type DraftAnswer } from "../answers.ts";
|
|
13
|
+
import type { AskUserAnswer, AskUserFormValue, Deadline, NormalizedFormField, NormalizedQuestion, NormalizedRequest } from "../types.ts";
|
|
14
14
|
import { CANCEL_TIMEOUT_KEY } from "../config.ts";
|
|
15
15
|
|
|
16
16
|
/** Structural theme surface the component needs (a real Pi Theme satisfies it). */
|
|
@@ -119,6 +119,16 @@ function printableText(data: string): string | undefined {
|
|
|
119
119
|
return data;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
interface FormUIState {
|
|
123
|
+
values: AskUserFormValue;
|
|
124
|
+
present: Set<string>;
|
|
125
|
+
usedDefaults: Set<string>;
|
|
126
|
+
inputs: Map<number, Input>;
|
|
127
|
+
optionCursors: number[];
|
|
128
|
+
clearedInputs: Set<number>;
|
|
129
|
+
active: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
122
132
|
/**
|
|
123
133
|
* Custom terminal UI for AskUser.
|
|
124
134
|
*
|
|
@@ -144,6 +154,7 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
144
154
|
private readonly drafts: DraftAnswer[];
|
|
145
155
|
private readonly cursors: number[];
|
|
146
156
|
private readonly inputs: Input[];
|
|
157
|
+
private readonly forms: Array<FormUIState | undefined>;
|
|
147
158
|
private readonly mdTheme: MarkdownTheme;
|
|
148
159
|
private readonly overlayToggleKey: string | undefined;
|
|
149
160
|
private readonly previewCache = new Map<string, Markdown>();
|
|
@@ -160,6 +171,8 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
160
171
|
private cachedLines: string[] | undefined;
|
|
161
172
|
private previewScroll = 0;
|
|
162
173
|
private manualScroll = 0;
|
|
174
|
+
private formScroll: number | undefined;
|
|
175
|
+
private formOffset = 0;
|
|
163
176
|
private viewport: { wide: boolean; maxBody: number; maxScroll: number } = {
|
|
164
177
|
wide: false,
|
|
165
178
|
maxBody: 1,
|
|
@@ -186,6 +199,41 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
186
199
|
placeholderStyle: (text) => this.theme.fg("muted", text),
|
|
187
200
|
}),
|
|
188
201
|
);
|
|
202
|
+
this.forms = options.request.questions.map((question, questionIndex) => {
|
|
203
|
+
if (question.kind !== "simple-form") return undefined;
|
|
204
|
+
const values: AskUserFormValue = {};
|
|
205
|
+
const present = new Set<string>();
|
|
206
|
+
const usedDefaults = new Set<string>();
|
|
207
|
+
const fieldInputs = new Map<number, Input>();
|
|
208
|
+
const fields = question.fields ?? [];
|
|
209
|
+
fields.forEach((field, fieldIndex) => {
|
|
210
|
+
if (field.hasDefault && field.default !== undefined) {
|
|
211
|
+
values[field.name] = Array.isArray(field.default) ? [...field.default] : field.default;
|
|
212
|
+
present.add(field.name);
|
|
213
|
+
usedDefaults.add(field.name);
|
|
214
|
+
}
|
|
215
|
+
if (field.kind === "input") {
|
|
216
|
+
const input = new Input({
|
|
217
|
+
prompt: "",
|
|
218
|
+
placeholder: field.required ? "Required" : "Optional; leave empty to omit",
|
|
219
|
+
placeholderStyle: (text) => this.theme.fg("muted", text),
|
|
220
|
+
});
|
|
221
|
+
if (typeof field.default === "string") input.setValue(field.default);
|
|
222
|
+
fieldInputs.set(fieldIndex, input);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
this.drafts[questionIndex] = {
|
|
226
|
+
selections: [], freeText: "", value: { ...values }, valueSet: true, usedDefault: usedDefaults.size > 0,
|
|
227
|
+
};
|
|
228
|
+
const optionCursors = fields.map((field) => {
|
|
229
|
+
if (field.kind === "bool" && field.default === false) return 1;
|
|
230
|
+
if (field.kind === "single" && typeof field.default === "string") {
|
|
231
|
+
return Math.max(0, field.options.findIndex((option) => option.label === field.default));
|
|
232
|
+
}
|
|
233
|
+
return 0;
|
|
234
|
+
});
|
|
235
|
+
return { values, present, usedDefaults, inputs: fieldInputs, optionCursors, clearedInputs: new Set(), active: 0 };
|
|
236
|
+
});
|
|
189
237
|
const remaining = Math.max(0, this.deadline.remainingMs());
|
|
190
238
|
this.remainingSeconds = Math.ceil(remaining / 1000);
|
|
191
239
|
this.timer = setTimeout(() => this.finish({ kind: "timeout" }), remaining);
|
|
@@ -241,6 +289,7 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
241
289
|
this.cachedWidth = undefined;
|
|
242
290
|
this.cachedRows = undefined;
|
|
243
291
|
for (const input of this.inputs) input.invalidate();
|
|
292
|
+
for (const form of this.forms) for (const input of form?.inputs.values() ?? []) input.invalidate();
|
|
244
293
|
for (const markdown of this.previewCache.values()) markdown.invalidate();
|
|
245
294
|
}
|
|
246
295
|
|
|
@@ -252,6 +301,7 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
252
301
|
private resetScroll(): void {
|
|
253
302
|
this.previewScroll = 0;
|
|
254
303
|
this.manualScroll = 0;
|
|
304
|
+
this.formScroll = undefined;
|
|
255
305
|
}
|
|
256
306
|
|
|
257
307
|
// -- tab helpers ---------------------------------------------------------
|
|
@@ -273,6 +323,8 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
273
323
|
}
|
|
274
324
|
|
|
275
325
|
private rowCount(question: NormalizedQuestion): number {
|
|
326
|
+
if (question.kind === "bool") return 2;
|
|
327
|
+
if (question.kind === "simple-form") return (question.fields?.length ?? 0) + 1;
|
|
276
328
|
return question.options.length + 1;
|
|
277
329
|
}
|
|
278
330
|
|
|
@@ -284,6 +336,7 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
284
336
|
|
|
285
337
|
private isInputRow(): boolean {
|
|
286
338
|
if (this.isReview) return false;
|
|
339
|
+
if (this.currentQuestion().kind === "bool" || this.currentQuestion().kind === "simple-form") return false;
|
|
287
340
|
return this.rowIndex() >= this.currentQuestion().options.length;
|
|
288
341
|
}
|
|
289
342
|
|
|
@@ -326,7 +379,45 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
326
379
|
}
|
|
327
380
|
|
|
328
381
|
private syncAllDrafts(): void {
|
|
329
|
-
for (let index = 0; index < this.inputs.length; index += 1)
|
|
382
|
+
for (let index = 0; index < this.inputs.length; index += 1) {
|
|
383
|
+
if (this.request.questions[index]?.kind === "simple-form") this.syncFormDraft(index);
|
|
384
|
+
else this.syncDraftFromInput(index);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private syncFormInput(questionIndex: number, fieldIndex: number, edited = false): void {
|
|
389
|
+
const question = this.request.questions[questionIndex];
|
|
390
|
+
const form = this.forms[questionIndex];
|
|
391
|
+
const field = question?.fields?.[fieldIndex];
|
|
392
|
+
const input = form?.inputs.get(fieldIndex);
|
|
393
|
+
if (!question || !form || !field || !input) return;
|
|
394
|
+
if (edited) form.usedDefaults.delete(field.name);
|
|
395
|
+
const text = form.usedDefaults.has(field.name) ? input.getValue() : input.getValue().trim();
|
|
396
|
+
if (edited && text === "") form.clearedInputs.add(fieldIndex);
|
|
397
|
+
else if (edited) form.clearedInputs.delete(fieldIndex);
|
|
398
|
+
if (text === "" && (!field.hasDefault || form.clearedInputs.has(fieldIndex))) {
|
|
399
|
+
delete form.values[field.name];
|
|
400
|
+
form.present.delete(field.name);
|
|
401
|
+
} else {
|
|
402
|
+
form.values[field.name] = text;
|
|
403
|
+
form.present.add(field.name);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private syncFormDraft(questionIndex: number): void {
|
|
408
|
+
const question = this.request.questions[questionIndex];
|
|
409
|
+
const form = this.forms[questionIndex];
|
|
410
|
+
if (!question || !form) return;
|
|
411
|
+
for (const fieldIndex of form.inputs.keys()) this.syncFormInput(questionIndex, fieldIndex);
|
|
412
|
+
const value: AskUserFormValue = {};
|
|
413
|
+
for (const field of question.fields ?? []) {
|
|
414
|
+
if (!form.present.has(field.name)) continue;
|
|
415
|
+
const fieldValue = form.values[field.name];
|
|
416
|
+
if (fieldValue !== undefined) value[field.name] = Array.isArray(fieldValue) ? [...fieldValue] : fieldValue;
|
|
417
|
+
}
|
|
418
|
+
this.drafts[questionIndex] = {
|
|
419
|
+
selections: [], freeText: "", value, valueSet: true, usedDefault: form.usedDefaults.size > 0,
|
|
420
|
+
};
|
|
330
421
|
}
|
|
331
422
|
|
|
332
423
|
/** Forward a key to the input and drop `usedDefault` if the text changed. */
|
|
@@ -342,7 +433,9 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
342
433
|
private scrollBy(direction: -1 | 1): void {
|
|
343
434
|
if (this.viewport.maxScroll <= 0) return;
|
|
344
435
|
const page = Math.max(1, this.viewport.maxBody - 1);
|
|
345
|
-
if (this.
|
|
436
|
+
if (!this.isReview && this.currentQuestion().kind === "simple-form") {
|
|
437
|
+
this.formScroll = clamp((this.formScroll ?? this.formOffset) + direction * page, 0, this.viewport.maxScroll);
|
|
438
|
+
} else if (this.viewport.wide) {
|
|
346
439
|
this.previewScroll = clamp(this.previewScroll + direction * page, 0, this.viewport.maxScroll);
|
|
347
440
|
} else {
|
|
348
441
|
this.manualScroll = clamp(this.manualScroll + direction * page, 0, this.viewport.maxScroll);
|
|
@@ -359,7 +452,9 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
359
452
|
this.scrollBy(1);
|
|
360
453
|
return true;
|
|
361
454
|
}
|
|
362
|
-
|
|
455
|
+
const form = this.forms[this.tab];
|
|
456
|
+
const formField = !this.isReview ? this.currentQuestion().fields?.[form?.active ?? -1] : undefined;
|
|
457
|
+
if (this.isInputRow() || formField?.kind === "input") return false;
|
|
363
458
|
if (matchesKey(data, "ctrl+u") || data === "[") {
|
|
364
459
|
this.scrollBy(-1);
|
|
365
460
|
return true;
|
|
@@ -371,6 +466,115 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
371
466
|
return false;
|
|
372
467
|
}
|
|
373
468
|
|
|
469
|
+
private moveFormFocus(delta: number): void {
|
|
470
|
+
const form = this.forms[this.tab];
|
|
471
|
+
const question = this.currentQuestion();
|
|
472
|
+
if (!form) return;
|
|
473
|
+
this.syncFormDraft(this.tab);
|
|
474
|
+
const slots = (question.fields?.length ?? 0) + 1;
|
|
475
|
+
form.active = (form.active + delta + slots) % slots;
|
|
476
|
+
this.notice = undefined;
|
|
477
|
+
this.resetScroll();
|
|
478
|
+
this.refresh();
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
private setFormValue(field: NormalizedFormField, value: boolean | string | string[], fieldIndex: number): void {
|
|
482
|
+
const form = this.forms[this.tab]!;
|
|
483
|
+
form.values[field.name] = Array.isArray(value) ? [...value] : value;
|
|
484
|
+
form.present.add(field.name);
|
|
485
|
+
form.usedDefaults.delete(field.name);
|
|
486
|
+
if (field.kind === "input") form.clearedInputs.delete(fieldIndex);
|
|
487
|
+
this.notice = undefined;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
private advanceFormField(): void {
|
|
491
|
+
const form = this.forms[this.tab]!;
|
|
492
|
+
const fields = this.currentQuestion().fields ?? [];
|
|
493
|
+
form.active = Math.min(fields.length, form.active + 1);
|
|
494
|
+
this.resetScroll();
|
|
495
|
+
this.refresh();
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
private commitForm(): void {
|
|
499
|
+
this.syncFormDraft(this.tab);
|
|
500
|
+
const question = this.currentQuestion();
|
|
501
|
+
const invalid = validateAnswer(question, toAnswer(question, this.drafts[this.tab]!));
|
|
502
|
+
if (invalid) {
|
|
503
|
+
const fields = question.fields ?? [];
|
|
504
|
+
const missing = fields.findIndex((field) => invalid.includes(`"${field.name}"`));
|
|
505
|
+
const form = this.forms[this.tab]!;
|
|
506
|
+
form.active = missing >= 0 ? missing : 0;
|
|
507
|
+
this.notice = `Complete the form first: ${invalid}.`;
|
|
508
|
+
this.resetScroll();
|
|
509
|
+
this.refresh();
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
this.notice = undefined;
|
|
513
|
+
this.advance();
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private handleFormInput(data: string): void {
|
|
517
|
+
const question = this.currentQuestion();
|
|
518
|
+
const fields = question.fields ?? [];
|
|
519
|
+
const form = this.forms[this.tab]!;
|
|
520
|
+
if (form.active >= fields.length) {
|
|
521
|
+
if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) this.commitForm();
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
const fieldIndex = form.active;
|
|
525
|
+
const field = fields[fieldIndex]!;
|
|
526
|
+
this.resetScroll();
|
|
527
|
+
let cursor = form.optionCursors[fieldIndex] ?? 0;
|
|
528
|
+
if (field.kind === "input") {
|
|
529
|
+
if (matchesKey(data, Key.enter)) {
|
|
530
|
+
this.syncFormInput(this.tab, fieldIndex);
|
|
531
|
+
this.advanceFormField();
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
const input = form.inputs.get(fieldIndex)!;
|
|
535
|
+
const before = input.getValue();
|
|
536
|
+
input.handleInput(data);
|
|
537
|
+
this.syncFormInput(this.tab, fieldIndex, input.getValue() !== before);
|
|
538
|
+
this.syncFormDraft(this.tab);
|
|
539
|
+
this.refresh();
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (field.kind === "bool") {
|
|
543
|
+
if (matchesKey(data, Key.left) || matchesKey(data, Key.up)) cursor = 0;
|
|
544
|
+
else if (matchesKey(data, Key.right) || matchesKey(data, Key.down)) cursor = 1;
|
|
545
|
+
else if (matchesKey(data, Key.space)) {
|
|
546
|
+
this.setFormValue(field, cursor === 0, fieldIndex);
|
|
547
|
+
} else if (matchesKey(data, Key.enter)) {
|
|
548
|
+
this.setFormValue(field, cursor === 0, fieldIndex);
|
|
549
|
+
this.advanceFormField();
|
|
550
|
+
return;
|
|
551
|
+
} else return;
|
|
552
|
+
form.optionCursors[fieldIndex] = cursor;
|
|
553
|
+
this.syncFormDraft(this.tab);
|
|
554
|
+
this.refresh();
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (matchesKey(data, Key.up) || matchesKey(data, Key.left)) cursor = clamp(cursor - 1, 0, field.options.length - 1);
|
|
558
|
+
else if (matchesKey(data, Key.down) || matchesKey(data, Key.right)) cursor = clamp(cursor + 1, 0, field.options.length - 1);
|
|
559
|
+
else if (matchesKey(data, Key.space)) {
|
|
560
|
+
if (field.kind === "single") this.setFormValue(field, field.options[cursor]!.label, fieldIndex);
|
|
561
|
+
else {
|
|
562
|
+
const current = Array.isArray(form.values[field.name]) ? [...form.values[field.name] as string[]] : [];
|
|
563
|
+
const label = field.options[cursor]!.label;
|
|
564
|
+
const next = current.includes(label) ? current.filter((entry) => entry !== label) : [...current, label];
|
|
565
|
+
this.setFormValue(field, field.options.filter((option) => next.includes(option.label)).map((option) => option.label), fieldIndex);
|
|
566
|
+
}
|
|
567
|
+
} else if (matchesKey(data, Key.enter)) {
|
|
568
|
+
if (field.kind === "single") this.setFormValue(field, field.options[cursor]!.label, fieldIndex);
|
|
569
|
+
this.advanceFormField();
|
|
570
|
+
return;
|
|
571
|
+
} else return;
|
|
572
|
+
form.optionCursors[fieldIndex] = cursor;
|
|
573
|
+
this.syncFormDraft(this.tab);
|
|
574
|
+
this.resetScroll();
|
|
575
|
+
this.refresh();
|
|
576
|
+
}
|
|
577
|
+
|
|
374
578
|
// -- key handling --------------------------------------------------------
|
|
375
579
|
|
|
376
580
|
handleInput(data: string): void {
|
|
@@ -392,6 +596,27 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
392
596
|
this.submitAll();
|
|
393
597
|
return;
|
|
394
598
|
}
|
|
599
|
+
if (matchesKey(data, "ctrl+left")) {
|
|
600
|
+
this.moveTab(-1);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (matchesKey(data, "ctrl+right")) {
|
|
604
|
+
this.moveTab(1);
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
if (!this.isReview && this.currentQuestion().kind === "simple-form") {
|
|
608
|
+
if (matchesKey(data, "shift+tab")) {
|
|
609
|
+
this.moveFormFocus(-1);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
if (matchesKey(data, Key.tab)) {
|
|
613
|
+
this.moveFormFocus(1);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (this.isScrollKey(data)) return;
|
|
617
|
+
this.handleFormInput(data);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
395
620
|
if (matchesKey(data, "shift+tab")) {
|
|
396
621
|
this.moveTab(-1);
|
|
397
622
|
return;
|
|
@@ -416,6 +641,31 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
416
641
|
}
|
|
417
642
|
|
|
418
643
|
const question = this.currentQuestion();
|
|
644
|
+
if (question.kind === "bool") {
|
|
645
|
+
if (matchesKey(data, Key.up) || matchesKey(data, Key.left) || matchesKey(data, "ctrl+p")) {
|
|
646
|
+
this.cursors[this.tab] = 0;
|
|
647
|
+
this.refresh();
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
if (matchesKey(data, Key.down) || matchesKey(data, Key.right) || matchesKey(data, "ctrl+n")) {
|
|
651
|
+
this.cursors[this.tab] = 1;
|
|
652
|
+
this.refresh();
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
if (matchesKey(data, "s")) {
|
|
656
|
+
this.skipWithDefault();
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
if (matchesKey(data, Key.space) || matchesKey(data, Key.enter)) {
|
|
660
|
+
this.drafts[this.tab] = {
|
|
661
|
+
selections: [], freeText: "", usedDefault: false, value: this.rowIndex() === 0, valueSet: true,
|
|
662
|
+
};
|
|
663
|
+
this.notice = undefined;
|
|
664
|
+
if (matchesKey(data, Key.enter)) this.advance();
|
|
665
|
+
else this.refresh();
|
|
666
|
+
}
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
419
669
|
if (question.kind === "input") {
|
|
420
670
|
this.cursors[this.tab] = question.options.length;
|
|
421
671
|
}
|
|
@@ -573,6 +823,14 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
573
823
|
this.reviewButtonFocused = false;
|
|
574
824
|
this.cursors[first] = 0;
|
|
575
825
|
this.notice = `Question ${first + 1} has no answer yet; answer it first.`;
|
|
826
|
+
const question = this.request.questions[first]!;
|
|
827
|
+
const form = this.forms[first];
|
|
828
|
+
if (form) {
|
|
829
|
+
const invalid = validateAnswer(question, toAnswer(question, this.drafts[first]!));
|
|
830
|
+
const fieldIndex = question.fields?.findIndex((field) => invalid?.includes(`"${field.name}"`)) ?? -1;
|
|
831
|
+
form.active = fieldIndex >= 0 ? fieldIndex : 0;
|
|
832
|
+
if (invalid) this.notice = `Complete the form first: ${invalid}.`;
|
|
833
|
+
}
|
|
576
834
|
this.resetScroll();
|
|
577
835
|
this.refresh();
|
|
578
836
|
return;
|
|
@@ -646,7 +904,7 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
646
904
|
const chips: string[] = [];
|
|
647
905
|
this.request.questions.forEach((question, index) => {
|
|
648
906
|
const draft = this.drafts[index]!;
|
|
649
|
-
const answered =
|
|
907
|
+
const answered = validateAnswer(question, toAnswer(question, draft)) === undefined || question.hasDefault;
|
|
650
908
|
const marker = answered ? "●" : "○";
|
|
651
909
|
const label = truncateToWidth(question.title, TAB_LABEL_WIDTH);
|
|
652
910
|
const chip = `${marker} ${index + 1}.${label}`;
|
|
@@ -686,9 +944,28 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
686
944
|
return lines;
|
|
687
945
|
}
|
|
688
946
|
|
|
947
|
+
private renderBoolOptions(width: number): { lines: string[]; focusLine: number } {
|
|
948
|
+
const draft = this.drafts[this.tab]!;
|
|
949
|
+
const row = this.rowIndex();
|
|
950
|
+
const selected = draft.valueSet ? draft.value : undefined;
|
|
951
|
+
const choices: Array<{ value: boolean; mark: string; label: string }> = [
|
|
952
|
+
{ value: true, mark: "✓", label: "True" },
|
|
953
|
+
{ value: false, mark: "✗", label: "False" },
|
|
954
|
+
];
|
|
955
|
+
const lines = choices.map((choice, index) => {
|
|
956
|
+
const cursor = index === row ? this.theme.fg("accent", "▸") : " ";
|
|
957
|
+
const chosen = selected === choice.value;
|
|
958
|
+
const control = chosen ? this.theme.fg(choice.value ? "success" : "error", `[${choice.mark}]`) : this.theme.fg("muted", `[ ] ${choice.mark}`);
|
|
959
|
+
const label = index === row ? this.theme.bold(this.theme.fg("accent", choice.label)) : choice.label;
|
|
960
|
+
return truncateToWidth(` ${cursor} ${control} ${label}`, width);
|
|
961
|
+
});
|
|
962
|
+
return { lines, focusLine: row };
|
|
963
|
+
}
|
|
964
|
+
|
|
689
965
|
/** Options only (no input row): the input stays pinned outside the body. */
|
|
690
966
|
private renderOptions(width: number): { lines: string[]; focusLine: number } {
|
|
691
967
|
const question = this.currentQuestion();
|
|
968
|
+
if (question.kind === "bool") return this.renderBoolOptions(width);
|
|
692
969
|
const lines: string[] = [];
|
|
693
970
|
let focusLine = 0;
|
|
694
971
|
for (let index = 0; index < question.options.length; index += 1) {
|
|
@@ -698,6 +975,86 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
698
975
|
return { lines: lines.map((line) => truncateToWidth(line, width)), focusLine };
|
|
699
976
|
}
|
|
700
977
|
|
|
978
|
+
private formValueText(value: boolean | string | string[] | undefined): string {
|
|
979
|
+
if (value === undefined) return "(unset)";
|
|
980
|
+
if (typeof value === "boolean") return value ? "✓ True" : "✗ False";
|
|
981
|
+
if (Array.isArray(value)) return value.length > 0 ? value.join(", ") : "(none selected)";
|
|
982
|
+
return value === "" ? "(empty)" : value;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
private renderFormProjection(width: number): { lines: string[]; focusLine: number } {
|
|
986
|
+
const question = this.currentQuestion();
|
|
987
|
+
const fields = question.fields ?? [];
|
|
988
|
+
const form = this.forms[this.tab]!;
|
|
989
|
+
const lines: string[] = [];
|
|
990
|
+
let focusLine = 0;
|
|
991
|
+
fields.forEach((field, fieldIndex) => {
|
|
992
|
+
const active = form.active === fieldIndex;
|
|
993
|
+
if (active) focusLine = lines.length;
|
|
994
|
+
const pointer = active ? this.theme.fg("accent", "▸") : " ";
|
|
995
|
+
const required = field.required ? this.theme.fg("error", " *") : "";
|
|
996
|
+
const defaultMark = form.usedDefaults.has(field.name) ? this.theme.fg("muted", " (default)") : "";
|
|
997
|
+
lines.push(truncateToWidth(`${pointer} ${this.theme.bold(field.label)}${required}${defaultMark}`, width));
|
|
998
|
+
|
|
999
|
+
if (!active) {
|
|
1000
|
+
lines.push(truncateToWidth(this.theme.fg("muted", ` ${this.formValueText(form.present.has(field.name) ? form.values[field.name] : undefined)}`), width));
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
if (field.kind === "input") {
|
|
1004
|
+
const input = form.inputs.get(fieldIndex)!;
|
|
1005
|
+
input.focused = this.focused;
|
|
1006
|
+
const indent = " ".repeat(Math.min(4, Math.max(0, width - 1)));
|
|
1007
|
+
const [line = ""] = input.render(Math.max(1, width - indent.length));
|
|
1008
|
+
focusLine = lines.length;
|
|
1009
|
+
lines.push(truncateToWidth(`${indent}${line}`, width));
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
if (field.kind === "bool") {
|
|
1013
|
+
const value = form.present.has(field.name) ? form.values[field.name] : undefined;
|
|
1014
|
+
const cursor = form.optionCursors[fieldIndex] ?? 0;
|
|
1015
|
+
for (const [choiceIndex, choice] of [true, false].entries()) {
|
|
1016
|
+
if (cursor === choiceIndex) focusLine = lines.length;
|
|
1017
|
+
const at = cursor === choiceIndex ? this.theme.fg("accent", "▸") : " ";
|
|
1018
|
+
const selected = value === choice;
|
|
1019
|
+
const mark = choice ? "✓" : "✗";
|
|
1020
|
+
const box = selected ? this.theme.fg(choice ? "success" : "error", `[${mark}]`) : this.theme.fg("muted", `[ ] ${mark}`);
|
|
1021
|
+
lines.push(truncateToWidth(` ${at} ${box} ${choice ? "True" : "False"}`, width));
|
|
1022
|
+
}
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
const cursor = form.optionCursors[fieldIndex] ?? 0;
|
|
1027
|
+
const current = form.values[field.name];
|
|
1028
|
+
field.options.forEach((option, optionIndex) => {
|
|
1029
|
+
if (cursor === optionIndex) focusLine = lines.length;
|
|
1030
|
+
const at = cursor === optionIndex ? this.theme.fg("accent", "▸") : " ";
|
|
1031
|
+
const selected = field.kind === "single" ? current === option.label : Array.isArray(current) && current.includes(option.label);
|
|
1032
|
+
const box = field.kind === "single" ? (selected ? "◉" : "○") : (selected ? "▣" : "▢");
|
|
1033
|
+
lines.push(truncateToWidth(` ${at} ${selected ? this.theme.fg("success", box) : this.theme.fg("muted", box)} ${option.label}`, width));
|
|
1034
|
+
if (option.description) lines.push(...this.wrap(this.theme.fg("muted", ` ${option.description}`), width));
|
|
1035
|
+
});
|
|
1036
|
+
const selectedOption = field.options[cursor];
|
|
1037
|
+
if (selectedOption?.preview?.trim()) {
|
|
1038
|
+
lines.push(this.theme.fg("muted", " Preview:"));
|
|
1039
|
+
lines.push(...this.markdownFor(selectedOption.preview).render(Math.max(1, width - 4)).map((line) => ` ${line}`));
|
|
1040
|
+
}
|
|
1041
|
+
});
|
|
1042
|
+
if (form.active >= fields.length) focusLine = lines.length;
|
|
1043
|
+
const action = this.request.questions.length === 1 ? "Submit" : "Continue";
|
|
1044
|
+
const button = form.active >= fields.length ? this.theme.inverse?.(` [ Enter ${action} ] `) ?? `[ Enter ${action} ]` : `[ Enter ${action} ]`;
|
|
1045
|
+
lines.push(truncateToWidth(`${form.active >= fields.length ? this.theme.fg("accent", "▸") : " "} ${this.theme.fg("accent", button)}`, width));
|
|
1046
|
+
return { lines: lines.map((line) => truncateToWidth(line, width)), focusLine };
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
private renderFormBody(width: number, height: number): string[] {
|
|
1050
|
+
const projection = this.renderFormProjection(width);
|
|
1051
|
+
const maxScroll = Math.max(0, projection.lines.length - height);
|
|
1052
|
+
this.viewport = { wide: false, maxBody: height, maxScroll };
|
|
1053
|
+
if (this.formScroll !== undefined) this.formScroll = clamp(this.formScroll, 0, maxScroll);
|
|
1054
|
+
this.formOffset = this.formScroll ?? this.autoOffset(projection.lines.length, projection.focusLine, height);
|
|
1055
|
+
return projection.lines.slice(this.formOffset, this.formOffset + height);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
701
1058
|
private renderBodyArea(width: number, height: number): string[] {
|
|
702
1059
|
if (height <= 0) return [];
|
|
703
1060
|
if (this.isReview) {
|
|
@@ -707,6 +1064,7 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
707
1064
|
this.manualScroll = clamp(this.manualScroll, 0, maxScroll);
|
|
708
1065
|
return lines.slice(this.manualScroll, this.manualScroll + height);
|
|
709
1066
|
}
|
|
1067
|
+
if (this.currentQuestion().kind === "simple-form") return this.renderFormBody(width, height);
|
|
710
1068
|
|
|
711
1069
|
const preview = this.currentPreview();
|
|
712
1070
|
const wide = preview.trim() !== "" && width >= SPLIT_MIN_WIDTH;
|
|
@@ -758,6 +1116,7 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
758
1116
|
|
|
759
1117
|
/** The free-input row, rendered as a fixed line that never scrolls away. */
|
|
760
1118
|
private renderInputBar(width: number): string[] {
|
|
1119
|
+
if (this.currentQuestion().kind === "bool" || this.currentQuestion().kind === "simple-form") return [];
|
|
761
1120
|
const input = this.inputs[this.tab]!;
|
|
762
1121
|
input.focused = this.focused && this.isInputRow();
|
|
763
1122
|
return input
|
|
@@ -781,7 +1140,7 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
781
1140
|
const lines: string[] = [];
|
|
782
1141
|
this.request.questions.forEach((question, index) => {
|
|
783
1142
|
const draft = this.drafts[index]!;
|
|
784
|
-
const answered =
|
|
1143
|
+
const answered = validateAnswer(question, toAnswer(question, draft)) === undefined || question.hasDefault;
|
|
785
1144
|
const mark = answered ? this.theme.fg("success", "✓") : this.theme.fg("warning", "!");
|
|
786
1145
|
lines.push(truncateToWidth(`${mark} ${this.theme.bold(`${index + 1}. ${question.title}`)}`, width));
|
|
787
1146
|
lines.push(truncateToWidth(this.theme.fg("muted", ` ${this.describeDraft(question, draft)}`), width));
|
|
@@ -790,6 +1149,13 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
790
1149
|
}
|
|
791
1150
|
|
|
792
1151
|
private describeDraft(question: NormalizedQuestion, draft: DraftAnswer): string {
|
|
1152
|
+
if (question.kind === "bool" && draft.valueSet && typeof draft.value === "boolean") {
|
|
1153
|
+
return `${draft.value ? "True" : "False"}${draft.usedDefault ? " (default)" : ""}`;
|
|
1154
|
+
}
|
|
1155
|
+
if (question.kind === "simple-form" && draft.valueSet && draft.value && typeof draft.value === "object") {
|
|
1156
|
+
const entries = Object.entries(draft.value).map(([name, value]) => `${name}=${this.formValueText(value)}`);
|
|
1157
|
+
return `${entries.length > 0 ? entries.join("; ") : "(optional fields omitted)"}${draft.usedDefault ? " (includes defaults)" : ""}`;
|
|
1158
|
+
}
|
|
793
1159
|
const parts: string[] = [];
|
|
794
1160
|
if (draft.selections.length > 0) {
|
|
795
1161
|
parts.push(draft.selections.map((index) => question.options[index]?.label ?? `#${index + 1}`).join(", "));
|
|
@@ -821,6 +1187,22 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
821
1187
|
return withControls(reviewHints);
|
|
822
1188
|
}
|
|
823
1189
|
const question = this.currentQuestion();
|
|
1190
|
+
if (question.kind === "simple-form") {
|
|
1191
|
+
const hints = ["Tab next field", "Shift+Tab previous", "Ctrl+←/→ question"];
|
|
1192
|
+
const form = this.forms[this.tab]!;
|
|
1193
|
+
const field = question.fields?.[form.active];
|
|
1194
|
+
if (field?.kind === "multi") hints.push("Space toggle", "Enter next");
|
|
1195
|
+
else if (field?.kind === "bool") hints.push("←/→ choose", "Space set", "Enter next");
|
|
1196
|
+
else if (field) hints.push("Enter next");
|
|
1197
|
+
else hints.push("Enter continue");
|
|
1198
|
+
if (this.viewport.maxScroll > 0) hints.push("PgUp/PgDn scroll");
|
|
1199
|
+
return withControls(hints);
|
|
1200
|
+
}
|
|
1201
|
+
if (question.kind === "bool") {
|
|
1202
|
+
const hints = ["↑/↓ choose", "Space set", "Enter confirm", "Tab switch"];
|
|
1203
|
+
if (question.hasDefault) hints.push("s use default");
|
|
1204
|
+
return withControls(hints);
|
|
1205
|
+
}
|
|
824
1206
|
const hints = ["Tab switch", "↑/↓ move", `Enter ${this.isInputRow() ? "confirm" : "select"}`];
|
|
825
1207
|
if (question.kind === "multi") hints.push("Space multi-select");
|
|
826
1208
|
if (question.hasDefault) hints.push("s use default");
|
|
@@ -877,9 +1259,11 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
877
1259
|
const hintBlock = [this.hintLine(contentWidth)];
|
|
878
1260
|
|
|
879
1261
|
let remaining = contentRows;
|
|
1262
|
+
const typedQuestion = !this.isReview && (this.currentQuestion().kind === "simple-form" || this.currentQuestion().kind === "bool");
|
|
1263
|
+
const reserveBody = typedQuestion && contentRows > 0 ? 1 : 0;
|
|
880
1264
|
const take = (block: string[]): string[] => {
|
|
881
|
-
if (remaining <=
|
|
882
|
-
const use = Math.min(block.length, remaining);
|
|
1265
|
+
if (remaining <= reserveBody || block.length === 0) return [];
|
|
1266
|
+
const use = Math.min(block.length, remaining - reserveBody);
|
|
883
1267
|
remaining -= use;
|
|
884
1268
|
return block.slice(0, use);
|
|
885
1269
|
};
|