@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/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(() => emptyDraft());
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
- return question.options.length + 1;
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) this.syncDraftFromInput(index);
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.viewport.wide) {
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
- if (this.isInputRow()) return false;
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 = "This question is required: select an option or type text.";
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 = !draftIsEmpty(draft) || question.hasDefault;
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 = !draftIsEmpty(draft) || question.hasDefault;
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
- const hints = ["Tab switch", "↑/↓ move", `Enter ${this.isInputRow() ? "confirm" : "select"}`];
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 <= 0 || block.length === 0) return [];
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
  };