@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/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) 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
+ };
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.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) {
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
- 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;
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 = !draftIsEmpty(draft) || question.hasDefault;
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 = !draftIsEmpty(draft) || question.hasDefault;
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
- const toggleHint = this.overlayToggleKey ? `${this.overlayToggleKey} hide` : undefined;
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
- if (toggleHint) reviewHints.push(toggleHint);
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
- if (toggleHint) hints.push(toggleHint);
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 <= 0 || block.length === 0) return [];
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
  };