@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/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) this.syncDraftFromInput(index);
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.viewport.wide) {
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
- if (this.isInputRow()) return false;
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 = !draftIsEmpty(draft) || question.hasDefault;
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 = !draftIsEmpty(draft) || question.hasDefault;
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 <= 0 || block.length === 0) return [];
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
  };