@black942026/pi-ask-user 0.1.0 → 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 +160 -40
- package/package.json +2 -2
- package/src/adapters/pi.ts +4 -1
- package/src/answers.ts +118 -33
- package/src/config.ts +14 -10
- package/src/core.ts +49 -12
- package/src/deadline.ts +22 -6
- package/src/index.ts +17 -2
- package/src/parse.ts +7 -0
- package/src/schema.ts +188 -89
- package/src/types.ts +107 -15
- package/src/ui/custom.ts +423 -18
- package/src/ui/native.ts +162 -59
package/src/ui/custom.ts
CHANGED
|
@@ -2,14 +2,16 @@ import {
|
|
|
2
2
|
Input,
|
|
3
3
|
Key,
|
|
4
4
|
Markdown,
|
|
5
|
+
isKeyRelease,
|
|
5
6
|
matchesKey,
|
|
6
7
|
truncateToWidth,
|
|
7
8
|
visibleWidth,
|
|
8
9
|
wrapTextWithAnsi,
|
|
9
10
|
} from "@earendil-works/pi-tui";
|
|
10
11
|
import type { Component, Focusable, MarkdownTheme } from "@earendil-works/pi-tui";
|
|
11
|
-
import { draftFromDefault, draftIsEmpty, emptyDraft, finalizeAnswers, type DraftAnswer } from "../answers.ts";
|
|
12
|
-
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
|
+
import { CANCEL_TIMEOUT_KEY } from "../config.ts";
|
|
13
15
|
|
|
14
16
|
/** Structural theme surface the component needs (a real Pi Theme satisfies it). */
|
|
15
17
|
export interface AskUserTheme {
|
|
@@ -35,6 +37,8 @@ export interface CustomUIResult {
|
|
|
35
37
|
export interface AskUserComponentOptions {
|
|
36
38
|
request: NormalizedRequest;
|
|
37
39
|
deadline: Deadline;
|
|
40
|
+
/** Disable the core deadline as well as this component's local timer. */
|
|
41
|
+
cancelTimeout?: () => boolean;
|
|
38
42
|
theme: AskUserTheme;
|
|
39
43
|
tui: AskUserTUI;
|
|
40
44
|
done: (result: CustomUIResult) => void;
|
|
@@ -115,6 +119,16 @@ function printableText(data: string): string | undefined {
|
|
|
115
119
|
return data;
|
|
116
120
|
}
|
|
117
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
|
+
|
|
118
132
|
/**
|
|
119
133
|
* Custom terminal UI for AskUser.
|
|
120
134
|
*
|
|
@@ -132,12 +146,15 @@ function printableText(data: string): string | undefined {
|
|
|
132
146
|
export class AskUserComponent implements Component, Focusable {
|
|
133
147
|
private readonly request: NormalizedRequest;
|
|
134
148
|
private readonly deadline: Deadline;
|
|
149
|
+
private readonly cancelTimeout: (() => boolean) | undefined;
|
|
150
|
+
private timeoutCancelled = false;
|
|
135
151
|
private readonly theme: AskUserTheme;
|
|
136
152
|
private readonly tui: AskUserTUI;
|
|
137
153
|
private readonly done: (result: CustomUIResult) => void;
|
|
138
154
|
private readonly drafts: DraftAnswer[];
|
|
139
155
|
private readonly cursors: number[];
|
|
140
156
|
private readonly inputs: Input[];
|
|
157
|
+
private readonly forms: Array<FormUIState | undefined>;
|
|
141
158
|
private readonly mdTheme: MarkdownTheme;
|
|
142
159
|
private readonly overlayToggleKey: string | undefined;
|
|
143
160
|
private readonly previewCache = new Map<string, Markdown>();
|
|
@@ -154,6 +171,8 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
154
171
|
private cachedLines: string[] | undefined;
|
|
155
172
|
private previewScroll = 0;
|
|
156
173
|
private manualScroll = 0;
|
|
174
|
+
private formScroll: number | undefined;
|
|
175
|
+
private formOffset = 0;
|
|
157
176
|
private viewport: { wide: boolean; maxBody: number; maxScroll: number } = {
|
|
158
177
|
wide: false,
|
|
159
178
|
maxBody: 1,
|
|
@@ -164,6 +183,7 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
164
183
|
constructor(options: AskUserComponentOptions) {
|
|
165
184
|
this.request = options.request;
|
|
166
185
|
this.deadline = options.deadline;
|
|
186
|
+
this.cancelTimeout = options.cancelTimeout;
|
|
167
187
|
this.theme = options.theme;
|
|
168
188
|
this.tui = options.tui;
|
|
169
189
|
this.done = options.done;
|
|
@@ -179,6 +199,41 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
179
199
|
placeholderStyle: (text) => this.theme.fg("muted", text),
|
|
180
200
|
}),
|
|
181
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
|
+
});
|
|
182
237
|
const remaining = Math.max(0, this.deadline.remainingMs());
|
|
183
238
|
this.remainingSeconds = Math.ceil(remaining / 1000);
|
|
184
239
|
this.timer = setTimeout(() => this.finish({ kind: "timeout" }), remaining);
|
|
@@ -234,6 +289,7 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
234
289
|
this.cachedWidth = undefined;
|
|
235
290
|
this.cachedRows = undefined;
|
|
236
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();
|
|
237
293
|
for (const markdown of this.previewCache.values()) markdown.invalidate();
|
|
238
294
|
}
|
|
239
295
|
|
|
@@ -245,6 +301,7 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
245
301
|
private resetScroll(): void {
|
|
246
302
|
this.previewScroll = 0;
|
|
247
303
|
this.manualScroll = 0;
|
|
304
|
+
this.formScroll = undefined;
|
|
248
305
|
}
|
|
249
306
|
|
|
250
307
|
// -- tab helpers ---------------------------------------------------------
|
|
@@ -266,6 +323,8 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
266
323
|
}
|
|
267
324
|
|
|
268
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;
|
|
269
328
|
return question.options.length + 1;
|
|
270
329
|
}
|
|
271
330
|
|
|
@@ -277,6 +336,7 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
277
336
|
|
|
278
337
|
private isInputRow(): boolean {
|
|
279
338
|
if (this.isReview) return false;
|
|
339
|
+
if (this.currentQuestion().kind === "bool" || this.currentQuestion().kind === "simple-form") return false;
|
|
280
340
|
return this.rowIndex() >= this.currentQuestion().options.length;
|
|
281
341
|
}
|
|
282
342
|
|
|
@@ -319,7 +379,45 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
319
379
|
}
|
|
320
380
|
|
|
321
381
|
private syncAllDrafts(): void {
|
|
322
|
-
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
|
+
};
|
|
323
421
|
}
|
|
324
422
|
|
|
325
423
|
/** Forward a key to the input and drop `usedDefault` if the text changed. */
|
|
@@ -335,7 +433,9 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
335
433
|
private scrollBy(direction: -1 | 1): void {
|
|
336
434
|
if (this.viewport.maxScroll <= 0) return;
|
|
337
435
|
const page = Math.max(1, this.viewport.maxBody - 1);
|
|
338
|
-
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) {
|
|
339
439
|
this.previewScroll = clamp(this.previewScroll + direction * page, 0, this.viewport.maxScroll);
|
|
340
440
|
} else {
|
|
341
441
|
this.manualScroll = clamp(this.manualScroll + direction * page, 0, this.viewport.maxScroll);
|
|
@@ -352,7 +452,9 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
352
452
|
this.scrollBy(1);
|
|
353
453
|
return true;
|
|
354
454
|
}
|
|
355
|
-
|
|
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;
|
|
356
458
|
if (matchesKey(data, "ctrl+u") || data === "[") {
|
|
357
459
|
this.scrollBy(-1);
|
|
358
460
|
return true;
|
|
@@ -364,11 +466,128 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
364
466
|
return false;
|
|
365
467
|
}
|
|
366
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
|
+
|
|
367
578
|
// -- key handling --------------------------------------------------------
|
|
368
579
|
|
|
369
580
|
handleInput(data: string): void {
|
|
370
581
|
if (this.finished) return;
|
|
371
582
|
|
|
583
|
+
if (matchesKey(data, CANCEL_TIMEOUT_KEY)) {
|
|
584
|
+
if (isKeyRelease(data) || this.timeoutCancelled || this.deadline.expired()) return;
|
|
585
|
+
if (this.cancelTimeout && !this.cancelTimeout()) return;
|
|
586
|
+
this.timeoutCancelled = true;
|
|
587
|
+
this.dispose();
|
|
588
|
+
this.refresh();
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
372
591
|
if (matchesKey(data, Key.escape)) {
|
|
373
592
|
this.finish({ kind: "cancelled" });
|
|
374
593
|
return;
|
|
@@ -377,6 +596,27 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
377
596
|
this.submitAll();
|
|
378
597
|
return;
|
|
379
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
|
+
}
|
|
380
620
|
if (matchesKey(data, "shift+tab")) {
|
|
381
621
|
this.moveTab(-1);
|
|
382
622
|
return;
|
|
@@ -401,6 +641,31 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
401
641
|
}
|
|
402
642
|
|
|
403
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
|
+
}
|
|
404
669
|
if (question.kind === "input") {
|
|
405
670
|
this.cursors[this.tab] = question.options.length;
|
|
406
671
|
}
|
|
@@ -558,6 +823,14 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
558
823
|
this.reviewButtonFocused = false;
|
|
559
824
|
this.cursors[first] = 0;
|
|
560
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
|
+
}
|
|
561
834
|
this.resetScroll();
|
|
562
835
|
this.refresh();
|
|
563
836
|
return;
|
|
@@ -574,6 +847,7 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
574
847
|
}
|
|
575
848
|
|
|
576
849
|
private countdownLabel(): string {
|
|
850
|
+
if (this.timeoutCancelled) return "No timeout";
|
|
577
851
|
const hours = Math.floor(this.remainingSeconds / 3600);
|
|
578
852
|
const minutes = Math.floor((this.remainingSeconds % 3600) / 60);
|
|
579
853
|
const seconds = String(this.remainingSeconds % 60).padStart(2, "0");
|
|
@@ -630,7 +904,7 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
630
904
|
const chips: string[] = [];
|
|
631
905
|
this.request.questions.forEach((question, index) => {
|
|
632
906
|
const draft = this.drafts[index]!;
|
|
633
|
-
const answered =
|
|
907
|
+
const answered = validateAnswer(question, toAnswer(question, draft)) === undefined || question.hasDefault;
|
|
634
908
|
const marker = answered ? "●" : "○";
|
|
635
909
|
const label = truncateToWidth(question.title, TAB_LABEL_WIDTH);
|
|
636
910
|
const chip = `${marker} ${index + 1}.${label}`;
|
|
@@ -670,9 +944,28 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
670
944
|
return lines;
|
|
671
945
|
}
|
|
672
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
|
+
|
|
673
965
|
/** Options only (no input row): the input stays pinned outside the body. */
|
|
674
966
|
private renderOptions(width: number): { lines: string[]; focusLine: number } {
|
|
675
967
|
const question = this.currentQuestion();
|
|
968
|
+
if (question.kind === "bool") return this.renderBoolOptions(width);
|
|
676
969
|
const lines: string[] = [];
|
|
677
970
|
let focusLine = 0;
|
|
678
971
|
for (let index = 0; index < question.options.length; index += 1) {
|
|
@@ -682,6 +975,86 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
682
975
|
return { lines: lines.map((line) => truncateToWidth(line, width)), focusLine };
|
|
683
976
|
}
|
|
684
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
|
+
|
|
685
1058
|
private renderBodyArea(width: number, height: number): string[] {
|
|
686
1059
|
if (height <= 0) return [];
|
|
687
1060
|
if (this.isReview) {
|
|
@@ -691,6 +1064,7 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
691
1064
|
this.manualScroll = clamp(this.manualScroll, 0, maxScroll);
|
|
692
1065
|
return lines.slice(this.manualScroll, this.manualScroll + height);
|
|
693
1066
|
}
|
|
1067
|
+
if (this.currentQuestion().kind === "simple-form") return this.renderFormBody(width, height);
|
|
694
1068
|
|
|
695
1069
|
const preview = this.currentPreview();
|
|
696
1070
|
const wide = preview.trim() !== "" && width >= SPLIT_MIN_WIDTH;
|
|
@@ -742,6 +1116,7 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
742
1116
|
|
|
743
1117
|
/** The free-input row, rendered as a fixed line that never scrolls away. */
|
|
744
1118
|
private renderInputBar(width: number): string[] {
|
|
1119
|
+
if (this.currentQuestion().kind === "bool" || this.currentQuestion().kind === "simple-form") return [];
|
|
745
1120
|
const input = this.inputs[this.tab]!;
|
|
746
1121
|
input.focused = this.focused && this.isInputRow();
|
|
747
1122
|
return input
|
|
@@ -765,7 +1140,7 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
765
1140
|
const lines: string[] = [];
|
|
766
1141
|
this.request.questions.forEach((question, index) => {
|
|
767
1142
|
const draft = this.drafts[index]!;
|
|
768
|
-
const answered =
|
|
1143
|
+
const answered = validateAnswer(question, toAnswer(question, draft)) === undefined || question.hasDefault;
|
|
769
1144
|
const mark = answered ? this.theme.fg("success", "✓") : this.theme.fg("warning", "!");
|
|
770
1145
|
lines.push(truncateToWidth(`${mark} ${this.theme.bold(`${index + 1}. ${question.title}`)}`, width));
|
|
771
1146
|
lines.push(truncateToWidth(this.theme.fg("muted", ` ${this.describeDraft(question, draft)}`), width));
|
|
@@ -774,6 +1149,13 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
774
1149
|
}
|
|
775
1150
|
|
|
776
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
|
+
}
|
|
777
1159
|
const parts: string[] = [];
|
|
778
1160
|
if (draft.selections.length > 0) {
|
|
779
1161
|
parts.push(draft.selections.map((index) => question.options[index]?.label ?? `#${index + 1}`).join(", "));
|
|
@@ -789,23 +1171,44 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
789
1171
|
return parts.join(" | ") + suffix;
|
|
790
1172
|
}
|
|
791
1173
|
|
|
792
|
-
private hintLine(): string {
|
|
793
|
-
|
|
1174
|
+
private hintLine(width: number): string {
|
|
1175
|
+
// Keep cancellation controls discoverable even when contextual hints grow.
|
|
1176
|
+
const controls = ["Esc cancel"];
|
|
1177
|
+
if (!this.timeoutCancelled) controls.push(`${CANCEL_TIMEOUT_KEY} stop timer`);
|
|
1178
|
+
if (this.overlayToggleKey) controls.push(`${this.overlayToggleKey} hide`);
|
|
1179
|
+
const controlText = controls.join(" · ");
|
|
1180
|
+
const withControls = (hints: string[]): string => {
|
|
1181
|
+
const contextWidth = width - visibleWidth(controlText) - 3;
|
|
1182
|
+
const context = contextWidth > 0 ? truncateToWidth(hints.join(" · "), contextWidth) : "";
|
|
1183
|
+
return this.theme.fg("dim", context ? `${context} · ${controlText}` : controlText);
|
|
1184
|
+
};
|
|
794
1185
|
if (this.isReview) {
|
|
795
1186
|
const reviewHints = ["↓ focus submit", "Enter submit", "Tab switch question"];
|
|
796
|
-
|
|
797
|
-
reviewHints.push("Esc cancel");
|
|
798
|
-
return this.theme.fg("dim", reviewHints.join(" · "));
|
|
1187
|
+
return withControls(reviewHints);
|
|
799
1188
|
}
|
|
800
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
|
+
}
|
|
801
1206
|
const hints = ["Tab switch", "↑/↓ move", `Enter ${this.isInputRow() ? "confirm" : "select"}`];
|
|
802
1207
|
if (question.kind === "multi") hints.push("Space multi-select");
|
|
803
1208
|
if (question.hasDefault) hints.push("s use default");
|
|
804
1209
|
if (this.viewport.maxScroll > 0) hints.push("PgUp/PgDn preview");
|
|
805
1210
|
if (this.isInputRow() && question.options.length > 0) hints.push("↑ back to options");
|
|
806
|
-
|
|
807
|
-
hints.push("Esc cancel");
|
|
808
|
-
return this.theme.fg("dim", hints.join(" · "));
|
|
1211
|
+
return withControls(hints);
|
|
809
1212
|
}
|
|
810
1213
|
|
|
811
1214
|
render(width: number): string[] {
|
|
@@ -853,12 +1256,14 @@ export class AskUserComponent implements Component, Focusable {
|
|
|
853
1256
|
? this.wrap(this.theme.fg("warning", `⚠ ${this.notice}`), contentWidth).slice(0, MAX_NOTICE_LINES)
|
|
854
1257
|
: [];
|
|
855
1258
|
const inputBlock = this.isReview ? this.renderSubmitBar(contentWidth) : this.renderInputBar(contentWidth);
|
|
856
|
-
const hintBlock = [this.hintLine()];
|
|
1259
|
+
const hintBlock = [this.hintLine(contentWidth)];
|
|
857
1260
|
|
|
858
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;
|
|
859
1264
|
const take = (block: string[]): string[] => {
|
|
860
|
-
if (remaining <=
|
|
861
|
-
const use = Math.min(block.length, remaining);
|
|
1265
|
+
if (remaining <= reserveBody || block.length === 0) return [];
|
|
1266
|
+
const use = Math.min(block.length, remaining - reserveBody);
|
|
862
1267
|
remaining -= use;
|
|
863
1268
|
return block.slice(0, use);
|
|
864
1269
|
};
|