@black942026/pi-ask-user 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +160 -25
- package/package.json +1 -1
- package/src/adapters/pi.ts +5 -2
- package/src/answers.ts +119 -33
- package/src/config.ts +11 -9
- package/src/core.ts +23 -6
- package/src/index.ts +19 -3
- package/src/parse.ts +17 -0
- package/src/schema.ts +201 -90
- package/src/types.ts +113 -13
- package/src/ui/custom.ts +395 -16
- package/src/ui/native.ts +232 -60
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,
|
|
@@ -177,7 +190,9 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
177
190
|
this.mdTheme = buildMarkdownTheme(options.theme);
|
|
178
191
|
this.overlayToggleKey =
|
|
179
192
|
options.request.displayMode === "overlay" ? options.overlayToggleKey : undefined;
|
|
180
|
-
this.drafts = options.request.questions.map(() =>
|
|
193
|
+
this.drafts = options.request.questions.map((question) =>
|
|
194
|
+
question.kind === "bool" ? draftFromDefault(question) ?? emptyDraft() : emptyDraft(),
|
|
195
|
+
);
|
|
181
196
|
this.cursors = options.request.questions.map(() => 0);
|
|
182
197
|
this.inputs = options.request.questions.map(
|
|
183
198
|
(question) => new Input({
|
|
@@ -186,6 +201,40 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
186
201
|
placeholderStyle: (text) => this.theme.fg("muted", text),
|
|
187
202
|
}),
|
|
188
203
|
);
|
|
204
|
+
this.forms = options.request.questions.map((question, questionIndex) => {
|
|
205
|
+
if (question.kind !== "simple-form") return undefined;
|
|
206
|
+
const values: AskUserFormValue = {};
|
|
207
|
+
const present = new Set<string>();
|
|
208
|
+
const usedDefaults = new Set<string>();
|
|
209
|
+
const fieldInputs = new Map<number, Input>();
|
|
210
|
+
const fields = question.fields ?? [];
|
|
211
|
+
fields.forEach((field, fieldIndex) => {
|
|
212
|
+
if (field.hasDefault && field.default !== undefined) {
|
|
213
|
+
values[field.name] = Array.isArray(field.default) ? [...field.default] : field.default;
|
|
214
|
+
present.add(field.name);
|
|
215
|
+
usedDefaults.add(field.name);
|
|
216
|
+
}
|
|
217
|
+
if (field.kind === "input") {
|
|
218
|
+
const input = new Input({
|
|
219
|
+
prompt: "",
|
|
220
|
+
placeholder: field.required ? "Required" : "Optional; leave empty to omit",
|
|
221
|
+
placeholderStyle: (text) => this.theme.fg("muted", text),
|
|
222
|
+
});
|
|
223
|
+
if (typeof field.default === "string") input.setValue(field.default);
|
|
224
|
+
fieldInputs.set(fieldIndex, input);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
this.drafts[questionIndex] = {
|
|
228
|
+
selections: [], freeText: "", value: { ...values }, valueSet: true, usedDefault: usedDefaults.size > 0,
|
|
229
|
+
};
|
|
230
|
+
const optionCursors = fields.map((field) => {
|
|
231
|
+
if (field.kind === "single" && typeof field.default === "string") {
|
|
232
|
+
return Math.max(0, field.options.findIndex((option) => option.label === field.default));
|
|
233
|
+
}
|
|
234
|
+
return 0;
|
|
235
|
+
});
|
|
236
|
+
return { values, present, usedDefaults, inputs: fieldInputs, optionCursors, clearedInputs: new Set(), active: 0 };
|
|
237
|
+
});
|
|
189
238
|
const remaining = Math.max(0, this.deadline.remainingMs());
|
|
190
239
|
this.remainingSeconds = Math.ceil(remaining / 1000);
|
|
191
240
|
this.timer = setTimeout(() => this.finish({ kind: "timeout" }), remaining);
|
|
@@ -241,6 +290,7 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
241
290
|
this.cachedWidth = undefined;
|
|
242
291
|
this.cachedRows = undefined;
|
|
243
292
|
for (const input of this.inputs) input.invalidate();
|
|
293
|
+
for (const form of this.forms) for (const input of form?.inputs.values() ?? []) input.invalidate();
|
|
244
294
|
for (const markdown of this.previewCache.values()) markdown.invalidate();
|
|
245
295
|
}
|
|
246
296
|
|
|
@@ -252,6 +302,7 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
252
302
|
private resetScroll(): void {
|
|
253
303
|
this.previewScroll = 0;
|
|
254
304
|
this.manualScroll = 0;
|
|
305
|
+
this.formScroll = undefined;
|
|
255
306
|
}
|
|
256
307
|
|
|
257
308
|
// -- tab helpers ---------------------------------------------------------
|
|
@@ -272,8 +323,15 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
272
323
|
return this.request.questions[this.tab]!;
|
|
273
324
|
}
|
|
274
325
|
|
|
326
|
+
private hasInputBar(question: NormalizedQuestion): boolean {
|
|
327
|
+
return question.kind === "input" ||
|
|
328
|
+
((question.kind === "single" || question.kind === "multi") && question.allowFreeText !== false && this.request.allowFreeText !== false);
|
|
329
|
+
}
|
|
330
|
+
|
|
275
331
|
private rowCount(question: NormalizedQuestion): number {
|
|
276
|
-
|
|
332
|
+
if (question.kind === "bool") return 1;
|
|
333
|
+
if (question.kind === "simple-form") return (question.fields?.length ?? 0) + 1;
|
|
334
|
+
return question.options.length + (this.hasInputBar(question) ? 1 : 0);
|
|
277
335
|
}
|
|
278
336
|
|
|
279
337
|
private rowIndex(): number {
|
|
@@ -284,6 +342,7 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
284
342
|
|
|
285
343
|
private isInputRow(): boolean {
|
|
286
344
|
if (this.isReview) return false;
|
|
345
|
+
if (!this.hasInputBar(this.currentQuestion())) return false;
|
|
287
346
|
return this.rowIndex() >= this.currentQuestion().options.length;
|
|
288
347
|
}
|
|
289
348
|
|
|
@@ -321,12 +380,50 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
321
380
|
private syncDraftFromInput(index: number): void {
|
|
322
381
|
const draft = this.drafts[index];
|
|
323
382
|
const input = this.inputs[index];
|
|
324
|
-
if (!draft || !input) return;
|
|
383
|
+
if (!draft || !input || !this.hasInputBar(this.request.questions[index]!)) return;
|
|
325
384
|
draft.freeText = input.getValue();
|
|
326
385
|
}
|
|
327
386
|
|
|
328
387
|
private syncAllDrafts(): void {
|
|
329
|
-
for (let index = 0; index < this.inputs.length; index += 1)
|
|
388
|
+
for (let index = 0; index < this.inputs.length; index += 1) {
|
|
389
|
+
if (this.request.questions[index]?.kind === "simple-form") this.syncFormDraft(index);
|
|
390
|
+
else this.syncDraftFromInput(index);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private syncFormInput(questionIndex: number, fieldIndex: number, edited = false): void {
|
|
395
|
+
const question = this.request.questions[questionIndex];
|
|
396
|
+
const form = this.forms[questionIndex];
|
|
397
|
+
const field = question?.fields?.[fieldIndex];
|
|
398
|
+
const input = form?.inputs.get(fieldIndex);
|
|
399
|
+
if (!question || !form || !field || !input) return;
|
|
400
|
+
if (edited) form.usedDefaults.delete(field.name);
|
|
401
|
+
const text = form.usedDefaults.has(field.name) ? input.getValue() : input.getValue().trim();
|
|
402
|
+
if (edited && text === "") form.clearedInputs.add(fieldIndex);
|
|
403
|
+
else if (edited) form.clearedInputs.delete(fieldIndex);
|
|
404
|
+
if (text === "" && (!field.hasDefault || form.clearedInputs.has(fieldIndex))) {
|
|
405
|
+
delete form.values[field.name];
|
|
406
|
+
form.present.delete(field.name);
|
|
407
|
+
} else {
|
|
408
|
+
form.values[field.name] = text;
|
|
409
|
+
form.present.add(field.name);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
private syncFormDraft(questionIndex: number): void {
|
|
414
|
+
const question = this.request.questions[questionIndex];
|
|
415
|
+
const form = this.forms[questionIndex];
|
|
416
|
+
if (!question || !form) return;
|
|
417
|
+
for (const fieldIndex of form.inputs.keys()) this.syncFormInput(questionIndex, fieldIndex);
|
|
418
|
+
const value: AskUserFormValue = {};
|
|
419
|
+
for (const field of question.fields ?? []) {
|
|
420
|
+
if (!form.present.has(field.name)) continue;
|
|
421
|
+
const fieldValue = form.values[field.name];
|
|
422
|
+
if (fieldValue !== undefined) value[field.name] = Array.isArray(fieldValue) ? [...fieldValue] : fieldValue;
|
|
423
|
+
}
|
|
424
|
+
this.drafts[questionIndex] = {
|
|
425
|
+
selections: [], freeText: "", value, valueSet: true, usedDefault: form.usedDefaults.size > 0,
|
|
426
|
+
};
|
|
330
427
|
}
|
|
331
428
|
|
|
332
429
|
/** Forward a key to the input and drop `usedDefault` if the text changed. */
|
|
@@ -342,7 +439,9 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
342
439
|
private scrollBy(direction: -1 | 1): void {
|
|
343
440
|
if (this.viewport.maxScroll <= 0) return;
|
|
344
441
|
const page = Math.max(1, this.viewport.maxBody - 1);
|
|
345
|
-
if (this.
|
|
442
|
+
if (!this.isReview && this.currentQuestion().kind === "simple-form") {
|
|
443
|
+
this.formScroll = clamp((this.formScroll ?? this.formOffset) + direction * page, 0, this.viewport.maxScroll);
|
|
444
|
+
} else if (this.viewport.wide) {
|
|
346
445
|
this.previewScroll = clamp(this.previewScroll + direction * page, 0, this.viewport.maxScroll);
|
|
347
446
|
} else {
|
|
348
447
|
this.manualScroll = clamp(this.manualScroll + direction * page, 0, this.viewport.maxScroll);
|
|
@@ -359,7 +458,9 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
359
458
|
this.scrollBy(1);
|
|
360
459
|
return true;
|
|
361
460
|
}
|
|
362
|
-
|
|
461
|
+
const form = this.forms[this.tab];
|
|
462
|
+
const formField = !this.isReview ? this.currentQuestion().fields?.[form?.active ?? -1] : undefined;
|
|
463
|
+
if (this.isInputRow() || formField?.kind === "input") return false;
|
|
363
464
|
if (matchesKey(data, "ctrl+u") || data === "[") {
|
|
364
465
|
this.scrollBy(-1);
|
|
365
466
|
return true;
|
|
@@ -371,6 +472,120 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
371
472
|
return false;
|
|
372
473
|
}
|
|
373
474
|
|
|
475
|
+
private moveFormFocus(delta: number): void {
|
|
476
|
+
const form = this.forms[this.tab];
|
|
477
|
+
const question = this.currentQuestion();
|
|
478
|
+
if (!form) return;
|
|
479
|
+
this.syncFormDraft(this.tab);
|
|
480
|
+
const slots = (question.fields?.length ?? 0) + 1;
|
|
481
|
+
form.active = (form.active + delta + slots) % slots;
|
|
482
|
+
this.notice = undefined;
|
|
483
|
+
this.resetScroll();
|
|
484
|
+
this.refresh();
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
private setFormValue(field: NormalizedFormField, value: boolean | string | string[], fieldIndex: number): void {
|
|
488
|
+
const form = this.forms[this.tab]!;
|
|
489
|
+
form.values[field.name] = Array.isArray(value) ? [...value] : value;
|
|
490
|
+
form.present.add(field.name);
|
|
491
|
+
form.usedDefaults.delete(field.name);
|
|
492
|
+
if (field.kind === "input") form.clearedInputs.delete(fieldIndex);
|
|
493
|
+
this.notice = undefined;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private advanceFormField(): void {
|
|
497
|
+
const form = this.forms[this.tab]!;
|
|
498
|
+
const fields = this.currentQuestion().fields ?? [];
|
|
499
|
+
form.active = Math.min(fields.length, form.active + 1);
|
|
500
|
+
this.resetScroll();
|
|
501
|
+
this.refresh();
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
private commitForm(): void {
|
|
505
|
+
this.syncFormDraft(this.tab);
|
|
506
|
+
const question = this.currentQuestion();
|
|
507
|
+
const invalid = validateAnswer(question, toAnswer(question, this.drafts[this.tab]!));
|
|
508
|
+
if (invalid) {
|
|
509
|
+
const fields = question.fields ?? [];
|
|
510
|
+
const missing = fields.findIndex((field) => invalid.includes(`"${field.name}"`));
|
|
511
|
+
const form = this.forms[this.tab]!;
|
|
512
|
+
form.active = missing >= 0 ? missing : 0;
|
|
513
|
+
this.notice = `Complete the form first: ${invalid}.`;
|
|
514
|
+
this.resetScroll();
|
|
515
|
+
this.refresh();
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
this.notice = undefined;
|
|
519
|
+
this.advance();
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private handleFormInput(data: string): void {
|
|
523
|
+
const question = this.currentQuestion();
|
|
524
|
+
const fields = question.fields ?? [];
|
|
525
|
+
const form = this.forms[this.tab]!;
|
|
526
|
+
if (form.active >= fields.length) {
|
|
527
|
+
if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) this.commitForm();
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
const fieldIndex = form.active;
|
|
531
|
+
const field = fields[fieldIndex]!;
|
|
532
|
+
this.resetScroll();
|
|
533
|
+
let cursor = form.optionCursors[fieldIndex] ?? 0;
|
|
534
|
+
if (field.kind === "input") {
|
|
535
|
+
if (matchesKey(data, Key.enter)) {
|
|
536
|
+
this.syncFormInput(this.tab, fieldIndex);
|
|
537
|
+
this.advanceFormField();
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
const input = form.inputs.get(fieldIndex)!;
|
|
541
|
+
const before = input.getValue();
|
|
542
|
+
input.handleInput(data);
|
|
543
|
+
this.syncFormInput(this.tab, fieldIndex, input.getValue() !== before);
|
|
544
|
+
this.syncFormDraft(this.tab);
|
|
545
|
+
this.refresh();
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (field.kind === "bool") {
|
|
549
|
+
const current = form.present.has(field.name) ? form.values[field.name] : undefined;
|
|
550
|
+
const next = this.boolValueForKey(data, typeof current === "boolean" ? current : undefined);
|
|
551
|
+
if (next === undefined) return;
|
|
552
|
+
// Confirming an existing default preserves its provenance.
|
|
553
|
+
if (!matchesKey(data, Key.enter) || current === undefined) this.setFormValue(field, next, fieldIndex);
|
|
554
|
+
this.syncFormDraft(this.tab);
|
|
555
|
+
if (matchesKey(data, Key.enter)) this.advanceFormField();
|
|
556
|
+
else this.refresh();
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
if (matchesKey(data, Key.up) || matchesKey(data, Key.left)) cursor = clamp(cursor - 1, 0, field.options.length - 1);
|
|
560
|
+
else if (matchesKey(data, Key.down) || matchesKey(data, Key.right)) cursor = clamp(cursor + 1, 0, field.options.length - 1);
|
|
561
|
+
else if (matchesKey(data, Key.space)) {
|
|
562
|
+
if (field.kind === "single") this.setFormValue(field, field.options[cursor]!.label, fieldIndex);
|
|
563
|
+
else {
|
|
564
|
+
const current = Array.isArray(form.values[field.name]) ? [...form.values[field.name] as string[]] : [];
|
|
565
|
+
const label = field.options[cursor]!.label;
|
|
566
|
+
const next = current.includes(label) ? current.filter((entry) => entry !== label) : [...current, label];
|
|
567
|
+
this.setFormValue(field, field.options.filter((option) => next.includes(option.label)).map((option) => option.label), fieldIndex);
|
|
568
|
+
}
|
|
569
|
+
} else if (matchesKey(data, Key.enter)) {
|
|
570
|
+
if (field.kind === "single") this.setFormValue(field, field.options[cursor]!.label, fieldIndex);
|
|
571
|
+
this.advanceFormField();
|
|
572
|
+
return;
|
|
573
|
+
} else return;
|
|
574
|
+
form.optionCursors[fieldIndex] = cursor;
|
|
575
|
+
this.syncFormDraft(this.tab);
|
|
576
|
+
this.resetScroll();
|
|
577
|
+
this.refresh();
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** One boolean control: arrows set, Space toggles, Enter confirms (unset -> true). */
|
|
581
|
+
private boolValueForKey(data: string, current: boolean | undefined): boolean | undefined {
|
|
582
|
+
if (matchesKey(data, Key.up) || matchesKey(data, Key.left) || matchesKey(data, "ctrl+p")) return true;
|
|
583
|
+
if (matchesKey(data, Key.down) || matchesKey(data, Key.right) || matchesKey(data, "ctrl+n")) return false;
|
|
584
|
+
if (matchesKey(data, Key.space)) return current === undefined ? true : !current;
|
|
585
|
+
if (matchesKey(data, Key.enter)) return current ?? true;
|
|
586
|
+
return undefined;
|
|
587
|
+
}
|
|
588
|
+
|
|
374
589
|
// -- key handling --------------------------------------------------------
|
|
375
590
|
|
|
376
591
|
handleInput(data: string): void {
|
|
@@ -392,6 +607,27 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
392
607
|
this.submitAll();
|
|
393
608
|
return;
|
|
394
609
|
}
|
|
610
|
+
if (matchesKey(data, "ctrl+left")) {
|
|
611
|
+
this.moveTab(-1);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
if (matchesKey(data, "ctrl+right")) {
|
|
615
|
+
this.moveTab(1);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
if (!this.isReview && this.currentQuestion().kind === "simple-form") {
|
|
619
|
+
if (matchesKey(data, "shift+tab")) {
|
|
620
|
+
this.moveFormFocus(-1);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
if (matchesKey(data, Key.tab)) {
|
|
624
|
+
this.moveFormFocus(1);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
if (this.isScrollKey(data)) return;
|
|
628
|
+
this.handleFormInput(data);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
395
631
|
if (matchesKey(data, "shift+tab")) {
|
|
396
632
|
this.moveTab(-1);
|
|
397
633
|
return;
|
|
@@ -416,6 +652,22 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
416
652
|
}
|
|
417
653
|
|
|
418
654
|
const question = this.currentQuestion();
|
|
655
|
+
if (question.kind === "bool") {
|
|
656
|
+
if (matchesKey(data, "s")) {
|
|
657
|
+
this.skipWithDefault();
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
const draft = this.drafts[this.tab]!;
|
|
661
|
+
const next = this.boolValueForKey(data, draft.valueSet && typeof draft.value === "boolean" ? draft.value : undefined);
|
|
662
|
+
if (next === undefined) return;
|
|
663
|
+
if (!matchesKey(data, Key.enter) || !draft.valueSet) {
|
|
664
|
+
this.drafts[this.tab] = { selections: [], freeText: "", usedDefault: false, value: next, valueSet: true };
|
|
665
|
+
}
|
|
666
|
+
this.notice = undefined;
|
|
667
|
+
if (matchesKey(data, Key.enter)) this.advance();
|
|
668
|
+
else this.refresh();
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
419
671
|
if (question.kind === "input") {
|
|
420
672
|
this.cursors[this.tab] = question.options.length;
|
|
421
673
|
}
|
|
@@ -471,7 +723,7 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
471
723
|
}
|
|
472
724
|
|
|
473
725
|
const printable = printableText(data);
|
|
474
|
-
if (printable !== undefined) {
|
|
726
|
+
if (printable !== undefined && this.hasInputBar(question)) {
|
|
475
727
|
this.moveRowToInput();
|
|
476
728
|
this.forwardToInput(data);
|
|
477
729
|
this.refresh();
|
|
@@ -481,7 +733,8 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
481
733
|
private onOptionEnter(): void {
|
|
482
734
|
const question = this.currentQuestion();
|
|
483
735
|
if (question.kind === "multi") {
|
|
484
|
-
this.toggleRow();
|
|
736
|
+
if (this.hasInputBar(question)) this.toggleRow();
|
|
737
|
+
else this.commitAndAdvance();
|
|
485
738
|
return;
|
|
486
739
|
}
|
|
487
740
|
// Sync any typed note before advancing, so selecting an option never drops
|
|
@@ -532,7 +785,9 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
532
785
|
this.syncDraftFromInput(this.tab);
|
|
533
786
|
const draft = this.drafts[this.tab]!;
|
|
534
787
|
if (draftIsEmpty(draft) && !question.hasDefault) {
|
|
535
|
-
this.notice =
|
|
788
|
+
this.notice = this.hasInputBar(question)
|
|
789
|
+
? "This question is required: select an option or type text."
|
|
790
|
+
: "This question is required: select an option.";
|
|
536
791
|
this.refresh();
|
|
537
792
|
return;
|
|
538
793
|
}
|
|
@@ -573,6 +828,14 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
573
828
|
this.reviewButtonFocused = false;
|
|
574
829
|
this.cursors[first] = 0;
|
|
575
830
|
this.notice = `Question ${first + 1} has no answer yet; answer it first.`;
|
|
831
|
+
const question = this.request.questions[first]!;
|
|
832
|
+
const form = this.forms[first];
|
|
833
|
+
if (form) {
|
|
834
|
+
const invalid = validateAnswer(question, toAnswer(question, this.drafts[first]!));
|
|
835
|
+
const fieldIndex = question.fields?.findIndex((field) => invalid?.includes(`"${field.name}"`)) ?? -1;
|
|
836
|
+
form.active = fieldIndex >= 0 ? fieldIndex : 0;
|
|
837
|
+
if (invalid) this.notice = `Complete the form first: ${invalid}.`;
|
|
838
|
+
}
|
|
576
839
|
this.resetScroll();
|
|
577
840
|
this.refresh();
|
|
578
841
|
return;
|
|
@@ -646,7 +909,7 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
646
909
|
const chips: string[] = [];
|
|
647
910
|
this.request.questions.forEach((question, index) => {
|
|
648
911
|
const draft = this.drafts[index]!;
|
|
649
|
-
const answered =
|
|
912
|
+
const answered = validateAnswer(question, toAnswer(question, draft)) === undefined || question.hasDefault;
|
|
650
913
|
const marker = answered ? "●" : "○";
|
|
651
914
|
const label = truncateToWidth(question.title, TAB_LABEL_WIDTH);
|
|
652
915
|
const chip = `${marker} ${index + 1}.${label}`;
|
|
@@ -686,9 +949,25 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
686
949
|
return lines;
|
|
687
950
|
}
|
|
688
951
|
|
|
952
|
+
private renderBoolControl(value: boolean | undefined): string {
|
|
953
|
+
if (value === undefined) return this.theme.fg("muted", "[ ] True (unset)");
|
|
954
|
+
return this.theme.fg(value ? "success" : "error", value ? "[✓] True" : "[✗] False");
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
private renderBoolOptions(width: number): { lines: string[]; focusLine: number } {
|
|
958
|
+
const draft = this.drafts[this.tab]!;
|
|
959
|
+
const value = draft.valueSet && typeof draft.value === "boolean" ? draft.value : undefined;
|
|
960
|
+
const defaultMark = draft.usedDefault ? this.theme.fg("muted", " (default)") : "";
|
|
961
|
+
return {
|
|
962
|
+
lines: [truncateToWidth(` ${this.theme.fg("accent", "▸")} ${this.renderBoolControl(value)}${defaultMark}`, width)],
|
|
963
|
+
focusLine: 0,
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
|
|
689
967
|
/** Options only (no input row): the input stays pinned outside the body. */
|
|
690
968
|
private renderOptions(width: number): { lines: string[]; focusLine: number } {
|
|
691
969
|
const question = this.currentQuestion();
|
|
970
|
+
if (question.kind === "bool") return this.renderBoolOptions(width);
|
|
692
971
|
const lines: string[] = [];
|
|
693
972
|
let focusLine = 0;
|
|
694
973
|
for (let index = 0; index < question.options.length; index += 1) {
|
|
@@ -698,6 +977,79 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
698
977
|
return { lines: lines.map((line) => truncateToWidth(line, width)), focusLine };
|
|
699
978
|
}
|
|
700
979
|
|
|
980
|
+
private formValueText(value: boolean | string | string[] | undefined): string {
|
|
981
|
+
if (value === undefined) return "(unset)";
|
|
982
|
+
if (typeof value === "boolean") return value ? "✓ True" : "✗ False";
|
|
983
|
+
if (Array.isArray(value)) return value.length > 0 ? value.join(", ") : "(none selected)";
|
|
984
|
+
return value === "" ? "(empty)" : value;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
private renderFormProjection(width: number): { lines: string[]; focusLine: number } {
|
|
988
|
+
const question = this.currentQuestion();
|
|
989
|
+
const fields = question.fields ?? [];
|
|
990
|
+
const form = this.forms[this.tab]!;
|
|
991
|
+
const lines: string[] = [];
|
|
992
|
+
let focusLine = 0;
|
|
993
|
+
fields.forEach((field, fieldIndex) => {
|
|
994
|
+
const active = form.active === fieldIndex;
|
|
995
|
+
if (active) focusLine = lines.length;
|
|
996
|
+
const pointer = active ? this.theme.fg("accent", "▸") : " ";
|
|
997
|
+
const required = field.required ? this.theme.fg("error", " *") : "";
|
|
998
|
+
const defaultMark = form.usedDefaults.has(field.name) ? this.theme.fg("muted", " (default)") : "";
|
|
999
|
+
lines.push(truncateToWidth(`${pointer} ${this.theme.bold(field.label)}${required}${defaultMark}`, width));
|
|
1000
|
+
|
|
1001
|
+
if (!active) {
|
|
1002
|
+
lines.push(truncateToWidth(this.theme.fg("muted", ` ${this.formValueText(form.present.has(field.name) ? form.values[field.name] : undefined)}`), width));
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
if (field.kind === "input") {
|
|
1006
|
+
const input = form.inputs.get(fieldIndex)!;
|
|
1007
|
+
input.focused = this.focused;
|
|
1008
|
+
const indent = " ".repeat(Math.min(4, Math.max(0, width - 1)));
|
|
1009
|
+
const [line = ""] = input.render(Math.max(1, width - indent.length));
|
|
1010
|
+
focusLine = lines.length;
|
|
1011
|
+
lines.push(truncateToWidth(`${indent}${line}`, width));
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
if (field.kind === "bool") {
|
|
1015
|
+
const value = form.present.has(field.name) ? form.values[field.name] : undefined;
|
|
1016
|
+
focusLine = lines.length;
|
|
1017
|
+
lines.push(truncateToWidth(` ${this.renderBoolControl(typeof value === "boolean" ? value : undefined)}`, width));
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
const cursor = form.optionCursors[fieldIndex] ?? 0;
|
|
1022
|
+
const current = form.values[field.name];
|
|
1023
|
+
field.options.forEach((option, optionIndex) => {
|
|
1024
|
+
if (cursor === optionIndex) focusLine = lines.length;
|
|
1025
|
+
const at = cursor === optionIndex ? this.theme.fg("accent", "▸") : " ";
|
|
1026
|
+
const selected = field.kind === "single" ? current === option.label : Array.isArray(current) && current.includes(option.label);
|
|
1027
|
+
const box = field.kind === "single" ? (selected ? "◉" : "○") : (selected ? "▣" : "▢");
|
|
1028
|
+
lines.push(truncateToWidth(` ${at} ${selected ? this.theme.fg("success", box) : this.theme.fg("muted", box)} ${option.label}`, width));
|
|
1029
|
+
if (option.description) lines.push(...this.wrap(this.theme.fg("muted", ` ${option.description}`), width));
|
|
1030
|
+
});
|
|
1031
|
+
const selectedOption = field.options[cursor];
|
|
1032
|
+
if (selectedOption?.preview?.trim()) {
|
|
1033
|
+
lines.push(this.theme.fg("muted", " Preview:"));
|
|
1034
|
+
lines.push(...this.markdownFor(selectedOption.preview).render(Math.max(1, width - 4)).map((line) => ` ${line}`));
|
|
1035
|
+
}
|
|
1036
|
+
});
|
|
1037
|
+
if (form.active >= fields.length) focusLine = lines.length;
|
|
1038
|
+
const action = this.request.questions.length === 1 ? "Submit" : "Continue";
|
|
1039
|
+
const button = form.active >= fields.length ? this.theme.inverse?.(` [ Enter ${action} ] `) ?? `[ Enter ${action} ]` : `[ Enter ${action} ]`;
|
|
1040
|
+
lines.push(truncateToWidth(`${form.active >= fields.length ? this.theme.fg("accent", "▸") : " "} ${this.theme.fg("accent", button)}`, width));
|
|
1041
|
+
return { lines: lines.map((line) => truncateToWidth(line, width)), focusLine };
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
private renderFormBody(width: number, height: number): string[] {
|
|
1045
|
+
const projection = this.renderFormProjection(width);
|
|
1046
|
+
const maxScroll = Math.max(0, projection.lines.length - height);
|
|
1047
|
+
this.viewport = { wide: false, maxBody: height, maxScroll };
|
|
1048
|
+
if (this.formScroll !== undefined) this.formScroll = clamp(this.formScroll, 0, maxScroll);
|
|
1049
|
+
this.formOffset = this.formScroll ?? this.autoOffset(projection.lines.length, projection.focusLine, height);
|
|
1050
|
+
return projection.lines.slice(this.formOffset, this.formOffset + height);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
701
1053
|
private renderBodyArea(width: number, height: number): string[] {
|
|
702
1054
|
if (height <= 0) return [];
|
|
703
1055
|
if (this.isReview) {
|
|
@@ -707,6 +1059,7 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
707
1059
|
this.manualScroll = clamp(this.manualScroll, 0, maxScroll);
|
|
708
1060
|
return lines.slice(this.manualScroll, this.manualScroll + height);
|
|
709
1061
|
}
|
|
1062
|
+
if (this.currentQuestion().kind === "simple-form") return this.renderFormBody(width, height);
|
|
710
1063
|
|
|
711
1064
|
const preview = this.currentPreview();
|
|
712
1065
|
const wide = preview.trim() !== "" && width >= SPLIT_MIN_WIDTH;
|
|
@@ -758,6 +1111,7 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
758
1111
|
|
|
759
1112
|
/** The free-input row, rendered as a fixed line that never scrolls away. */
|
|
760
1113
|
private renderInputBar(width: number): string[] {
|
|
1114
|
+
if (!this.hasInputBar(this.currentQuestion())) return [];
|
|
761
1115
|
const input = this.inputs[this.tab]!;
|
|
762
1116
|
input.focused = this.focused && this.isInputRow();
|
|
763
1117
|
return input
|
|
@@ -781,7 +1135,7 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
781
1135
|
const lines: string[] = [];
|
|
782
1136
|
this.request.questions.forEach((question, index) => {
|
|
783
1137
|
const draft = this.drafts[index]!;
|
|
784
|
-
const answered =
|
|
1138
|
+
const answered = validateAnswer(question, toAnswer(question, draft)) === undefined || question.hasDefault;
|
|
785
1139
|
const mark = answered ? this.theme.fg("success", "✓") : this.theme.fg("warning", "!");
|
|
786
1140
|
lines.push(truncateToWidth(`${mark} ${this.theme.bold(`${index + 1}. ${question.title}`)}`, width));
|
|
787
1141
|
lines.push(truncateToWidth(this.theme.fg("muted", ` ${this.describeDraft(question, draft)}`), width));
|
|
@@ -790,6 +1144,13 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
790
1144
|
}
|
|
791
1145
|
|
|
792
1146
|
private describeDraft(question: NormalizedQuestion, draft: DraftAnswer): string {
|
|
1147
|
+
if (question.kind === "bool" && draft.valueSet && typeof draft.value === "boolean") {
|
|
1148
|
+
return `${draft.value ? "True" : "False"}${draft.usedDefault ? " (default)" : ""}`;
|
|
1149
|
+
}
|
|
1150
|
+
if (question.kind === "simple-form" && draft.valueSet && draft.value && typeof draft.value === "object") {
|
|
1151
|
+
const entries = Object.entries(draft.value).map(([name, value]) => `${name}=${this.formValueText(value)}`);
|
|
1152
|
+
return `${entries.length > 0 ? entries.join("; ") : "(optional fields omitted)"}${draft.usedDefault ? " (includes defaults)" : ""}`;
|
|
1153
|
+
}
|
|
793
1154
|
const parts: string[] = [];
|
|
794
1155
|
if (draft.selections.length > 0) {
|
|
795
1156
|
parts.push(draft.selections.map((index) => question.options[index]?.label ?? `#${index + 1}`).join(", "));
|
|
@@ -821,7 +1182,23 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
821
1182
|
return withControls(reviewHints);
|
|
822
1183
|
}
|
|
823
1184
|
const question = this.currentQuestion();
|
|
824
|
-
|
|
1185
|
+
if (question.kind === "simple-form") {
|
|
1186
|
+
const hints = ["Tab next field", "Shift+Tab previous", "Ctrl+←/→ question"];
|
|
1187
|
+
const form = this.forms[this.tab]!;
|
|
1188
|
+
const field = question.fields?.[form.active];
|
|
1189
|
+
if (field?.kind === "multi") hints.push("Space toggle", "Enter next");
|
|
1190
|
+
else if (field?.kind === "bool") hints.push("Space toggle", "←/→ set", "Enter next");
|
|
1191
|
+
else if (field) hints.push("Enter next");
|
|
1192
|
+
else hints.push("Enter continue");
|
|
1193
|
+
if (this.viewport.maxScroll > 0) hints.push("PgUp/PgDn scroll");
|
|
1194
|
+
return withControls(hints);
|
|
1195
|
+
}
|
|
1196
|
+
if (question.kind === "bool") {
|
|
1197
|
+
const hints = ["Space toggle", "↑/↓ set", "Enter confirm", "Tab switch"];
|
|
1198
|
+
if (question.hasDefault) hints.push("s use default");
|
|
1199
|
+
return withControls(hints);
|
|
1200
|
+
}
|
|
1201
|
+
const hints = ["Tab switch", "↑/↓ move", `Enter ${this.isInputRow() || (question.kind === "multi" && !this.hasInputBar(question)) ? "confirm" : "select"}`];
|
|
825
1202
|
if (question.kind === "multi") hints.push("Space multi-select");
|
|
826
1203
|
if (question.hasDefault) hints.push("s use default");
|
|
827
1204
|
if (this.viewport.maxScroll > 0) hints.push("PgUp/PgDn preview");
|
|
@@ -877,9 +1254,11 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
877
1254
|
const hintBlock = [this.hintLine(contentWidth)];
|
|
878
1255
|
|
|
879
1256
|
let remaining = contentRows;
|
|
1257
|
+
const typedQuestion = !this.isReview && !this.hasInputBar(this.currentQuestion());
|
|
1258
|
+
const reserveBody = typedQuestion && contentRows > 0 ? 1 : 0;
|
|
880
1259
|
const take = (block: string[]): string[] => {
|
|
881
|
-
if (remaining <=
|
|
882
|
-
const use = Math.min(block.length, remaining);
|
|
1260
|
+
if (remaining <= reserveBody || block.length === 0) return [];
|
|
1261
|
+
const use = Math.min(block.length, remaining - reserveBody);
|
|
883
1262
|
remaining -= use;
|
|
884
1263
|
return block.slice(0, use);
|
|
885
1264
|
};
|